596 lines
18 KiB
Dart
596 lines
18 KiB
Dart
import 'dart:math';
|
|
import 'dart:ui';
|
|
|
|
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/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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
data: (state) {
|
|
final controller = ref.read(
|
|
workoutSessionControllerProvider(planId).notifier,
|
|
);
|
|
|
|
if (state.isFinished) {
|
|
return _CompletionScreen(
|
|
totalTimeElapsed: state.totalTimeElapsed,
|
|
);
|
|
}
|
|
|
|
final isRest = state.currentActivity?.isRest ?? false;
|
|
|
|
return _ActiveSessionView(
|
|
state: state,
|
|
isRest: isRest,
|
|
controller: controller,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Active session view (gradient background + timer + controls)
|
|
// ---------------------------------------------------------------------------
|
|
class _ActiveSessionView extends StatelessWidget {
|
|
final WorkoutSessionState state;
|
|
final bool isRest;
|
|
final WorkoutSessionController controller;
|
|
|
|
const _ActiveSessionView({
|
|
required this.state,
|
|
required this.isRest,
|
|
required this.controller,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Compute the time progress for the circular ring.
|
|
final activity = state.currentActivity;
|
|
final double timeProgress;
|
|
if (activity != null && activity.duration > 0) {
|
|
timeProgress = 1.0 - (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 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: [
|
|
// -- Top progress bar --
|
|
SessionProgressBar(progress: state.progress),
|
|
|
|
// -- Elapsed time badge --
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
top: UIConstants.spacing16,
|
|
right: UIConstants.spacing24,
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: 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(state.totalTimeElapsed),
|
|
style: const TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// -- Central content --
|
|
Expanded(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: UIConstants.spacing24,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Activity info card
|
|
if (activity != null)
|
|
ActivityCard(activity: activity),
|
|
|
|
const SizedBox(height: UIConstants.spacing32),
|
|
|
|
// Circular progress ring + timer
|
|
_CircularTimerDisplay(
|
|
timeRemaining: state.timeRemaining,
|
|
progress: timeProgress,
|
|
ringColor: ringColor,
|
|
isRunning: state.isRunning,
|
|
isTimeBased: activity?.isTimeBased ?? false,
|
|
),
|
|
|
|
const SizedBox(height: UIConstants.spacing24),
|
|
|
|
// "Up next" pill
|
|
if (state.nextActivity != null)
|
|
_UpNextPill(
|
|
nextActivityName: state.nextActivity!.name,
|
|
isNextRest: state.nextActivity!.isRest,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// -- Bottom controls --
|
|
SessionControls(
|
|
isRunning: state.isRunning,
|
|
isFinished: state.isFinished,
|
|
onPause: controller.pauseTimer,
|
|
onPlay: controller.startTimer,
|
|
onNext: controller.next,
|
|
onPrevious: controller.previous,
|
|
),
|
|
|
|
const SizedBox(height: UIConstants.spacing24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(int seconds) {
|
|
final m = seconds ~/ 60;
|
|
final s = seconds % 60;
|
|
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Circular timer with arc progress ring
|
|
// ---------------------------------------------------------------------------
|
|
class _CircularTimerDisplay extends StatelessWidget {
|
|
final int timeRemaining;
|
|
final double progress;
|
|
final Color ringColor;
|
|
final bool isRunning;
|
|
final bool isTimeBased;
|
|
|
|
const _CircularTimerDisplay({
|
|
required this.timeRemaining,
|
|
required this.progress,
|
|
required this.ringColor,
|
|
required this.isRunning,
|
|
required this.isTimeBased,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const double size = 220;
|
|
const double strokeWidth = 6.0;
|
|
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Background track
|
|
SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: CircularProgressIndicator(
|
|
value: 1.0,
|
|
strokeWidth: strokeWidth,
|
|
color: AppColors.zinc800.withValues(alpha: 0.5),
|
|
strokeCap: StrokeCap.round,
|
|
),
|
|
),
|
|
|
|
// Progress arc
|
|
if (isTimeBased)
|
|
SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: CircularProgressIndicator(
|
|
value: progress.clamp(0.0, 1.0),
|
|
strokeWidth: strokeWidth,
|
|
color: ringColor,
|
|
backgroundColor: Colors.transparent,
|
|
strokeCap: StrokeCap.round,
|
|
),
|
|
),
|
|
|
|
// Glow behind the timer text
|
|
Container(
|
|
width: size * 0.7,
|
|
height: size * 0.7,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: ringColor.withValues(alpha: 0.08),
|
|
blurRadius: 40,
|
|
spreadRadius: 10,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Timer text
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_formatTime(timeRemaining),
|
|
style: TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: 52,
|
|
fontWeight: FontWeight.w300,
|
|
letterSpacing: 2,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
if (!isTimeBased)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'UNTIMED',
|
|
style: TextStyle(
|
|
color: AppColors.textMuted,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 2,
|
|
),
|
|
),
|
|
),
|
|
if (isTimeBased && !isRunning)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'PAUSED',
|
|
style: TextStyle(
|
|
color: AppColors.textMuted,
|
|
fontSize: 11,
|
|
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')}';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// "Up next" pill
|
|
// ---------------------------------------------------------------------------
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Completion screen
|
|
// ---------------------------------------------------------------------------
|
|
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: [
|
|
// Checkmark circle
|
|
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),
|
|
|
|
// Total time card
|
|
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),
|
|
|
|
// Finish button
|
|
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')}';
|
|
}
|
|
}
|