Files
trainhub-flutter/lib/presentation/workout_session/workout_session_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

982 lines
32 KiB
Dart

import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_controller.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
import 'package:trainhub_flutter/presentation/workout_session/widgets/activity_card.dart';
import 'package:trainhub_flutter/presentation/workout_session/widgets/session_controls.dart';
import 'package:trainhub_flutter/presentation/workout_session/widgets/session_progress_bar.dart';
@RoutePage()
class WorkoutSessionPage extends ConsumerWidget {
final String planId;
const WorkoutSessionPage({
super.key,
@PathParam('planId') required this.planId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncState = ref.watch(workoutSessionControllerProvider(planId));
return Scaffold(
backgroundColor: AppColors.zinc950,
body: asyncState.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: AppColors.accent,
strokeWidth: 2,
),
),
error: (err, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: AppColors.destructive,
size: 48,
),
const SizedBox(height: UIConstants.spacing16),
Text(
'Failed to load workout',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'$err',
style: TextStyle(color: AppColors.textMuted, fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: UIConstants.spacing16),
OutlinedButton(
onPressed: () => context.router.maybePop(),
child: const Text('Go Back'),
),
],
),
),
data: (state) {
final controller = ref.read(
workoutSessionControllerProvider(planId).notifier,
);
if (state.isFinished) {
return _CompletionScreen(
totalTimeElapsed: state.totalTimeElapsed,
);
}
return _ActiveSessionView(
state: state,
controller: controller,
);
},
),
);
}
}
class _ActiveSessionView extends StatefulWidget {
final WorkoutSessionState state;
final WorkoutSessionController controller;
const _ActiveSessionView({
required this.state,
required this.controller,
});
@override
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
}
class _ActiveSessionViewState extends State<_ActiveSessionView> {
bool _showActivitiesList = false;
@override
Widget build(BuildContext context) {
final activity = widget.state.currentActivity;
final isRest = activity?.isRest ?? false;
final isTimeBased = activity?.isTimeBased ?? false;
final double timeProgress;
if (activity != null && activity.duration > 0) {
timeProgress =
1.0 - (widget.state.timeRemaining / activity.duration);
} else {
timeProgress = 0.0;
}
final accentTint = isRest
? AppColors.info.withValues(alpha: 0.06)
: AppColors.accent.withValues(alpha: 0.06);
final ringColor = isRest ? AppColors.info : AppColors.accent;
return Stack(
children: [
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.zinc950,
accentTint,
AppColors.zinc950,
],
stops: const [0.0, 0.5, 1.0],
),
),
child: Column(
children: [
SessionProgressBar(progress: widget.state.progress),
_buildTopBar(context),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (activity != null)
ActivityCard(activity: activity),
const SizedBox(height: UIConstants.spacing32),
if (isTimeBased)
_CircularTimerDisplay(
timeRemaining: widget.state.timeRemaining,
progress: timeProgress,
ringColor: ringColor,
isRunning: widget.state.isRunning,
)
else
_RepsDisplay(
reps: activity?.originalExercise?.value ?? 0,
),
const SizedBox(height: UIConstants.spacing24),
if (widget.state.nextActivity != null)
_UpNextPill(
nextActivityName: widget.state.nextActivity!.name,
isNextRest: widget.state.nextActivity!.isRest,
),
],
),
),
),
),
SessionControls(
isRunning: widget.state.isRunning,
isFinished: widget.state.isFinished,
isTimeBased: isTimeBased,
onPause: widget.controller.pauseTimer,
onPlay: widget.controller.startTimer,
onNext: widget.controller.next,
onPrevious: widget.controller.previous,
onRewind: () => widget.controller.rewindSeconds(10),
onFastForward: () => widget.controller.rewindSeconds(-10),
),
const SizedBox(height: UIConstants.spacing24),
],
),
),
if (_showActivitiesList)
_ActivitiesListPanel(
activities: widget.state.activities,
currentIndex: widget.state.currentIndex,
onJumpTo: (index) {
widget.controller.jumpTo(index);
setState(() => _showActivitiesList = false);
},
onClose: () => setState(() => _showActivitiesList = false),
),
],
);
}
Widget _buildTopBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
child: Row(
children: [
IconButton(
onPressed: () => _confirmExit(context),
icon: const Icon(
Icons.arrow_back_rounded,
color: AppColors.textSecondary,
),
tooltip: 'Exit workout',
),
const Spacer(),
_buildElapsedTimeBadge(),
const SizedBox(width: UIConstants.spacing8),
IconButton(
onPressed: () =>
setState(() => _showActivitiesList = !_showActivitiesList),
icon: Icon(
_showActivitiesList
? Icons.list_rounded
: Icons.format_list_bulleted_rounded,
color: _showActivitiesList
? AppColors.accent
: AppColors.textSecondary,
),
tooltip: 'Exercise list',
),
],
),
);
}
Widget _buildElapsedTimeBadge() {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing12,
vertical: UIConstants.spacing4,
),
decoration: BoxDecoration(
color: AppColors.zinc800.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.border.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer_outlined,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
_formatDuration(widget.state.totalTimeElapsed),
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
);
}
Future<void> _confirmExit(BuildContext context) async {
final shouldExit = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Exit Workout?'),
content: const Text(
'Your progress will not be saved. Are you sure you want to exit?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: AppColors.destructive,
),
child: const Text('Exit'),
),
],
),
);
if (shouldExit == true && context.mounted) {
widget.controller.pauseTimer();
context.router.maybePop();
}
}
String _formatDuration(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}
class _RepsDisplay extends StatelessWidget {
final int reps;
const _RepsDisplay({required this.reps});
@override
Widget build(BuildContext context) {
const double size = 260;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size * 0.75,
height: size * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.15),
blurRadius: 60,
spreadRadius: 10,
),
],
),
),
CustomPaint(
size: const Size(size, size),
painter: TimerRingPainter(
progress: 1.0,
ringColor: AppColors.accent,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$reps',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 72,
fontWeight: FontWeight.w200,
letterSpacing: -2,
shadows: [
Shadow(
color: AppColors.accent.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
),
const Text(
'REPS',
style: TextStyle(
color: AppColors.accent,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 4,
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.zinc800.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.accent.withValues(alpha: 0.2),
),
),
child: const Text(
'TAP NEXT WHEN DONE',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 1.5,
),
),
),
],
),
],
),
);
}
}
class _CircularTimerDisplay extends StatelessWidget {
final int timeRemaining;
final double progress;
final Color ringColor;
final bool isRunning;
const _CircularTimerDisplay({
required this.timeRemaining,
required this.progress,
required this.ringColor,
required this.isRunning,
});
@override
Widget build(BuildContext context) {
const double size = 260;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size * 0.75,
height: size * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: ringColor.withValues(alpha: isRunning ? 0.15 : 0.05),
blurRadius: 60,
spreadRadius: 10,
),
],
),
),
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: progress, end: progress),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutQuart,
builder: (context, value, child) {
return CustomPaint(
size: const Size(size, size),
painter: TimerRingPainter(
progress: value,
ringColor: ringColor,
),
);
},
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(timeRemaining),
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 64,
fontWeight: FontWeight.w200,
letterSpacing: -1,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
shadows: [
Shadow(
color: ringColor.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
),
AnimatedOpacity(
opacity: isRunning ? 0.0 : 1.0,
duration: const Duration(milliseconds: 200),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.zinc800.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.zinc700.withValues(alpha: 0.5),
),
),
child: Text(
'PAUSED',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 2,
),
),
),
),
),
],
),
],
),
);
}
String _formatTime(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}
class TimerRingPainter extends CustomPainter {
final double progress;
final Color ringColor;
TimerRingPainter({
required this.progress,
required this.ringColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2;
const strokeWidth = 10.0;
// Draw background track
final trackPaint = Paint()
..color = AppColors.zinc800.withValues(alpha: 0.4)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
canvas.drawCircle(center, radius, trackPaint);
if (progress <= 0.0) return;
// Draw glowing shadow for the progress
final shadowPaint = Paint()
..color = ringColor.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth * 2
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16);
final sweepAngle = 2 * math.pi * progress;
final startAngle = -math.pi / 2;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
shadowPaint,
);
// Draw progress ring with gradient
final gradient = SweepGradient(
startAngle: 0.0,
endAngle: 2 * math.pi,
colors: [
ringColor.withValues(alpha: 0.5),
ringColor,
ringColor.withValues(alpha: 0.8),
],
transform: const GradientRotation(-math.pi / 2),
);
final progressPaint = Paint()
..shader = gradient.createShader(Rect.fromCircle(center: center, radius: radius))
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(TimerRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.ringColor != ringColor;
}
}
class _UpNextPill extends StatelessWidget {
final String nextActivityName;
final bool isNextRest;
const _UpNextPill({
required this.nextActivityName,
required this.isNextRest,
});
@override
Widget build(BuildContext context) {
final pillColor = isNextRest
? AppColors.info.withValues(alpha: 0.12)
: AppColors.accent.withValues(alpha: 0.12);
final pillBorderColor = isNextRest
? AppColors.info.withValues(alpha: 0.25)
: AppColors.accent.withValues(alpha: 0.25);
final labelColor = isNextRest ? AppColors.info : AppColors.accent;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing8,
),
decoration: BoxDecoration(
color: pillColor,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: pillBorderColor),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'UP NEXT',
style: TextStyle(
color: labelColor,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
const SizedBox(width: UIConstants.spacing8),
Container(
width: 3,
height: 3,
decoration: BoxDecoration(
color: AppColors.textMuted,
shape: BoxShape.circle,
),
),
const SizedBox(width: UIConstants.spacing8),
Flexible(
child: Text(
nextActivityName,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _ActivitiesListPanel extends StatelessWidget {
final List<WorkoutActivityEntity> activities;
final int currentIndex;
final Function(int) onJumpTo;
final VoidCallback onClose;
const _ActivitiesListPanel({
required this.activities,
required this.currentIndex,
required this.onJumpTo,
required this.onClose,
});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: onClose,
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.5),
child: Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {},
child: Container(
width: 320,
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(
left: BorderSide(color: AppColors.border),
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(UIConstants.spacing16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.border),
),
),
child: Row(
children: [
const Text(
'All Exercises',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: onClose,
icon: const Icon(
Icons.close,
color: AppColors.textSecondary,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
final isCurrent = index == currentIndex;
final isRest = activity.isRest;
return InkWell(
onTap: () => onJumpTo(index),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing16,
vertical: UIConstants.spacing12,
),
decoration: BoxDecoration(
color: isCurrent
? AppColors.accent.withValues(alpha: 0.12)
: null,
border: const Border(
bottom: BorderSide(
color: AppColors.border,
width: 0.5,
),
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCurrent
? AppColors.accent
: isRest
? AppColors.info
: AppColors.zinc600,
),
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
activity.name,
style: TextStyle(
color: isCurrent
? AppColors.textPrimary
: AppColors.textSecondary,
fontSize: 13,
fontWeight: isCurrent
? FontWeight.w600
: FontWeight.normal,
),
),
if (!isRest && activity.setIndex != null)
Text(
'Set ${activity.setIndex}/${activity.totalSets} · ${activity.sectionName ?? ''}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
],
),
),
if (activity.isTimeBased)
Text(
_formatDuration(activity.duration),
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
fontFeatures: [
FontFeature.tabularFigures(),
],
),
)
else if (!isRest)
Text(
'${activity.originalExercise?.value ?? 0} reps',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
),
);
},
),
),
],
),
),
),
),
),
),
);
}
String _formatDuration(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}
class _CompletionScreen extends StatelessWidget {
final int totalTimeElapsed;
const _CompletionScreen({required this.totalTimeElapsed});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.zinc950,
AppColors.success.withValues(alpha: 0.06),
AppColors.zinc950,
],
stops: const [0.0, 0.45, 1.0],
),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.success.withValues(alpha: 0.12),
border: Border.all(
color: AppColors.success.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: AppColors.success.withValues(alpha: 0.15),
blurRadius: 40,
spreadRadius: 8,
),
],
),
child: const Icon(
Icons.check_rounded,
color: AppColors.success,
size: 48,
),
),
const SizedBox(height: UIConstants.spacing24),
const Text(
'Workout Complete',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const SizedBox(height: UIConstants.spacing8),
Text(
'Great job! You crushed it.',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 15,
),
),
const SizedBox(height: UIConstants.spacing32),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing16,
),
decoration: BoxDecoration(
color: AppColors.surfaceContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
border: Border.all(
color: AppColors.border.withValues(alpha: 0.5),
),
),
child: Column(
children: [
Text(
'TOTAL TIME',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.5,
),
),
const SizedBox(height: UIConstants.spacing4),
Text(
_formatDuration(totalTimeElapsed),
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 36,
fontWeight: FontWeight.w300,
fontFeatures: const [FontFeature.tabularFigures()],
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: UIConstants.spacing32),
SizedBox(
width: 200,
height: 48,
child: FilledButton(
onPressed: () => context.router.maybePop(),
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: AppColors.zinc950,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: const Text('Finish'),
),
),
],
),
),
);
}
String _formatDuration(int seconds) {
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) {
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
}