import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/network/websocket_client.dart'; import '../../data/datasources/chat_local_datasource.dart'; import '../../data/datasources/chat_remote_datasource.dart'; import '../../data/models/chat_message_model.dart'; import '../../data/repositories/chat_repository_impl.dart'; import '../../domain/entities/chat_message.dart'; import '../../domain/entities/stream_event.dart'; import '../../domain/repositories/chat_repository.dart'; import '../../domain/usecases/cancel_task.dart'; import '../../domain/usecases/get_session_history.dart'; import '../../domain/usecases/send_message.dart'; // --------------------------------------------------------------------------- // Dependency providers // --------------------------------------------------------------------------- final sharedPreferencesProvider = FutureProvider((ref) { return SharedPreferences.getInstance(); }); final chatRemoteDatasourceProvider = Provider((ref) { final dio = ref.watch(dioClientProvider); return ChatRemoteDatasource(dio); }); final chatLocalDatasourceProvider = Provider((ref) { final prefsAsync = ref.watch(sharedPreferencesProvider); return prefsAsync.whenOrNull( data: (prefs) => ChatLocalDatasource(prefs), ); }); final chatRepositoryProvider = Provider((ref) { final remote = ref.watch(chatRemoteDatasourceProvider); final local = ref.watch(chatLocalDatasourceProvider); final ws = ref.watch(webSocketClientProvider); // Use a no-op local datasource if SharedPreferences is not yet ready return ChatRepositoryImpl( remoteDatasource: remote, localDatasource: local ?? _NoOpLocalDatasource(), webSocketClient: ws, ); }); // --------------------------------------------------------------------------- // Use case providers // --------------------------------------------------------------------------- final sendMessageUseCaseProvider = Provider((ref) { return SendMessage(ref.watch(chatRepositoryProvider)); }); final getSessionHistoryUseCaseProvider = Provider((ref) { return GetSessionHistory(ref.watch(chatRepositoryProvider)); }); final cancelTaskUseCaseProvider = Provider((ref) { return CancelTask(ref.watch(chatRepositoryProvider)); }); // --------------------------------------------------------------------------- // Chat state // --------------------------------------------------------------------------- enum AgentStatus { idle, thinking, executing, awaitingApproval, error } class ChatState { final List messages; final AgentStatus agentStatus; final String? sessionId; final String? error; const ChatState({ this.messages = const [], this.agentStatus = AgentStatus.idle, this.sessionId, this.error, }); bool get isStreaming => agentStatus != AgentStatus.idle && agentStatus != AgentStatus.error; ChatState copyWith({ List? messages, AgentStatus? agentStatus, String? sessionId, String? error, }) { return ChatState( messages: messages ?? this.messages, agentStatus: agentStatus ?? this.agentStatus, sessionId: sessionId ?? this.sessionId, error: error, ); } } // --------------------------------------------------------------------------- // Chat notifier // --------------------------------------------------------------------------- class ChatNotifier extends StateNotifier { final Ref _ref; StreamSubscription? _eventSubscription; ChatNotifier(this._ref) : super(const ChatState()); /// Sends a user message to the agent and processes the streamed response. Future sendMessage(String prompt) async { if (prompt.trim().isEmpty) return; // Add the user message locally final userMsg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.user, content: prompt, timestamp: DateTime.now(), type: MessageType.text, ); state = state.copyWith( messages: [...state.messages, userMsg], agentStatus: AgentStatus.thinking, error: null, ); try { final useCase = _ref.read(sendMessageUseCaseProvider); final sessionId = state.sessionId ?? 'new'; final stream = useCase.execute( sessionId: sessionId, message: prompt, ); _eventSubscription?.cancel(); _eventSubscription = stream.listen( (event) => _handleStreamEvent(event), onError: (error) { state = state.copyWith( agentStatus: AgentStatus.error, error: error.toString(), ); }, onDone: () { if (state.agentStatus != AgentStatus.error) { state = state.copyWith(agentStatus: AgentStatus.idle); } }, ); } catch (e) { state = state.copyWith( agentStatus: AgentStatus.error, error: e.toString(), ); } } void _handleStreamEvent(StreamEvent event) { switch (event) { case ThinkingEvent(:final content): _appendOrUpdateAssistantMessage(content, MessageType.thinking); state = state.copyWith(agentStatus: AgentStatus.thinking); case TextEvent(:final content): _appendOrUpdateAssistantMessage(content, MessageType.text); state = state.copyWith(agentStatus: AgentStatus.executing); case ToolUseEvent(:final toolName, :final input): final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: 'Executing: $toolName', timestamp: DateTime.now(), type: MessageType.toolUse, toolExecution: ToolExecution( toolName: toolName, input: input.toString(), riskLevel: 0, status: ToolStatus.executing, ), ); state = state.copyWith( messages: [...state.messages, msg], agentStatus: AgentStatus.executing, ); case ToolResultEvent(:final toolName, :final output, :final isError): final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: output, timestamp: DateTime.now(), type: MessageType.toolResult, toolExecution: ToolExecution( toolName: toolName, input: '', output: output, riskLevel: 0, status: isError ? ToolStatus.error : ToolStatus.completed, ), ); state = state.copyWith(messages: [...state.messages, msg]); case ApprovalRequiredEvent(:final taskId, :final command, :final riskLevel): final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: 'Approval required for: $command', timestamp: DateTime.now(), type: MessageType.approval, approvalRequest: ApprovalRequest( taskId: taskId, command: command, riskLevel: riskLevel, expiresAt: DateTime.now().add(const Duration(minutes: 5)), ), ); state = state.copyWith( messages: [...state.messages, msg], agentStatus: AgentStatus.awaitingApproval, ); case CompletedEvent(:final summary): if (summary.isNotEmpty) { _appendOrUpdateAssistantMessage(summary, MessageType.text); } state = state.copyWith(agentStatus: AgentStatus.idle); case ErrorEvent(:final message): state = state.copyWith( agentStatus: AgentStatus.error, error: message, ); case StandingOrderDraftEvent(:final draft): final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: 'Standing order draft proposed', timestamp: DateTime.now(), type: MessageType.standingOrderDraft, metadata: draft, ); state = state.copyWith( messages: [...state.messages, msg], agentStatus: AgentStatus.awaitingApproval, ); case StandingOrderConfirmedEvent(:final orderId, :final orderName): final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: 'Standing order "$orderName" confirmed (ID: $orderId)', timestamp: DateTime.now(), type: MessageType.text, ); state = state.copyWith( messages: [...state.messages, msg], agentStatus: AgentStatus.idle, ); } } /// Appends text content to the last assistant message if it is of the same /// type, or creates a new message bubble. void _appendOrUpdateAssistantMessage(String content, MessageType type) { if (state.messages.isNotEmpty) { final last = state.messages.last; if (last.role == MessageRole.assistant && last.type == type) { final updated = last.copyWith(content: last.content + content); state = state.copyWith( messages: [ ...state.messages.sublist(0, state.messages.length - 1), updated, ], ); return; } } final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: MessageRole.assistant, content: content, timestamp: DateTime.now(), type: type, isStreaming: true, ); state = state.copyWith(messages: [...state.messages, msg]); } /// Approves a pending command. Future approveCommand(String taskId) async { try { final repo = _ref.read(chatRepositoryProvider); await repo.approveCommand(taskId); state = state.copyWith(agentStatus: AgentStatus.executing); } catch (e) { state = state.copyWith(error: 'Failed to approve: $e'); } } /// Rejects a pending command. Future rejectCommand(String taskId, {String? reason}) async { try { final repo = _ref.read(chatRepositoryProvider); await repo.rejectCommand(taskId, reason: reason); state = state.copyWith(agentStatus: AgentStatus.idle); } catch (e) { state = state.copyWith(error: 'Failed to reject: $e'); } } /// Confirms a standing order draft. Future confirmStandingOrder(Map draft) async { if (state.sessionId == null) return; try { final repo = _ref.read(chatRepositoryProvider); await repo.confirmStandingOrder(state.sessionId!, draft); state = state.copyWith(agentStatus: AgentStatus.idle); } catch (e) { state = state.copyWith(error: 'Failed to confirm standing order: $e'); } } /// Cancels the current agent task. Future cancelCurrentTask() async { if (state.sessionId == null) return; try { final useCase = _ref.read(cancelTaskUseCaseProvider); await useCase.execute(state.sessionId!); _eventSubscription?.cancel(); state = state.copyWith(agentStatus: AgentStatus.idle); } catch (e) { state = state.copyWith(error: 'Failed to cancel: $e'); } } /// Loads session history from the backend. Future loadSessionHistory(String sessionId) async { try { final useCase = _ref.read(getSessionHistoryUseCaseProvider); final messages = await useCase.execute(sessionId); state = state.copyWith( messages: messages, sessionId: sessionId, ); } catch (e) { state = state.copyWith(error: 'Failed to load history: $e'); } } /// Clears the chat state and cancels any active subscriptions. void clearChat() { _eventSubscription?.cancel(); state = const ChatState(); } @override void dispose() { _eventSubscription?.cancel(); super.dispose(); } } // --------------------------------------------------------------------------- // Main providers // --------------------------------------------------------------------------- final chatProvider = StateNotifierProvider((ref) { return ChatNotifier(ref); }); final agentStatusProvider = Provider((ref) { return ref.watch(chatProvider).agentStatus; }); final currentSessionIdProvider = Provider((ref) { return ref.watch(chatProvider).sessionId; }); final chatMessagesListProvider = Provider>((ref) { return ref.watch(chatProvider).messages; }); // --------------------------------------------------------------------------- // No-op local datasource fallback // --------------------------------------------------------------------------- class _NoOpLocalDatasource implements ChatLocalDatasource { @override Future cacheMessages( String sessionId, List messages, ) async {} @override List getCachedMessages(String sessionId) => []; @override Future clearCache(String sessionId) async {} @override Future clearAllCaches() async {} }