Lots F1+F2 : écran progression_screen.dart, intégration home/history/navigation, tests associés (193 tests suite complète, 4 échecs préexistants sans rapport confirmés). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
744 lines
24 KiB
Dart
744 lines
24 KiB
Dart
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.performanceReferenceUseCase,
|
|
this.onOpenProgression,
|
|
super.key,
|
|
});
|
|
|
|
final WorkoutHistoryUseCases historyUseCases;
|
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
|
final ActiveExerciseStepUseCases? stepUseCases;
|
|
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,
|
|
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.performanceReferenceUseCase,
|
|
super.key,
|
|
});
|
|
|
|
final WorkoutHistory history;
|
|
final WorkoutHistoryUseCases historyUseCases;
|
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
|
final ActiveExerciseStepUseCases? stepUseCases;
|
|
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)}'),
|
|
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 {
|
|
ActiveWorkoutSession session;
|
|
final sourceId = history.sourceWorkoutTemplateId;
|
|
if (sourceId != null &&
|
|
await workoutTemplateUseCases.findById(sourceId) != null) {
|
|
session = await activeUseCases.startFromTemplate(sourceId);
|
|
} else {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
"La séance originale n'existe plus. Une copie va être utilisée.",
|
|
),
|
|
),
|
|
);
|
|
}
|
|
session = await activeUseCases.startFromHistory(history);
|
|
}
|
|
if (!context.mounted) return;
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => WorkoutExecutionScreen(
|
|
initialSession: session,
|
|
activeUseCases: activeUseCases,
|
|
stepUseCases: stepUseCases,
|
|
closeUseCase: closeUseCase,
|
|
historyUseCases: historyUseCases,
|
|
workoutTemplateUseCases: workoutTemplateUseCases,
|
|
performanceReferenceUseCase: performanceReferenceUseCase,
|
|
mediaUseCases: mediaUseCases,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 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),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|