import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:speech_to_text/speech_to_text.dart' as stt; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/network/websocket_client.dart'; import '../../../../core/theme/app_colors.dart'; // --------------------------------------------------------------------------- // TODO 39 & 40 – Chat page: message send + voice input // --------------------------------------------------------------------------- // ---- Chat message model ---------------------------------------------------- enum ChatRole { user, assistant, system } enum ChatContentType { text, toolUse } class ChatMessage { final String id; final ChatRole role; final ChatContentType contentType; final String content; final DateTime timestamp; const ChatMessage({ required this.id, required this.role, required this.contentType, required this.content, required this.timestamp, }); } // ---- Chat state notifier --------------------------------------------------- class ChatState { final List messages; final bool isStreaming; final String? sessionId; final String? error; const ChatState({ this.messages = const [], this.isStreaming = false, this.sessionId, this.error, }); ChatState copyWith({ List? messages, bool? isStreaming, String? sessionId, String? error, }) { return ChatState( messages: messages ?? this.messages, isStreaming: isStreaming ?? this.isStreaming, sessionId: sessionId ?? this.sessionId, error: error, ); } } class ChatNotifier extends StateNotifier { final Ref _ref; StreamSubscription>? _wsSubscription; ChatNotifier(this._ref) : super(const ChatState()); /// Sends a user prompt, creates a task via the agent endpoint, subscribes to /// the session WebSocket, and streams response messages into the chat. Future sendMessage(String prompt) async { if (prompt.trim().isEmpty) return; // Add the user message locally final userMsg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: ChatRole.user, contentType: ChatContentType.text, content: prompt, timestamp: DateTime.now(), ); state = state.copyWith( messages: [...state.messages, userMsg], isStreaming: true, error: null, ); try { // POST to agent tasks endpoint final dio = _ref.read(dioClientProvider); final response = await dio.post( ApiEndpoints.tasks, data: {'prompt': prompt}, ); final data = response.data as Map; final sessionId = data['sessionId'] as String? ?? data['session_id'] as String?; final taskId = data['taskId'] as String? ?? data['task_id'] as String?; if (sessionId == null) { state = state.copyWith(isStreaming: false, error: 'No sessionId returned'); return; } state = state.copyWith(sessionId: sessionId); // Connect to agent WebSocket and subscribe final wsClient = _ref.read(webSocketClientProvider); await wsClient.connect('/ws/agent'); wsClient.send({ 'event': 'subscribe_session', 'data': {'sessionId': sessionId, 'taskId': taskId}, }); // Listen for stream_event messages _wsSubscription?.cancel(); _wsSubscription = wsClient.messages.listen((msg) { final event = msg['event'] as String? ?? msg['type'] as String? ?? ''; if (event == 'stream_event' || event == 'message') { _handleStreamEvent(msg['data'] as Map? ?? msg); } else if (event == 'stream_end' || event == 'done' || event == 'complete') { state = state.copyWith(isStreaming: false); _wsSubscription?.cancel(); } else if (event == 'error') { state = state.copyWith( isStreaming: false, error: msg['message'] as String? ?? 'Stream error', ); _wsSubscription?.cancel(); } }); } catch (e) { state = state.copyWith(isStreaming: false, error: e.toString()); } } void _handleStreamEvent(Map data) { final type = data['type'] as String? ?? 'text'; final content = data['content'] as String? ?? data['text'] as String? ?? ''; final ChatContentType contentType; if (type == 'tool_use' || type == 'tool_call') { contentType = ChatContentType.toolUse; } else { contentType = ChatContentType.text; } // If the last message is an assistant text message that is still streaming, // append to it instead of creating a new bubble. if (contentType == ChatContentType.text && state.messages.isNotEmpty) { final last = state.messages.last; if (last.role == ChatRole.assistant && last.contentType == ChatContentType.text) { final updated = ChatMessage( id: last.id, role: ChatRole.assistant, contentType: ChatContentType.text, content: last.content + content, timestamp: last.timestamp, ); state = state.copyWith( messages: [...state.messages.sublist(0, state.messages.length - 1), updated], ); return; } } final msg = ChatMessage( id: DateTime.now().microsecondsSinceEpoch.toString(), role: ChatRole.assistant, contentType: contentType, content: content, timestamp: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, msg]); } void clearChat() { _wsSubscription?.cancel(); state = const ChatState(); } @override void dispose() { _wsSubscription?.cancel(); super.dispose(); } } final chatMessagesProvider = StateNotifierProvider((ref) { return ChatNotifier(ref); }); // --------------------------------------------------------------------------- // Chat page – ConsumerStatefulWidget // --------------------------------------------------------------------------- class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState with SingleTickerProviderStateMixin { final _messageController = TextEditingController(); final _scrollController = ScrollController(); // -- Voice input (TODO 40) ------------------------------------------------ late final stt.SpeechToText _speech; bool _speechAvailable = false; bool _isListening = false; late AnimationController _micPulseController; @override void initState() { super.initState(); _speech = stt.SpeechToText(); _initSpeech(); _micPulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); } Future _initSpeech() async { _speechAvailable = await _speech.initialize( onStatus: (status) { if (status == 'done' || status == 'notListening') { _stopListening(autoSubmit: true); } }, onError: (_) => _stopListening(), ); if (mounted) setState(() {}); } void _startListening() { if (!_speechAvailable) return; setState(() => _isListening = true); _micPulseController.repeat(reverse: true); _speech.listen( onResult: (result) { _messageController.text = result.recognizedWords; if (result.finalResult) { _stopListening(autoSubmit: true); } }, listenFor: const Duration(seconds: 30), pauseFor: const Duration(seconds: 3), ); } void _stopListening({bool autoSubmit = false}) { _speech.stop(); _micPulseController.stop(); _micPulseController.reset(); if (!mounted) return; setState(() => _isListening = false); if (autoSubmit && _messageController.text.trim().isNotEmpty) { _send(); } } // -- Send ------------------------------------------------------------------ void _send() { final text = _messageController.text.trim(); if (text.isEmpty) return; _messageController.clear(); ref.read(chatMessagesProvider.notifier).sendMessage(text); _scrollToBottom(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent + 80, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } // -- Build ----------------------------------------------------------------- @override Widget build(BuildContext context) { final chatState = ref.watch(chatMessagesProvider); // Auto-scroll when messages change ref.listen(chatMessagesProvider, (_, __) => _scrollToBottom()); return Scaffold( appBar: AppBar( title: const Text('AI Agent'), actions: [ if (chatState.messages.isNotEmpty) IconButton( icon: const Icon(Icons.delete_outline), tooltip: 'Clear chat', onPressed: () => ref.read(chatMessagesProvider.notifier).clearChat(), ), // Voice input button (TODO 40) GestureDetector( onLongPressStart: (_) => _startListening(), onLongPressEnd: (_) => _stopListening(autoSubmit: true), child: AnimatedBuilder( animation: _micPulseController, builder: (context, child) { return IconButton( icon: Icon( _isListening ? Icons.mic : Icons.mic_none, color: _isListening ? Color.lerp(AppColors.error, AppColors.warning, _micPulseController.value) : null, size: _isListening ? 28 + (_micPulseController.value * 4) : 24, ), onPressed: () { if (_isListening) { _stopListening(autoSubmit: true); } else { _startListening(); } }, ); }, ), ), ], ), body: Column( children: [ // Error banner if (chatState.error != null) MaterialBanner( content: Text(chatState.error!, style: const TextStyle(color: AppColors.error)), backgroundColor: AppColors.error.withOpacity(0.1), actions: [ TextButton( onPressed: () => ref.read(chatMessagesProvider.notifier).clearChat(), child: const Text('Dismiss'), ), ], ), // Message list Expanded( child: chatState.messages.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.smart_toy_outlined, size: 64, color: AppColors.textMuted), const SizedBox(height: 16), Text( 'Start a conversation with IT0 Agent', style: TextStyle(color: AppColors.textSecondary, fontSize: 16), ), const SizedBox(height: 8), Text( 'Hold the mic button for voice input', style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ], ), ) : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), itemCount: chatState.messages.length + (chatState.isStreaming ? 1 : 0), itemBuilder: (context, index) { if (index == chatState.messages.length) { // Typing indicator return _TypingIndicator(); } return _ChatBubble(message: chatState.messages[index]); }, ), ), // Voice listening indicator if (_isListening) Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), color: AppColors.error.withOpacity(0.1), child: Row( children: [ AnimatedBuilder( animation: _micPulseController, builder: (context, _) => Icon( Icons.mic, color: AppColors.error, size: 20 + (_micPulseController.value * 4), ), ), const SizedBox(width: 8), const Text('Listening...', style: TextStyle(color: AppColors.error)), const Spacer(), TextButton( onPressed: () => _stopListening(), child: const Text('Cancel'), ), ], ), ), // Input row Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppColors.surface, border: Border(top: BorderSide(color: AppColors.surfaceLight.withOpacity(0.5))), ), child: SafeArea( child: Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: const InputDecoration( hintText: 'Ask IT0 Agent...', border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(24)), ), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), enabled: !chatState.isStreaming, ), ), const SizedBox(width: 8), IconButton( icon: chatState.isStreaming ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), onPressed: chatState.isStreaming ? null : _send, ), ], ), ), ), ], ), ); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); _micPulseController.dispose(); _speech.stop(); super.dispose(); } } // --------------------------------------------------------------------------- // Private helper widgets // --------------------------------------------------------------------------- class _ChatBubble extends StatelessWidget { final ChatMessage message; const _ChatBubble({required this.message}); bool get _isUser => message.role == ChatRole.user; @override Widget build(BuildContext context) { if (message.contentType == ChatContentType.toolUse) { return _ToolUseCard(content: message.content); } return Align( alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.78, ), decoration: BoxDecoration( color: _isUser ? AppColors.primary : AppColors.surface, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(_isUser ? 16 : 4), bottomRight: Radius.circular(_isUser ? 4 : 16), ), ), child: Text( message.content, style: TextStyle( color: _isUser ? Colors.white : AppColors.textPrimary, fontSize: 15, ), ), ), ); } } class _ToolUseCard extends StatelessWidget { final String content; const _ToolUseCard({required this.content}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.surfaceLight.withOpacity(0.5), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.primary.withOpacity(0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.code, size: 16, color: AppColors.primary), const SizedBox(width: 6), Text( 'Tool Execution', style: TextStyle( color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(8), ), child: SelectableText( content, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, color: AppColors.textSecondary, ), ), ), ], ), ); } } class _TypingIndicator extends StatefulWidget { @override State<_TypingIndicator> createState() => _TypingIndicatorState(); } class _TypingIndicatorState extends State<_TypingIndicator> with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(16), ), child: AnimatedBuilder( animation: _controller, builder: (context, _) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { final delay = i * 0.33; final t = ((_controller.value + delay) % 1.0); final opacity = (t < 0.5) ? 0.3 + t * 1.0 : 1.3 - t * 1.0; return Padding( padding: const EdgeInsets.symmetric(horizontal: 3), child: Opacity( opacity: opacity.clamp(0.3, 1.0), child: Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.textSecondary, shape: BoxShape.circle, ), ), ), ); }), ); }, ), ), ); } } /// A mini [AnimatedWidget] wrapper used in lieu of [AnimatedBuilder]. /// Flutter's actual class is [AnimatedBuilder]; we re-export via this alias /// so that the code reads naturally and works with the stable API. /// (AnimatedBuilder is the stable name since Flutter 3.x.)