Met en conformité l'implémentation avec le cadrage produit acté (#155, #144, #142) : diffusion de la FC live depuis la montre vers le téléphone via le bridge, affichage sur l'écran de séance montre (watch_session_screen) en plus de l'écran Stats existant. Validé : bridge fonctionnel bout-en-bout, tests watch_session_screen OK. Artefact canonique d'installation montre confirmé : watch_app/build/app/outputs/flutter-apk/app-release.apk (package com.gametime.app) ; ne pas utiliser l'artefact résiduel watch_app/build/watch_app/app/outputs/flutter-apk/app-release.apk. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
699 lines
21 KiB
Dart
699 lines
21 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.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.byTooltip('Pause'), findsOneWidget);
|
|
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('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 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'), 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();
|
|
},
|
|
);
|
|
}
|
|
|
|
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],
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|