Initial commit
This commit is contained in:
595
lib/presentation/workout_session/workout_session_page.dart
Normal file
595
lib/presentation/workout_session/workout_session_page.dart
Normal file
@@ -0,0 +1,595 @@
|
||||
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')}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user