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:
@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent {
|
||||
abstract interface class WatchBridgeNativeChannel {
|
||||
Stream<WatchCommandEnvelope> get commands;
|
||||
|
||||
Stream<WatchSensorSummary> get sensorSummaries;
|
||||
|
||||
Stream<WatchSensorSample> get sensorSamples;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
|
||||
Future<void> publishProjection(WatchSessionProjection projection);
|
||||
@ -38,17 +42,31 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
const MethodChannelWatchBridgeNativeChannel({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
EventChannel commandChannel = const EventChannel(_commandChannelName),
|
||||
EventChannel sensorSummaryChannel = const EventChannel(
|
||||
_sensorSummaryChannelName,
|
||||
),
|
||||
EventChannel sensorSampleChannel = const EventChannel(
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_commandChannel = commandChannel,
|
||||
_sensorSummaryChannel = sensorSummaryChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
static const _commandChannelName = 'gametime.watch_bridge/commands';
|
||||
static const _sensorSummaryChannelName =
|
||||
'gametime.watch_bridge/sensor_summaries';
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _commandChannel;
|
||||
final EventChannel _sensorSummaryChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@override
|
||||
@ -63,6 +81,30 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSummary> get sensorSummaries {
|
||||
return _sensorSummaryChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSummary.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples {
|
||||
return _sensorSampleChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSample.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents {
|
||||
return _connectionChannel
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
import '../../application/use_cases.dart';
|
||||
import '../../application/watch_companion_use_cases.dart';
|
||||
import 'native_watch_bridge_channel.dart';
|
||||
|
||||
@ -10,20 +11,26 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
required WatchBridgeNativeChannel nativeChannel,
|
||||
required WatchCommandIngress commandIngress,
|
||||
required WatchProjectionSource projectionSource,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||
Duration projectionRefreshInterval = const Duration(seconds: 2),
|
||||
}) : _nativeChannel = nativeChannel,
|
||||
_commandIngress = commandIngress,
|
||||
_projectionSource = projectionSource,
|
||||
_heartbeatInterval = heartbeatInterval;
|
||||
_workoutHistoryUseCases = workoutHistoryUseCases,
|
||||
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
|
||||
_projectionRefreshInterval = projectionRefreshInterval;
|
||||
|
||||
final WatchBridgeNativeChannel _nativeChannel;
|
||||
final WatchCommandIngress _commandIngress;
|
||||
final WatchProjectionSource _projectionSource;
|
||||
final Duration _heartbeatInterval;
|
||||
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
|
||||
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
|
||||
final Duration _projectionRefreshInterval;
|
||||
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
Future<void> _commandTail = Future<void>.value();
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _projectionRefreshTimer;
|
||||
WatchSessionProjection? _latestProjection;
|
||||
bool _started = false;
|
||||
bool _foregroundActive = false;
|
||||
@ -33,6 +40,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
_ensureProjectionRefreshLoop();
|
||||
_subscriptions.add(
|
||||
_projectionSource.projections.listen((projection) {
|
||||
unawaited(publish(projection));
|
||||
@ -43,6 +51,22 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
unawaited(_enqueueCommand(command));
|
||||
}),
|
||||
);
|
||||
final workoutHistoryUseCases = _workoutHistoryUseCases;
|
||||
if (workoutHistoryUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSummaries.listen((summary) {
|
||||
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
|
||||
}),
|
||||
);
|
||||
}
|
||||
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
|
||||
if (activeWorkoutSensorUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSamples.listen((sample) {
|
||||
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
|
||||
}),
|
||||
);
|
||||
}
|
||||
_subscriptions.add(
|
||||
_nativeChannel.connectionEvents.listen((event) {
|
||||
if (event.isReachable || event.requestsResync) {
|
||||
@ -55,8 +79,8 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_projectionRefreshTimer?.cancel();
|
||||
_projectionRefreshTimer = null;
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
@ -66,10 +90,16 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
|
||||
@override
|
||||
Future<void> publish(WatchSessionProjection projection) async {
|
||||
final previousProjection = _latestProjection;
|
||||
_latestProjection = projection;
|
||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||
final previousSessionId = previousProjection?.deviceSessionId;
|
||||
if (previousSessionId != null && previousSessionId.isNotEmpty) {
|
||||
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
||||
}
|
||||
}
|
||||
await _nativeChannel.publishProjection(projection);
|
||||
await _syncForegroundService(projection);
|
||||
_syncHeartbeat(projection);
|
||||
}
|
||||
|
||||
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
|
||||
@ -136,26 +166,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
void _syncHeartbeat(WatchSessionProjection projection) {
|
||||
if (!_hasRunningTimer(projection)) {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
return;
|
||||
}
|
||||
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
|
||||
void _ensureProjectionRefreshLoop() {
|
||||
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
||||
unawaited(_projectionSource.emitCurrentProjection());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasRunningTimer(WatchSessionProjection projection) {
|
||||
final timers = [
|
||||
if (projection.dominantTimer != null) projection.dominantTimer!,
|
||||
...projection.secondaryTimers,
|
||||
];
|
||||
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
|
||||
}
|
||||
|
||||
final class _WatchAdapterCommandKey {
|
||||
_WatchAdapterCommandKey(WatchCommandEnvelope command)
|
||||
: sessionId = command.sessionId,
|
||||
|
||||
Reference in New Issue
Block a user