import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:async'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:trainhub_flutter/core/theme/app_colors.dart'; import 'package:trainhub_flutter/core/constants/ui_constants.dart'; import 'package:trainhub_flutter/core/router/app_router.dart'; import 'package:trainhub_flutter/domain/entities/exercise.dart'; import 'package:trainhub_flutter/domain/entities/training_plan.dart'; import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dart'; import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart'; import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart'; import 'package:trainhub_flutter/presentation/common/dialogs/confirm_dialog.dart'; @RoutePage() class TrainingsPage extends ConsumerWidget { const TrainingsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncState = ref.watch(trainingsControllerProvider); return DefaultTabController( length: 2, child: Column( children: [ Container( decoration: BoxDecoration( color: AppColors.surfaceContainer, border: const Border(bottom: BorderSide(color: AppColors.border)), ), child: const TabBar( tabs: [ Tab(text: 'Training Plans'), Tab(text: 'Exercises'), ], ), ), Expanded( child: asyncState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center(child: Text('Error: $err')), data: (state) => TabBarView( children: [ _PlansTab(plans: state.plans, ref: ref), _ExercisesTab(exercises: state.exercises, ref: ref), ], ), ), ), ], ), ); } } class _PlansTab extends StatelessWidget { final List plans; final WidgetRef ref; const _PlansTab({required this.plans, required this.ref}); @override Widget build(BuildContext context) { if (plans.isEmpty) { return AppEmptyState( icon: Icons.fitness_center, title: 'No training plans yet', subtitle: 'Create your first training plan to get started', actionLabel: 'Create Plan', onAction: () => _createPlan(context), ); } return Column( children: [ Padding( padding: const EdgeInsets.all(UIConstants.spacing16), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ FilledButton.icon( onPressed: () => _createPlan(context), icon: const Icon(Icons.add, size: 18), label: const Text('New Plan'), ), ], ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, ), itemCount: plans.length, itemBuilder: (context, index) { final plan = plans[index]; return _PlanListItem( plan: plan, onEdit: () { context.router.push(PlanEditorRoute(planId: plan.id)); }, onStart: () { context.router.push(WorkoutSessionRoute(planId: plan.id)); }, onDelete: () => _deletePlan(context, plan), ); }, ), ), ], ); } Future _createPlan(BuildContext context) async { final name = await TextInputDialog.show( context, title: 'New Plan Name', hintText: 'e.g. Push Pull Legs', ); if (name != null && name.isNotEmpty) { final plan = await ref .read(trainingsControllerProvider.notifier) .createPlan(name); if (context.mounted) { context.router.push(PlanEditorRoute(planId: plan.id)); } } } Future _deletePlan( BuildContext context, TrainingPlanEntity plan, ) async { final confirmed = await ConfirmDialog.show( context, title: 'Delete Plan?', message: 'Are you sure you want to delete "${plan.name}"?', ); if (confirmed == true) { ref.read(trainingsControllerProvider.notifier).deletePlan(plan.id); } } } class _PlanListItem extends StatefulWidget { final TrainingPlanEntity plan; final VoidCallback onEdit; final VoidCallback onStart; final VoidCallback onDelete; const _PlanListItem({ required this.plan, required this.onEdit, required this.onStart, required this.onDelete, }); @override State<_PlanListItem> createState() => _PlanListItemState(); } class _PlanListItemState extends State<_PlanListItem> { bool _isHovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), child: Card( margin: const EdgeInsets.only(bottom: UIConstants.spacing8), child: Padding( padding: const EdgeInsets.all(UIConstants.spacing12), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.description_outlined, color: AppColors.accent, size: 20, ), ), const SizedBox(width: UIConstants.spacing12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.plan.name, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14, ), ), const SizedBox(height: 2), Text( '${widget.plan.sections.length} sections, ${widget.plan.totalExercises} exercises', style: const TextStyle( color: AppColors.textMuted, fontSize: 12, ), ), ], ), ), if (_isHovered) ...[ IconButton( icon: const Icon( Icons.delete_outline, color: AppColors.destructive, size: 18, ), onPressed: widget.onDelete, tooltip: 'Delete', ), const SizedBox(width: 4), ], OutlinedButton( onPressed: widget.onEdit, child: const Text('Edit'), ), const SizedBox(width: UIConstants.spacing8), FilledButton.icon( onPressed: widget.onStart, icon: const Icon(Icons.play_arrow, size: 18), label: const Text('Start'), ), ], ), ), ), ); } } class _ExercisesTab extends StatelessWidget { final List exercises; final WidgetRef ref; const _ExercisesTab({required this.exercises, required this.ref}); @override Widget build(BuildContext context) { return Scaffold( floatingActionButton: FloatingActionButton( onPressed: () => _showExerciseDialog(context), backgroundColor: AppColors.zinc50, foregroundColor: AppColors.zinc950, child: const Icon(Icons.add), ), body: exercises.isEmpty ? const AppEmptyState( icon: Icons.fitness_center, title: 'No exercises yet', subtitle: 'Add exercises to use in your training plans', ) : ListView.builder( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing8, ), itemCount: exercises.length, itemBuilder: (context, index) { final exercise = exercises[index]; return _ExerciseListItem( exercise: exercise, onEdit: () => _showExerciseDialog(context, exercise: exercise), onDelete: () => ref .read(trainingsControllerProvider.notifier) .deleteExercise(exercise.id), onPreview: () => _showExercisePreview(context, exercise), ); }, ), ); } void _showExerciseDialog(BuildContext context, {ExerciseEntity? exercise}) { final nameCtrl = TextEditingController(text: exercise?.name); final instructionsCtrl = TextEditingController( text: exercise?.instructions, ); final tagsCtrl = TextEditingController(text: exercise?.tags); final videoUrlCtrl = TextEditingController(text: exercise?.videoUrl); showDialog( context: context, builder: (context) => AlertDialog( title: Text(exercise == null ? 'New Exercise' : 'Edit Exercise'), content: SizedBox( width: UIConstants.dialogWidth, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameCtrl, decoration: const InputDecoration(labelText: 'Name'), autofocus: true, ), const SizedBox(height: UIConstants.spacing12), TextField( controller: instructionsCtrl, decoration: const InputDecoration(labelText: 'Instructions'), maxLines: 3, ), const SizedBox(height: UIConstants.spacing12), TextField( controller: tagsCtrl, decoration: const InputDecoration( labelText: 'Tags (comma separated)', ), ), const SizedBox(height: UIConstants.spacing12), TextField( controller: videoUrlCtrl, decoration: const InputDecoration( labelText: 'Video path or URL', ), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), FilledButton( onPressed: () async { if (nameCtrl.text.isEmpty) return; if (exercise == null) { await ref .read(trainingsControllerProvider.notifier) .addExercise( name: nameCtrl.text, instructions: instructionsCtrl.text, tags: tagsCtrl.text, videoUrl: videoUrlCtrl.text, ); } else { await ref .read(trainingsControllerProvider.notifier) .updateExercise( exercise.copyWith( name: nameCtrl.text, instructions: instructionsCtrl.text, tags: tagsCtrl.text, videoUrl: videoUrlCtrl.text, ), ); } if (context.mounted) Navigator.pop(context); }, child: const Text('Save'), ), ], ), ); } void _showExercisePreview(BuildContext context, ExerciseEntity exercise) { showDialog( context: context, builder: (context) => _ExercisePreviewDialog(exercise: exercise), ); } } class _ExerciseListItem extends StatefulWidget { final ExerciseEntity exercise; final VoidCallback onEdit; final VoidCallback onDelete; final VoidCallback onPreview; const _ExerciseListItem({ required this.exercise, required this.onEdit, required this.onDelete, required this.onPreview, }); @override State<_ExerciseListItem> createState() => _ExerciseListItemState(); } class _ExerciseListItemState extends State<_ExerciseListItem> { bool _isHovered = false; @override Widget build(BuildContext context) { final hasVideo = widget.exercise.videoUrl != null && widget.exercise.videoUrl!.isNotEmpty; return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), child: Card( margin: const EdgeInsets.only(bottom: UIConstants.spacing8), child: InkWell( onTap: widget.onPreview, borderRadius: UIConstants.cardBorderRadius, child: Padding( padding: const EdgeInsets.symmetric( horizontal: UIConstants.spacing16, vertical: UIConstants.spacing12, ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: hasVideo ? AppColors.info.withValues(alpha: 0.15) : AppColors.zinc800, borderRadius: BorderRadius.circular(8), ), child: Icon( hasVideo ? Icons.videocam : Icons.fitness_center, color: hasVideo ? AppColors.info : AppColors.textMuted, size: 20, ), ), const SizedBox(width: UIConstants.spacing12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.exercise.name, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14, ), ), if (widget.exercise.instructions != null && widget.exercise.instructions!.isNotEmpty) ...[ const SizedBox(height: 2), Text( widget.exercise.instructions!, style: const TextStyle( color: AppColors.textMuted, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], if (widget.exercise.tags != null && widget.exercise.tags!.isNotEmpty) ...[ const SizedBox(height: 6), Wrap( spacing: 4, children: widget.exercise.tags! .split(',') .take(4) .map( (tag) => Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: AppColors.zinc800, borderRadius: BorderRadius.circular(4), ), child: Text( tag.trim(), style: const TextStyle( fontSize: 10, color: AppColors.textSecondary, ), ), ), ) .toList(), ), ], ], ), ), if (_isHovered) ...[ IconButton( icon: const Icon( Icons.open_in_new, size: 16, color: AppColors.textMuted, ), onPressed: widget.onPreview, tooltip: 'Preview', ), IconButton( icon: const Icon(Icons.edit, size: 16), onPressed: widget.onEdit, tooltip: 'Edit', ), IconButton( icon: const Icon( Icons.delete_outline, size: 16, color: AppColors.destructive, ), onPressed: widget.onDelete, tooltip: 'Delete', ), ], ], ), ), ), ), ); } } class _ExercisePreviewDialog extends StatelessWidget { final ExerciseEntity exercise; const _ExercisePreviewDialog({required this.exercise}); @override Widget build(BuildContext context) { final hasVideo = exercise.videoUrl != null && exercise.videoUrl!.isNotEmpty; return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600, maxHeight: 600), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (hasVideo) _ExerciseVideoPreview(videoPath: exercise.videoUrl!), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.all(UIConstants.spacing24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( exercise.name, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, ), ), if (exercise.muscleGroup != null && exercise.muscleGroup!.isNotEmpty) ...[ const SizedBox(height: UIConstants.spacing8), Row( children: [ const Icon( Icons.accessibility_new, size: 14, color: AppColors.textMuted, ), const SizedBox(width: 4), Text( exercise.muscleGroup!, style: const TextStyle( color: AppColors.textMuted, fontSize: 13, ), ), ], ), ], if (exercise.tags != null && exercise.tags!.isNotEmpty) ...[ const SizedBox(height: UIConstants.spacing12), Wrap( spacing: 6, runSpacing: 6, children: exercise.tags! .split(',') .map( (tag) => Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: AppColors.accentMuted, borderRadius: BorderRadius.circular(12), ), child: Text( tag.trim(), style: const TextStyle( fontSize: 11, color: AppColors.accent, ), ), ), ) .toList(), ), ], if (exercise.instructions != null && exercise.instructions!.isNotEmpty) ...[ const SizedBox(height: UIConstants.spacing16), const Text( 'Instructions', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), const SizedBox(height: UIConstants.spacing8), Text( exercise.instructions!, style: const TextStyle( fontSize: 14, color: AppColors.textPrimary, height: 1.5, ), ), ], if (exercise.enrichment != null && exercise.enrichment!.isNotEmpty) ...[ const SizedBox(height: UIConstants.spacing16), const Text( 'Notes', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), const SizedBox(height: UIConstants.spacing8), Text( exercise.enrichment!, style: const TextStyle( fontSize: 14, color: AppColors.textPrimary, height: 1.5, ), ), ], ], ), ), ), Padding( padding: const EdgeInsets.all(UIConstants.spacing16), child: Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => Navigator.pop(context), child: const Text('Close'), ), ), ), ], ), ), ); } } class _ExerciseVideoPreview extends StatefulWidget { final String videoPath; const _ExerciseVideoPreview({required this.videoPath}); @override State<_ExerciseVideoPreview> createState() => _ExerciseVideoPreviewState(); } class _ExerciseVideoPreviewState extends State<_ExerciseVideoPreview> { late final Player _player; late final VideoController _videoController; bool _isInitialized = false; String? _error; bool _isPlaying = false; // Clip boundaries parsed from the '#t=start,end' fragment. double _clipStart = 0.0; double _clipEnd = double.infinity; // infinity means play to end of file double _position = 0.0; StreamSubscription? _positionSub; StreamSubscription? _durationSub; StreamSubscription? _playingSub; StreamSubscription? _errorSub; bool _initialSeekDone = false; @override void initState() { super.initState(); _player = Player(); _videoController = VideoController( _player, configuration: const VideoControllerConfiguration( enableHardwareAcceleration: false, ), ); _parseClipTimes(); _setupListeners(); _initialize(); } void _parseClipTimes() { final parts = widget.videoPath.split('#'); if (parts.length > 1 && parts[1].startsWith('t=')) { final times = parts[1].substring(2).split(','); _clipStart = double.tryParse(times[0]) ?? 0.0; if (times.length > 1) { _clipEnd = double.tryParse(times[1]) ?? double.infinity; } } } void _setupListeners() { _errorSub = _player.stream.error.listen((error) { if (mounted) setState(() => _error = error); }); // Wait for the file to load (duration > 0), seek to clip start, then // mark as initialized. Doing it in one chain prevents the Video widget // from rendering frame 0 before the seek completes. _durationSub = _player.stream.duration.listen((duration) { if (!_initialSeekDone && duration > Duration.zero) { _initialSeekDone = true; if (_clipStart > 0) { _player .seek(Duration(milliseconds: (_clipStart * 1000).round())) .then((_) { if (mounted) setState(() => _isInitialized = true); }); } else { if (mounted) setState(() => _isInitialized = true); } } }); _positionSub = _player.stream.position.listen((pos) { final secs = pos.inMilliseconds / 1000.0; if (_clipEnd != double.infinity && secs >= _clipEnd) { // Loop: seek back to clip start without pausing. _player.seek(Duration(milliseconds: (_clipStart * 1000).round())); } else if (mounted) { setState(() => _position = secs); } }); _playingSub = _player.stream.playing.listen((playing) { if (mounted) setState(() => _isPlaying = playing); }); } Future _initialize() async { try { final rawPath = widget.videoPath.split('#').first; await _player.open(Media(rawPath), play: false); // _isInitialized is set in _durationSub after the seek to _clipStart // completes, so the Video widget never renders frame 0. } catch (e) { if (mounted) setState(() => _error = e.toString()); } } @override void dispose() { _positionSub?.cancel(); _durationSub?.cancel(); _playingSub?.cancel(); _errorSub?.cancel(); _player.dispose(); super.dispose(); } void _togglePlay() { if (_isPlaying) { _player.pause(); } else { if (_clipEnd != double.infinity && _position >= _clipEnd - 0.1) { _player.seek(Duration(milliseconds: (_clipStart * 1000).round())); } _player.play(); } } @override Widget build(BuildContext context) { if (_error != null) { return Container( height: 180, color: Colors.black, child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.videocam_off, color: Colors.grey, size: 32), SizedBox(height: 8), Text( 'Unable to load video', style: TextStyle(color: Colors.grey, fontSize: 12), ), ], ), ), ); } if (!_isInitialized) { return const SizedBox( height: 180, child: Center(child: CircularProgressIndicator()), ); } final hasClip = _clipEnd != double.infinity; final clipDuration = hasClip ? (_clipEnd - _clipStart) : 0.0; final clipPosition = (_position - _clipStart).clamp(0.0, clipDuration); final progress = (hasClip && clipDuration > 0) ? clipPosition / clipDuration : 0.0; return Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 220, child: Stack( children: [ Video( controller: _videoController, controls: NoVideoControls, fit: BoxFit.contain, ), Positioned( bottom: 0, left: 0, right: 0, child: Container( color: Colors.black54, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), child: Row( children: [ GestureDetector( onTap: _togglePlay, child: Icon( _isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 20, ), ), const SizedBox(width: 8), Expanded( child: LinearProgressIndicator( value: progress.clamp(0.0, 1.0), backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation( Colors.white, ), ), ), if (hasClip) ...[ const SizedBox(width: 8), Text( '${_fmt(clipPosition)} / ${_fmt(clipDuration)}', style: const TextStyle( color: Colors.white, fontSize: 11, ), ), ], ], ), ), ), ], ), ), if (hasClip) Padding( padding: const EdgeInsets.only(top: 4), child: Text( 'Clip ${_fmt(_clipStart)} — ${_fmt(_clipEnd)}', style: const TextStyle(color: Colors.grey, fontSize: 11), ), ), ], ); } String _fmt(double seconds) { final m = seconds ~/ 60; final s = (seconds % 60).toInt(); return '$m:${s.toString().padLeft(2, '0')}'; } }