fix(watch): stabilise stats live, foreground et resync apres perte de connexion (#179-#189)

Reduit le cout radio des samples live et la cadence des projections telephone -> montre (#179-#183).
Restaure les statistiques live FC/distance/calories et le maintien foreground/ongoing activity (#184-#185).
Fiabilise le demarrage de seance et l'orchestration des permissions montre (#187).
Renforce la resynchronisation des statistiques live et du score apres perte puis retour de connexion (#188-#189).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:08:45 +02:00
parent 639231e3fd
commit 7130635177
15 changed files with 1023 additions and 119 deletions

View File

@ -713,6 +713,128 @@ void main() {
},
);
testWidgets('blocks score commands until reconnect projection resyncs', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
await tester.pump();
client.emitConnection(
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
);
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands, isEmpty);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_manualScoreProjection());
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.incrementScore);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('blocks session actions until reconnect projection resyncs', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
await tester.pump();
client.emitConnection(
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
);
await tester.pump();
await viewModel.sendPrimaryAction();
await tester.pump();
expect(client.sentCommands, isEmpty);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_runningProjection());
await tester.pump();
await viewModel.sendPrimaryAction();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('blocks score commands while projection is stale', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 50),
connectionLostThreshold: const Duration(milliseconds: 150),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_manualScoreProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump(const Duration(milliseconds: 60));
await viewModel.incrementScore();
await tester.pump();
expect(viewModel.value.staleProjection, isTrue);
expect(client.sentCommands, isEmpty);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await viewModel.incrementScore();
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.incrementScore);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'uses a strong pulse sequence when a countdown timer reaches zero',
(tester) async {
@ -767,6 +889,103 @@ void main() {
},
);
testWidgets(
'keeps ticking a visible running countdown until completion without a new projection',
(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);
var nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
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,
nowEpochMs: () => nowMs,
),
),
);
client.emitProjection(
_liveCountdownProjection(remainingMs: 1200, nowMs: nowMs),
);
await tester.pump();
expect(find.text('00:01'), findsOneWidget);
expect(hapticCalls, isEmpty);
nowMs += 1300;
await tester.pump(const Duration(milliseconds: 1300));
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('00:00'), findsOneWidget);
expect(hapticCalls, hasLength(3));
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('does not tick a visible paused countdown', (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(_pausedCountdownProjection(remainingMs: 1200));
await tester.pump();
expect(find.text('00:01'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2300));
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('00:01'), findsOneWidget);
expect(hapticCalls, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('expires an orphaned active projection after its TTL', (
tester,
) async {
@ -801,6 +1020,91 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('marks projection freshness only when thresholds are reached', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 100),
connectionLostThreshold: const Duration(milliseconds: 220),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump();
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pump(const Duration(milliseconds: 90));
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pump(const Duration(milliseconds: 20));
expect(viewModel.value.staleProjection, isTrue);
expect(viewModel.value.connectionLost, isFalse);
expect(find.text('Dernier état reçu'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 130));
expect(viewModel.value.staleProjection, isTrue);
expect(viewModel.value.connectionLost, isTrue);
expect(find.text('Connexion au téléphone perdue'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('reconnects and refreshes freshness state immediately', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
staleProjectionThreshold: const Duration(milliseconds: 100),
connectionLostThreshold: const Duration(milliseconds: 220),
);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump(const Duration(milliseconds: 240));
expect(viewModel.value.connectionLost, isTrue);
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: true));
await tester.pump();
expect(viewModel.value.connectionLost, isFalse);
expect(viewModel.value.staleProjection, isTrue);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(
_expiringProjection(expiresIn: const Duration(seconds: 3)),
);
await tester.pump();
expect(viewModel.value.staleProjection, isFalse);
expect(viewModel.value.connectionLost, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
}
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
@ -1037,11 +1341,15 @@ void _expectSessionPageVisible(WidgetTester tester) {
);
}
WatchSessionProjection _manualScoreProjection() {
WatchSessionProjection _manualScoreProjection({
Duration expiresIn = const Duration(seconds: 12),
}) {
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 2,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
@ -1159,6 +1467,60 @@ WatchSessionProjection _countdownProjection({required int accumulatedMs}) {
);
}
WatchSessionProjection _liveCountdownProjection({
required int remainingMs,
required int nowMs,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: remainingMs,
projectedAtEpochMs: nowMs,
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: nowMs,
accumulatedMs: 30000 - remainingMs,
startedAtEpochMs: nowMs,
targetMs: 30000,
),
);
}
WatchSessionProjection _pausedCountdownProjection({required int remainingMs}) {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: remainingMs,
projectedAtEpochMs: nowMs,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Gainage',
statusLabel: 'Chrono étape',
primaryAction: WatchPrimaryAction.resumeSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.paused,
referenceEpochMs: nowMs,
accumulatedMs: 30000 - remainingMs,
startedAtEpochMs: null,
targetMs: 30000,
),
);
}
WatchTimerProjection _runningStepTimer() {
return WatchTimerProjection(
kind: WatchTimerKind.step,