Stage 4: guided Player for both Modes and the Session summary

- buildSegments lays a Plan out as holds with Transitions (get ready,
  switch sides, next round, next exercise) or sets with count-up Rests;
  Supersets alternate members and rest per round unless asked otherwise
- SessionRunner drives segments against an injected clock: auto-advance
  with a chime, pause, skip, back, rep sets confirmed with logged reps
  and kg, timed sets started on demand, early end
- Player screen: countdown ring, accent pulse in the last 3 s, Flip
  Animation, next-up cards, Rest Screen, mute toggle, screen kept on
- Sessions table (schema v2 migration) keeps a Plan snapshot and Set
  Logs; nothing is saved when no hold or set was done
- Summary with time, exercises or sets logged, and every Set Log
- Synthesised quiet cue sounds (tools/sounds), status bar per Mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 00:20:16 +01:00
1 parent bbdca95541
commit 971ff64117
28 files changed
+3465 -84

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/zen_style.dart';
import '../../exercises/exercise_library.dart';
import '../../history/session_repository.dart';
import '../../localization/app_localizations.dart';
import '../../modes/mode.dart';
import '../../plans/models/plan.dart';
import '../../plans/plan_labels.dart';
import '../session_runner.dart';
/// Shown when a Session ends: how long it took, what was done and, for
/// Workouts, every logged set.
class SummaryView extends StatelessWidget {
const SummaryView({super.key, required this.sessionId});
static const routeName = '/summary';
final String sessionId;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Scaffold(
body: FutureBuilder(
future: context.read<SessionRepository>().get(sessionId),
builder: (context, snapshot) {
final session = snapshot.data;
if (session == null) return const SizedBox.shrink();
final exercises = {
for (final item in session.items)
...switch (item) {
WorkoutStep(:final exerciseId) || StretchingStep(:final exerciseId) => [exerciseId],
Superset(:final members) => members.map((m) => m.exerciseId),
},
};
return SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 40, 20, 24),
children: [
Icon(
session.complete ? Icons.check_circle_rounded : Icons.flag_rounded,
size: 56,
color: session.complete ? s.accent : s.inkMuted,
),
const SizedBox(height: 16),
Text(session.complete ? l10n.summaryComplete : l10n.summaryIncomplete, style: text.headlineLarge),
const SizedBox(height: 4),
Text(session.planName, style: text.titleMedium?.copyWith(color: s.inkMuted)),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: _Stat(
label: l10n.summaryDuration,
value: l10n.durationMinutes((session.duration.inSeconds / 60).round()),
),
),
const SizedBox(width: 10),
Expanded(
child: session.mode == Mode.workout
? _Stat(label: l10n.summarySets, value: '${session.setLogs.length}')
: _Stat(label: l10n.summaryExercises, value: '${exercises.length}'),
),
],
),
if (session.setLogs.isNotEmpty) ...[
const SizedBox(height: 28),
Text(l10n.summaryLogTitle, style: text.titleMedium),
const SizedBox(height: 10),
for (final group in _byExercise(session.setLogs)) _LogGroup(logs: group),
],
const SizedBox(height: 28),
SizedBox(
height: 52,
child: FilledButton(onPressed: () => Navigator.of(context).pop(), child: Text(l10n.done)),
),
],
),
);
},
),
);
}
/// Consecutive logs of the same Exercise together, as they were done.
static List<List<SetLog>> _byExercise(List<SetLog> logs) {
final groups = <List<SetLog>>[];
for (final log in logs) {
if (groups.isNotEmpty && groups.last.first.exerciseId == log.exerciseId) {
groups.last.add(log);
} else {
groups.add([log]);
}
}
return groups;
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(s.radius * 0.7)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: text.bodySmall?.copyWith(color: s.inkMuted)),
const SizedBox(height: 4),
Text(value, style: text.headlineSmall),
],
),
);
}
}
class _LogGroup extends StatelessWidget {
const _LogGroup({required this.logs});
final List<SetLog> logs;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
final name = context.read<ExerciseLibrary>().byId(logs.first.exerciseId)?.name ?? logs.first.exerciseId;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: text.titleSmall),
for (final log in logs)
Text(
[
l10n.setLogLine(log.set),
if (log.reps != null) l10n.repsCount(log.reps!),
if (log.seconds != null) l10n.secondsShort(log.seconds!),
if (log.weightKg != null) l10n.weightKg(kgNumber(log.weightKg!)),
].join(' · '),
style: text.bodyMedium?.copyWith(color: s.inkMuted),
),
],
),
);
}
}