Files
GameTime/lib/presentation/history_screen.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

871 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.dart';
import 'workout_execution_screen.dart';
final class HistoryListScreen extends StatefulWidget {
const HistoryListScreen({
required this.historyUseCases,
required this.workoutTemplateUseCases,
required this.activeUseCases,
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
super.key,
});
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
final VoidCallback? onOpenProgression;
@override
State<HistoryListScreen> createState() => _HistoryListScreenState();
}
final class _HistoryListScreenState extends State<HistoryListScreen> {
late Future<List<WorkoutHistory>> _histories;
@override
void initState() {
super.initState();
_histories = widget.historyUseCases.listActive();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Historique'),
actions: [
if (widget.onOpenProgression != null)
TextButton(
onPressed: widget.onOpenProgression,
child: const Text('Progression'),
),
],
),
body: FutureBuilder<List<WorkoutHistory>>(
future: _histories,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final histories = snapshot.data ?? const <WorkoutHistory>[];
if (histories.isEmpty) {
return const _CenteredMessage(
title: 'Aucune séance terminée',
message: 'Les séances terminées apparaîtront ici.',
);
}
final groups = groupHistoriesByPeriod(histories, DateTime.now());
return ListView(
children: [
for (final group in groups.entries) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 8),
child: Text(
group.key,
style: Theme.of(context).textTheme.titleMedium,
),
),
for (final history in group.value)
CourtBlazerAccentPanel(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: ListTile(
title: Text(history.nameSnapshot),
subtitle: Text(
'${_formatDateTime(history.startedAt)} · '
'${_formatDurationMs(history.totalActiveMs)} · '
'${_historySummary(history)}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(history),
),
),
],
],
);
},
),
);
}
Future<void> _openDetail(WorkoutHistory history) async {
final deleted = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => HistoryDetailScreen(
history: history,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sensorUseCases: widget.sensorUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
),
),
);
if (deleted == true) {
setState(() {
_histories = widget.historyUseCases.listActive();
});
}
}
}
final class HistoryDetailScreen extends StatelessWidget {
const HistoryDetailScreen({
required this.history,
required this.historyUseCases,
required this.workoutTemplateUseCases,
required this.activeUseCases,
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
super.key,
});
final WorkoutHistory history;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@override
Widget build(BuildContext context) {
final detail = HistoryDetailData.fromHistory(history);
return Scaffold(
appBar: AppBar(title: Text(history.nameSnapshot)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(_formatDateTime(history.startedAt)),
const SizedBox(height: 4),
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (history.averageHeartRateBpm != null &&
history.maxHeartRateBpm != null) ...[
const SizedBox(height: 16),
_HistoryHeartRateSummary(
averageHeartRateBpm: history.averageHeartRateBpm!,
maxHeartRateBpm: history.maxHeartRateBpm!,
),
],
const SizedBox(height: 16),
for (final program in detail.programs) ...[
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final exercise in program.exercises) ...[
Text(
exercise.name,
style: Theme.of(context).textTheme.titleSmall,
),
for (final set in exercise.sets)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('Série ${set.setIndex + 1}'),
subtitle: Text(set.summary),
),
if (set.stepPassages.isNotEmpty)
_HistoryStepResultsSection(passages: set.stepPassages),
],
),
),
],
const SizedBox(height: 12),
],
const SizedBox(height: 16),
FilledButton(
onPressed: () => _restart(context),
child: const Text('Relancer cette séance'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _confirmDelete(context),
child: const Text("Supprimer de l'historique"),
),
],
),
);
}
Future<void> _restart(BuildContext context) async {
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!context.mounted) return;
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
ActiveWorkoutSession session;
final sourceId = history.sourceWorkoutTemplateId;
try {
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
session = await activeUseCases.startFromHistory(history);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"La séance originale n'existe plus. Une copie va être utilisée.",
),
),
);
}
} on DomainException {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
),
);
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
stepUseCases: stepUseCases,
sensorUseCases: sensorUseCases,
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
performanceReferenceUseCase: performanceReferenceUseCase,
mediaUseCases: mediaUseCases,
),
),
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a restart.
}
return false;
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Supprimer de l'historique ?"),
content: const Text('Cette action est définitive.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true) return;
await historyUseCases.delete(history.metadata.id);
if (!context.mounted) return;
Navigator.of(context).pop(true);
}
}
final class _HistoryHeartRateSummary extends StatelessWidget {
const _HistoryHeartRateSummary({
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Fréquence cardiaque',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _HistoryHeartRateMetric(
label: 'Moyenne',
value: '${averageHeartRateBpm.round()}',
),
),
const SizedBox(width: 12),
Expanded(
child: _HistoryHeartRateMetric(
label: 'Max',
value: '$maxHeartRateBpm',
),
),
],
),
],
),
);
}
}
final class _HistoryHeartRateMetric extends StatelessWidget {
const _HistoryHeartRateMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
RichText(
text: TextSpan(
style: AppTextStyles.scoreNumber(context),
children: [
TextSpan(text: value),
TextSpan(
text: ' bpm',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
);
}
}
final class HistoryDetailData {
const HistoryDetailData({required this.programs});
factory HistoryDetailData.fromHistory(WorkoutHistory history) {
final snapshot =
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
final resolved =
jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String)
as Map<String, dynamic>;
final rawResults = history.results.isNotEmpty
? history.results.map(_resultFromDomain).toList()
: (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final rawStepResults = history.stepResults.isNotEmpty
? history.stepResults.map(_stepResultFromDomain).toList()
: (snapshot['stepResults'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final programs = <HistoryProgramDetail>[];
final rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
for (
var programIndex = 0;
programIndex < rawPrograms.length;
programIndex++
) {
final rawProgram = rawPrograms[programIndex] as Map<String, dynamic>;
final programSnapshot =
jsonDecode(rawProgram['programSnapshotJson'] as String)
as Map<String, dynamic>;
final exercises = <HistoryExerciseDetail>[];
final rawExercises =
programSnapshot['exercises'] as List<dynamic>? ?? const [];
for (
var exerciseIndex = 0;
exerciseIndex < rawExercises.length;
exerciseIndex++
) {
final rawExercise = rawExercises[exerciseIndex] as Map<String, dynamic>;
final exerciseResults = rawResults.where((result) {
return result['programIndex'] == programIndex &&
result['exerciseIndex'] == exerciseIndex;
}).toList();
final exerciseStepResults = rawStepResults.where((result) {
return result['programIndex'] == programIndex &&
result['exerciseIndex'] == exerciseIndex;
}).toList();
exercises.add(
HistoryExerciseDetail(
name: rawExercise['exerciseNameSnapshot'] as String,
sets: [
for (final result in exerciseResults)
HistorySetDetail.fromSnapshot(
rawExercise,
result,
exerciseStepResults
.where(
(stepResult) =>
stepResult['setIndex'] == result['setIndex'],
)
.toList(),
),
],
),
);
}
programs.add(
HistoryProgramDetail(
name: rawProgram['programNameSnapshot'] as String,
exercises: exercises,
),
);
}
return HistoryDetailData(programs: programs);
}
final List<HistoryProgramDetail> programs;
}
final class HistoryProgramDetail {
const HistoryProgramDetail({required this.name, required this.exercises});
final String name;
final List<HistoryExerciseDetail> exercises;
}
final class HistoryExerciseDetail {
const HistoryExerciseDetail({required this.name, required this.sets});
final String name;
final List<HistorySetDetail> sets;
}
final class HistorySetDetail {
const HistorySetDetail({
required this.setIndex,
required this.summary,
required this.stepPassages,
});
factory HistorySetDetail.fromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
List<Map<String, dynamic>> stepResults,
) {
final parts = <String>[];
if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) {
parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}');
}
if (exercise['repsEnabled'] == true && result['actualReps'] != null) {
parts.add('${result['actualReps']} répétitions');
}
if (exercise['scoreEnabled'] == true && result['actualScore'] != null) {
final unit = exercise['scoreUnitSnapshot'] as String?;
final value = _formatScoreValue(result['actualScore'] as num);
parts.add(unit == null ? 'Score $value' : 'Score $value $unit');
}
if (exercise['scoreEnabled'] == true &&
_historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch &&
result['actualScoreTimeMs'] != null) {
final value = _formatScoreStopwatch(
Duration(milliseconds: result['actualScoreTimeMs'] as int),
);
final target = _targetScoreTimeMsFromSnapshot(exercise, result);
if (target == null) {
parts.add('Temps réalisé : $value');
} else {
parts.add(
'Temps réalisé : $value / objectif '
'${_formatScoreStopwatchTarget(Duration(milliseconds: target))}',
);
}
}
return HistorySetDetail(
setIndex: result['setIndex'] as int,
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
stepPassages: _stepPassagesFromResults(stepResults),
);
}
final int setIndex;
final String summary;
final List<HistoryStepPassageDetail> stepPassages;
}
final class HistoryStepPassageDetail {
const HistoryStepPassageDetail({required this.index, required this.steps});
final int index;
final List<HistoryStepResultDetail> steps;
}
final class HistoryStepResultDetail {
const HistoryStepResultDetail({
required this.index,
required this.name,
required this.status,
required this.valueLabel,
this.scoreLabel,
});
factory HistoryStepResultDetail.fromResult(Map<String, dynamic> result) {
final status = _setResultStatusFromSnapshot(result['status']);
return HistoryStepResultDetail(
index: result['stepIndex'] as int,
name: result['stepNameSnapshot'] as String,
status: status,
valueLabel: status == SetResultStatus.skipped
? null
: _historyStepValueLabel(result),
scoreLabel: status == SetResultStatus.skipped
? null
: _historyStepScoreLabel(result),
);
}
final int index;
final String name;
final SetResultStatus status;
final String? valueLabel;
final String? scoreLabel;
}
final class _HistoryStepResultsSection extends StatelessWidget {
const _HistoryStepResultsSection({required this.passages});
final List<HistoryStepPassageDetail> passages;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Étapes réalisées',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
for (final passage in passages) ...[
Text(
'Passage ${passage.index + 1}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
for (final step in passage.steps)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(_historyStepLine(step))),
if (step.status == SetResultStatus.skipped) ...[
const SizedBox(width: 8),
Chip(
visualDensity: VisualDensity.compact,
label: const Text('Passée'),
side: BorderSide(color: tokens.border),
),
],
],
),
),
const SizedBox(height: 8),
],
],
),
);
}
}
Map<String, List<WorkoutHistory>> groupHistoriesByPeriod(
List<WorkoutHistory> histories,
DateTime now,
) {
final today = DateTime(now.year, now.month, now.day);
final weekStart = today.subtract(Duration(days: today.weekday - 1));
final groups = <String, List<WorkoutHistory>>{
"Aujourd'hui": [],
'Cette semaine': [],
'Plus ancien': [],
};
for (final history in histories) {
final startedDay = DateTime(
history.startedAt.year,
history.startedAt.month,
history.startedAt.day,
);
if (startedDay == today) {
groups["Aujourd'hui"]!.add(history);
} else if (startedDay.isAfter(weekStart) || startedDay == weekStart) {
groups['Cette semaine']!.add(history);
} else {
groups['Plus ancien']!.add(history);
}
}
groups.removeWhere((key, value) => value.isEmpty);
return groups;
}
Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
return {
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
};
}
Map<String, dynamic> _stepResultFromDomain(WorkoutHistoryStepResult result) {
return {
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'status': result.status.name,
};
}
List<HistoryStepPassageDetail> _stepPassagesFromResults(
List<Map<String, dynamic>> results,
) {
if (results.isEmpty) return const [];
final byPassage = <int, List<Map<String, dynamic>>>{};
for (final result in results) {
final passageIndex = result['passageIndex'] as int;
byPassage.putIfAbsent(passageIndex, () => []).add(result);
}
final passages = byPassage.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
return passages
.map((entry) {
final steps = entry.value
..sort(
(left, right) =>
(left['stepIndex'] as int).compareTo(right['stepIndex'] as int),
);
return HistoryStepPassageDetail(
index: entry.key,
steps: steps.map(HistoryStepResultDetail.fromResult).toList(),
);
})
.toList(growable: false);
}
String _historyStepLine(HistoryStepResultDetail step) {
final parts = <String>['Étape ${step.index + 1}', step.name];
if (step.valueLabel != null) {
parts.add(step.valueLabel!);
}
if (step.scoreLabel != null) {
parts.add(step.scoreLabel!);
}
return parts.join(' · ');
}
String? _historyStepValueLabel(Map<String, dynamic> result) {
final type = result['stepTypeSnapshot'];
if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) {
return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}';
}
if (type == ExerciseStepType.reps.name && result['actualReps'] != null) {
return 'Répétitions ${result['actualReps']}';
}
return null;
}
String? _historyStepScoreLabel(Map<String, dynamic> result) {
if (result['hasScoreSnapshot'] != true) return null;
final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']);
if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) {
return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}';
}
if (mode == ScoreInputMode.manual && result['actualScore'] != null) {
final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score';
final score = _formatScoreValue(result['actualScore'] as num);
final unit = result['scoreUnitSnapshot'] as String?;
final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit';
return '$scoreLabel : $score$suffix';
}
return null;
}
SetResultStatus _setResultStatusFromSnapshot(Object? value) {
return switch (value) {
'skipped' => SetResultStatus.skipped,
_ => SetResultStatus.completed,
};
}
String _historySummary(WorkoutHistory history) {
final snapshot =
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
final rawResults = history.results.isNotEmpty
? history.results.map(_resultFromDomain).toList()
: (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final scoreCount = rawResults
.where(
(result) =>
result['actualScore'] != null ||
result['actualScoreTimeMs'] != null,
)
.length;
return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}';
}
String _formatScoreValue(num value) {
if (value is int || value == value.roundToDouble()) {
return value.round().toString();
}
return value.toString();
}
String _formatDateTime(DateTime value) {
final day = value.day.toString().padLeft(2, '0');
final month = value.month.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');
return '$day/$month/${value.year} $hour:$minute';
}
String _formatDurationMs(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) {
return '$hours:$minutes:$seconds';
}
return '$minutes:$seconds';
}
String _formatScoreStopwatch(Duration duration) {
final totalTenths = (duration.inMilliseconds / 100).floor();
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths';
}
String _formatScoreStopwatchTarget(Duration duration) {
if (duration.inMilliseconds % 1000 == 0) {
return _formatDurationMs(duration.inMilliseconds);
}
return _formatScoreStopwatch(duration);
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
int? _targetScoreTimeMsFromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
return result['targetScoreTimeMsSnapshot'] as int? ??
(exercise['targetScoreTimeMs'] as int?);
}
ScoreInputMode _historyScoreInputMode(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
final resultMode = result['scoreInputModeSnapshot'];
if (resultMode != null) {
return _scoreInputModeFromSnapshot(resultMode);
}
return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({required this.title, this.message});
final String title;
final String? message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
],
),
),
);
}
}