import 'dart:math' as math; 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/router/app_router.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/domain/entities/chat_message.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/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.isNotEmpty) { 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) { // ── Gate: check whether AI models are present on disk ───────────────── final modelsValidated = ref .watch(aiModelSettingsControllerProvider) .areModelsValidated; // Watch chat state regardless of gate so Riverpod keeps the provider // alive and the scroll listener is always registered. 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(); } }); // ── Show "models missing" placeholder ───────────────────────────────── if (!modelsValidated) { return const Scaffold( backgroundColor: AppColors.surface, body: _MissingModelsState(), ); } // ── Normal chat UI ───────────────────────────────────────────────────── return Scaffold( backgroundColor: AppColors.surface, body: Row( children: [ _buildSidePanel(state, controller), Expanded(child: _buildChatArea(state, controller)), ], ), ); } // --------------------------------------------------------------------------- // Side Panel // --------------------------------------------------------------------------- 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 Center( child: Padding( padding: const 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]; final isActive = session.id == data.activeSession?.id; return _buildSessionTile( session: session, isActive: isActive, controller: controller, ); }, ); }, error: (_, __) => 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), width: 1, ) : 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', ), ), ), ), ], ), ), ), ); } // --------------------------------------------------------------------------- // Chat Area // --------------------------------------------------------------------------- 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) { return const _TypingIndicator(); } final msg = data.messages[index]; return _MessageBubble( message: msg, formattedTime: _formatTimestamp(msg.createdAt), ); }, ); }, error: (e, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.error_outline_rounded, color: AppColors.destructive, size: 40, ), const 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), ), ], ), ); } // --------------------------------------------------------------------------- // Input Bar // --------------------------------------------------------------------------- Widget _buildInputBar( AsyncValue asyncState, ChatController controller, ) { final isTyping = asyncState.valueOrNull?.isTyping ?? false; 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: 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, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, ), maxLines: 4, minLines: 1, decoration: InputDecoration( hintText: 'Type a message...', hintStyle: 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, ), onSubmitted: (_) => _sendMessage(controller), textInputAction: TextInputAction.send, ), ), ), const SizedBox(width: UIConstants.spacing8), SizedBox( width: 40, height: 40, child: Material( color: isTyping ? AppColors.zinc700 : AppColors.accent, borderRadius: BorderRadius.circular(UIConstants.borderRadius), child: InkWell( borderRadius: BorderRadius.circular(UIConstants.borderRadius), onTap: isTyping ? null : () => _sendMessage(controller), child: Icon( Icons.arrow_upward_rounded, color: isTyping ? AppColors.textMuted : AppColors.zinc950, size: 20, ), ), ), ), ], ), ); } } // ============================================================================= // Missing Models State — shown when AI files have not been downloaded yet // ============================================================================= class _MissingModelsState extends StatelessWidget { const _MissingModelsState(); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Icon Container( width: 72, height: 72, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(18), ), child: const Icon( Icons.cloud_download_outlined, size: 36, color: AppColors.textMuted, ), ), const SizedBox(height: UIConstants.spacing24), Text( 'AI models are missing.', style: GoogleFonts.inter( fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, letterSpacing: -0.2, ), ), const SizedBox(height: UIConstants.spacing8), Text( 'Please download them to use the AI chat.', style: GoogleFonts.inter( fontSize: 14, color: AppColors.textSecondary, ), ), const SizedBox(height: UIConstants.spacing32), // "Go to Settings" button _GoToSettingsButton(), ], ), ); } } class _GoToSettingsButton extends StatefulWidget { @override State<_GoToSettingsButton> createState() => _GoToSettingsButtonState(); } class _GoToSettingsButtonState extends State<_GoToSettingsButton> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: UIConstants.animationDuration, height: 44, decoration: BoxDecoration( color: _hovered ? AppColors.accent.withValues(alpha: 0.85) : AppColors.accent, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: () => context.router.push(const SettingsRoute()), child: Padding( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing24, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.settings_outlined, size: 16, color: AppColors.zinc950, ), const SizedBox(width: UIConstants.spacing8), Text( 'Go to Settings', style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.zinc950, ), ), ], ), ), ), ), ), ); } } // ============================================================================= // New Chat Button // ============================================================================= class _NewChatButton extends StatefulWidget { const _NewChatButton({required this.onPressed}); final VoidCallback onPressed; @override State<_NewChatButton> createState() => _NewChatButtonState(); } class _NewChatButtonState extends State<_NewChatButton> { bool _isHovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: _isHovered ? AppColors.zinc700 : AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all(color: AppColors.border, width: 1), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing12, vertical: UIConstants.spacing8, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon( Icons.add_rounded, size: 16, color: AppColors.textSecondary, ), SizedBox(width: UIConstants.spacing8), Text( 'New Chat', style: TextStyle( color: AppColors.textSecondary, fontSize: 13, fontWeight: FontWeight.w500, ), ), ], ), ), ), ), ), ); } } // ============================================================================= // Message Bubble // ============================================================================= class _MessageBubble extends StatelessWidget { const _MessageBubble({ required this.message, required this.formattedTime, }); final ChatMessageEntity message; final String formattedTime; @override Widget build(BuildContext context) { final isUser = message.isUser; final maxWidth = MediaQuery.of(context).size.width * 0.55; return Padding( padding: const EdgeInsets.only(bottom: UIConstants.spacing12), child: Row( mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) ...[ Container( width: 28, height: 28, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.auto_awesome_rounded, size: 14, color: AppColors.accent, ), ), const SizedBox(width: UIConstants.spacing8), ], Flexible( child: Column( crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( constraints: BoxConstraints(maxWidth: maxWidth), padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), decoration: BoxDecoration( color: isUser ? AppColors.zinc700 : AppColors.surfaceContainer, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: isUser ? const Radius.circular(16) : const Radius.circular(4), bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(16), ), border: isUser ? null : Border.all(color: AppColors.border, width: 1), ), child: SelectableText( message.content, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14, height: 1.5, ), ), ), const SizedBox(height: 4), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( formattedTime, style: const TextStyle( color: AppColors.textMuted, fontSize: 11, ), ), ), ], ), ), if (isUser) ...[ const SizedBox(width: UIConstants.spacing8), Container( width: 28, height: 28, decoration: BoxDecoration( color: AppColors.accent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.person_rounded, size: 14, color: AppColors.accent, ), ), ], ], ), ); } } // ============================================================================= // Typing Indicator (3 animated bouncing dots) // ============================================================================= class _TypingIndicator extends StatefulWidget { const _TypingIndicator(); @override State<_TypingIndicator> createState() => _TypingIndicatorState(); } class _TypingIndicatorState extends State<_TypingIndicator> with SingleTickerProviderStateMixin { late final 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 Padding( padding: const EdgeInsets.only(bottom: UIConstants.spacing12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.auto_awesome_rounded, size: 14, color: AppColors.accent, ), ), const SizedBox(width: UIConstants.spacing8), Container( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), decoration: BoxDecoration( color: AppColors.surfaceContainer, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), border: Border.all(color: AppColors.border, width: 1), ), child: AnimatedBuilder( animation: _controller, builder: (context, _) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (index) { final delay = index * 0.2; final t = (_controller.value - delay) % 1.0; final bounce = t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0; return Padding( padding: EdgeInsets.only(left: index == 0 ? 0 : 4), child: Transform.translate( offset: Offset(0, -bounce.abs()), child: Container( width: 7, height: 7, decoration: BoxDecoration( color: AppColors.textMuted, shape: BoxShape.circle, ), ), ), ); }), ); }, ), ), ], ), ); } }