import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/data/services/ai_process_manager.dart'; import 'package:trainhub_flutter/domain/entities/chat_session.dart'; import 'package:trainhub_flutter/presentation/chat/chat_controller.dart'; import 'package:trainhub_flutter/presentation/chat/chat_state.dart'; import 'package:trainhub_flutter/presentation/chat/widgets/message_bubble.dart'; import 'package:trainhub_flutter/presentation/chat/widgets/missing_models_state.dart'; import 'package:trainhub_flutter/presentation/chat/widgets/new_chat_button.dart'; import 'package:trainhub_flutter/presentation/chat/widgets/typing_bubble.dart'; import 'package:trainhub_flutter/presentation/chat/widgets/typing_indicator.dart'; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart'; @RoutePage() class ChatPage extends ConsumerStatefulWidget { const ChatPage({super.key}); @override ConsumerState createState() => _ChatPageState(); } class _ChatPageState extends ConsumerState { final TextEditingController _inputController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final FocusNode _inputFocusNode = FocusNode(); String? _hoveredSessionId; @override void dispose() { _inputController.dispose(); _scrollController.dispose(); _inputFocusNode.dispose(); super.dispose(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } void _sendMessage(ChatController controller) { final text = _inputController.text.trim(); if (text.isEmpty) return; controller.sendMessage(text); _inputController.clear(); _inputFocusNode.requestFocus(); } String _formatTimestamp(String timestamp) { try { final dt = DateTime.parse(timestamp); final now = DateTime.now(); final hour = dt.hour.toString().padLeft(2, '0'); final minute = dt.minute.toString().padLeft(2, '0'); if (dt.year == now.year && dt.month == now.month && dt.day == now.day) { return '$hour:$minute'; } return '${dt.day}/${dt.month} $hour:$minute'; } catch (_) { return ''; } } @override Widget build(BuildContext context) { final modelsValidated = ref.watch(aiModelSettingsControllerProvider).areModelsValidated; final state = ref.watch(chatControllerProvider); final controller = ref.read(chatControllerProvider.notifier); ref.listen(chatControllerProvider, (prev, next) { if (next.hasValue && (prev?.value?.messages.length ?? 0) < next.value!.messages.length) { _scrollToBottom(); } if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) { _scrollToBottom(); } }); if (!modelsValidated) { return const Scaffold( backgroundColor: AppColors.surface, body: MissingModelsState(), ); } return Scaffold( backgroundColor: AppColors.surface, body: Row( children: [ _buildSidePanel(state, controller), Expanded(child: _buildChatArea(state, controller)), ], ), ); } Widget _buildSidePanel( AsyncValue asyncState, ChatController controller, ) { return Container( width: 250, decoration: const BoxDecoration( color: AppColors.surfaceContainer, border: Border(right: BorderSide(color: AppColors.border, width: 1)), ), child: Column( children: [ Padding( padding: const EdgeInsets.all(UIConstants.spacing12), child: SizedBox( width: double.infinity, child: NewChatButton(onPressed: controller.createSession), ), ), const Divider(height: 1, color: AppColors.border), Expanded( child: asyncState.when( data: (data) { if (data.sessions.isEmpty) { return const Center( child: Padding( padding: EdgeInsets.all(UIConstants.spacing24), child: Text( 'No conversations yet', style: TextStyle( color: AppColors.textMuted, fontSize: 13, ), ), ), ); } return ListView.builder( padding: const EdgeInsets.symmetric( vertical: UIConstants.spacing8, ), itemCount: data.sessions.length, itemBuilder: (context, index) { final session = data.sessions[index]; return _buildSessionTile( session: session, isActive: session.id == data.activeSession?.id, controller: controller, ); }, ); }, error: (_, __) => const Center( child: Text( 'Error loading sessions', style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ), loading: () => const Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.textMuted, ), ), ), ), ), ], ), ); } Widget _buildSessionTile({ required ChatSessionEntity session, required bool isActive, required ChatController controller, }) { final isHovered = _hoveredSessionId == session.id; return MouseRegion( onEnter: (_) => setState(() => _hoveredSessionId = session.id), onExit: (_) => setState(() { if (_hoveredSessionId == session.id) _hoveredSessionId = null; }), child: GestureDetector( onTap: () => controller.loadSession(session.id), child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric( horizontal: UIConstants.spacing8, vertical: 2, ), padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing12, vertical: UIConstants.spacing8, ), decoration: BoxDecoration( color: isActive ? AppColors.zinc700.withValues(alpha: 0.7) : isHovered ? AppColors.zinc800.withValues(alpha: 0.6) : Colors.transparent, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: isActive ? Border.all( color: AppColors.accent.withValues(alpha: 0.3), ) : null, ), child: Row( children: [ Icon( Icons.chat_bubble_outline_rounded, size: 14, color: isActive ? AppColors.accent : AppColors.textMuted, ), const SizedBox(width: UIConstants.spacing8), Expanded( child: Text( session.title ?? 'New Chat', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isActive ? AppColors.textPrimary : AppColors.textSecondary, fontSize: 13, fontWeight: isActive ? FontWeight.w500 : FontWeight.normal, ), ), ), AnimatedOpacity( duration: const Duration(milliseconds: 150), opacity: isHovered ? 1.0 : 0.0, child: IgnorePointer( ignoring: !isHovered, child: SizedBox( width: 24, height: 24, child: IconButton( padding: EdgeInsets.zero, iconSize: 14, splashRadius: 14, icon: const Icon( Icons.delete_outline_rounded, color: AppColors.textMuted, ), onPressed: () => controller.deleteSession(session.id), tooltip: 'Delete', ), ), ), ), ], ), ), ), ); } Widget _buildChatArea( AsyncValue asyncState, ChatController controller, ) { return Column( children: [ Expanded( child: asyncState.when( data: (data) { if (data.messages.isEmpty) return _buildEmptyState(); return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing24, vertical: UIConstants.spacing16, ), itemCount: data.messages.length + (data.isTyping ? 1 : 0), itemBuilder: (context, index) { if (index == data.messages.length) { if (data.thinkingSteps.isNotEmpty || (data.streamingContent != null && data.streamingContent!.isNotEmpty)) { return TypingBubble( thinkingSteps: data.thinkingSteps, streamingContent: data.streamingContent, ); } return const TypingIndicator(); } final msg = data.messages[index]; return MessageBubble( message: msg, formattedTime: _formatTimestamp(msg.createdAt), ); }, ); }, error: (e, _) => const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.error_outline_rounded, color: AppColors.destructive, size: 40, ), SizedBox(height: UIConstants.spacing12), Text( 'Something went wrong', style: TextStyle( color: AppColors.textSecondary, fontSize: 14, ), ), ], ), ), loading: () => const Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: AppColors.textMuted, ), ), ), ), ), _buildInputBar(asyncState, controller), ], ); } Widget _buildEmptyState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 64, height: 64, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(16), ), child: const Icon( Icons.auto_awesome_outlined, size: 32, color: AppColors.textMuted, ), ), const SizedBox(height: UIConstants.spacing16), const Text( 'Ask me anything about your training!', style: TextStyle( color: AppColors.textSecondary, fontSize: 15, fontWeight: FontWeight.w500, ), ), const SizedBox(height: UIConstants.spacing8), const Text( 'Start a conversation to get personalized advice.', style: TextStyle(color: AppColors.textMuted, fontSize: 13), ), ], ), ); } Widget _buildInputBar( AsyncValue asyncState, ChatController controller, ) { final aiManager = ref.watch(aiProcessManagerProvider); final isTyping = asyncState.valueOrNull?.isTyping ?? false; final isStarting = aiManager.status == AiServerStatus.starting; final isError = aiManager.status == AiServerStatus.error; final isReady = aiManager.status == AiServerStatus.ready; String? statusMessage; Color statusColor = AppColors.textMuted; if (isStarting) { statusMessage = 'Starting AI inference server (this may take a moment)...'; statusColor = AppColors.info; } else if (isError) { statusMessage = 'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}'; statusColor = AppColors.destructive; } else if (!isReady) { statusMessage = 'AI Server offline. Reconnecting...'; } return Container( decoration: const BoxDecoration( color: AppColors.surface, border: Border(top: BorderSide(color: AppColors.border, width: 1)), ), padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), child: Column( children: [ if (statusMessage != null) Padding( padding: const EdgeInsets.only(bottom: UIConstants.spacing8), child: Row( children: [ if (isStarting) Container( margin: const EdgeInsets.only(right: 8), width: 12, height: 12, child: const CircularProgressIndicator( strokeWidth: 2, color: AppColors.info, ), ) else if (isError) const Padding( padding: EdgeInsets.only(right: 8), child: Icon( Icons.error_outline, size: 14, color: AppColors.destructive, ), ), Expanded( child: Text( statusMessage, style: GoogleFonts.inter( fontSize: 12, color: statusColor, fontWeight: FontWeight.w500, ), ), ), if (isError) TextButton( style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: () => aiManager.startServers(), child: Text( 'Retry', style: GoogleFonts.inter( fontSize: 12, color: AppColors.accent, ), ), ), ], ), ), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Container( decoration: BoxDecoration( color: AppColors.surfaceContainer, borderRadius: BorderRadius.circular(UIConstants.borderRadius), border: Border.all(color: AppColors.border, width: 1), ), child: TextField( controller: _inputController, focusNode: _inputFocusNode, enabled: isReady, style: TextStyle( color: isReady ? AppColors.textPrimary : AppColors.textMuted, fontSize: 14, ), maxLines: 4, minLines: 1, decoration: InputDecoration( hintText: isReady ? 'Type a message...' : 'Waiting for AI...', hintStyle: const TextStyle( color: AppColors.textMuted, fontSize: 14, ), contentPadding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, ), onSubmitted: isReady ? (_) => _sendMessage(controller) : null, textInputAction: TextInputAction.send, ), ), ), const SizedBox(width: UIConstants.spacing8), SizedBox( width: 40, height: 40, child: Material( color: (isTyping || !isReady) ? AppColors.zinc700 : AppColors.accent, borderRadius: BorderRadius.circular(UIConstants.borderRadius), child: InkWell( borderRadius: BorderRadius.circular(UIConstants.borderRadius), onTap: (isTyping || !isReady) ? null : () => _sendMessage(controller), child: Icon( Icons.arrow_upward_rounded, color: (isTyping || !isReady) ? AppColors.textMuted : AppColors.zinc950, size: 20, ), ), ), ), ], ), ], ), ); } }