Files
trainhub-flutter/lib/presentation/trainings/trainings_page.dart
Kazimierz Ciołek 0c9eb8878d
Some checks failed
Build Linux App / build (push) Failing after 1m33s
Refactoring
2026-02-23 10:02:23 -05:00

909 lines
30 KiB
Dart

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<TrainingPlanEntity> 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<void> _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<void> _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<ExerciseEntity> 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<Duration>? _positionSub;
StreamSubscription<Duration>? _durationSub;
StreamSubscription<bool>? _playingSub;
StreamSubscription<String>? _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<void> _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')}';
}
}