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:
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user