Files
ZenXercise/lib/src/player/views/summary_view.dart
T
CedricandClaude Opus 5.5 1ffe6269ea Stage 5: History, the shared daily Streak and Reminders
- Streak computed from all complete Sessions: Grace Hour (00:00-01:00
  counts for yesterday unless it is done), a Freeze per 7 Streak Days
  (max 2) spent automatically on a missed day (ADR 0006)
- Top bar flame (grey until today is done) opening a panel with current
  and longest Streak, Freezes and a month calendar; 'Day N of your
  streak' on a fresh summary
- History per Mode grouped by day, reopening each summary
- Two adjustable Reminders (19:00, 22:30) scheduled 14 days ahead with
  flutter_local_notifications, none on a done day; permission asked
  after the first complete Session or from Settings
- Settings: Reminders, the Player chime, exercise credits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:34:49 +01:00

214 lines
7.5 KiB
Dart

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 '../../streak/streak.dart';
import '../../streak/views/streak_views.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, this.justFinished = false});
static const routeName = '/summary';
final String sessionId;
/// Straight from the Player rather than opened again from History: only
/// then is the Session's Streak Day worth pointing out.
final bool justFinished;
@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)),
if (justFinished && session.complete) _StreakLine(session: session),
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;
}
}
/// "Day 12 of your streak", when this Session was the first to complete its day.
class _StreakLine extends StatelessWidget {
const _StreakLine({required this.session});
final Session session;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final sessions = context.read<SessionRepository>();
return StreakBuilder(
builder: (context, streak) => FutureBuilder(
future: sessions.completeEndTimes(),
builder: (context, snapshot) {
final times = snapshot.data;
if (times == null) return const SizedBox.shrink();
final before = streakDays(times.where((t) => t != session.endedAt));
final fresh = streakDays(times).length > before.length;
if (!fresh || streak.current == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Icon(Icons.local_fire_department_rounded, color: s.accent),
const SizedBox(width: 6),
Text(
l10n.summaryStreak(streak.current),
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: s.accent),
),
],
),
);
},
),
);
}
}
/// What the summary route takes.
class SummaryArguments {
const SummaryArguments(this.sessionId, {this.justFinished = false});
final String sessionId;
final bool justFinished;
}
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),
),
],
),
);
}
}