This commit is contained in:
@@ -5,19 +5,25 @@ import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
class SessionControls extends StatelessWidget {
|
||||
final bool isRunning;
|
||||
final bool isFinished;
|
||||
final bool isTimeBased;
|
||||
final VoidCallback onPause;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback onRewind;
|
||||
final VoidCallback onFastForward;
|
||||
|
||||
const SessionControls({
|
||||
super.key,
|
||||
required this.isRunning,
|
||||
required this.isFinished,
|
||||
required this.isTimeBased,
|
||||
required this.onPause,
|
||||
required this.onPlay,
|
||||
required this.onNext,
|
||||
required this.onPrevious,
|
||||
required this.onRewind,
|
||||
required this.onFastForward,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -39,13 +45,20 @@ class SessionControls extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isTimeBased) ...[
|
||||
_ControlButton(
|
||||
icon: Icons.replay_10,
|
||||
onTap: onRewind,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
_ControlButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
onTap: onPrevious,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing24),
|
||||
// Play/Pause - larger main button
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
@@ -79,6 +92,14 @@ class SessionControls extends StatelessWidget {
|
||||
onTap: onNext,
|
||||
size: 28,
|
||||
),
|
||||
if (isTimeBased) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
_ControlButton(
|
||||
icon: Icons.forward_10,
|
||||
onTap: onFastForward,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -20,12 +20,20 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
final activities = _buildSequence(plan);
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
|
||||
final initialState = WorkoutSessionState(activities: activities);
|
||||
|
||||
if (activities.isNotEmpty) {
|
||||
final first = activities.first;
|
||||
return initialState.copyWith(timeRemaining: first.duration);
|
||||
if (activities.isEmpty) {
|
||||
return WorkoutSessionState(activities: activities);
|
||||
}
|
||||
|
||||
final first = activities.first;
|
||||
final initialState = WorkoutSessionState(
|
||||
activities: activities,
|
||||
timeRemaining: first.duration,
|
||||
);
|
||||
|
||||
if (first.isTimeBased) {
|
||||
Future.microtask(startTimer);
|
||||
}
|
||||
|
||||
return initialState;
|
||||
}
|
||||
|
||||
@@ -85,6 +93,19 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
}
|
||||
}
|
||||
|
||||
void rewindSeconds(int amount) {
|
||||
final currentState = state.value;
|
||||
if (currentState == null) return;
|
||||
final maxDuration = currentState.currentActivity?.duration ?? 0;
|
||||
final newRemaining = (currentState.timeRemaining + amount).clamp(
|
||||
0,
|
||||
maxDuration,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(timeRemaining: newRemaining),
|
||||
);
|
||||
}
|
||||
|
||||
void _tick(Timer timer) {
|
||||
if (state.value?.isFinished ?? true) return;
|
||||
var currentState = state.value!;
|
||||
@@ -98,7 +119,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
if (newState.timeRemaining > 0) {
|
||||
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
|
||||
} else {
|
||||
state = AsyncValue.data(newState); // update interim state before next
|
||||
state = AsyncValue.data(newState);
|
||||
_goNext(newState);
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +144,7 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
|
||||
state = AsyncValue.data(newState);
|
||||
|
||||
if (nextActivity.isRest) {
|
||||
if (nextActivity.isTimeBased) {
|
||||
startTimer();
|
||||
} else {
|
||||
pauseTimer();
|
||||
@@ -152,23 +173,21 @@ class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
|
||||
void jumpTo(int index) {
|
||||
final currentState = state.value;
|
||||
if (currentState != null &&
|
||||
index >= 0 &&
|
||||
index < currentState.activities.length) {
|
||||
final activity = currentState.activities[index];
|
||||
if (currentState == null) return;
|
||||
if (index < 0 || index >= currentState.activities.length) return;
|
||||
final activity = currentState.activities[index];
|
||||
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(
|
||||
currentIndex: index,
|
||||
timeRemaining: activity.duration,
|
||||
),
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(
|
||||
currentIndex: index,
|
||||
timeRemaining: activity.duration,
|
||||
),
|
||||
);
|
||||
|
||||
if (activity.isRest) {
|
||||
startTimer();
|
||||
} else {
|
||||
pauseTimer();
|
||||
}
|
||||
if (activity.isTimeBased) {
|
||||
startTimer();
|
||||
} else {
|
||||
pauseTimer();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ part of 'workout_session_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$workoutSessionControllerHash() =>
|
||||
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
|
||||
r'ba4c44e3bc2de98cced4eef96f8a337fd1e43665';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
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';
|
||||
@@ -55,12 +54,14 @@ class WorkoutSessionPage extends ConsumerWidget {
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'$err',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -75,11 +76,8 @@ class WorkoutSessionPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final isRest = state.currentActivity?.isRest ?? false;
|
||||
|
||||
return _ActiveSessionView(
|
||||
state: state,
|
||||
isRest: isRest,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
@@ -88,27 +86,32 @@ class WorkoutSessionPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session view (gradient background + timer + controls)
|
||||
// ---------------------------------------------------------------------------
|
||||
class _ActiveSessionView extends StatelessWidget {
|
||||
class _ActiveSessionView extends StatefulWidget {
|
||||
final WorkoutSessionState state;
|
||||
final bool isRest;
|
||||
final WorkoutSessionController controller;
|
||||
|
||||
const _ActiveSessionView({
|
||||
required this.state,
|
||||
required this.isRest,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ActiveSessionView> createState() => _ActiveSessionViewState();
|
||||
}
|
||||
|
||||
class _ActiveSessionViewState extends State<_ActiveSessionView> {
|
||||
bool _showActivitiesList = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Compute the time progress for the circular ring.
|
||||
final activity = state.currentActivity;
|
||||
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 - (state.timeRemaining / activity.duration);
|
||||
timeProgress =
|
||||
1.0 - (widget.state.timeRemaining / activity.duration);
|
||||
} else {
|
||||
timeProgress = 0.0;
|
||||
}
|
||||
@@ -118,123 +121,190 @@ class _ActiveSessionView extends StatelessWidget {
|
||||
: 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,
|
||||
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: 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: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
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',
|
||||
),
|
||||
|
||||
// -- Bottom controls --
|
||||
SessionControls(
|
||||
isRunning: state.isRunning,
|
||||
isFinished: state.isFinished,
|
||||
onPause: controller.pauseTimer,
|
||||
onPlay: controller.startTimer,
|
||||
onNext: controller.next,
|
||||
onPrevious: controller.previous,
|
||||
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',
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -242,28 +312,116 @@ class _ActiveSessionView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circular timer with arc progress ring
|
||||
// ---------------------------------------------------------------------------
|
||||
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;
|
||||
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;
|
||||
const double size = 260;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
@@ -271,49 +429,34 @@ class _CircularTimerDisplay extends StatelessWidget {
|
||||
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,
|
||||
width: size * 0.75,
|
||||
height: size * 0.75,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ringColor.withValues(alpha: 0.08),
|
||||
blurRadius: 40,
|
||||
color: ringColor.withValues(alpha: isRunning ? 0.15 : 0.05),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Timer text
|
||||
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: [
|
||||
@@ -321,39 +464,49 @@ class _CircularTimerDisplay extends StatelessWidget {
|
||||
_formatTime(timeRemaining),
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 52,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 2,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -368,9 +521,82 @@ class _CircularTimerDisplay extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Up next" pill
|
||||
// ---------------------------------------------------------------------------
|
||||
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;
|
||||
@@ -439,9 +665,180 @@ class _UpNextPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion screen
|
||||
// ---------------------------------------------------------------------------
|
||||
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;
|
||||
|
||||
@@ -466,7 +863,6 @@ class _CompletionScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Checkmark circle
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
@@ -491,9 +887,7 @@ class _CompletionScreen extends StatelessWidget {
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
const Text(
|
||||
'Workout Complete',
|
||||
style: TextStyle(
|
||||
@@ -503,9 +897,7 @@ class _CompletionScreen extends StatelessWidget {
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
Text(
|
||||
'Great job! You crushed it.',
|
||||
style: TextStyle(
|
||||
@@ -513,10 +905,7 @@ class _CompletionScreen extends StatelessWidget {
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Total time card
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
@@ -554,10 +943,7 @@ class _CompletionScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Finish button
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 48,
|
||||
|
||||
Reference in New Issue
Block a user