feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -2,7 +2,9 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart'
|
||||
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
@ -120,19 +122,122 @@ void main() {
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test('patches workout history when a sensor summary arrives', () async {
|
||||
final native = _FakeWatchBridgeNativeChannel();
|
||||
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||
final historyRepository = _FakeWorkoutHistoryRepository()
|
||||
..histories.add(
|
||||
WorkoutHistory(
|
||||
metadata: _metadata('history-1'),
|
||||
sourceActiveWorkoutSessionId: 'session-1',
|
||||
nameSnapshot: 'Seance',
|
||||
startedAt: _now.subtract(const Duration(hours: 1)),
|
||||
endedAt: _now,
|
||||
totalActiveMs: 3600000,
|
||||
completed: true,
|
||||
historySnapshotJson: '{"programs":[]}',
|
||||
),
|
||||
);
|
||||
final adapter = _adapter(
|
||||
native: native,
|
||||
source: source,
|
||||
historyUseCases: WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: _FakeClock(_now),
|
||||
),
|
||||
);
|
||||
await adapter.start();
|
||||
|
||||
native.emitSensorSummary(
|
||||
const WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 8,
|
||||
averageHeartRateBpm: 121.5,
|
||||
maxHeartRateBpm: 168,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(historyRepository.histories.single.averageHeartRateBpm, 121.5);
|
||||
expect(historyRepository.histories.single.maxHeartRateBpm, 168);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test('records live telemetry samples in active sensor state', () async {
|
||||
final native = _FakeWatchBridgeNativeChannel();
|
||||
final source = _FakeProjectionSource(_runningProjection(revision: 1));
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now));
|
||||
final adapter = _adapter(
|
||||
native: native,
|
||||
source: source,
|
||||
sensorUseCases: sensorUseCases,
|
||||
);
|
||||
await adapter.start();
|
||||
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
),
|
||||
);
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now
|
||||
.add(const Duration(minutes: 30))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 150,
|
||||
distanceMeters: 900,
|
||||
caloriesKcal: 120,
|
||||
),
|
||||
);
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now
|
||||
.add(const Duration(minutes: 31))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 170,
|
||||
distanceMeters: 100,
|
||||
caloriesKcal: 10,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = sensorUseCases.current('session-1');
|
||||
expect(state?.latestHeartRateBpm, 150);
|
||||
expect(state?.sampleCount, 2);
|
||||
expect(state?.averageHeartRateBpm, 135);
|
||||
expect(state?.maxHeartRateBpm, 150);
|
||||
expect(state?.latestDistanceMeters, 900);
|
||||
expect(state?.latestCaloriesKcal, 120);
|
||||
expect(state?.estimatedCaloriesKcal, 187.5);
|
||||
await adapter.stop();
|
||||
await sensorUseCases.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
WatchWearDataLayerAdapter _adapter({
|
||||
required _FakeWatchBridgeNativeChannel native,
|
||||
WatchCommandIngress? ingress,
|
||||
required _FakeProjectionSource source,
|
||||
WorkoutHistoryUseCases? historyUseCases,
|
||||
ActiveWorkoutSensorUseCases? sensorUseCases,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
}) {
|
||||
return WatchWearDataLayerAdapter(
|
||||
nativeChannel: native,
|
||||
commandIngress: ingress ?? _FakeCommandIngress(),
|
||||
projectionSource: source,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
workoutHistoryUseCases: historyUseCases,
|
||||
activeWorkoutSensorUseCases: sensorUseCases,
|
||||
projectionRefreshInterval: heartbeatInterval,
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,6 +294,24 @@ WatchSessionProjection _runningProjection({required int revision}) {
|
||||
|
||||
final _now = DateTime.utc(2026, 7, 25, 12);
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
createdAt: _now,
|
||||
updatedAt: _now,
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeProjectionSource implements WatchProjectionSource {
|
||||
_FakeProjectionSource(this.current);
|
||||
|
||||
@ -260,6 +383,8 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
final published = <WatchSessionProjection>[];
|
||||
final acks = <_SentAck>[];
|
||||
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
|
||||
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
|
||||
final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
|
||||
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
var capabilityRefreshCount = 0;
|
||||
var foregroundStartCount = 0;
|
||||
@ -268,6 +393,12 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
@override
|
||||
Stream<WatchCommandEnvelope> get commands => _commands.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples => _sensorSamples.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connections.stream;
|
||||
@ -276,6 +407,14 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
_commands.add(command);
|
||||
}
|
||||
|
||||
void emitSensorSummary(WatchSensorSummary summary) {
|
||||
_sensorSummaries.add(summary);
|
||||
}
|
||||
|
||||
void emitSensorSample(WatchSensorSample sample) {
|
||||
_sensorSamples.add(sample);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connections.add(event);
|
||||
}
|
||||
@ -310,6 +449,54 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
final histories = <WorkoutHistory>[];
|
||||
|
||||
@override
|
||||
Future<WorkoutHistory?> findById(String id) async {
|
||||
return histories.where((history) => history.metadata.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutHistory>> listActive() async => histories;
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
final index = histories.indexWhere(
|
||||
(history) =>
|
||||
history.metadata.id == historyId &&
|
||||
history.averageHeartRateBpm == null &&
|
||||
history.maxHeartRateBpm == null,
|
||||
);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final history = histories[index];
|
||||
histories[index] = history.copyWith(
|
||||
metadata: history.metadata.touch(patchedAt),
|
||||
averageHeartRateBpm: averageHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id, DateTime deletedAt) async {}
|
||||
}
|
||||
|
||||
final class _SentAck {
|
||||
const _SentAck(this.command, this.ack, this.revisionAtAck);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user