wip(watch): sauvegarde travail en cours lot critique (#157 #163 #164 #174 #175 #178)

This commit is contained in:
2026-07-29 12:34:07 +02:00
parent f523a5a8b5
commit 74d6000606
26 changed files with 516 additions and 62 deletions

View File

@ -106,6 +106,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
_subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample));
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
_subscriptions.add(_nativeClient.alerts.listen(_handleAlert));
_subscriptions.add(
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
);
@ -122,6 +123,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
final Duration _staleProjectionThreshold;
final Duration _connectionLostThreshold;
final _subscriptions = <StreamSubscription<dynamic>>[];
final _handledAlertIds = <String>{};
Timer? _waitingTimer;
Timer? _commandTimeoutTimer;
@ -379,6 +381,26 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
}
}
void _handleAlert(WatchAlertEnvelope alert) {
final alertId = alert.alertId.trim();
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
if (alertId.isEmpty ||
!_handledAlertIds.add(alertId) ||
alert.sessionId != value.projection.deviceSessionId ||
(alert.expiresAtEpochMs > 0 && nowMs > alert.expiresAtEpochMs)) {
return;
}
if (_handledAlertIds.length > 64) {
_handledAlertIds.remove(_handledAlertIds.first);
}
switch (alert.pattern) {
case WatchAlertPattern.countdownTick:
unawaited(HapticFeedback.selectionClick());
case WatchAlertPattern.timerFinished:
_triggerTimerFinishedHaptic();
}
}
void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
value = value.copyWith(connectionLost: !event.isReachable);
if (event.isReachable || event.requestsResync) {
@ -392,15 +414,12 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
return;
}
final now = DateTime.now();
final expiresAtEpochMs = value.projection.expiresAtEpochMs;
final fallbackExpired =
expiresAtEpochMs <= 0 &&
now.difference(receivedAt) >= const Duration(seconds: 12);
final expiryAge = Duration(
milliseconds: _projectionTtlMs(value.projection),
);
final expired =
value.projection.deviceSessionId.isNotEmpty &&
(fallbackExpired ||
(expiresAtEpochMs > 0 &&
now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) &&
now.difference(receivedAt) >= expiryAge &&
_pendingCommand == null &&
_pendingScoreCommandIds.isEmpty;
if (expired) {
@ -444,22 +463,15 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
if (projection.deviceSessionId.isEmpty) {
return;
}
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
final expiresAtEpochMs = projection.expiresAtEpochMs > 0
? projection.expiresAtEpochMs
: nowMs + const Duration(seconds: 12).inMilliseconds;
final delayMs = expiresAtEpochMs - nowMs;
_projectionExpiryTimer = Timer(
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
() {
if (value.projection.deviceSessionId.isEmpty ||
_pendingCommand != null ||
_pendingScoreCommandIds.isNotEmpty) {
return;
}
_invalidateExpiredProjection();
},
);
final delayMs = _projectionTtlMs(projection);
_projectionExpiryTimer = Timer(Duration(milliseconds: delayMs), () {
if (value.projection.deviceSessionId.isEmpty ||
_pendingCommand != null ||
_pendingScoreCommandIds.isNotEmpty) {
return;
}
_invalidateExpiredProjection();
});
}
void _clearCommandTimers() {
@ -538,6 +550,29 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
);
}
}
void _triggerTimerFinishedHaptic() {
unawaited(HapticFeedback.heavyImpact());
unawaited(
Future<void>.delayed(const Duration(milliseconds: 140), () {
return HapticFeedback.heavyImpact();
}),
);
unawaited(
Future<void>.delayed(const Duration(milliseconds: 320), () {
return HapticFeedback.heavyImpact();
}),
);
}
}
int _projectionTtlMs(WatchSessionProjection projection) {
final projectedAtEpochMs = projection.projectedAtEpochMs;
final expiresAtEpochMs = projection.expiresAtEpochMs;
if (projectedAtEpochMs > 0 && expiresAtEpochMs > projectedAtEpochMs) {
return expiresAtEpochMs - projectedAtEpochMs;
}
return const Duration(seconds: 12).inMilliseconds;
}
bool _isRejected(WatchCommandAck ack) {