982 lines
32 KiB
Dart
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')}';
|
|
}
|
|
}
|