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

@@ -1,11 +1,15 @@
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/training_plan.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';
@@ -260,24 +264,22 @@ class _ExercisesTab extends StatelessWidget {
title: 'No exercises yet',
subtitle: 'Add exercises to use in your training plans',
)
: GridView.builder(
padding: const EdgeInsets.all(UIConstants.spacing16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3.0,
crossAxisSpacing: UIConstants.spacing12,
mainAxisSpacing: UIConstants.spacing12,
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
itemCount: exercises.length,
itemBuilder: (context, index) {
final exercise = exercises[index];
return _ExerciseCard(
return _ExerciseListItem(
exercise: exercise,
onEdit: () =>
_showExerciseDialog(context, exercise: exercise),
onDelete: () => ref
.read(trainingsControllerProvider.notifier)
.deleteExercise(exercise.id),
onPreview: () => _showExercisePreview(context, exercise),
);
},
),
@@ -311,7 +313,7 @@ class _ExercisesTab extends StatelessWidget {
TextField(
controller: instructionsCtrl,
decoration: const InputDecoration(labelText: 'Instructions'),
maxLines: 2,
maxLines: 3,
),
const SizedBox(height: UIConstants.spacing12),
TextField(
@@ -323,7 +325,9 @@ class _ExercisesTab extends StatelessWidget {
const SizedBox(height: UIConstants.spacing12),
TextField(
controller: videoUrlCtrl,
decoration: const InputDecoration(labelText: 'Video URL'),
decoration: const InputDecoration(
labelText: 'Video path or URL',
),
),
],
),
@@ -335,10 +339,10 @@ class _ExercisesTab extends StatelessWidget {
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onPressed: () async {
if (nameCtrl.text.isEmpty) return;
if (exercise == null) {
ref
await ref
.read(trainingsControllerProvider.notifier)
.addExercise(
name: nameCtrl.text,
@@ -347,7 +351,7 @@ class _ExercisesTab extends StatelessWidget {
videoUrl: videoUrlCtrl.text,
);
} else {
ref
await ref
.read(trainingsControllerProvider.notifier)
.updateExercise(
exercise.copyWith(
@@ -358,7 +362,7 @@ class _ExercisesTab extends StatelessWidget {
),
);
}
Navigator.pop(context);
if (context.mounted) Navigator.pop(context);
},
child: const Text('Save'),
),
@@ -366,43 +370,74 @@ class _ExercisesTab extends StatelessWidget {
),
);
}
void _showExercisePreview(BuildContext context, ExerciseEntity exercise) {
showDialog(
context: context,
builder: (context) => _ExercisePreviewDialog(exercise: exercise),
);
}
}
class _ExerciseCard extends StatefulWidget {
class _ExerciseListItem extends StatefulWidget {
final ExerciseEntity exercise;
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onPreview;
const _ExerciseCard({
const _ExerciseListItem({
required this.exercise,
required this.onEdit,
required this.onDelete,
required this.onPreview,
});
@override
State<_ExerciseCard> createState() => _ExerciseCardState();
State<_ExerciseListItem> createState() => _ExerciseListItemState();
}
class _ExerciseCardState extends State<_ExerciseCard> {
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.onEdit,
onTap: widget.onPreview,
borderRadius: UIConstants.cardBorderRadius,
child: Padding(
padding: const EdgeInsets.all(UIConstants.spacing12),
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,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.exercise.name,
@@ -410,12 +445,10 @@ class _ExerciseCardState extends State<_ExerciseCard> {
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.exercise.instructions != null &&
widget.exercise.instructions!.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
widget.exercise.instructions!,
style: const TextStyle(
@@ -433,7 +466,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
spacing: 4,
children: widget.exercise.tags!
.split(',')
.take(3)
.take(4)
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
@@ -459,20 +492,20 @@ class _ExerciseCardState extends State<_ExerciseCard> {
],
),
),
if (widget.exercise.videoUrl != null &&
widget.exercise.videoUrl!.isNotEmpty)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.videocam,
size: 16,
color: AppColors.info,
),
),
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(
@@ -481,6 +514,7 @@ class _ExerciseCardState extends State<_ExerciseCard> {
color: AppColors.destructive,
),
onPressed: widget.onDelete,
tooltip: 'Delete',
),
],
],
@@ -491,3 +525,384 @@ class _ExerciseCardState extends State<_ExerciseCard> {
);
}
}
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')}';
}
}