Files
GameTime/watch_app/test/presentation/watch_session_screen_test.dart
Blomios 917777e18b 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>
2026-07-28 16:48:54 +02:00

934 lines
29 KiB
Dart

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 = <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 {
final _projectionController =
StreamController<WatchSessionProjection>.broadcast();
final _sensorSampleController =
StreamController<WatchSensorSample>.broadcast();
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
final _connectionController =
StreamController<WatchBridgeConnectionEvent>.broadcast();
var resyncRequests = 0;
var capabilityRefreshRequests = 0;
final sentCommands = <WatchCommandEnvelope>[];
@override
Stream<WatchSessionProjection> get projections =>
_projectionController.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;
@override
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
@override
Stream<WatchBridgeConnectionEvent> 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<void> requestCapabilityRefresh() async {
capabilityRefreshRequests += 1;
}
@override
Future<void> requestResync() async {
resyncRequests += 1;
}
@override
Future<void> 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,
);
}