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:
@ -169,20 +169,27 @@ object WatchBridgePlugin {
|
|||||||
result.error("invalid_projection", "Projection payload must be a map.", null)
|
result.error("invalid_projection", "Projection payload must be a map.", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val projectionJson = JSONObject(map).toString()
|
val projection = map.filterKeys { it != "urgent" }
|
||||||
|
val projectionJson = JSONObject(projection).toString()
|
||||||
val request = PutDataMapRequest.create(STATE_PATH).apply {
|
val request = PutDataMapRequest.create(STATE_PATH).apply {
|
||||||
dataMap.putString("projectionJson", projectionJson)
|
dataMap.putString("projectionJson", projectionJson)
|
||||||
dataMap.putInt("schemaVersion", (map["schemaVersion"] as? Number)?.toInt() ?: 1)
|
dataMap.putInt(
|
||||||
dataMap.putInt("revision", (map["revision"] as? Number)?.toInt() ?: 0)
|
"schemaVersion",
|
||||||
|
(projection["schemaVersion"] as? Number)?.toInt() ?: 1,
|
||||||
|
)
|
||||||
|
dataMap.putInt("revision", (projection["revision"] as? Number)?.toInt() ?: 0)
|
||||||
dataMap.putLong(
|
dataMap.putLong(
|
||||||
"projectedAtEpochMs",
|
"projectedAtEpochMs",
|
||||||
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
(projection["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
||||||
)
|
)
|
||||||
dataMap.putLong(
|
dataMap.putLong(
|
||||||
"expiresAtEpochMs",
|
"expiresAtEpochMs",
|
||||||
(map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
(projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L,
|
||||||
)
|
)
|
||||||
}.asPutDataRequest().setUrgent()
|
}.asPutDataRequest()
|
||||||
|
if (map["urgent"] == true) {
|
||||||
|
request.setUrgent()
|
||||||
|
}
|
||||||
Wearable.getDataClient(context).putDataItem(request)
|
Wearable.getDataClient(context).putDataItem(request)
|
||||||
.addOnSuccessListener { result.success(null) }
|
.addOnSuccessListener { result.success(null) }
|
||||||
.addOnFailureListener { error ->
|
.addOnFailureListener { error ->
|
||||||
|
|||||||
@ -5,7 +5,7 @@ abstract interface class WatchCommandIngress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class WatchProjectionPublisher {
|
abstract interface class WatchProjectionPublisher {
|
||||||
Future<void> publish(WatchSessionProjection projection);
|
Future<void> publish(WatchSessionProjection projection, {bool urgent = true});
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class WatchAlertPublisher {
|
abstract interface class WatchAlertPublisher {
|
||||||
|
|||||||
@ -22,7 +22,10 @@ abstract interface class WatchBridgeNativeChannel {
|
|||||||
|
|
||||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||||
|
|
||||||
Future<void> publishProjection(WatchSessionProjection projection);
|
Future<void> publishProjection(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
bool urgent = true,
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> publishAlert(WatchAlertEnvelope alert);
|
Future<void> publishAlert(WatchAlertEnvelope alert);
|
||||||
|
|
||||||
@ -124,11 +127,14 @@ final class MethodChannelWatchBridgeNativeChannel
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> publishProjection(WatchSessionProjection projection) {
|
Future<void> publishProjection(
|
||||||
return _invokeIgnoringMissingPlugin(
|
WatchSessionProjection projection, {
|
||||||
'publishProjection',
|
bool urgent = true,
|
||||||
projection.toJson(),
|
}) {
|
||||||
);
|
return _invokeIgnoringMissingPlugin('publishProjection', {
|
||||||
|
...projection.toJson(),
|
||||||
|
'urgent': urgent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -15,7 +15,7 @@ final class WatchWearDataLayerAdapter
|
|||||||
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
||||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||||
WorkoutTelemetryUseCases? workoutTelemetryUseCases,
|
WorkoutTelemetryUseCases? workoutTelemetryUseCases,
|
||||||
Duration projectionRefreshInterval = const Duration(seconds: 2),
|
Duration projectionRefreshInterval = const Duration(seconds: 5),
|
||||||
}) : _nativeChannel = nativeChannel,
|
}) : _nativeChannel = nativeChannel,
|
||||||
_commandIngress = commandIngress,
|
_commandIngress = commandIngress,
|
||||||
_projectionSource = projectionSource,
|
_projectionSource = projectionSource,
|
||||||
@ -36,6 +36,9 @@ final class WatchWearDataLayerAdapter
|
|||||||
Future<void> _commandTail = Future<void>.value();
|
Future<void> _commandTail = Future<void>.value();
|
||||||
Timer? _projectionRefreshTimer;
|
Timer? _projectionRefreshTimer;
|
||||||
WatchSessionProjection? _latestProjection;
|
WatchSessionProjection? _latestProjection;
|
||||||
|
WatchSessionProjection? _lastPublishedProjection;
|
||||||
|
bool _skipNextProjectionEmissionForForcedResync = false;
|
||||||
|
int? _lastPublishedProjectionRevision;
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
bool _foregroundActive = false;
|
bool _foregroundActive = false;
|
||||||
|
|
||||||
@ -47,6 +50,10 @@ final class WatchWearDataLayerAdapter
|
|||||||
_ensureProjectionRefreshLoop();
|
_ensureProjectionRefreshLoop();
|
||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
_projectionSource.projections.listen((projection) {
|
_projectionSource.projections.listen((projection) {
|
||||||
|
if (_skipNextProjectionEmissionForForcedResync) {
|
||||||
|
_skipNextProjectionEmissionForForcedResync = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
unawaited(publish(projection));
|
unawaited(publish(projection));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -77,7 +84,7 @@ final class WatchWearDataLayerAdapter
|
|||||||
_subscriptions.add(
|
_subscriptions.add(
|
||||||
_nativeChannel.connectionEvents.listen((event) {
|
_nativeChannel.connectionEvents.listen((event) {
|
||||||
if (event.isReachable || event.requestsResync) {
|
if (event.isReachable || event.requestsResync) {
|
||||||
unawaited(_projectionSource.emitCurrentProjection());
|
unawaited(_forceProjectionResync());
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -96,7 +103,18 @@ final class WatchWearDataLayerAdapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> publish(WatchSessionProjection projection) async {
|
Future<void> publish(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
bool urgent = true,
|
||||||
|
}) async {
|
||||||
|
await _publishProjection(projection, urgent: urgent, force: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _publishProjection(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
required bool urgent,
|
||||||
|
required bool force,
|
||||||
|
}) async {
|
||||||
final previousProjection = _latestProjection;
|
final previousProjection = _latestProjection;
|
||||||
_latestProjection = projection;
|
_latestProjection = projection;
|
||||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||||
@ -105,7 +123,23 @@ final class WatchWearDataLayerAdapter
|
|||||||
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _nativeChannel.publishProjection(projection);
|
if (!force &&
|
||||||
|
_lastPublishedProjection != null &&
|
||||||
|
_hasSameSignificantProjectionState(
|
||||||
|
_lastPublishedProjection!,
|
||||||
|
projection,
|
||||||
|
)) {
|
||||||
|
await _syncForegroundService(projection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final revisionChanged =
|
||||||
|
_lastPublishedProjectionRevision != projection.revision;
|
||||||
|
await _nativeChannel.publishProjection(
|
||||||
|
projection,
|
||||||
|
urgent: force ? urgent : urgent && revisionChanged,
|
||||||
|
);
|
||||||
|
_lastPublishedProjection = projection;
|
||||||
|
_lastPublishedProjectionRevision = projection.revision;
|
||||||
await _syncForegroundService(projection);
|
await _syncForegroundService(projection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,9 +214,114 @@ final class WatchWearDataLayerAdapter
|
|||||||
|
|
||||||
void _ensureProjectionRefreshLoop() {
|
void _ensureProjectionRefreshLoop() {
|
||||||
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
||||||
unawaited(_projectionSource.emitCurrentProjection());
|
unawaited(_publishHeartbeat());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _publishHeartbeat() async {
|
||||||
|
final projection = await _emitCurrentProjectionSkippingSourceEcho();
|
||||||
|
await _publishProjection(projection, urgent: false, force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _forceProjectionResync() async {
|
||||||
|
final projection = await _emitCurrentProjectionSkippingSourceEcho();
|
||||||
|
await _publishProjection(projection, urgent: true, force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WatchSessionProjection>
|
||||||
|
_emitCurrentProjectionSkippingSourceEcho() async {
|
||||||
|
_skipNextProjectionEmissionForForcedResync = true;
|
||||||
|
try {
|
||||||
|
return await _projectionSource.emitCurrentProjection();
|
||||||
|
} finally {
|
||||||
|
unawaited(
|
||||||
|
Future<void>.delayed(Duration.zero, () {
|
||||||
|
_skipNextProjectionEmissionForForcedResync = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasSameSignificantProjectionState(
|
||||||
|
WatchSessionProjection left,
|
||||||
|
WatchSessionProjection right,
|
||||||
|
) {
|
||||||
|
return left.schemaVersion == right.schemaVersion &&
|
||||||
|
left.deviceSessionId == right.deviceSessionId &&
|
||||||
|
left.revision == right.revision &&
|
||||||
|
left.phase == right.phase &&
|
||||||
|
left.phoneReachable == right.phoneReachable &&
|
||||||
|
left.seriesIndex == right.seriesIndex &&
|
||||||
|
left.seriesTotal == right.seriesTotal &&
|
||||||
|
left.exerciseName == right.exerciseName &&
|
||||||
|
left.programIndex == right.programIndex &&
|
||||||
|
left.exerciseIndex == right.exerciseIndex &&
|
||||||
|
left.setIndex == right.setIndex &&
|
||||||
|
left.passageIndex == right.passageIndex &&
|
||||||
|
left.passageTotal == right.passageTotal &&
|
||||||
|
left.stepIndex == right.stepIndex &&
|
||||||
|
left.stepTotal == right.stepTotal &&
|
||||||
|
left.stepName == right.stepName &&
|
||||||
|
left.stepType == right.stepType &&
|
||||||
|
left.stepTargetValue == right.stepTargetValue &&
|
||||||
|
_hasSameSignificantTimerState(left.dominantTimer, right.dominantTimer) &&
|
||||||
|
_hasSameSignificantTimerListState(
|
||||||
|
left.secondaryTimers,
|
||||||
|
right.secondaryTimers,
|
||||||
|
) &&
|
||||||
|
left.primaryAction == right.primaryAction &&
|
||||||
|
_listEquals(left.secondaryActions, right.secondaryActions) &&
|
||||||
|
left.nextExerciseName == right.nextExerciseName &&
|
||||||
|
left.statusLabel == right.statusLabel &&
|
||||||
|
left.hasManualScore == right.hasManualScore &&
|
||||||
|
left.currentManualScoreValue == right.currentManualScoreValue &&
|
||||||
|
left.canDecrementScore == right.canDecrementScore &&
|
||||||
|
left.manualScoreTargetValue == right.manualScoreTargetValue &&
|
||||||
|
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
|
||||||
|
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
|
||||||
|
left.manualScoreScope == right.manualScoreScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasSameSignificantTimerListState(
|
||||||
|
List<WatchTimerProjection> left,
|
||||||
|
List<WatchTimerProjection> right,
|
||||||
|
) {
|
||||||
|
if (left.length != right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var index = 0; index < left.length; index += 1) {
|
||||||
|
if (!_hasSameSignificantTimerState(left[index], right[index])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasSameSignificantTimerState(
|
||||||
|
WatchTimerProjection? left,
|
||||||
|
WatchTimerProjection? right,
|
||||||
|
) {
|
||||||
|
if (left == null || right == null) {
|
||||||
|
return left == right;
|
||||||
|
}
|
||||||
|
return left.kind == right.kind &&
|
||||||
|
left.label == right.label &&
|
||||||
|
left.displayMode == right.displayMode &&
|
||||||
|
left.runState == right.runState &&
|
||||||
|
left.targetMs == right.targetMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEquals<T>(List<T> left, List<T> right) {
|
||||||
|
if (left.length != right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var index = 0; index < left.length; index += 1) {
|
||||||
|
if (left[index] != right[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _WatchAdapterCommandKey {
|
final class _WatchAdapterCommandKey {
|
||||||
|
|||||||
@ -384,6 +384,8 @@ void main() {
|
|||||||
type: ExerciseStepType.reps,
|
type: ExerciseStepType.reps,
|
||||||
defaultTargetValue: 10,
|
defaultTargetValue: 10,
|
||||||
hasScore: true,
|
hasScore: true,
|
||||||
|
scoreLabel: 'Cible',
|
||||||
|
scoreUnit: 'pts',
|
||||||
defaultTargetScore: 8,
|
defaultTargetScore: 8,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -736,7 +736,10 @@ final class _FakeWatchProjectionPublisher implements WatchProjectionPublisher {
|
|||||||
final published = <WatchSessionProjection>[];
|
final published = <WatchSessionProjection>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> publish(WatchSessionProjection projection) async {
|
Future<void> publish(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
bool urgent = true,
|
||||||
|
}) async {
|
||||||
published.add(projection);
|
published.add(projection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,36 +18,89 @@ void main() {
|
|||||||
final source = _FakeProjectionSource(_projection(revision: 0));
|
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||||
final adapter = _adapter(native: native, source: source);
|
final adapter = _adapter(native: native, source: source);
|
||||||
await adapter.start();
|
await adapter.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
native.published.clear();
|
native.published.clear();
|
||||||
|
|
||||||
source.emit(_projection(revision: 1));
|
|
||||||
source.emit(_projection(revision: 2));
|
source.emit(_projection(revision: 2));
|
||||||
|
source.emit(_projection(revision: 3));
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
expect(native.published.map((projection) => projection.revision), [1, 2]);
|
expect(native.published.map((projection) => projection.revision), [2, 3]);
|
||||||
await adapter.stop();
|
await adapter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('heartbeats while a timer is running', () async {
|
test('republishes volatile heartbeats as non urgent keepalives', () async {
|
||||||
final native = _FakeWatchBridgeNativeChannel();
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
final source = _FakeProjectionSource(_runningProjection(revision: 1));
|
final source = _FakeProjectionSource(
|
||||||
|
_runningProjection(revision: 1),
|
||||||
|
incrementsRevisionOnEmit: false,
|
||||||
|
);
|
||||||
final adapter = _adapter(
|
final adapter = _adapter(
|
||||||
native: native,
|
native: native,
|
||||||
source: source,
|
source: source,
|
||||||
heartbeatInterval: const Duration(milliseconds: 10),
|
heartbeatInterval: const Duration(milliseconds: 10),
|
||||||
);
|
);
|
||||||
await adapter.start();
|
await adapter.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
native.published.clear();
|
native.published.clear();
|
||||||
|
native.urgentFlags.clear();
|
||||||
source.emitCount = 0;
|
source.emitCount = 0;
|
||||||
|
|
||||||
await adapter.publish(_runningProjection(revision: 1));
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 35));
|
await Future<void>.delayed(const Duration(milliseconds: 35));
|
||||||
|
|
||||||
expect(source.emitCount, greaterThanOrEqualTo(1));
|
expect(source.emitCount, greaterThanOrEqualTo(1));
|
||||||
expect(native.published.length, greaterThanOrEqualTo(2));
|
expect(native.published.length, greaterThanOrEqualTo(1));
|
||||||
|
expect(native.urgentFlags, everyElement(false));
|
||||||
await adapter.stop();
|
await adapter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('marks only projection revision changes as urgent', () async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final source = _FakeProjectionSource(_runningProjection(revision: 0));
|
||||||
|
final adapter = _adapter(native: native, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
native.published.clear();
|
||||||
|
native.urgentFlags.clear();
|
||||||
|
|
||||||
|
await adapter.publish(_runningProjection(revision: 2));
|
||||||
|
await adapter.publish(_runningProjection(revision: 2));
|
||||||
|
|
||||||
|
expect(native.published.map((projection) => projection.revision), [2]);
|
||||||
|
expect(native.urgentFlags, [true]);
|
||||||
|
await adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'forces an urgent projection resync even when state is unchanged',
|
||||||
|
() async {
|
||||||
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
|
final source = _FakeProjectionSource(
|
||||||
|
_runningProjection(revision: 1),
|
||||||
|
incrementsRevisionOnEmit: false,
|
||||||
|
);
|
||||||
|
final adapter = _adapter(native: native, source: source);
|
||||||
|
await adapter.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
native.published.clear();
|
||||||
|
native.urgentFlags.clear();
|
||||||
|
source.emitCount = 0;
|
||||||
|
|
||||||
|
native.emitConnection(
|
||||||
|
const WatchBridgeConnectionEvent(
|
||||||
|
isReachable: true,
|
||||||
|
requestsResync: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(source.emitCount, 1);
|
||||||
|
expect(native.published.map((projection) => projection.revision), [1]);
|
||||||
|
expect(native.urgentFlags, [true]);
|
||||||
|
await adapter.stop();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('dispatches watch command and sends ack back to native layer', () async {
|
test('dispatches watch command and sends ack back to native layer', () async {
|
||||||
final native = _FakeWatchBridgeNativeChannel();
|
final native = _FakeWatchBridgeNativeChannel();
|
||||||
final ingress = _FakeCommandIngress();
|
final ingress = _FakeCommandIngress();
|
||||||
@ -89,6 +142,7 @@ void main() {
|
|||||||
final source = _FakeProjectionSource(_projection(revision: 3));
|
final source = _FakeProjectionSource(_projection(revision: 3));
|
||||||
final adapter = _adapter(native: native, source: source);
|
final adapter = _adapter(native: native, source: source);
|
||||||
await adapter.start();
|
await adapter.start();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
native.published.clear();
|
native.published.clear();
|
||||||
source.emitCount = 0;
|
source.emitCount = 0;
|
||||||
|
|
||||||
@ -372,9 +426,10 @@ final class _FakeIds implements IdGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class _FakeProjectionSource implements WatchProjectionSource {
|
final class _FakeProjectionSource implements WatchProjectionSource {
|
||||||
_FakeProjectionSource(this.current);
|
_FakeProjectionSource(this.current, {this.incrementsRevisionOnEmit = true});
|
||||||
|
|
||||||
WatchSessionProjection current;
|
WatchSessionProjection current;
|
||||||
|
final bool incrementsRevisionOnEmit;
|
||||||
var emitCount = 0;
|
var emitCount = 0;
|
||||||
final _controller = StreamController<WatchSessionProjection>.broadcast();
|
final _controller = StreamController<WatchSessionProjection>.broadcast();
|
||||||
|
|
||||||
@ -392,19 +447,44 @@ final class _FakeProjectionSource implements WatchProjectionSource {
|
|||||||
@override
|
@override
|
||||||
Future<WatchSessionProjection> emitCurrentProjection() async {
|
Future<WatchSessionProjection> emitCurrentProjection() async {
|
||||||
emitCount += 1;
|
emitCount += 1;
|
||||||
|
final nextRevision = incrementsRevisionOnEmit
|
||||||
|
? current.revision + 1
|
||||||
|
: current.revision;
|
||||||
current = WatchSessionProjection(
|
current = WatchSessionProjection(
|
||||||
deviceSessionId: current.deviceSessionId,
|
deviceSessionId: current.deviceSessionId,
|
||||||
revision: current.revision + 1,
|
revision: nextRevision,
|
||||||
projectedAtEpochMs: current.projectedAtEpochMs,
|
projectedAtEpochMs: current.projectedAtEpochMs + 1000,
|
||||||
|
expiresAtEpochMs: current.expiresAtEpochMs == 0
|
||||||
|
? 0
|
||||||
|
: current.expiresAtEpochMs + 1000,
|
||||||
phase: current.phase,
|
phase: current.phase,
|
||||||
phoneReachable: current.phoneReachable,
|
phoneReachable: current.phoneReachable,
|
||||||
seriesIndex: current.seriesIndex,
|
seriesIndex: current.seriesIndex,
|
||||||
seriesTotal: current.seriesTotal,
|
seriesTotal: current.seriesTotal,
|
||||||
exerciseName: current.exerciseName,
|
exerciseName: current.exerciseName,
|
||||||
|
programIndex: current.programIndex,
|
||||||
|
exerciseIndex: current.exerciseIndex,
|
||||||
|
setIndex: current.setIndex,
|
||||||
|
passageIndex: current.passageIndex,
|
||||||
|
passageTotal: current.passageTotal,
|
||||||
|
stepIndex: current.stepIndex,
|
||||||
|
stepTotal: current.stepTotal,
|
||||||
|
stepName: current.stepName,
|
||||||
|
stepType: current.stepType,
|
||||||
|
stepTargetValue: current.stepTargetValue,
|
||||||
dominantTimer: current.dominantTimer,
|
dominantTimer: current.dominantTimer,
|
||||||
secondaryTimers: current.secondaryTimers,
|
secondaryTimers: current.secondaryTimers,
|
||||||
primaryAction: current.primaryAction,
|
primaryAction: current.primaryAction,
|
||||||
secondaryActions: current.secondaryActions,
|
secondaryActions: current.secondaryActions,
|
||||||
|
nextExerciseName: current.nextExerciseName,
|
||||||
|
statusLabel: current.statusLabel,
|
||||||
|
hasManualScore: current.hasManualScore,
|
||||||
|
currentManualScoreValue: current.currentManualScoreValue,
|
||||||
|
canDecrementScore: current.canDecrementScore,
|
||||||
|
manualScoreTargetValue: current.manualScoreTargetValue,
|
||||||
|
manualScoreTargetLabel: current.manualScoreTargetLabel,
|
||||||
|
manualScoreRepsTargetValue: current.manualScoreRepsTargetValue,
|
||||||
|
manualScoreScope: current.manualScoreScope,
|
||||||
);
|
);
|
||||||
_controller.add(current);
|
_controller.add(current);
|
||||||
return current;
|
return current;
|
||||||
@ -479,9 +559,15 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
|||||||
_connections.add(event);
|
_connections.add(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final urgentFlags = <bool>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> publishProjection(WatchSessionProjection projection) async {
|
Future<void> publishProjection(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
bool urgent = true,
|
||||||
|
}) async {
|
||||||
published.add(projection);
|
published.add(projection);
|
||||||
|
urgentFlags.add(urgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class WatchBridgeListenerService : WearableListenerService() {
|
|||||||
requestsResync = capabilityInfo.nodes.isNotEmpty(),
|
requestsResync = capabilityInfo.nodes.isNotEmpty(),
|
||||||
)
|
)
|
||||||
if (capabilityInfo.nodes.isNotEmpty()) {
|
if (capabilityInfo.nodes.isNotEmpty()) {
|
||||||
|
WatchBridgePlugin.resyncLiveStatsAfterReconnect(applicationContext)
|
||||||
WatchBridgePlugin.requestLatestProjection(applicationContext)
|
WatchBridgePlugin.requestLatestProjection(applicationContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -275,6 +275,7 @@ object WatchBridgePlugin {
|
|||||||
"requestResync" -> {
|
"requestResync" -> {
|
||||||
requestLatestProjection(context)
|
requestLatestProjection(context)
|
||||||
requestCapabilityRefresh(context)
|
requestCapabilityRefresh(context)
|
||||||
|
resyncLiveStatsAfterReconnect(context)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"invalidateActiveProjection" -> {
|
"invalidateActiveProjection" -> {
|
||||||
@ -291,7 +292,7 @@ object WatchBridgePlugin {
|
|||||||
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
activeProjectionExpiryRunnable = null
|
activeProjectionExpiryRunnable = null
|
||||||
WatchOngoingActivityController.cancel(context)
|
WatchOngoingActivityController.cancel(context)
|
||||||
WatchHeartRateForegroundService.stop(context)
|
WatchHeartRateForegroundService.stop(context, force = true)
|
||||||
heartRateCollector.finishCurrentSession(context)
|
heartRateCollector.finishCurrentSession(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,6 +348,9 @@ object WatchBridgePlugin {
|
|||||||
Wearable.getCapabilityClient(context)
|
Wearable.getCapabilityClient(context)
|
||||||
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
|
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
|
||||||
.addOnSuccessListener { capability ->
|
.addOnSuccessListener { capability ->
|
||||||
|
if (capability.nodes.isNotEmpty()) {
|
||||||
|
resyncLiveStatsAfterReconnect(context)
|
||||||
|
}
|
||||||
emitConnection(
|
emitConnection(
|
||||||
isReachable = capability.nodes.isNotEmpty(),
|
isReachable = capability.nodes.isNotEmpty(),
|
||||||
requestsResync = capability.nodes.isNotEmpty(),
|
requestsResync = capability.nodes.isNotEmpty(),
|
||||||
@ -357,6 +361,12 @@ object WatchBridgePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun resyncLiveStatsAfterReconnect(context: Context) {
|
||||||
|
heartRateCollector.onPhoneReconnected(context)
|
||||||
|
val projection = lastSensorProjection ?: lastActiveProjection ?: return
|
||||||
|
updateHeartRateCollection(context, projection)
|
||||||
|
}
|
||||||
|
|
||||||
fun requestLatestProjection(context: Context) {
|
fun requestLatestProjection(context: Context) {
|
||||||
val uri = Uri.Builder()
|
val uri = Uri.Builder()
|
||||||
.scheme("wear")
|
.scheme("wear")
|
||||||
@ -468,13 +478,13 @@ object WatchBridgePlugin {
|
|||||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||||
lastSensorProjection = null
|
lastSensorProjection = null
|
||||||
WatchHeartRateForegroundService.stop(context)
|
WatchHeartRateForegroundService.stop(context, force = phase == "noActiveSession")
|
||||||
heartRateCollector.finishCurrentSession(context)
|
heartRateCollector.finishCurrentSession(context)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastSensorProjection = projection
|
lastSensorProjection = projection
|
||||||
val shouldAggregate = phase == "running"
|
val shouldAggregate = phase == "running"
|
||||||
if (!hasRequiredSensorPermissions(context)) {
|
if (!hasRequiredRuntimePermissions(context)) {
|
||||||
WatchHeartRateForegroundService.stop(context)
|
WatchHeartRateForegroundService.stop(context)
|
||||||
heartRateCollector.noteActiveSession(
|
heartRateCollector.noteActiveSession(
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -506,12 +516,18 @@ object WatchBridgePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasRequiredRuntimePermissions(context: Context): Boolean {
|
||||||
|
return requiredRuntimePermissions().all { permission ->
|
||||||
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestSensorPermissionsOnce() {
|
private fun requestSensorPermissionsOnce() {
|
||||||
val activity = activity ?: run {
|
val activity = activity ?: run {
|
||||||
Log.d(TAG, "sensor permission request pending: activity unavailable")
|
Log.d(TAG, "sensor permission request pending: activity unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val permissions = requiredSensorPermissions()
|
val permissions = requiredRuntimePermissions()
|
||||||
.filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
|
.filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
|
||||||
.toTypedArray()
|
.toTypedArray()
|
||||||
if (permissions.isEmpty()) {
|
if (permissions.isEmpty()) {
|
||||||
@ -546,7 +562,7 @@ object WatchBridgePlugin {
|
|||||||
|
|
||||||
private fun requestPendingSensorPermissionIfPossible() {
|
private fun requestPendingSensorPermissionIfPossible() {
|
||||||
val context = appContext ?: return
|
val context = appContext ?: return
|
||||||
if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) {
|
if (!pendingSensorPermissionRequest || hasRequiredRuntimePermissions(context)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requestSensorPermissionsOnce()
|
requestSensorPermissionsOnce()
|
||||||
@ -565,6 +581,14 @@ object WatchBridgePlugin {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requiredRuntimePermissions(): List<String> {
|
||||||
|
val permissions = requiredSensorPermissions().toMutableList()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissions.add(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
|
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"programIndex" to projection["programIndex"],
|
"programIndex" to projection["programIndex"],
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package com.gametime.watch.bridge
|
package com.gametime.watch.bridge
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.health.services.client.ExerciseClient
|
import androidx.health.services.client.ExerciseClient
|
||||||
import androidx.health.services.client.ExerciseUpdateCallback
|
import androidx.health.services.client.ExerciseUpdateCallback
|
||||||
@ -16,6 +19,7 @@ import androidx.health.services.client.data.ExerciseEvent
|
|||||||
import androidx.health.services.client.data.ExerciseLapSummary
|
import androidx.health.services.client.data.ExerciseLapSummary
|
||||||
import androidx.health.services.client.data.ExerciseType
|
import androidx.health.services.client.data.ExerciseType
|
||||||
import com.google.android.gms.wearable.CapabilityClient
|
import com.google.android.gms.wearable.CapabilityClient
|
||||||
|
import com.google.android.gms.wearable.Node
|
||||||
import com.google.android.gms.wearable.Wearable
|
import com.google.android.gms.wearable.Wearable
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
@ -29,8 +33,16 @@ internal class WatchHeartRateCollector(
|
|||||||
) {
|
) {
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "GTWatchHeartRate"
|
const val TAG = "GTWatchHeartRate"
|
||||||
|
const val SAMPLE_FLUSH_INTERVAL_MS = 1500L
|
||||||
|
const val NODE_CACHE_TTL_MS = 10000L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var pendingSample: Map<String, Any?>? = null
|
||||||
|
private var sampleFlushRunnable: Runnable? = null
|
||||||
|
private var cachedReachableNodes: List<Node> = emptyList()
|
||||||
|
private var cachedReachableNodesAtEpochMs = 0L
|
||||||
|
private var nodeLookupInFlight = false
|
||||||
private var sessionId: String? = null
|
private var sessionId: String? = null
|
||||||
private var sampleCount = 0
|
private var sampleCount = 0
|
||||||
private var sampleSum = 0.0
|
private var sampleSum = 0.0
|
||||||
@ -43,6 +55,8 @@ internal class WatchHeartRateCollector(
|
|||||||
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
|
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
|
||||||
private var exerciseMetricsStarted = false
|
private var exerciseMetricsStarted = false
|
||||||
private var exerciseMetricsStartInFlight = false
|
private var exerciseMetricsStartInFlight = false
|
||||||
|
private var exerciseHeartRateSupported = false
|
||||||
|
private var exerciseHeartRateObserved = false
|
||||||
private var shouldAggregate = false
|
private var shouldAggregate = false
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
|
||||||
@ -63,8 +77,7 @@ internal class WatchHeartRateCollector(
|
|||||||
latestHeartRateBpm = recordHeartRate(point.value)
|
latestHeartRateBpm = recordHeartRate(point.value)
|
||||||
}
|
}
|
||||||
if (latestHeartRateBpm != null) {
|
if (latestHeartRateBpm != null) {
|
||||||
Log.d(
|
logHotPath(
|
||||||
TAG,
|
|
||||||
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
|
"heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
|
||||||
)
|
)
|
||||||
sendSample(latestHeartRateBpm)
|
sendSample(latestHeartRateBpm)
|
||||||
@ -90,6 +103,14 @@ internal class WatchHeartRateCollector(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var updated = false
|
var updated = false
|
||||||
|
var latestHeartRateBpm: Int? = null
|
||||||
|
for (point in update.latestMetrics.getData(DataType.HEART_RATE_BPM)) {
|
||||||
|
latestHeartRateBpm = recordHeartRate(point.value)
|
||||||
|
}
|
||||||
|
if (latestHeartRateBpm != null && !exerciseHeartRateObserved) {
|
||||||
|
exerciseHeartRateObserved = true
|
||||||
|
appContext?.let(::unregister)
|
||||||
|
}
|
||||||
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
|
for (point in update.latestMetrics.getData(DataType.DISTANCE)) {
|
||||||
val value = point.value
|
val value = point.value
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
@ -104,12 +125,11 @@ internal class WatchHeartRateCollector(
|
|||||||
updated = true
|
updated = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updated) {
|
if (latestHeartRateBpm != null || updated) {
|
||||||
Log.d(
|
logHotPath(
|
||||||
TAG,
|
"exercise metrics received sessionId=$sessionId bpm=$latestHeartRateBpm distance=$distanceMeters calories=$caloriesKcal",
|
||||||
"exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal",
|
|
||||||
)
|
)
|
||||||
sendSample(null)
|
sendSample(latestHeartRateBpm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,23 +165,31 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onPhoneReconnected(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
cachedReachableNodes = emptyList()
|
||||||
|
cachedReachableNodesAtEpochMs = 0L
|
||||||
|
flushPendingSample(context, forceNodeRefresh = true)
|
||||||
|
}
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
if (sessionId.isNullOrBlank()) {
|
if (sessionId.isNullOrBlank()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
val measureClient = HealthServices.getClient(context).measureClient
|
startMeasureHeartRateFallback(context)
|
||||||
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
|
|
||||||
startExerciseMetrics(context)
|
startExerciseMetrics(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(context: Context) {
|
fun pause(context: Context) {
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
|
flushPendingSample(context, forceNodeRefresh = false)
|
||||||
unregister(context)
|
unregister(context)
|
||||||
stopExerciseMetrics(context)
|
stopExerciseMetrics(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finishCurrentSession(context: Context) {
|
fun finishCurrentSession(context: Context) {
|
||||||
|
flushPendingSample(context, forceNodeRefresh = false)
|
||||||
unregister(context)
|
unregister(context)
|
||||||
stopExerciseMetrics(context)
|
stopExerciseMetrics(context)
|
||||||
val completedSessionId = sessionId
|
val completedSessionId = sessionId
|
||||||
@ -209,22 +237,8 @@ internal class WatchHeartRateCollector(
|
|||||||
"caloriesKcal" to caloriesKcal,
|
"caloriesKcal" to caloriesKcal,
|
||||||
)
|
)
|
||||||
onLocalSample(sample)
|
onLocalSample(sample)
|
||||||
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
|
pendingSample = sample
|
||||||
Wearable.getCapabilityClient(context)
|
scheduleSampleFlush(context)
|
||||||
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
|
||||||
.addOnSuccessListener { capability ->
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}",
|
|
||||||
)
|
|
||||||
for (node in capability.nodes) {
|
|
||||||
Wearable.getMessageClient(context)
|
|
||||||
.sendMessage(node.id, sensorSamplePath, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.addOnFailureListener { error ->
|
|
||||||
Log.w(TAG, "sample capability lookup failed", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendSummary(context: Context, completedSessionId: String) {
|
private fun sendSummary(context: Context, completedSessionId: String) {
|
||||||
@ -245,6 +259,7 @@ internal class WatchHeartRateCollector(
|
|||||||
Wearable.getCapabilityClient(context)
|
Wearable.getCapabilityClient(context)
|
||||||
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
||||||
.addOnSuccessListener { capability ->
|
.addOnSuccessListener { capability ->
|
||||||
|
cacheReachableNodes(capability.nodes.toList())
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
|
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
|
||||||
@ -300,9 +315,17 @@ internal class WatchHeartRateCollector(
|
|||||||
val config = exerciseConfigFromCapabilities(capabilities)
|
val config = exerciseConfigFromCapabilities(capabilities)
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
exerciseMetricsStartInFlight = false
|
exerciseMetricsStartInFlight = false
|
||||||
Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId")
|
Log.w(TAG, "no exercise type supports heart rate or distance sessionId=$sessionId")
|
||||||
|
if (shouldAggregate) {
|
||||||
|
startMeasureHeartRateFallback(context)
|
||||||
|
}
|
||||||
return@addListener
|
return@addListener
|
||||||
}
|
}
|
||||||
|
if (!shouldAggregate) {
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
|
exerciseHeartRateSupported = DataType.HEART_RATE_BPM in config.dataTypes
|
||||||
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
|
exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback)
|
||||||
val startFuture = exerciseClient.startExerciseAsync(config)
|
val startFuture = exerciseClient.startExerciseAsync(config)
|
||||||
startFuture.addListener(
|
startFuture.addListener(
|
||||||
@ -318,6 +341,10 @@ internal class WatchHeartRateCollector(
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "exercise metrics start failed", error)
|
Log.w(TAG, "exercise metrics start failed", error)
|
||||||
clearExerciseCallback(exerciseClient)
|
clearExerciseCallback(exerciseClient)
|
||||||
|
exerciseHeartRateSupported = false
|
||||||
|
if (shouldAggregate) {
|
||||||
|
startMeasureHeartRateFallback(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
context.mainExecutor,
|
context.mainExecutor,
|
||||||
@ -340,6 +367,7 @@ internal class WatchHeartRateCollector(
|
|||||||
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
|
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
|
||||||
ExerciseType.WORKOUT,
|
ExerciseType.WORKOUT,
|
||||||
)
|
)
|
||||||
|
var heartRateOnlyConfig: ExerciseConfig? = null
|
||||||
for (exerciseType in requestedTypes) {
|
for (exerciseType in requestedTypes) {
|
||||||
if (exerciseType !in capabilities.supportedExerciseTypes) {
|
if (exerciseType !in capabilities.supportedExerciseTypes) {
|
||||||
continue
|
continue
|
||||||
@ -348,23 +376,37 @@ internal class WatchHeartRateCollector(
|
|||||||
.supportedDataTypes
|
.supportedDataTypes
|
||||||
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
|
val dataTypes = mutableSetOf<androidx.health.services.client.data.DataType<*, *>>()
|
||||||
if (DataType.DISTANCE !in supported) {
|
if (DataType.DISTANCE !in supported) {
|
||||||
Log.w(
|
if (DataType.HEART_RATE_BPM !in supported) {
|
||||||
TAG,
|
Log.w(
|
||||||
"exercise type lacks distance type=$exerciseType supported=$supported",
|
TAG,
|
||||||
)
|
"exercise type lacks distance and heart rate type=$exerciseType supported=$supported",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dataTypes.add(DataType.HEART_RATE_BPM)
|
||||||
|
if (heartRateOnlyConfig == null) {
|
||||||
|
heartRateOnlyConfig = ExerciseConfig.builder(exerciseType)
|
||||||
|
.setDataTypes(dataTypes)
|
||||||
|
.setIsAutoPauseAndResumeEnabled(false)
|
||||||
|
.setIsGpsEnabled(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dataTypes.add(DataType.DISTANCE)
|
dataTypes.add(DataType.DISTANCE)
|
||||||
if (DataType.CALORIES in supported) {
|
if (DataType.CALORIES in supported) {
|
||||||
dataTypes.add(DataType.CALORIES)
|
dataTypes.add(DataType.CALORIES)
|
||||||
}
|
}
|
||||||
|
if (DataType.HEART_RATE_BPM in supported) {
|
||||||
|
dataTypes.add(DataType.HEART_RATE_BPM)
|
||||||
|
}
|
||||||
return ExerciseConfig.builder(exerciseType)
|
return ExerciseConfig.builder(exerciseType)
|
||||||
.setDataTypes(dataTypes)
|
.setDataTypes(dataTypes)
|
||||||
.setIsAutoPauseAndResumeEnabled(false)
|
.setIsAutoPauseAndResumeEnabled(false)
|
||||||
.setIsGpsEnabled(true)
|
.setIsGpsEnabled(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
return null
|
return heartRateOnlyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopExerciseMetrics(context: Context) {
|
private fun stopExerciseMetrics(context: Context) {
|
||||||
@ -378,6 +420,7 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
exerciseMetricsStarted = false
|
exerciseMetricsStarted = false
|
||||||
exerciseMetricsStartInFlight = false
|
exerciseMetricsStartInFlight = false
|
||||||
|
exerciseHeartRateSupported = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
private fun clearExerciseCallback(exerciseClient: ExerciseClient) {
|
||||||
@ -389,6 +432,9 @@ internal class WatchHeartRateCollector(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reset(nextSessionId: String?) {
|
private fun reset(nextSessionId: String?) {
|
||||||
|
sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
sampleFlushRunnable = null
|
||||||
|
pendingSample = null
|
||||||
sessionId = nextSessionId
|
sessionId = nextSessionId
|
||||||
sampleCount = 0
|
sampleCount = 0
|
||||||
sampleSum = 0.0
|
sampleSum = 0.0
|
||||||
@ -399,5 +445,102 @@ internal class WatchHeartRateCollector(
|
|||||||
sampleSequence = 0
|
sampleSequence = 0
|
||||||
executionContext = emptyMap()
|
executionContext = emptyMap()
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
|
exerciseHeartRateSupported = false
|
||||||
|
exerciseHeartRateObserved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startMeasureHeartRateFallback(context: Context) {
|
||||||
|
if (exerciseHeartRateSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val measureClient = HealthServices.getClient(context).measureClient
|
||||||
|
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
|
||||||
|
Log.d(TAG, "heart rate fallback MeasureClient active sessionId=$sessionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logHotPath(message: String) {
|
||||||
|
val context = appContext ?: return
|
||||||
|
if ((context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
|
||||||
|
Log.d(TAG, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSampleFlush(context: Context) {
|
||||||
|
if (sampleFlushRunnable != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
sampleFlushRunnable = Runnable {
|
||||||
|
sampleFlushRunnable = null
|
||||||
|
flushPendingSample(appContext, forceNodeRefresh = false)
|
||||||
|
}.also { runnable ->
|
||||||
|
mainHandler.postDelayed(runnable, SAMPLE_FLUSH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushPendingSample(context: Context, forceNodeRefresh: Boolean) {
|
||||||
|
val sample = pendingSample ?: return
|
||||||
|
pendingSample = null
|
||||||
|
sampleFlushRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
sampleFlushRunnable = null
|
||||||
|
sendSampleToNodes(context, sample, forceNodeRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSampleToNodes(
|
||||||
|
context: Context,
|
||||||
|
sample: Map<String, Any?>,
|
||||||
|
forceNodeRefresh: Boolean,
|
||||||
|
) {
|
||||||
|
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
|
||||||
|
val cachedNodes = cachedNodesIfFresh()
|
||||||
|
if (!forceNodeRefresh && cachedNodes.isNotEmpty()) {
|
||||||
|
sendSamplePayload(context, payload, cachedNodes, sample)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nodeLookupInFlight && cachedReachableNodes.isNotEmpty()) {
|
||||||
|
sendSamplePayload(context, payload, cachedReachableNodes, sample)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeLookupInFlight = true
|
||||||
|
Wearable.getCapabilityClient(context)
|
||||||
|
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
||||||
|
.addOnSuccessListener { capability ->
|
||||||
|
nodeLookupInFlight = false
|
||||||
|
val nodes = capability.nodes.toList()
|
||||||
|
cacheReachableNodes(nodes)
|
||||||
|
sendSamplePayload(context, payload, nodes, sample)
|
||||||
|
}
|
||||||
|
.addOnFailureListener { error ->
|
||||||
|
nodeLookupInFlight = false
|
||||||
|
Log.w(TAG, "sample capability lookup failed", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSamplePayload(
|
||||||
|
context: Context,
|
||||||
|
payload: ByteArray,
|
||||||
|
nodes: List<Node>,
|
||||||
|
sample: Map<String, Any?>,
|
||||||
|
) {
|
||||||
|
logHotPath(
|
||||||
|
"send sample sessionId=${sample["sessionId"]} bpm=${sample["heartRateBpm"]} distance=${sample["distanceMeters"]} calories=${sample["caloriesKcal"]} nodes=${nodes.size}",
|
||||||
|
)
|
||||||
|
for (node in nodes) {
|
||||||
|
Wearable.getMessageClient(context)
|
||||||
|
.sendMessage(node.id, sensorSamplePath, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cachedNodesIfFresh(): List<Node> {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - cachedReachableNodesAtEpochMs > NODE_CACHE_TTL_MS) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return cachedReachableNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheReachableNodes(nodes: List<Node>) {
|
||||||
|
cachedReachableNodes = nodes
|
||||||
|
cachedReachableNodesAtEpochMs = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,17 +21,25 @@ internal class WatchHeartRateForegroundService : Service() {
|
|||||||
const val CHANNEL_NAME = "Collecte cardio GameTime"
|
const val CHANNEL_NAME = "Collecte cardio GameTime"
|
||||||
const val NOTIFICATION_ID = 9102
|
const val NOTIFICATION_ID = 9102
|
||||||
const val EXTRA_EXERCISE_NAME = "exerciseName"
|
const val EXTRA_EXERCISE_NAME = "exerciseName"
|
||||||
|
private var lastCommandKey: String? = null
|
||||||
|
private var serviceRequested = false
|
||||||
|
|
||||||
fun start(context: Context, projection: Map<String, Any?>) {
|
fun start(context: Context, projection: Map<String, Any?>) {
|
||||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
if (sessionId.isBlank() || phase != "running") {
|
if (sessionId.isBlank() || phase != "running") {
|
||||||
stop(context)
|
stop(context, force = phase == "noActiveSession")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val exerciseName = (projection["exerciseName"] as? String)
|
val exerciseName = (projection["exerciseName"] as? String)
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: "Séance en cours"
|
?: "Séance en cours"
|
||||||
|
val key = "$sessionId|$phase|$exerciseName"
|
||||||
|
if (serviceRequested && key == lastCommandKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceRequested = true
|
||||||
|
lastCommandKey = key
|
||||||
ContextCompat.startForegroundService(
|
ContextCompat.startForegroundService(
|
||||||
context,
|
context,
|
||||||
Intent(context, WatchHeartRateForegroundService::class.java)
|
Intent(context, WatchHeartRateForegroundService::class.java)
|
||||||
@ -39,7 +47,12 @@ internal class WatchHeartRateForegroundService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context, force: Boolean = false) {
|
||||||
|
if (!force && !serviceRequested) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceRequested = false
|
||||||
|
lastCommandKey = null
|
||||||
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
|
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,25 +21,47 @@ object WatchOngoingActivityController {
|
|||||||
private const val CHANNEL_NAME = "Séance GameTime"
|
private const val CHANNEL_NAME = "Séance GameTime"
|
||||||
private const val NOTIFICATION_ID = 9101
|
private const val NOTIFICATION_ID = 9101
|
||||||
private const val ONGOING_ACTIVITY_ID = 9101
|
private const val ONGOING_ACTIVITY_ID = 9101
|
||||||
|
private const val REPUBLISH_INTERVAL_MS = 60000L
|
||||||
|
private var lastAppliedKey: String? = null
|
||||||
|
private var lastAppliedAtEpochMs = 0L
|
||||||
|
|
||||||
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
|
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
|
||||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||||
cancel(context)
|
cancel(context, force = phase == "noActiveSession")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val key = ongoingKey(projection)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (key == lastAppliedKey && now - lastAppliedAtEpochMs < REPUBLISH_INTERVAL_MS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!hasPostNotificationsPermission(context)) {
|
if (!hasPostNotificationsPermission(context)) {
|
||||||
Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
|
Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
lastAppliedKey = key
|
||||||
|
lastAppliedAtEpochMs = now
|
||||||
post(context, projection)
|
post(context, projection)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel(context: Context) {
|
fun cancel(context: Context, force: Boolean = true) {
|
||||||
|
if (!force && lastAppliedKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAppliedKey = null
|
||||||
|
lastAppliedAtEpochMs = 0L
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ongoingKey(projection: Map<String, Any?>): String {
|
||||||
|
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||||
|
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||||
|
val exerciseName = projection["exerciseName"] as? String ?: ""
|
||||||
|
return "$sessionId|$phase|$exerciseName"
|
||||||
|
}
|
||||||
|
|
||||||
private fun post(context: Context, projection: Map<String, Any?>) {
|
private fun post(context: Context, projection: Map<String, Any?>) {
|
||||||
ensureNotificationChannel(context)
|
ensureNotificationChannel(context)
|
||||||
val touchIntent = PendingIntent.getActivity(
|
val touchIntent = PendingIntent.getActivity(
|
||||||
|
|||||||
@ -37,7 +37,8 @@ final class WatchSessionUiState {
|
|||||||
final WatchCommandAckEvent? lastAck;
|
final WatchCommandAckEvent? lastAck;
|
||||||
final WatchSensorSample? sensorSample;
|
final WatchSensorSample? sensorSample;
|
||||||
|
|
||||||
bool get actionsEnabled => !commandPending && !connectionLost;
|
bool get actionsEnabled =>
|
||||||
|
!commandPending && !connectionLost && !staleProjection;
|
||||||
|
|
||||||
WatchSessionUiState copyWith({
|
WatchSessionUiState copyWith({
|
||||||
WatchSessionProjection? projection,
|
WatchSessionProjection? projection,
|
||||||
@ -112,9 +113,6 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
);
|
);
|
||||||
unawaited(_nativeClient.requestCapabilityRefresh());
|
unawaited(_nativeClient.requestCapabilityRefresh());
|
||||||
unawaited(_nativeClient.requestResync());
|
unawaited(_nativeClient.requestResync());
|
||||||
_freshnessTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
_syncFreshnessState();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final NativeWatchBridgeClient _nativeClient;
|
final NativeWatchBridgeClient _nativeClient;
|
||||||
@ -136,6 +134,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
final _pendingScoreCommandIds = <String>{};
|
final _pendingScoreCommandIds = <String>{};
|
||||||
double? _optimisticManualScoreValue;
|
double? _optimisticManualScoreValue;
|
||||||
DateTime? _lastProjectionReceivedAt;
|
DateTime? _lastProjectionReceivedAt;
|
||||||
|
bool _requiresAuthoritativeProjection = true;
|
||||||
var _commandCounter = 0;
|
var _commandCounter = 0;
|
||||||
var _commandFailureSerial = 0;
|
var _commandFailureSerial = 0;
|
||||||
|
|
||||||
@ -211,6 +210,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
|
|
||||||
Future<void> _sendCommand(WatchCommandType type) async {
|
Future<void> _sendCommand(WatchCommandType type) async {
|
||||||
if (!value.actionsEnabled ||
|
if (!value.actionsEnabled ||
|
||||||
|
_requiresAuthoritativeProjection ||
|
||||||
(_requiresActiveSession(type) &&
|
(_requiresActiveSession(type) &&
|
||||||
value.projection.deviceSessionId.isEmpty)) {
|
value.projection.deviceSessionId.isEmpty)) {
|
||||||
return;
|
return;
|
||||||
@ -263,6 +263,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
|
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
|
||||||
final projection = value.projection;
|
final projection = value.projection;
|
||||||
if (value.connectionLost ||
|
if (value.connectionLost ||
|
||||||
|
value.staleProjection ||
|
||||||
|
_requiresAuthoritativeProjection ||
|
||||||
!projection.phoneReachable ||
|
!projection.phoneReachable ||
|
||||||
!projection.hasManualScore ||
|
!projection.hasManualScore ||
|
||||||
projection.deviceSessionId.isEmpty) {
|
projection.deviceSessionId.isEmpty) {
|
||||||
@ -313,6 +315,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
void _handleProjection(WatchSessionProjection projection) {
|
void _handleProjection(WatchSessionProjection projection) {
|
||||||
final previousProjection = value.projection;
|
final previousProjection = value.projection;
|
||||||
_lastProjectionReceivedAt = DateTime.now();
|
_lastProjectionReceivedAt = DateTime.now();
|
||||||
|
_requiresAuthoritativeProjection = false;
|
||||||
_scheduleProjectionExpiry(projection);
|
_scheduleProjectionExpiry(projection);
|
||||||
_pendingCommand = null;
|
_pendingCommand = null;
|
||||||
_clearCommandTimers();
|
_clearCommandTimers();
|
||||||
@ -334,6 +337,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
_triggerProjectionHaptic(previousProjection, projection);
|
_triggerProjectionHaptic(previousProjection, projection);
|
||||||
|
_scheduleFreshnessCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSensorSample(WatchSensorSample sample) {
|
void _handleSensorSample(WatchSensorSample sample) {
|
||||||
@ -403,34 +407,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
|
|
||||||
void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
|
void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
|
||||||
value = value.copyWith(connectionLost: !event.isReachable);
|
value = value.copyWith(connectionLost: !event.isReachable);
|
||||||
|
if (!event.isReachable) {
|
||||||
|
_requiresAuthoritativeProjection = true;
|
||||||
|
_clearScorePending(recalibrate: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.isReachable || event.requestsResync) {
|
if (event.isReachable || event.requestsResync) {
|
||||||
|
_requiresAuthoritativeProjection = true;
|
||||||
unawaited(_nativeClient.requestResync());
|
unawaited(_nativeClient.requestResync());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncFreshnessState() {
|
void _syncFreshnessState() {
|
||||||
final receivedAt = _lastProjectionReceivedAt;
|
_freshnessTimer = null;
|
||||||
if (receivedAt == null) {
|
if (_lastProjectionReceivedAt == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
if (!value.staleProjection) {
|
||||||
final expiryAge = Duration(
|
_requiresAuthoritativeProjection = true;
|
||||||
milliseconds: _projectionTtlMs(value.projection),
|
value = value.copyWith(staleProjection: true);
|
||||||
);
|
_scheduleFreshnessCheck();
|
||||||
final expired =
|
|
||||||
value.projection.deviceSessionId.isNotEmpty &&
|
|
||||||
now.difference(receivedAt) >= expiryAge &&
|
|
||||||
_pendingCommand == null &&
|
|
||||||
_pendingScoreCommandIds.isEmpty;
|
|
||||||
if (expired) {
|
|
||||||
_invalidateExpiredProjection();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final age = now.difference(receivedAt);
|
if (!value.connectionLost) {
|
||||||
final stale = age >= _staleProjectionThreshold;
|
value = value.copyWith(connectionLost: true);
|
||||||
final lost = age >= _connectionLostThreshold;
|
|
||||||
if (stale != value.staleProjection || lost != value.connectionLost) {
|
|
||||||
value = value.copyWith(staleProjection: stale, connectionLost: lost);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,6 +446,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
_scoreCommandTimeoutTimer?.cancel();
|
_scoreCommandTimeoutTimer?.cancel();
|
||||||
_scoreCommandTimeoutTimer = null;
|
_scoreCommandTimeoutTimer = null;
|
||||||
_lastProjectionReceivedAt = null;
|
_lastProjectionReceivedAt = null;
|
||||||
|
_freshnessTimer?.cancel();
|
||||||
|
_freshnessTimer = null;
|
||||||
value = WatchSessionUiState(
|
value = WatchSessionUiState(
|
||||||
projection: _expiredProjection(),
|
projection: _expiredProjection(),
|
||||||
connectionLost: true,
|
connectionLost: true,
|
||||||
@ -474,6 +476,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleFreshnessCheck() {
|
||||||
|
_freshnessTimer?.cancel();
|
||||||
|
_freshnessTimer = null;
|
||||||
|
final receivedAt = _lastProjectionReceivedAt;
|
||||||
|
if (receivedAt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Duration? nextThreshold;
|
||||||
|
if (!value.staleProjection) {
|
||||||
|
nextThreshold = _staleProjectionThreshold;
|
||||||
|
} else if (!value.connectionLost) {
|
||||||
|
nextThreshold = _connectionLostThreshold - _staleProjectionThreshold;
|
||||||
|
} else {
|
||||||
|
nextThreshold = null;
|
||||||
|
}
|
||||||
|
if (nextThreshold == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_freshnessTimer = Timer(
|
||||||
|
nextThreshold.isNegative ? Duration.zero : nextThreshold,
|
||||||
|
_syncFreshnessState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _clearCommandTimers() {
|
void _clearCommandTimers() {
|
||||||
_waitingTimer?.cancel();
|
_waitingTimer?.cancel();
|
||||||
_waitingTimer = null;
|
_waitingTimer = null;
|
||||||
|
|||||||
@ -6,10 +6,21 @@ import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
|||||||
|
|
||||||
import '../application/watch_session_view_model.dart';
|
import '../application/watch_session_view_model.dart';
|
||||||
|
|
||||||
|
typedef WatchNowEpochMs = int Function();
|
||||||
|
|
||||||
|
int _defaultNowEpochMs() {
|
||||||
|
return DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
final class WatchSessionScreen extends StatefulWidget {
|
final class WatchSessionScreen extends StatefulWidget {
|
||||||
const WatchSessionScreen({required this.viewModel, super.key});
|
const WatchSessionScreen({
|
||||||
|
required this.viewModel,
|
||||||
|
this.nowEpochMs = _defaultNowEpochMs,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final WatchSessionViewModel viewModel;
|
final WatchSessionViewModel viewModel;
|
||||||
|
final WatchNowEpochMs nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<WatchSessionScreen> createState() => _WatchSessionScreenState();
|
State<WatchSessionScreen> createState() => _WatchSessionScreenState();
|
||||||
@ -30,11 +41,6 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController();
|
_pageController = PageController();
|
||||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -53,7 +59,9 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
_syncFailureNotice(state);
|
_syncFailureNotice(state);
|
||||||
_syncSecondaryNavigation(state);
|
_syncSecondaryNavigation(state);
|
||||||
final projection = state.projection;
|
final projection = state.projection;
|
||||||
_syncTimerCompletionHaptic(projection);
|
final nowEpochMs = widget.nowEpochMs();
|
||||||
|
_syncTimerCompletionHaptic(projection, nowEpochMs: nowEpochMs);
|
||||||
|
_syncUiTicker(projection);
|
||||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||||
final phoneReachable =
|
final phoneReachable =
|
||||||
projection.phoneReachable && !state.connectionLost;
|
projection.phoneReachable && !state.connectionLost;
|
||||||
@ -78,6 +86,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
onIncrementScore: widget.viewModel.incrementScore,
|
onIncrementScore: widget.viewModel.incrementScore,
|
||||||
onDecrementScore: widget.viewModel.decrementScore,
|
onDecrementScore: widget.viewModel.decrementScore,
|
||||||
onCompleteStep: widget.viewModel.completeCurrentStep,
|
onCompleteStep: widget.viewModel.completeCurrentStep,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_RoundScaffold(
|
_RoundScaffold(
|
||||||
@ -207,7 +216,10 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncTimerCompletionHaptic(WatchSessionProjection projection) {
|
void _syncTimerCompletionHaptic(
|
||||||
|
WatchSessionProjection projection, {
|
||||||
|
required int nowEpochMs,
|
||||||
|
}) {
|
||||||
final timer = _primaryDisplayTimer(projection);
|
final timer = _primaryDisplayTimer(projection);
|
||||||
if (timer == null ||
|
if (timer == null ||
|
||||||
timer.displayMode != WatchTimerDisplayMode.countdown ||
|
timer.displayMode != WatchTimerDisplayMode.countdown ||
|
||||||
@ -215,7 +227,10 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final key = _timerHapticKey(projection, timer);
|
final key = _timerHapticKey(projection, timer);
|
||||||
final remainingMs = _displayDuration(timer).inMilliseconds;
|
final remainingMs = _displayDuration(
|
||||||
|
timer,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
|
).inMilliseconds;
|
||||||
final previousRemainingMs = _timerRemainingMsByKey[key];
|
final previousRemainingMs = _timerRemainingMsByKey[key];
|
||||||
_timerRemainingMsByKey[key] = remainingMs;
|
_timerRemainingMsByKey[key] = remainingMs;
|
||||||
if (remainingMs > 0 ||
|
if (remainingMs > 0 ||
|
||||||
@ -227,6 +242,20 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
|||||||
_triggerTimerCompletionHaptic();
|
_triggerTimerCompletionHaptic();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncUiTicker(WatchSessionProjection projection) {
|
||||||
|
final shouldTick = _hasRunningVisibleTimer(projection);
|
||||||
|
if (shouldTick) {
|
||||||
|
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ticker?.cancel();
|
||||||
|
_ticker = null;
|
||||||
|
}
|
||||||
|
|
||||||
void _triggerTimerCompletionHaptic() {
|
void _triggerTimerCompletionHaptic() {
|
||||||
unawaited(HapticFeedback.heavyImpact());
|
unawaited(HapticFeedback.heavyImpact());
|
||||||
unawaited(
|
unawaited(
|
||||||
@ -460,6 +489,7 @@ final class _SessionMainView extends StatelessWidget {
|
|||||||
required this.onIncrementScore,
|
required this.onIncrementScore,
|
||||||
required this.onDecrementScore,
|
required this.onDecrementScore,
|
||||||
required this.onCompleteStep,
|
required this.onCompleteStep,
|
||||||
|
required this.nowEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WatchSessionUiState state;
|
final WatchSessionUiState state;
|
||||||
@ -469,6 +499,7 @@ final class _SessionMainView extends StatelessWidget {
|
|||||||
final VoidCallback onIncrementScore;
|
final VoidCallback onIncrementScore;
|
||||||
final VoidCallback onDecrementScore;
|
final VoidCallback onDecrementScore;
|
||||||
final VoidCallback onCompleteStep;
|
final VoidCallback onCompleteStep;
|
||||||
|
final int nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -484,7 +515,7 @@ final class _SessionMainView extends StatelessWidget {
|
|||||||
final canToggleTimer =
|
final canToggleTimer =
|
||||||
showsTimer &&
|
showsTimer &&
|
||||||
!connectionLost &&
|
!connectionLost &&
|
||||||
!state.commandPending &&
|
state.actionsEnabled &&
|
||||||
_timerButtonCommandMatches(
|
_timerButtonCommandMatches(
|
||||||
timer: timer,
|
timer: timer,
|
||||||
primaryAction: projection.primaryAction,
|
primaryAction: projection.primaryAction,
|
||||||
@ -512,6 +543,7 @@ final class _SessionMainView extends StatelessWidget {
|
|||||||
? _RestContent(
|
? _RestContent(
|
||||||
state: state,
|
state: state,
|
||||||
onTogglePause: canToggleTimer ? onTogglePause : null,
|
onTogglePause: canToggleTimer ? onTogglePause : null,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
)
|
)
|
||||||
: _ActiveContent(
|
: _ActiveContent(
|
||||||
state: state,
|
state: state,
|
||||||
@ -519,6 +551,7 @@ final class _SessionMainView extends StatelessWidget {
|
|||||||
onIncrementScore: onIncrementScore,
|
onIncrementScore: onIncrementScore,
|
||||||
onDecrementScore: onDecrementScore,
|
onDecrementScore: onDecrementScore,
|
||||||
onCompleteStep: onCompleteStep,
|
onCompleteStep: onCompleteStep,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -767,6 +800,7 @@ final class _ActiveContent extends StatelessWidget {
|
|||||||
required this.onIncrementScore,
|
required this.onIncrementScore,
|
||||||
required this.onDecrementScore,
|
required this.onDecrementScore,
|
||||||
required this.onCompleteStep,
|
required this.onCompleteStep,
|
||||||
|
required this.nowEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WatchSessionUiState state;
|
final WatchSessionUiState state;
|
||||||
@ -774,6 +808,7 @@ final class _ActiveContent extends StatelessWidget {
|
|||||||
final VoidCallback onIncrementScore;
|
final VoidCallback onIncrementScore;
|
||||||
final VoidCallback onDecrementScore;
|
final VoidCallback onDecrementScore;
|
||||||
final VoidCallback onCompleteStep;
|
final VoidCallback onCompleteStep;
|
||||||
|
final int nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -784,6 +819,7 @@ final class _ActiveContent extends StatelessWidget {
|
|||||||
onTogglePause: onTogglePause,
|
onTogglePause: onTogglePause,
|
||||||
onIncrement: onIncrementScore,
|
onIncrement: onIncrementScore,
|
||||||
onDecrement: onDecrementScore,
|
onDecrement: onDecrementScore,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final timer = _primaryDisplayTimer(projection);
|
final timer = _primaryDisplayTimer(projection);
|
||||||
@ -794,12 +830,12 @@ final class _ActiveContent extends StatelessWidget {
|
|||||||
final repsTarget = _repsStepTarget(projection);
|
final repsTarget = _repsStepTarget(projection);
|
||||||
final dominantValue = timer == null
|
final dominantValue = timer == null
|
||||||
? _seriesValue(projection)
|
? _seriesValue(projection)
|
||||||
: _timerText(timer);
|
: _timerText(timer, nowEpochMs: nowEpochMs);
|
||||||
final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
|
final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
|
||||||
final controlsEnabled =
|
final controlsEnabled =
|
||||||
!state.connectionLost &&
|
!state.connectionLost &&
|
||||||
projection.phoneReachable &&
|
projection.phoneReachable &&
|
||||||
!state.commandPending;
|
state.actionsEnabled;
|
||||||
return _ScaledContent(
|
return _ScaledContent(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -833,7 +869,12 @@ final class _ActiveContent extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
secondaryTimers.map(_compactTimerText).join(' · '),
|
secondaryTimers
|
||||||
|
.map(
|
||||||
|
(timer) =>
|
||||||
|
_compactTimerText(timer, nowEpochMs: nowEpochMs),
|
||||||
|
)
|
||||||
|
.join(' · '),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@ -956,12 +997,14 @@ final class _ManualScoreContent extends StatelessWidget {
|
|||||||
required this.onTogglePause,
|
required this.onTogglePause,
|
||||||
required this.onIncrement,
|
required this.onIncrement,
|
||||||
required this.onDecrement,
|
required this.onDecrement,
|
||||||
|
required this.nowEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WatchSessionUiState state;
|
final WatchSessionUiState state;
|
||||||
final VoidCallback? onTogglePause;
|
final VoidCallback? onTogglePause;
|
||||||
final VoidCallback onIncrement;
|
final VoidCallback onIncrement;
|
||||||
final VoidCallback onDecrement;
|
final VoidCallback onDecrement;
|
||||||
|
final int nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -970,7 +1013,7 @@ final class _ManualScoreContent extends StatelessWidget {
|
|||||||
state.optimisticManualScoreValue ??
|
state.optimisticManualScoreValue ??
|
||||||
projection.currentManualScoreValue ??
|
projection.currentManualScoreValue ??
|
||||||
0;
|
0;
|
||||||
final controlsEnabled = !state.connectionLost && projection.phoneReachable;
|
final controlsEnabled = state.actionsEnabled && projection.phoneReachable;
|
||||||
final canDecrement =
|
final canDecrement =
|
||||||
controlsEnabled &&
|
controlsEnabled &&
|
||||||
score > 0 &&
|
score > 0 &&
|
||||||
@ -1047,6 +1090,7 @@ final class _ManualScoreContent extends StatelessWidget {
|
|||||||
timer: timer,
|
timer: timer,
|
||||||
pending: state.timerTogglePending,
|
pending: state.timerTogglePending,
|
||||||
onTogglePause: canToggleTimer ? onTogglePause : null,
|
onTogglePause: canToggleTimer ? onTogglePause : null,
|
||||||
|
nowEpochMs: nowEpochMs,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_StatusLine(projection.statusLabel),
|
_StatusLine(projection.statusLabel),
|
||||||
@ -1061,11 +1105,13 @@ final class _CompactTimerLine extends StatelessWidget {
|
|||||||
required this.timer,
|
required this.timer,
|
||||||
required this.pending,
|
required this.pending,
|
||||||
required this.onTogglePause,
|
required this.onTogglePause,
|
||||||
|
required this.nowEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final WatchTimerProjection timer;
|
final WatchTimerProjection timer;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final VoidCallback? onTogglePause;
|
final VoidCallback? onTogglePause;
|
||||||
|
final int nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -1081,7 +1127,7 @@ final class _CompactTimerLine extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
_timerText(timer),
|
_timerText(timer, nowEpochMs: nowEpochMs),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@ -1191,10 +1237,15 @@ final class _PendingDot extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class _RestContent extends StatelessWidget {
|
final class _RestContent extends StatelessWidget {
|
||||||
const _RestContent({required this.state, required this.onTogglePause});
|
const _RestContent({
|
||||||
|
required this.state,
|
||||||
|
required this.onTogglePause,
|
||||||
|
required this.nowEpochMs,
|
||||||
|
});
|
||||||
|
|
||||||
final WatchSessionUiState state;
|
final WatchSessionUiState state;
|
||||||
final VoidCallback? onTogglePause;
|
final VoidCallback? onTogglePause;
|
||||||
|
final int nowEpochMs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -1209,7 +1260,7 @@ final class _RestContent extends StatelessWidget {
|
|||||||
if (timer != null) ...[
|
if (timer != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
_DominantTimerLine(
|
_DominantTimerLine(
|
||||||
value: _timerText(timer),
|
value: _timerText(timer, nowEpochMs: nowEpochMs),
|
||||||
timer: timer,
|
timer: timer,
|
||||||
pending: state.timerTogglePending,
|
pending: state.timerTogglePending,
|
||||||
onTogglePause: onTogglePause,
|
onTogglePause: onTogglePause,
|
||||||
@ -1675,16 +1726,30 @@ List<WatchTimerProjection> _visibleSecondaryTimers(
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _timerText(WatchTimerProjection timer) {
|
bool _hasRunningVisibleTimer(WatchSessionProjection projection) {
|
||||||
final duration = _displayDuration(timer);
|
final primaryTimer = _primaryDisplayTimer(projection);
|
||||||
|
if (primaryTimer?.runState == WatchTimerRunState.running) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return _visibleSecondaryTimers(
|
||||||
|
projection,
|
||||||
|
primaryTimer: primaryTimer,
|
||||||
|
).any((timer) => timer.runState == WatchTimerRunState.running);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _timerText(WatchTimerProjection timer, {required int nowEpochMs}) {
|
||||||
|
final duration = _displayDuration(timer, nowEpochMs: nowEpochMs);
|
||||||
final totalSeconds = duration.inSeconds;
|
final totalSeconds = duration.inSeconds;
|
||||||
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
|
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
|
||||||
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
|
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
|
||||||
return '$minutes:$seconds';
|
return '$minutes:$seconds';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _compactTimerText(WatchTimerProjection timer) {
|
String _compactTimerText(
|
||||||
return '${timer.label} ${_timerText(timer)}';
|
WatchTimerProjection timer, {
|
||||||
|
required int nowEpochMs,
|
||||||
|
}) {
|
||||||
|
return '${timer.label} ${_timerText(timer, nowEpochMs: nowEpochMs)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _timerHapticKey(
|
String _timerHapticKey(
|
||||||
@ -1731,8 +1796,11 @@ String? _caloriesLabel(WatchSensorSample? sample) {
|
|||||||
return '${calories.round()} kcal';
|
return '${calories.round()} kcal';
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration _displayDuration(WatchTimerProjection timer) {
|
Duration _displayDuration(
|
||||||
final elapsedMs = _interpolatedElapsedMs(timer);
|
WatchTimerProjection timer, {
|
||||||
|
required int nowEpochMs,
|
||||||
|
}) {
|
||||||
|
final elapsedMs = _interpolatedElapsedMs(timer, nowEpochMs: nowEpochMs);
|
||||||
final displayMs = switch (timer.displayMode) {
|
final displayMs = switch (timer.displayMode) {
|
||||||
WatchTimerDisplayMode.elapsed => elapsedMs,
|
WatchTimerDisplayMode.elapsed => elapsedMs,
|
||||||
WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs,
|
WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs,
|
||||||
@ -1740,13 +1808,15 @@ Duration _displayDuration(WatchTimerProjection timer) {
|
|||||||
return Duration(milliseconds: displayMs < 0 ? 0 : displayMs);
|
return Duration(milliseconds: displayMs < 0 ? 0 : displayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _interpolatedElapsedMs(WatchTimerProjection timer) {
|
int _interpolatedElapsedMs(
|
||||||
|
WatchTimerProjection timer, {
|
||||||
|
required int nowEpochMs,
|
||||||
|
}) {
|
||||||
if (timer.runState != WatchTimerRunState.running ||
|
if (timer.runState != WatchTimerRunState.running ||
|
||||||
timer.startedAtEpochMs == null) {
|
timer.startedAtEpochMs == null) {
|
||||||
return timer.accumulatedMs;
|
return timer.accumulatedMs;
|
||||||
}
|
}
|
||||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
final elapsedSinceReference = (nowEpochMs - timer.referenceEpochMs).clamp(
|
||||||
final elapsedSinceReference = (nowMs - timer.referenceEpochMs).clamp(
|
|
||||||
0,
|
0,
|
||||||
1 << 31,
|
1 << 31,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
testWidgets(
|
||||||
'uses a strong pulse sequence when a countdown timer reaches zero',
|
'uses a strong pulse sequence when a countdown timer reaches zero',
|
||||||
(tester) async {
|
(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', (
|
testWidgets('expires an orphaned active projection after its TTL', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@ -801,6 +1020,91 @@ void main() {
|
|||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
viewModel.dispose();
|
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 {
|
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(
|
return WatchSessionProjection(
|
||||||
deviceSessionId: 'session-1',
|
deviceSessionId: 'session-1',
|
||||||
revision: 2,
|
revision: 2,
|
||||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
projectedAtEpochMs: projectedAt,
|
||||||
|
expiresAtEpochMs: projectedAt + expiresIn.inMilliseconds,
|
||||||
phase: WatchSessionPhase.running,
|
phase: WatchSessionPhase.running,
|
||||||
phoneReachable: true,
|
phoneReachable: true,
|
||||||
seriesIndex: 1,
|
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() {
|
WatchTimerProjection _runningStepTimer() {
|
||||||
return WatchTimerProjection(
|
return WatchTimerProjection(
|
||||||
kind: WatchTimerKind.step,
|
kind: WatchTimerKind.step,
|
||||||
|
|||||||
Reference in New Issue
Block a user