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

@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets
class WatchBridgeListenerService : WearableListenerService() {
override fun onDataChanged(dataEvents: DataEventBuffer) {
WatchBridgePlugin.attachApplicationContext(applicationContext)
try {
for (event in dataEvents) {
WatchBridgePlugin.handleDataEvent(event)
@ -19,6 +20,7 @@ class WatchBridgeListenerService : WearableListenerService() {
}
override fun onMessageReceived(messageEvent: MessageEvent) {
WatchBridgePlugin.attachApplicationContext(applicationContext)
if (messageEvent.path != WatchBridgePlugin.ACK_PATH) {
return
}
@ -27,6 +29,7 @@ class WatchBridgeListenerService : WearableListenerService() {
}
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
WatchBridgePlugin.attachApplicationContext(applicationContext)
if (capabilityInfo.name != WatchBridgePlugin.PHONE_CAPABILITY) {
return
}
@ -41,6 +44,7 @@ class WatchBridgeListenerService : WearableListenerService() {
override fun onCreate() {
super.onCreate()
WatchBridgePlugin.attachApplicationContext(applicationContext)
WatchBridgePlugin.requestCapabilityRefresh(applicationContext)
WatchBridgePlugin.requestLatestProjection(applicationContext)
}

View File

@ -56,8 +56,12 @@ object WatchBridgePlugin {
private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false
fun register(flutterEngine: FlutterEngine, context: Context) {
fun attachApplicationContext(context: Context) {
appContext = context.applicationContext
}
fun register(flutterEngine: FlutterEngine, context: Context) {
attachApplicationContext(context)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, PROJECTION_CHANNEL)

View File

@ -1,6 +1,8 @@
package com.gametime.watch.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import android.util.Log
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
@ -37,6 +39,7 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false
private var appContext: Context? = null
private var wakeLock: PowerManager.WakeLock? = null
private val callback = object : MeasureCallback {
override fun onAvailabilityChanged(
@ -101,6 +104,7 @@ internal class WatchHeartRateCollector(
return
}
appContext = context.applicationContext
acquireWakeLock(context)
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
@ -217,6 +221,7 @@ internal class WatchHeartRateCollector(
}
registeredDataTypes.clear()
appContext = null
releaseWakeLock()
}
private fun registerMeasureCallbackIfNeeded(
@ -235,6 +240,34 @@ internal class WatchHeartRateCollector(
}
}
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock(context: Context) {
val existing = wakeLock
if (existing?.isHeld == true) {
return
}
val powerManager = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as? PowerManager
?: return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GameTime:HeartRateCollection",
).apply {
setReferenceCounted(false)
acquire()
}
Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId")
}
private fun releaseWakeLock() {
val lock = wakeLock
wakeLock = null
if (lock?.isHeld == true) {
lock.release()
Log.d(TAG, "heart rate collection wake lock released")
}
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0

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) {

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime_watch/application/watch_session_view_model.dart';
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
@ -35,7 +36,10 @@ void main() {
expect(find.text('Squat jump'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(find.text('Séance active'), findsNothing);
expect(find.text('Série 00:45'), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
@ -83,6 +87,39 @@ void main() {
},
);
testWidgets('hides set timer even when it is projected as dominant', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_setTimerDominantProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.text('Série'), findsNothing);
expect(find.text('00:45'), findsNothing);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('does not offer a start action from the no-session screen', (
tester,
) async {
@ -204,7 +241,8 @@ void main() {
expect(find.text('3'), findsOneWidget);
expect(find.text('Routine de tir'), findsOneWidget);
expect(find.text('Chrono étape 02:14'), findsOneWidget);
expect(find.text('Chrono étape'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
@ -250,7 +288,9 @@ void main() {
);
await tester.pump();
expect(find.text('FC 142 bpm'), findsOneWidget);
expect(find.text('FC 142 bpm'), findsNothing);
expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(find.text('142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsNothing);
expect(find.text('186 kcal'), findsNothing);
expect(find.byTooltip('Stats'), findsOneWidget);
@ -478,6 +518,122 @@ void main() {
viewModel.dispose();
},
);
testWidgets(
'renders rest without overlapping series progress and next name',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_restProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('REPOS'), findsOneWidget);
expect(find.text('Après série 2/4'), findsOneWidget);
expect(find.text('Ensuite'), findsOneWidget);
expect(find.text('Gainage latéral'), findsOneWidget);
expect(find.text('Série 2/4'), findsNothing);
expect(find.textContaining('Ensuite :'), findsNothing);
expect(find.text('00:30'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('hides timer controls when no active timer is projected', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
expect(find.text('Chrono étape'), findsNothing);
expect(find.text('02:14'), findsNothing);
expect(find.byTooltip('Pause'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('vibrates once when a countdown timer reaches zero', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_countdownProjection(accumulatedMs: 29000));
await tester.pump();
expect(hapticCalls, isEmpty);
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
await tester.pump();
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact');
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
await tester.pump();
expect(hapticCalls, hasLength(1));
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
}
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
@ -622,6 +778,31 @@ WatchSessionProjection _secondaryRestActionProjection() {
);
}
WatchSessionProjection _setTimerDominantProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 6,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: const WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Série',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 0,
accumulatedMs: 45000,
),
secondaryTimers: [_runningStepTimer()],
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
);
}
void _expectActionsPageVisible(WidgetTester tester) {
expect(
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
@ -681,6 +862,60 @@ WatchSessionProjection _manualScoreProjectionWithTimer() {
);
}
WatchSessionProjection _restProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 5,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.restRunning,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
nextExerciseName: 'Gainage latéral',
statusLabel: 'Repos',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.rest,
label: 'Repos',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.now()
.toUtc()
.add(const Duration(minutes: 1))
.millisecondsSinceEpoch,
accumulatedMs: 30000,
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
targetMs: 60000,
),
);
}
WatchSessionProjection _countdownProjection({required int accumulatedMs}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: accumulatedMs,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Gainage',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: null,
targetMs: 30000,
),
);
}
WatchTimerProjection _runningStepTimer() {
return WatchTimerProjection(
kind: WatchTimerKind.step,