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/presentation/settings/ai_model_settings_controller.dart'; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; @RoutePage() class WelcomeScreen extends ConsumerStatefulWidget { const WelcomeScreen({super.key}); @override ConsumerState createState() => _WelcomeScreenState(); } class _WelcomeScreenState extends ConsumerState { bool _hasNavigated = false; @override void initState() { super.initState(); // Validate after the first frame so the provider is ready WidgetsBinding.instance.addPostFrameCallback((_) { ref .read(aiModelSettingsControllerProvider.notifier) .validateModels() .then((_) { if (!mounted) return; final validated = ref .read(aiModelSettingsControllerProvider) .areModelsValidated; if (validated) _navigateToApp(); }); }); } void _navigateToApp() { if (_hasNavigated) return; _hasNavigated = true; context.router.replace(const ShellRoute()); } void _skip() => _navigateToApp(); void _startDownload() { ref .read(aiModelSettingsControllerProvider.notifier) .downloadAll(); } @override Widget build(BuildContext context) { final modelState = ref.watch(aiModelSettingsControllerProvider); // Navigate once download completes and models are validated ref.listen(aiModelSettingsControllerProvider, (prev, next) { if (!_hasNavigated && next.areModelsValidated && !next.isDownloading) { _navigateToApp(); } }); return Scaffold( backgroundColor: AppColors.surface, body: Center( child: SizedBox( width: 560, child: AnimatedSwitcher( duration: UIConstants.animationDuration, child: modelState.isDownloading ? _DownloadProgress(modelState: modelState) : _InitialPrompt( onDownload: _startDownload, onSkip: _skip, ), ), ), ), ); } } // ============================================================================= // Initial prompt card — shown when models are missing // ============================================================================= class _InitialPrompt extends StatelessWidget { const _InitialPrompt({ required this.onDownload, required this.onSkip, }); final VoidCallback onDownload; final VoidCallback onSkip; @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Logo + wordmark Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.fitness_center, color: AppColors.accent, size: 24, ), ), const SizedBox(width: UIConstants.spacing12), Text( 'TrainHub', style: GoogleFonts.inter( fontSize: 26, fontWeight: FontWeight.w700, color: AppColors.textPrimary, letterSpacing: -0.5, ), ), ], ), const SizedBox(height: UIConstants.spacing32), // Headline Text( 'AI-powered coaching,\nright on your device.', style: GoogleFonts.inter( fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, height: 1.25, letterSpacing: -0.5, ), ), const SizedBox(height: UIConstants.spacing16), Text( 'TrainHub uses on-device AI models to give you intelligent ' 'training advice, exercise analysis, and personalised chat — ' 'with zero data sent to the cloud.', style: GoogleFonts.inter( fontSize: 14, color: AppColors.textSecondary, height: 1.6, ), ), const SizedBox(height: UIConstants.spacing24), // Feature list const _FeatureRow( icon: Icons.lock_outline_rounded, label: '100 % local — your data never leaves this machine.', ), const SizedBox(height: UIConstants.spacing12), const _FeatureRow( icon: Icons.psychology_outlined, label: 'Qwen 2.5 7B chat model for training advice.', ), const SizedBox(height: UIConstants.spacing12), const _FeatureRow( icon: Icons.search_rounded, label: 'Nomic embedding model for semantic exercise search.', ), const SizedBox(height: UIConstants.spacing32), // Download size notice Container( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all( color: AppColors.accent.withValues(alpha: 0.3), ), ), child: Row( children: [ const Icon( Icons.download_outlined, color: AppColors.accent, size: 18, ), const SizedBox(width: UIConstants.spacing12), Expanded( child: Text( 'The download is ~5 GB and only needs to happen once. ' 'You can skip now and download later from Settings.', style: GoogleFonts.inter( fontSize: 13, color: AppColors.accent, height: 1.5, ), ), ), ], ), ), const SizedBox(height: UIConstants.spacing32), // Action buttons Row( children: [ Expanded( child: _PrimaryButton( label: 'Download Now', icon: Icons.download_rounded, onPressed: onDownload, ), ), const SizedBox(width: UIConstants.spacing12), Expanded( child: _SecondaryButton( label: 'Skip for Now', onPressed: onSkip, ), ), ], ), ], ); } } // ============================================================================= // Download progress card // ============================================================================= class _DownloadProgress extends StatelessWidget { const _DownloadProgress({required this.modelState}); final AiModelSettingsState modelState; @override Widget build(BuildContext context) { final pct = (modelState.progress * 100).toStringAsFixed(1); return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Animated download icon Container( width: 56, height: 56, decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(14), ), child: const Icon( Icons.download_rounded, color: AppColors.accent, size: 28, ), ), const SizedBox(height: UIConstants.spacing24), Text( 'Setting up AI models…', style: GoogleFonts.inter( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, letterSpacing: -0.3, ), ), const SizedBox(height: UIConstants.spacing8), Text( 'Please keep the app open. This only happens once.', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, ), ), const SizedBox(height: UIConstants.spacing32), // Progress bar ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: modelState.progress, minHeight: 6, backgroundColor: AppColors.zinc800, valueColor: const AlwaysStoppedAnimation(AppColors.accent), ), ), const SizedBox(height: UIConstants.spacing12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( modelState.currentTask, style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), Text( '$pct %', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.accent, ), ), ], ), if (modelState.errorMessage != null) ...[ const SizedBox(height: UIConstants.spacing16), _ErrorBanner(message: modelState.errorMessage!), ], ], ); } } // ============================================================================= // Small reusable widgets // ============================================================================= class _FeatureRow extends StatelessWidget { const _FeatureRow({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: AppColors.surfaceContainerHigh, borderRadius: BorderRadius.circular(6), ), child: Icon(icon, size: 14, color: AppColors.accent), ), const SizedBox(width: UIConstants.spacing12), Expanded( child: Text( label, style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, height: 1.5, ), ), ), ], ); } } class _PrimaryButton extends StatefulWidget { const _PrimaryButton({ required this.label, required this.icon, required this.onPressed, }); final String label; final IconData icon; final VoidCallback onPressed; @override State<_PrimaryButton> createState() => _PrimaryButtonState(); } class _PrimaryButtonState extends State<_PrimaryButton> { 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: widget.onPressed, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(widget.icon, color: AppColors.zinc950, size: 16), const SizedBox(width: UIConstants.spacing8), Text( widget.label, style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.zinc950, ), ), ], ), ), ), ), ); } } class _SecondaryButton extends StatefulWidget { const _SecondaryButton({required this.label, required this.onPressed}); final String label; final VoidCallback onPressed; @override State<_SecondaryButton> createState() => _SecondaryButtonState(); } class _SecondaryButtonState extends State<_SecondaryButton> { 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.zinc800 : Colors.transparent, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all(color: AppColors.border), ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), onTap: widget.onPressed, child: Center( child: Text( widget.label, style: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), ), ), ), ), ); } } class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.message}); final String message; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(UIConstants.spacing12), decoration: BoxDecoration( color: AppColors.destructiveMuted, borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius), border: Border.all( color: AppColors.destructive.withValues(alpha: 0.4), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon( Icons.error_outline_rounded, color: AppColors.destructive, size: 16, ), const SizedBox(width: UIConstants.spacing8), Expanded( child: Text( message, style: GoogleFonts.inter( fontSize: 12, color: AppColors.destructive, height: 1.5, ), ), ), ], ), ); } }