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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user