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