Initial commit

This commit is contained in:
Kazimierz Ciołek
2026-02-19 02:49:29 +01:00
commit 782986a632
148 changed files with 29230 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
class ActivityCard extends StatelessWidget {
final WorkoutActivityEntity activity;
const ActivityCard({super.key, required this.activity});
@override
Widget build(BuildContext context) {
final isRest = activity.type == 'rest';
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
activity.name,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
if (!isRest) ...[
Text(
"${activity.sectionName} • Set ${activity.setIndex}/${activity.totalSets}",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
const SizedBox(height: 16),
if (activity.originalExercise != null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildInfo(
context,
"Sets",
"${activity.originalExercise!.sets}",
),
const SizedBox(width: 24),
_buildInfo(
context,
activity.originalExercise!.isTime ? "Secs" : "Reps",
"${activity.originalExercise!.value}",
),
],
),
] else
Text(
"Resting...",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
);
}
Widget _buildInfo(BuildContext context, String label, String value) {
return Column(
children: [
Text(value, style: Theme.of(context).textTheme.headlineSmall),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class SessionControls extends StatelessWidget {
final bool isRunning;
final bool isFinished;
final VoidCallback onPause;
final VoidCallback onPlay;
final VoidCallback onNext;
final VoidCallback onPrevious;
const SessionControls({
super.key,
required this.isRunning,
required this.isFinished,
required this.onPause,
required this.onPlay,
required this.onNext,
required this.onPrevious,
});
@override
Widget build(BuildContext context) {
if (isFinished) {
return ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Finish Workout'),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: onPrevious,
icon: const Icon(Icons.skip_previous),
iconSize: 32,
),
const SizedBox(width: 24),
IconButton.filled(
onPressed: isRunning ? onPause : onPlay,
icon: Icon(isRunning ? Icons.pause : Icons.play_arrow),
iconSize: 48,
style: IconButton.styleFrom(padding: const EdgeInsets.all(16)),
),
const SizedBox(width: 24),
IconButton.filledTonal(
onPressed: onNext,
icon: const Icon(Icons.skip_next),
iconSize: 32,
),
],
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
class SessionProgressBar extends StatelessWidget {
final double progress;
const SessionProgressBar({super.key, required this.progress});
@override
Widget build(BuildContext context) {
return LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
);
}
}

View File

@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
part 'workout_session_controller.g.dart';
@riverpod
class WorkoutSessionController extends _$WorkoutSessionController {
Timer? _timer;
@override
Future<WorkoutSessionState> build(String planId) async {
final planRepo = getIt<TrainingPlanRepository>();
final plan = await planRepo.getById(planId);
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);
}
return initialState;
}
List<WorkoutActivityEntity> _buildSequence(TrainingPlanEntity plan) {
final List<WorkoutActivityEntity> seq = [];
for (final section in plan.sections) {
for (final ex in section.exercises) {
for (int s = 1; s <= ex.sets; s++) {
seq.add(
WorkoutActivityEntity(
id: '${ex.instanceId}-s$s-work',
name: ex.name,
type: 'work',
duration: ex.isTime ? ex.value : 0,
originalExercise: ex,
sectionName: section.name,
setIndex: s,
totalSets: ex.sets,
),
);
final bool isLastOfWorkout =
s == ex.sets &&
section.exercises.last == ex &&
plan.sections.last == section;
if (ex.rest > 0 && !isLastOfWorkout) {
seq.add(
WorkoutActivityEntity(
id: '${ex.instanceId}-s$s-rest',
name: 'Rest',
type: 'rest',
duration: ex.rest,
sectionName: section.name,
),
);
}
}
}
}
return seq;
}
void startTimer() {
if (_timer != null && _timer!.isActive) return;
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
final currentState = state.value;
if (currentState != null) {
state = AsyncValue.data(currentState.copyWith(isRunning: true));
}
}
void pauseTimer() {
_timer?.cancel();
_timer = null;
final currentState = state.value;
if (currentState != null) {
state = AsyncValue.data(currentState.copyWith(isRunning: false));
}
}
void _tick(Timer timer) {
if (state.value?.isFinished ?? true) return;
var currentState = state.value!;
var newState = currentState.copyWith(
totalTimeElapsed: currentState.totalTimeElapsed + 1,
);
final activity = newState.currentActivity;
if (activity != null && activity.duration > 0 && newState.isRunning) {
if (newState.timeRemaining > 0) {
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
} else {
state = AsyncValue.data(newState); // update interim state before next
_goNext(newState);
return;
}
}
state = AsyncValue.data(newState);
}
void next() {
final currentState = state.value;
if (currentState != null) _goNext(currentState);
}
void _goNext(WorkoutSessionState currentState) {
if (currentState.currentIndex < currentState.activities.length - 1) {
final nextIndex = currentState.currentIndex + 1;
final nextActivity = currentState.activities[nextIndex];
final newState = currentState.copyWith(
currentIndex: nextIndex,
timeRemaining: nextActivity.duration,
);
state = AsyncValue.data(newState);
if (nextActivity.isRest) {
startTimer();
} else {
pauseTimer();
}
} else {
_finish();
}
}
void previous() {
final currentState = state.value;
if (currentState != null && currentState.currentIndex > 0) {
final prevIndex = currentState.currentIndex - 1;
final prevActivity = currentState.activities[prevIndex];
state = AsyncValue.data(
currentState.copyWith(
currentIndex: prevIndex,
timeRemaining: prevActivity.duration,
),
);
pauseTimer();
}
}
void jumpTo(int index) {
final currentState = state.value;
if (currentState != null &&
index >= 0 &&
index < currentState.activities.length) {
final activity = currentState.activities[index];
state = AsyncValue.data(
currentState.copyWith(
currentIndex: index,
timeRemaining: activity.duration,
),
);
if (activity.isRest) {
startTimer();
} else {
pauseTimer();
}
}
}
void _finish() {
pauseTimer();
final currentState = state.value;
if (currentState != null) {
state = AsyncValue.data(currentState.copyWith(isFinished: true));
}
}
}

View File

@@ -0,0 +1,179 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'workout_session_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$workoutSessionControllerHash() =>
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$WorkoutSessionController
extends BuildlessAutoDisposeAsyncNotifier<WorkoutSessionState> {
late final String planId;
FutureOr<WorkoutSessionState> build(String planId);
}
/// See also [WorkoutSessionController].
@ProviderFor(WorkoutSessionController)
const workoutSessionControllerProvider = WorkoutSessionControllerFamily();
/// See also [WorkoutSessionController].
class WorkoutSessionControllerFamily
extends Family<AsyncValue<WorkoutSessionState>> {
/// See also [WorkoutSessionController].
const WorkoutSessionControllerFamily();
/// See also [WorkoutSessionController].
WorkoutSessionControllerProvider call(String planId) {
return WorkoutSessionControllerProvider(planId);
}
@override
WorkoutSessionControllerProvider getProviderOverride(
covariant WorkoutSessionControllerProvider provider,
) {
return call(provider.planId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'workoutSessionControllerProvider';
}
/// See also [WorkoutSessionController].
class WorkoutSessionControllerProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
WorkoutSessionController,
WorkoutSessionState
> {
/// See also [WorkoutSessionController].
WorkoutSessionControllerProvider(String planId)
: this._internal(
() => WorkoutSessionController()..planId = planId,
from: workoutSessionControllerProvider,
name: r'workoutSessionControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$workoutSessionControllerHash,
dependencies: WorkoutSessionControllerFamily._dependencies,
allTransitiveDependencies:
WorkoutSessionControllerFamily._allTransitiveDependencies,
planId: planId,
);
WorkoutSessionControllerProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.planId,
}) : super.internal();
final String planId;
@override
FutureOr<WorkoutSessionState> runNotifierBuild(
covariant WorkoutSessionController notifier,
) {
return notifier.build(planId);
}
@override
Override overrideWith(WorkoutSessionController Function() create) {
return ProviderOverride(
origin: this,
override: WorkoutSessionControllerProvider._internal(
() => create()..planId = planId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
planId: planId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
WorkoutSessionController,
WorkoutSessionState
>
createElement() {
return _WorkoutSessionControllerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WorkoutSessionControllerProvider && other.planId == planId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, planId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WorkoutSessionControllerRef
on AutoDisposeAsyncNotifierProviderRef<WorkoutSessionState> {
/// The parameter `planId` of this provider.
String get planId;
}
class _WorkoutSessionControllerProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
WorkoutSessionController,
WorkoutSessionState
>
with WorkoutSessionControllerRef {
_WorkoutSessionControllerProviderElement(super.provider);
@override
String get planId => (origin as WorkoutSessionControllerProvider).planId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View 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')}';
}
}

View File

@@ -0,0 +1,29 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
part 'workout_session_state.freezed.dart';
@freezed
class WorkoutSessionState with _$WorkoutSessionState {
const factory WorkoutSessionState({
required List<WorkoutActivityEntity> activities,
@Default(0) int currentIndex,
@Default(0) int timeRemaining,
@Default(0) int totalTimeElapsed,
@Default(false) bool isRunning,
@Default(false) bool isFinished,
}) = _WorkoutSessionState;
const WorkoutSessionState._();
WorkoutActivityEntity? get currentActivity =>
currentIndex < activities.length ? activities[currentIndex] : null;
WorkoutActivityEntity? get nextActivity =>
currentIndex + 1 < activities.length
? activities[currentIndex + 1]
: null;
double get progress =>
activities.isEmpty ? 0.0 : currentIndex / activities.length;
}

View File

@@ -0,0 +1,293 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'workout_session_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$WorkoutSessionState {
List<WorkoutActivityEntity> get activities =>
throw _privateConstructorUsedError;
int get currentIndex => throw _privateConstructorUsedError;
int get timeRemaining => throw _privateConstructorUsedError;
int get totalTimeElapsed => throw _privateConstructorUsedError;
bool get isRunning => throw _privateConstructorUsedError;
bool get isFinished => throw _privateConstructorUsedError;
/// Create a copy of WorkoutSessionState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$WorkoutSessionStateCopyWith<WorkoutSessionState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $WorkoutSessionStateCopyWith<$Res> {
factory $WorkoutSessionStateCopyWith(
WorkoutSessionState value,
$Res Function(WorkoutSessionState) then,
) = _$WorkoutSessionStateCopyWithImpl<$Res, WorkoutSessionState>;
@useResult
$Res call({
List<WorkoutActivityEntity> activities,
int currentIndex,
int timeRemaining,
int totalTimeElapsed,
bool isRunning,
bool isFinished,
});
}
/// @nodoc
class _$WorkoutSessionStateCopyWithImpl<$Res, $Val extends WorkoutSessionState>
implements $WorkoutSessionStateCopyWith<$Res> {
_$WorkoutSessionStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of WorkoutSessionState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? activities = null,
Object? currentIndex = null,
Object? timeRemaining = null,
Object? totalTimeElapsed = null,
Object? isRunning = null,
Object? isFinished = null,
}) {
return _then(
_value.copyWith(
activities: null == activities
? _value.activities
: activities // ignore: cast_nullable_to_non_nullable
as List<WorkoutActivityEntity>,
currentIndex: null == currentIndex
? _value.currentIndex
: currentIndex // ignore: cast_nullable_to_non_nullable
as int,
timeRemaining: null == timeRemaining
? _value.timeRemaining
: timeRemaining // ignore: cast_nullable_to_non_nullable
as int,
totalTimeElapsed: null == totalTimeElapsed
? _value.totalTimeElapsed
: totalTimeElapsed // ignore: cast_nullable_to_non_nullable
as int,
isRunning: null == isRunning
? _value.isRunning
: isRunning // ignore: cast_nullable_to_non_nullable
as bool,
isFinished: null == isFinished
? _value.isFinished
: isFinished // ignore: cast_nullable_to_non_nullable
as bool,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$WorkoutSessionStateImplCopyWith<$Res>
implements $WorkoutSessionStateCopyWith<$Res> {
factory _$$WorkoutSessionStateImplCopyWith(
_$WorkoutSessionStateImpl value,
$Res Function(_$WorkoutSessionStateImpl) then,
) = __$$WorkoutSessionStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
List<WorkoutActivityEntity> activities,
int currentIndex,
int timeRemaining,
int totalTimeElapsed,
bool isRunning,
bool isFinished,
});
}
/// @nodoc
class __$$WorkoutSessionStateImplCopyWithImpl<$Res>
extends _$WorkoutSessionStateCopyWithImpl<$Res, _$WorkoutSessionStateImpl>
implements _$$WorkoutSessionStateImplCopyWith<$Res> {
__$$WorkoutSessionStateImplCopyWithImpl(
_$WorkoutSessionStateImpl _value,
$Res Function(_$WorkoutSessionStateImpl) _then,
) : super(_value, _then);
/// Create a copy of WorkoutSessionState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? activities = null,
Object? currentIndex = null,
Object? timeRemaining = null,
Object? totalTimeElapsed = null,
Object? isRunning = null,
Object? isFinished = null,
}) {
return _then(
_$WorkoutSessionStateImpl(
activities: null == activities
? _value._activities
: activities // ignore: cast_nullable_to_non_nullable
as List<WorkoutActivityEntity>,
currentIndex: null == currentIndex
? _value.currentIndex
: currentIndex // ignore: cast_nullable_to_non_nullable
as int,
timeRemaining: null == timeRemaining
? _value.timeRemaining
: timeRemaining // ignore: cast_nullable_to_non_nullable
as int,
totalTimeElapsed: null == totalTimeElapsed
? _value.totalTimeElapsed
: totalTimeElapsed // ignore: cast_nullable_to_non_nullable
as int,
isRunning: null == isRunning
? _value.isRunning
: isRunning // ignore: cast_nullable_to_non_nullable
as bool,
isFinished: null == isFinished
? _value.isFinished
: isFinished // ignore: cast_nullable_to_non_nullable
as bool,
),
);
}
}
/// @nodoc
class _$WorkoutSessionStateImpl extends _WorkoutSessionState {
const _$WorkoutSessionStateImpl({
required final List<WorkoutActivityEntity> activities,
this.currentIndex = 0,
this.timeRemaining = 0,
this.totalTimeElapsed = 0,
this.isRunning = false,
this.isFinished = false,
}) : _activities = activities,
super._();
final List<WorkoutActivityEntity> _activities;
@override
List<WorkoutActivityEntity> get activities {
if (_activities is EqualUnmodifiableListView) return _activities;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_activities);
}
@override
@JsonKey()
final int currentIndex;
@override
@JsonKey()
final int timeRemaining;
@override
@JsonKey()
final int totalTimeElapsed;
@override
@JsonKey()
final bool isRunning;
@override
@JsonKey()
final bool isFinished;
@override
String toString() {
return 'WorkoutSessionState(activities: $activities, currentIndex: $currentIndex, timeRemaining: $timeRemaining, totalTimeElapsed: $totalTimeElapsed, isRunning: $isRunning, isFinished: $isFinished)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$WorkoutSessionStateImpl &&
const DeepCollectionEquality().equals(
other._activities,
_activities,
) &&
(identical(other.currentIndex, currentIndex) ||
other.currentIndex == currentIndex) &&
(identical(other.timeRemaining, timeRemaining) ||
other.timeRemaining == timeRemaining) &&
(identical(other.totalTimeElapsed, totalTimeElapsed) ||
other.totalTimeElapsed == totalTimeElapsed) &&
(identical(other.isRunning, isRunning) ||
other.isRunning == isRunning) &&
(identical(other.isFinished, isFinished) ||
other.isFinished == isFinished));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_activities),
currentIndex,
timeRemaining,
totalTimeElapsed,
isRunning,
isFinished,
);
/// Create a copy of WorkoutSessionState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$WorkoutSessionStateImplCopyWith<_$WorkoutSessionStateImpl> get copyWith =>
__$$WorkoutSessionStateImplCopyWithImpl<_$WorkoutSessionStateImpl>(
this,
_$identity,
);
}
abstract class _WorkoutSessionState extends WorkoutSessionState {
const factory _WorkoutSessionState({
required final List<WorkoutActivityEntity> activities,
final int currentIndex,
final int timeRemaining,
final int totalTimeElapsed,
final bool isRunning,
final bool isFinished,
}) = _$WorkoutSessionStateImpl;
const _WorkoutSessionState._() : super._();
@override
List<WorkoutActivityEntity> get activities;
@override
int get currentIndex;
@override
int get timeRemaining;
@override
int get totalTimeElapsed;
@override
bool get isRunning;
@override
bool get isFinished;
/// Create a copy of WorkoutSessionState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$WorkoutSessionStateImplCopyWith<_$WorkoutSessionStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}