Refactoring
Some checks failed
Build Linux App / build (push) Failing after 1m33s

This commit is contained in:
2026-02-23 10:02:23 -05:00
parent 21f1387fa8
commit 0c9eb8878d
57 changed files with 8179 additions and 1114 deletions

View File

@@ -0,0 +1,535 @@
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<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends ConsumerState<WelcomeScreen> {
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<AiModelSettingsState>(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<Color>(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,
),
),
),
],
),
);
}
}