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'; import 'package:gametime_watch/presentation/watch_session_screen.dart'; import 'package:gametime_watch/presentation/watch_theme.dart'; import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); testWidgets( 'renders an active session on a compact round-sized viewport without overflow', (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(_runningProjection()); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); 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()); viewModel.dispose(); }, ); testWidgets( 'transitions from no-session to manual score controls without rendering a black screen', (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(_noSessionStartProjection()); await tester.pump(); expect(find.text('Aucune séance en cours'), findsOneWidget); client.emitProjection(_manualScoreProjection()); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('Lancers francs'), findsOneWidget); expect(find.text('Routine de tir'), findsOneWidget); expect(find.text('Cible : 8'), findsOneWidget); expect(find.text('3'), findsOneWidget); expect(find.byTooltip('Ajouter'), findsOneWidget); expect(find.byTooltip('Retirer'), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }, ); 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 { 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(_noSessionStartProjection()); await tester.pump(); expect(find.text('Aucune séance en cours'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing); expect(client.sentCommands, isEmpty); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); testWidgets('starts a stopped exercise timer from the timer button', ( 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(_readyProjection()); await tester.pump(); expect(find.text('Routine de tir'), findsOneWidget); await tester.tap(find.byTooltip('Démarrer')); await tester.pump(); expect( client.sentCommands.single.type, WatchCommandType.startCurrentExercise, ); expect( find.byKey(const ValueKey('timer-toggle-pending-dot')), findsOneWidget, ); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); testWidgets('keeps the no-session start action disabled without phone', ( 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.emitConnection(const WatchBridgeConnectionEvent(isReachable: false)); client.emitProjection(_noSessionStartProjection(phoneReachable: false)); await tester.pump(); expect(find.text('Téléphone indisponible'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); testWidgets('shows compact timer controls with a manual score step', ( 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(_manualScoreProjectionWithTimer()); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('3'), findsOneWidget); expect(find.text('Routine de tir'), findsOneWidget); expect(find.text('Chrono étape'), findsOneWidget); expect(find.text('02:14'), findsOneWidget); expect(find.byTooltip('Pause'), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); testWidgets( 'shows live heart rate on main page and full telemetry on stats', (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(_runningProjection()); await tester.pump(); expect(find.byTooltip('Stats'), findsNothing); client.emitSensorSample( WatchSensorSample( sessionId: 'session-1', capturedAtEpochMs: DateTime.utc( 2026, 7, 27, 10, ).millisecondsSinceEpoch, heartRateBpm: 142, distanceMeters: 840, caloriesKcal: 186, ), ); await tester.pump(); 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); await tester.drag( find.byKey(const ValueKey('watch-session-page')), const Offset(-220, 0), ); await tester.pumpAndSettle(); await tester.drag( find.byKey(const ValueKey('watch-actions-page')), const Offset(-220, 0), ); await tester.pumpAndSettle(); expect(find.text('Stats'), findsOneWidget); expect(find.text('FC'), findsOneWidget); expect(find.text('142 bpm'), findsOneWidget); expect(find.text('840 m'), findsOneWidget); expect(find.text('186 kcal'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }, ); testWidgets( 'sends pause command from the icon button when a timer dominates', (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(_runningProjection()); await tester.pump(); await tester.tap(find.byTooltip('Pause')); await tester.pump(); expect(client.sentCommands.single.type, WatchCommandType.pauseSession); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }, ); testWidgets('shows pending feedback inside the pause button', (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(_runningProjection()); await tester.pump(); await tester.tap(find.byTooltip('Pause')); await tester.pump(); expect( find.byKey(const ValueKey('timer-toggle-pending-dot')), findsOneWidget, ); expect(client.sentCommands.single.type, WatchCommandType.pauseSession); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }); testWidgets( 'returns to session and shows a discreet notice when a set command is rejected', (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(_runningProjection()); await tester.pump(); await tester.tap(find.byTooltip('Actions')); await tester.pumpAndSettle(); await tester.tap(find.text('Terminer la série')); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); expect( client.sentCommands.single.type, WatchCommandType.finishCurrentSet, ); expect(find.text('Squat jump'), findsOneWidget); client.emitAck( WatchCommandAckEvent( commandId: client.sentCommands.single.commandId, status: WatchCommandAck.rejectedNotApplicable, sessionId: 'session-1', ), ); await tester.pump(); await tester.pump(); expect(find.text('Série non modifiée'), findsOneWidget); await tester.pump(const Duration(milliseconds: 1900)); expect(find.text('Série non modifiée'), findsNothing); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }, ); testWidgets( 'keeps rest skip secondary action open until the session projection updates', (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(_secondaryRestActionProjection()); await tester.pump(); await tester.tap(find.byTooltip('Actions')); await tester.pumpAndSettle(); _expectActionsPageVisible(tester); await tester.tap(find.text('Passer le repos')); await tester.pump(); expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest); _expectActionsPageVisible(tester); client.emitProjection(_runningProjection()); await tester.pump(); await tester.pumpAndSettle(); _expectSessionPageVisible(tester); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); }, ); testWidgets( 'keeps rest skip secondary action open and shows feedback when rejected', (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(_secondaryRestActionProjection()); await tester.pump(); await tester.tap(find.byTooltip('Actions')); await tester.pumpAndSettle(); await tester.tap(find.text('Passer le repos')); await tester.pump(); client.emitAck( WatchCommandAckEvent( commandId: client.sentCommands.single.commandId, status: WatchCommandAck.rejectedNotApplicable, sessionId: 'session-1', ), ); await tester.pump(); await tester.pump(); _expectActionsPageVisible(tester); expect(find.text('Commande non appliquée'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); 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 = []; 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 { final _projectionController = StreamController.broadcast(); final _sensorSampleController = StreamController.broadcast(); final _ackController = StreamController.broadcast(); final _connectionController = StreamController.broadcast(); var resyncRequests = 0; var capabilityRefreshRequests = 0; final sentCommands = []; @override Stream get projections => _projectionController.stream; @override Stream get sensorSamples => _sensorSampleController.stream; @override Stream get acks => _ackController.stream; @override Stream get connectionEvents => _connectionController.stream; void emitProjection(WatchSessionProjection projection) { _projectionController.add(projection); } void emitSensorSample(WatchSensorSample sample) { _sensorSampleController.add(sample); } void emitAck(WatchCommandAckEvent ack) { _ackController.add(ack); } void emitConnection(WatchBridgeConnectionEvent event) { _connectionController.add(event); } @override Future requestCapabilityRefresh() async { capabilityRefreshRequests += 1; } @override Future requestResync() async { resyncRequests += 1; } @override Future sendCommand(WatchCommandEnvelope command) async { sentCommands.add(command); } } WatchSessionProjection _runningProjection() { return WatchSessionProjection( deviceSessionId: 'session-1', revision: 1, 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: _runningStepTimer(), secondaryTimers: const [ WatchTimerProjection( kind: WatchTimerKind.setTimer, label: 'Série', displayMode: WatchTimerDisplayMode.elapsed, runState: WatchTimerRunState.running, referenceEpochMs: 0, accumulatedMs: 45000, ), ], secondaryActions: const [WatchSecondaryAction.finishCurrentSet], ); } WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) { return WatchSessionProjection( deviceSessionId: '', revision: 0, projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, phase: WatchSessionPhase.noActiveSession, phoneReachable: phoneReachable, seriesIndex: 0, seriesTotal: 0, exerciseName: '', statusLabel: 'Aucune séance', primaryAction: WatchPrimaryAction.none, ); } WatchSessionProjection _readyProjection() { return WatchSessionProjection( deviceSessionId: 'session-1', revision: 1, projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, phase: WatchSessionPhase.ready, phoneReachable: true, seriesIndex: 1, seriesTotal: 3, exerciseName: 'Lancers francs', stepName: 'Routine de tir', statusLabel: 'Prêt à démarrer', primaryAction: WatchPrimaryAction.startCurrentExercise, dominantTimer: const WatchTimerProjection( kind: WatchTimerKind.step, label: 'Chrono étape', displayMode: WatchTimerDisplayMode.countdown, runState: WatchTimerRunState.stopped, referenceEpochMs: 0, accumulatedMs: 0, targetMs: 30000, ), ); } WatchSessionProjection _secondaryRestActionProjection() { return WatchSessionProjection( deviceSessionId: 'session-1', revision: 4, 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, secondaryActions: const [WatchSecondaryAction.skipCurrentRest], ); } 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, lessThan(96), ); } void _expectSessionPageVisible(WidgetTester tester) { expect( tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx, lessThan(96), ); } WatchSessionProjection _manualScoreProjection() { return WatchSessionProjection( deviceSessionId: 'session-1', revision: 2, projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, phase: WatchSessionPhase.running, phoneReachable: true, seriesIndex: 1, seriesTotal: 3, exerciseName: 'Lancers francs', stepName: 'Routine de tir', statusLabel: 'Score manuel', primaryAction: WatchPrimaryAction.pauseSession, hasManualScore: true, currentManualScoreValue: 3, canDecrementScore: true, manualScoreTargetValue: 8, manualScoreTargetLabel: 'Cible', manualScoreScope: WatchManualScoreScope.series, ); } WatchSessionProjection _manualScoreProjectionWithTimer() { return WatchSessionProjection( deviceSessionId: 'session-1', revision: 3, projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, phase: WatchSessionPhase.running, phoneReachable: true, seriesIndex: 1, seriesTotal: 3, exerciseName: 'Lancers francs', stepName: 'Routine de tir', statusLabel: 'Score manuel', primaryAction: WatchPrimaryAction.pauseSession, dominantTimer: _runningStepTimer(), hasManualScore: true, currentManualScoreValue: 3, canDecrementScore: true, manualScoreTargetValue: 8, manualScoreTargetLabel: 'Cible', manualScoreScope: WatchManualScoreScope.step, ); } 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, label: 'Chrono étape', displayMode: WatchTimerDisplayMode.elapsed, runState: WatchTimerRunState.running, referenceEpochMs: DateTime.now() .toUtc() .add(const Duration(minutes: 1)) .millisecondsSinceEpoch, accumulatedMs: 134000, startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch, targetMs: 180000, ); }