chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../application/watch_session_view_model.dart';
@ -22,6 +23,8 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
var _lastFailureNoticeSerial = 0;
var _returnToSessionAfterSecondarySuccess = false;
var _secondaryActionStartFailureSerial = 0;
final _timerRemainingMsByKey = <String, int>{};
final _completionHapticTimerKeys = <String>{};
@override
void initState() {
@ -50,6 +53,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
_syncFailureNotice(state);
_syncSecondaryNavigation(state);
final projection = state.projection;
_syncTimerCompletionHaptic(projection);
if (projection.phase == WatchSessionPhase.noActiveSession) {
final phoneReachable =
projection.phoneReachable && !state.connectionLost;
@ -198,6 +202,26 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
});
}
void _syncTimerCompletionHaptic(WatchSessionProjection projection) {
final timer = _primaryDisplayTimer(projection);
if (timer == null ||
timer.displayMode != WatchTimerDisplayMode.countdown ||
timer.runState != WatchTimerRunState.running) {
return;
}
final key = _timerHapticKey(projection, timer);
final remainingMs = _displayDuration(timer).inMilliseconds;
final previousRemainingMs = _timerRemainingMsByKey[key];
_timerRemainingMsByKey[key] = remainingMs;
if (remainingMs > 0 ||
previousRemainingMs == null ||
previousRemainingMs <= 0 ||
!_completionHapticTimerKeys.add(key)) {
return;
}
unawaited(HapticFeedback.heavyImpact());
}
Future<bool> _confirm({
required String title,
required String message,
@ -392,8 +416,9 @@ final class _SessionMainView extends StatelessWidget {
projection.phase == WatchSessionPhase.restPaused;
final connectionLost = state.connectionLost || !projection.phoneReachable;
final hasActions = projection.secondaryActions.isNotEmpty;
final timer = projection.dominantTimer;
final timer = _primaryDisplayTimer(projection);
final showsTimer = timer != null;
final footerText = _sessionFooterText(state);
final canToggleTimer =
showsTimer &&
!connectionLost &&
@ -485,24 +510,21 @@ final class _SessionMainView extends StatelessWidget {
],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Text(
state.commandPending
? _primaryLabel(state)
: state.staleProjection
? 'Dernier état reçu'
: _exerciseProgress(projection),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontSize: 10),
if (footerText.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Text(
footerText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontSize: 10),
),
),
),
],
);
}
@ -633,15 +655,23 @@ final class _MainHeartRateLine extends StatelessWidget {
}
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'FC $label',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFFA7ADBA),
fontSize: 11,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite, size: 11, color: Color(0xFFFF4D5E)),
const SizedBox(width: 3),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFFA7ADBA),
fontSize: 11,
),
),
],
),
);
}
@ -691,7 +721,11 @@ final class _ActiveContent extends StatelessWidget {
onDecrement: onDecrementScore,
);
}
final timer = projection.dominantTimer;
final timer = _primaryDisplayTimer(projection);
final secondaryTimers = _visibleSecondaryTimers(
projection,
primaryTimer: timer,
);
final dominantValue = timer == null
? _seriesValue(projection)
: _timerText(timer);
@ -705,23 +739,25 @@ final class _ActiveContent extends StatelessWidget {
const SizedBox(height: 2),
_SmallLabel(dominantLabel),
const SizedBox(height: 2),
_DominantValue(dominantValue),
_MainHeartRateLine(sample: state.sensorSample),
if (timer != null) ...[
const SizedBox(height: 3),
_TimerToggleButton(
runState: timer.runState,
if (timer == null)
_DominantValue(dominantValue)
else
_DominantTimerLine(
value: dominantValue,
timer: timer,
pending: state.timerTogglePending,
onPressed: onTogglePause,
onTogglePause: onTogglePause,
),
_MainHeartRateLine(sample: state.sensorSample),
if (timer == null) ...[
const SizedBox(height: 5),
_StatusLine(projection.statusLabel),
],
const SizedBox(height: 5),
_StatusLine(projection.statusLabel ?? timer?.label),
if (projection.secondaryTimers.isNotEmpty)
if (secondaryTimers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
projection.secondaryTimers.map(_compactTimerText).join(' · '),
secondaryTimers.map(_compactTimerText).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
@ -734,6 +770,54 @@ final class _ActiveContent extends StatelessWidget {
}
}
final class _DominantTimerLine extends StatelessWidget {
const _DominantTimerLine({
required this.value,
required this.timer,
required this.pending,
required this.onTogglePause,
this.compact = false,
});
final String value;
final WatchTimerProjection timer;
final bool pending;
final VoidCallback? onTogglePause;
final bool compact;
@override
Widget build(BuildContext context) {
return SizedBox(
height: compact ? 34 : 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: compact
? Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontSize: 26, height: 1),
)
: _DominantValue(value),
),
SizedBox(width: compact ? 4 : 6),
_TimerToggleButton(
runState: timer.runState,
pending: pending,
onPressed: onTogglePause,
compact: compact,
),
],
),
);
}
}
final class _ManualScoreContent extends StatelessWidget {
const _ManualScoreContent({
required this.state,
@ -759,7 +843,7 @@ final class _ManualScoreContent extends StatelessWidget {
controlsEnabled &&
score > 0 &&
(state.scoreCommandPending || projection.canDecrementScore);
final timer = projection.dominantTimer;
final timer = _primaryDisplayTimer(projection);
final canToggleTimer =
timer != null &&
controlsEnabled &&
@ -850,28 +934,35 @@ final class _CompactTimerLine extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 28,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
height: 45,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
_compactTimerText(timer),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
_SmallLabel(timer.label),
const SizedBox(height: 1),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
_timerText(timer),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontSize: 18, height: 1),
),
),
const SizedBox(width: 4),
_TimerToggleButton(
runState: timer.runState,
pending: pending,
onPressed: onTogglePause,
compact: true,
),
],
),
if (onTogglePause != null || pending) ...[
const SizedBox(width: 4),
_TimerToggleButton(
runState: timer.runState,
pending: pending,
onPressed: onTogglePause,
compact: true,
),
],
],
),
);
@ -972,40 +1063,37 @@ final class _RestContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final projection = state.projection;
final timer = projection.dominantTimer;
final timer = _primaryDisplayTimer(projection);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _SmallLabel('REPOS'),
const SizedBox(height: 2),
_DominantValue(timer == null ? '--:--' : _timerText(timer)),
_MainHeartRateLine(sample: state.sensorSample),
_SmallLabel(_afterSeriesLabel(projection)),
if (timer != null) ...[
const SizedBox(height: 3),
_TimerToggleButton(
runState: timer.runState,
const SizedBox(height: 2),
_DominantTimerLine(
value: _timerText(timer),
timer: timer,
pending: state.timerTogglePending,
onPressed: onTogglePause,
onTogglePause: onTogglePause,
compact: true,
),
],
const SizedBox(height: 6),
_MainHeartRateLine(sample: state.sensorSample),
const SizedBox(height: 3),
if (projection.nextExerciseName case final next?) ...[
const _SmallLabel('Ensuite'),
Text(
'Ensuite : $next',
next,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 5),
] else ...[
_ExerciseName(projection.exerciseName),
const SizedBox(height: 5),
],
_StatusLine(
projection.statusLabel ??
'Série ${projection.seriesIndex}/${projection.seriesTotal}',
),
],
);
}
@ -1398,6 +1486,51 @@ String _exerciseProgress(WatchSessionProjection projection) {
return projection.statusLabel ?? '';
}
String _sessionFooterText(WatchSessionUiState state) {
if (state.commandPending) {
return _primaryLabel(state);
}
if (state.staleProjection) {
return 'Dernier état reçu';
}
final projection = state.projection;
if (projection.phase == WatchSessionPhase.restRunning ||
projection.phase == WatchSessionPhase.restPaused) {
return '';
}
return _exerciseProgress(projection);
}
String _afterSeriesLabel(WatchSessionProjection projection) {
if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) {
return 'Après série';
}
return 'Après série ${projection.seriesIndex}/${projection.seriesTotal}';
}
WatchTimerProjection? _primaryDisplayTimer(WatchSessionProjection projection) {
final dominantTimer = projection.dominantTimer;
if (dominantTimer != null && dominantTimer.kind != WatchTimerKind.setTimer) {
return dominantTimer;
}
for (final timer in _visibleSecondaryTimers(projection)) {
return timer;
}
return null;
}
List<WatchTimerProjection> _visibleSecondaryTimers(
WatchSessionProjection projection, {
WatchTimerProjection? primaryTimer,
}) {
return projection.secondaryTimers
.where(
(timer) =>
timer.kind != WatchTimerKind.setTimer && timer != primaryTimer,
)
.toList(growable: false);
}
String _timerText(WatchTimerProjection timer) {
final duration = _displayDuration(timer);
final totalSeconds = duration.inSeconds;
@ -1410,6 +1543,23 @@ String _compactTimerText(WatchTimerProjection timer) {
return '${timer.label} ${_timerText(timer)}';
}
String _timerHapticKey(
WatchSessionProjection projection,
WatchTimerProjection timer,
) {
return [
projection.deviceSessionId,
projection.seriesIndex,
projection.passageIndex ?? '-',
projection.stepIndex ?? '-',
timer.kind.name,
timer.label,
timer.referenceEpochMs,
timer.startedAtEpochMs ?? '-',
timer.targetMs ?? '-',
].join(':');
}
String? _heartRateLabel(WatchSensorSample? sample) {
final bpm = sample?.heartRateBpm;
if (bpm == null || bpm <= 0) {