diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt index fc14d26..8e4493f 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt @@ -26,6 +26,7 @@ object WatchBridgePlugin { const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary" const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample" const val ACK_PATH = "/gametime/phone/ack" + const val ALERT_PATH = "/gametime/phone/alert" const val STATE_PATH = "/gametime/phone/projection" const val WATCH_CAPABILITY = "gametime_watch_companion" @@ -140,6 +141,7 @@ object WatchBridgePlugin { } when (call.method) { "publishProjection" -> publishProjection(context, call.arguments, result) + "publishAlert" -> publishAlert(context, call.arguments, result) "sendCommandAck" -> sendCommandAck(context, call.arguments, result) "requestCapabilityRefresh" -> { requestCapabilityRefresh(context) @@ -202,7 +204,7 @@ object WatchBridgePlugin { val targetNode = commandId?.let { pendingCommandNodes.remove(it) } val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8) if (targetNode != null) { - sendMessage(context, targetNode, payload, result) + sendMessage(context, targetNode, ACK_PATH, payload, result) return } Wearable.getCapabilityClient(context) @@ -236,14 +238,57 @@ object WatchBridgePlugin { private fun sendMessage( context: Context, nodeId: String, + path: String, payload: ByteArray, result: MethodChannel.Result, ) { Wearable.getMessageClient(context) - .sendMessage(nodeId, ACK_PATH, payload) + .sendMessage(nodeId, path, payload) .addOnSuccessListener { result.success(null) } .addOnFailureListener { error -> - result.error("send_ack_failed", error.message, null) + result.error("send_message_failed", error.message, null) + } + } + + private fun publishAlert( + context: Context, + arguments: Any?, + result: MethodChannel.Result, + ) { + val map = arguments as? Map<*, *> + if (map == null) { + result.error("invalid_alert", "Alert payload must be a map.", null) + return + } + val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8) + Wearable.getCapabilityClient(context) + .getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE) + .addOnSuccessListener { capability -> + val nodes = capability.nodes.toList() + if (nodes.isEmpty()) { + result.success(null) + return@addOnSuccessListener + } + var remaining = nodes.size + var failed = false + for (node in nodes) { + Wearable.getMessageClient(context) + .sendMessage(node.id, ALERT_PATH, payload) + .addOnSuccessListener { + remaining -= 1 + if (remaining == 0 && !failed) result.success(null) + } + .addOnFailureListener { error -> + if (failed) { + return@addOnFailureListener + } + failed = true + result.error("send_alert_failed", error.message, null) + } + } + } + .addOnFailureListener { error -> + result.error("capability_lookup_failed", error.message, null) } } diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 12b492c..f5034d7 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -19,6 +19,7 @@ abstract interface class AppDependencies { WorkoutHistoryUseCases get workoutHistoryUseCases; ProgressionStatsUseCase get progressionStatsUseCase; ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase; + WatchAlertPublisher get watchAlertPublisher; DataExportUseCase get dataExportUseCase; DataImportUseCase get dataImportUseCase; SyncUseCases get syncUseCases; @@ -74,6 +75,8 @@ final class AppBootstrap implements AppDependencies { final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchWearDataLayerAdapter watchWearDataLayerAdapter; + @override + WatchAlertPublisher get watchAlertPublisher => watchWearDataLayerAdapter; final SessionNotificationCoordinator sessionNotificationCoordinator; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 4c14150..9ba8abb 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3507,7 +3507,6 @@ bool _hasSameWatchCommandRevisionState( left.deviceSessionId == right.deviceSessionId && left.phase == right.phase && left.phoneReachable == right.phoneReachable && - left.expiresAtEpochMs == right.expiresAtEpochMs && left.seriesIndex == right.seriesIndex && left.seriesTotal == right.seriesTotal && left.exerciseName == right.exerciseName && diff --git a/lib/application/watch_companion_use_cases.dart b/lib/application/watch_companion_use_cases.dart index dd8803b..e9a032a 100644 --- a/lib/application/watch_companion_use_cases.dart +++ b/lib/application/watch_companion_use_cases.dart @@ -8,6 +8,10 @@ abstract interface class WatchProjectionPublisher { Future publish(WatchSessionProjection projection); } +abstract interface class WatchAlertPublisher { + Future publishAlert(WatchAlertEnvelope alert); +} + abstract interface class WatchProjectionSource { Stream get projections; diff --git a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart index 39c181f..738ae7f 100644 --- a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart +++ b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart @@ -24,6 +24,8 @@ abstract interface class WatchBridgeNativeChannel { Future publishProjection(WatchSessionProjection projection); + Future publishAlert(WatchAlertEnvelope alert); + Future sendCommandAck( WatchCommandEnvelope command, WatchCommandAck ack, { @@ -129,6 +131,11 @@ final class MethodChannelWatchBridgeNativeChannel ); } + @override + Future publishAlert(WatchAlertEnvelope alert) { + return _invokeIgnoringMissingPlugin('publishAlert', alert.toJson()); + } + @override Future requestCapabilityRefresh() { return _invokeIgnoringMissingPlugin('requestCapabilityRefresh'); diff --git a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart index 082b6ea..3a1dca1 100644 --- a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart +++ b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart @@ -6,7 +6,8 @@ import '../../application/use_cases.dart'; import '../../application/watch_companion_use_cases.dart'; import 'native_watch_bridge_channel.dart'; -final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { +final class WatchWearDataLayerAdapter + implements WatchProjectionPublisher, WatchAlertPublisher { WatchWearDataLayerAdapter({ required WatchBridgeNativeChannel nativeChannel, required WatchCommandIngress commandIngress, @@ -108,6 +109,11 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { await _syncForegroundService(projection); } + @override + Future publishAlert(WatchAlertEnvelope alert) { + return _nativeChannel.publishAlert(alert); + } + Future _enqueueCommand(WatchCommandEnvelope command) { final run = _commandTail.then( (_) => _handleCommand(command), diff --git a/lib/presentation/exercise_step_audio.dart b/lib/presentation/exercise_step_audio.dart index d0da0af..e7816ff 100644 --- a/lib/presentation/exercise_step_audio.dart +++ b/lib/presentation/exercise_step_audio.dart @@ -35,6 +35,9 @@ final class AudioplayersExerciseStepAudioCuePlayer Future _configureAudioContext() async { try { await _player.setAudioContext(_stepCueAudioContext); + await _player.setPlayerMode(PlayerMode.lowLatency); + await _player.setReleaseMode(ReleaseMode.stop); + await _player.setVolume(1); } on Object catch (error) { debugPrint('Audio cue context ignored: $error'); } diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index e6b630d..5626f1b 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -18,6 +18,7 @@ final class HistoryListScreen extends StatefulWidget { this.sensorUseCases, this.performanceReferenceUseCase, this.onOpenProgression, + this.watchAlertPublisher, super.key, }); @@ -30,6 +31,7 @@ final class HistoryListScreen extends StatefulWidget { final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; final VoidCallback? onOpenProgression; + final WatchAlertPublisher? watchAlertPublisher; @override State createState() => _HistoryListScreenState(); @@ -116,6 +118,7 @@ final class _HistoryListScreenState extends State { performanceReferenceUseCase: widget.performanceReferenceUseCase, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, + watchAlertPublisher: widget.watchAlertPublisher, ), ), ); @@ -138,6 +141,7 @@ final class HistoryDetailScreen extends StatelessWidget { this.stepUseCases, this.sensorUseCases, this.performanceReferenceUseCase, + this.watchAlertPublisher, super.key, }); @@ -150,6 +154,7 @@ final class HistoryDetailScreen extends StatelessWidget { final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; + final WatchAlertPublisher? watchAlertPublisher; @override Widget build(BuildContext context) { @@ -265,6 +270,7 @@ final class HistoryDetailScreen extends StatelessWidget { workoutTemplateUseCases: workoutTemplateUseCases, performanceReferenceUseCase: performanceReferenceUseCase, mediaUseCases: mediaUseCases, + watchAlertPublisher: watchAlertPublisher, ), ), ); diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 9e4e1ac..f9a50c8 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -170,6 +170,7 @@ final class _HomeScreenState extends State with RouteAware { shareUseCases: widget.bootstrap.shareUseCases, authUseCases: widget.bootstrap.authUseCases, syncUseCases: widget.bootstrap.syncUseCases, + watchAlertPublisher: widget.bootstrap.watchAlertPublisher, ), ), ), @@ -191,6 +192,7 @@ final class _HomeScreenState extends State with RouteAware { widget.bootstrap.exercisePerformanceReferenceUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, + watchAlertPublisher: widget.bootstrap.watchAlertPublisher, onOpenProgression: () => _openProgression(context), ), ), @@ -256,6 +258,7 @@ final class _HomeScreenState extends State with RouteAware { widget.bootstrap.exercisePerformanceReferenceUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, + watchAlertPublisher: widget.bootstrap.watchAlertPublisher, ), ), ); @@ -276,6 +279,7 @@ final class _HomeScreenState extends State with RouteAware { performanceReferenceUseCase: widget.bootstrap.exercisePerformanceReferenceUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, + watchAlertPublisher: widget.bootstrap.watchAlertPublisher, ), ), ); diff --git a/lib/presentation/progression_screen.dart b/lib/presentation/progression_screen.dart index 35475f0..0b41200 100644 --- a/lib/presentation/progression_screen.dart +++ b/lib/presentation/progression_screen.dart @@ -16,6 +16,7 @@ final class ProgressionScreen extends StatefulWidget { this.mediaUseCases, this.stepUseCases, this.performanceReferenceUseCase, + this.watchAlertPublisher, super.key, }); @@ -27,6 +28,7 @@ final class ProgressionScreen extends StatefulWidget { final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; + final WatchAlertPublisher? watchAlertPublisher; @override State createState() => _ProgressionScreenState(); @@ -247,6 +249,7 @@ final class _ProgressionScreenState extends State { performanceReferenceUseCase: widget.performanceReferenceUseCase, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, + watchAlertPublisher: widget.watchAlertPublisher, ), ), ); @@ -278,6 +281,7 @@ final class _ProgressionScreenState extends State { performanceReferenceUseCase: widget.performanceReferenceUseCase, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, + watchAlertPublisher: widget.watchAlertPublisher, ), ), ); diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 4577ba5..f264738 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; import '../application/application.dart'; import '../domain/domain.dart'; @@ -30,6 +31,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { this.stepUseCases, this.sensorUseCases, this.stepAudioCuePlayer, + this.watchAlertPublisher, super.key, }); @@ -45,6 +47,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { final VideoMediaBuilder? videoMediaBuilder; final ActiveWorkoutSensorUseCases? sensorUseCases; final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; + final WatchAlertPublisher? watchAlertPublisher; @override State createState() => _WorkoutExecutionScreenState(); @@ -1077,6 +1080,7 @@ final class _WorkoutExecutionScreenState extends State { } Future _playStepAudioCue({required bool short}) async { + unawaited(_publishStepAlert(short: short)); try { if (short) { await _stepAudioCuePlayer.playShortCountdownBeep(); @@ -1088,6 +1092,52 @@ final class _WorkoutExecutionScreenState extends State { } } + Future _publishStepAlert({required bool short}) async { + final publisher = widget.watchAlertPublisher; + if (publisher == null) { + return; + } + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + final position = _position; + final stepState = _stepProgress?.state; + final passageIndex = stepState?.currentPassageIndex; + final stepIndex = stepState?.currentStepIndex; + final ttlMs = short ? 700 : 2000; + final alert = WatchAlertEnvelope( + alertId: [ + _session.metadata.id, + position.programIndex, + position.exerciseIndex, + position.setIndex, + passageIndex, + stepIndex, + short ? 'tick' : 'finish', + nowMs, + ].join('-'), + sessionId: _session.metadata.id, + revision: 0, + kind: short + ? WatchAlertKind.countdownTick + : WatchAlertKind.countdownFinished, + timerKind: WatchTimerKind.step, + programIndex: position.programIndex, + exerciseIndex: position.exerciseIndex, + setIndex: position.setIndex, + passageIndex: passageIndex, + stepIndex: stepIndex, + scheduledForEpochMs: nowMs, + expiresAtEpochMs: nowMs + ttlMs, + pattern: short + ? WatchAlertPattern.countdownTick + : WatchAlertPattern.timerFinished, + ); + try { + await publisher.publishAlert(alert); + } on Object catch (error) { + debugPrint('Watch alert ignored: $error'); + } + } + Future _startCurrentStepTimer() async { if (_stepProgress?.currentStep?.type != ExerciseStepType.time) return; try { @@ -1818,6 +1868,7 @@ final class _WorkoutExecutionScreenState extends State { mediaAssetLoader: widget.mediaAssetLoader, videoMediaBuilder: widget.videoMediaBuilder, sensorUseCases: widget.sensorUseCases, + watchAlertPublisher: widget.watchAlertPublisher, ), ), ); diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index d514665..2181cc5 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -24,6 +24,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { this.shareUseCases, this.authUseCases, this.syncUseCases, + this.watchAlertPublisher, super.key, }); @@ -39,6 +40,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { final ShareUseCases? shareUseCases; final AuthUseCases? authUseCases; final SyncUseCases? syncUseCases; + final WatchAlertPublisher? watchAlertPublisher; @override State createState() => @@ -311,6 +313,7 @@ final class _WorkoutTemplateListScreenState performanceReferenceUseCase: widget.performanceReferenceUseCase, mediaUseCases: widget.mediaUseCases, sensorUseCases: widget.sensorUseCases, + watchAlertPublisher: widget.watchAlertPublisher, ), ), ); diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index 3b0d67d..927250c 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -63,6 +63,10 @@ enum WatchManualScoreScope { series, step } enum WatchStepType { time, reps } +enum WatchAlertKind { countdownTick, countdownFinished } + +enum WatchAlertPattern { countdownTick, timerFinished } + final class WatchCommandEnvelope { const WatchCommandEnvelope({ this.schemaVersion = watchBridgeSchemaVersion, @@ -134,6 +138,133 @@ final class WatchCommandEnvelope { } } +final class WatchAlertEnvelope { + const WatchAlertEnvelope({ + this.schemaVersion = watchBridgeSchemaVersion, + required this.alertId, + required this.sessionId, + required this.revision, + required this.kind, + required this.timerKind, + this.programIndex, + this.exerciseIndex, + this.setIndex, + this.passageIndex, + this.stepIndex, + required this.scheduledForEpochMs, + required this.expiresAtEpochMs, + required this.pattern, + }); + + factory WatchAlertEnvelope.fromJson(Map json) { + return WatchAlertEnvelope( + schemaVersion: _intFromJson( + json['schemaVersion'], + watchBridgeSchemaVersion, + ), + alertId: _stringFromJson(json['alertId']), + sessionId: _stringFromJson(json['sessionId']), + revision: _intFromJson(json['revision'], 0), + kind: _enumFromJson( + json['kind'], + WatchAlertKind.values, + WatchAlertKind.countdownTick, + ), + timerKind: _enumFromJson( + json['timerKind'], + WatchTimerKind.values, + WatchTimerKind.step, + ), + programIndex: _nullableIntFromJson(json['programIndex']), + exerciseIndex: _nullableIntFromJson(json['exerciseIndex']), + setIndex: _nullableIntFromJson(json['setIndex']), + passageIndex: _nullableIntFromJson(json['passageIndex']), + stepIndex: _nullableIntFromJson(json['stepIndex']), + scheduledForEpochMs: _intFromJson(json['scheduledForEpochMs'], 0), + expiresAtEpochMs: _intFromJson(json['expiresAtEpochMs'], 0), + pattern: _enumFromJson( + json['pattern'], + WatchAlertPattern.values, + WatchAlertPattern.countdownTick, + ), + ); + } + + final int schemaVersion; + final String alertId; + final String sessionId; + final int revision; + final WatchAlertKind kind; + final WatchTimerKind timerKind; + final int? programIndex; + final int? exerciseIndex; + final int? setIndex; + final int? passageIndex; + final int? stepIndex; + final int scheduledForEpochMs; + final int expiresAtEpochMs; + final WatchAlertPattern pattern; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'alertId': alertId, + 'sessionId': sessionId, + 'revision': revision, + 'kind': kind.name, + 'timerKind': timerKind.name, + 'programIndex': programIndex, + 'exerciseIndex': exerciseIndex, + 'setIndex': setIndex, + 'passageIndex': passageIndex, + 'stepIndex': stepIndex, + 'scheduledForEpochMs': scheduledForEpochMs, + 'expiresAtEpochMs': expiresAtEpochMs, + 'pattern': pattern.name, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WatchAlertEnvelope && + schemaVersion == other.schemaVersion && + alertId == other.alertId && + sessionId == other.sessionId && + revision == other.revision && + kind == other.kind && + timerKind == other.timerKind && + programIndex == other.programIndex && + exerciseIndex == other.exerciseIndex && + setIndex == other.setIndex && + passageIndex == other.passageIndex && + stepIndex == other.stepIndex && + scheduledForEpochMs == other.scheduledForEpochMs && + expiresAtEpochMs == other.expiresAtEpochMs && + pattern == other.pattern; + } + + @override + int get hashCode { + return Object.hash( + schemaVersion, + alertId, + sessionId, + revision, + kind, + timerKind, + programIndex, + exerciseIndex, + setIndex, + passageIndex, + stepIndex, + scheduledForEpochMs, + expiresAtEpochMs, + pattern, + ); + } +} + final class WatchSessionProjection { const WatchSessionProjection({ this.schemaVersion = watchBridgeSchemaVersion, diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index 08a0f52..a35cfdf 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -52,6 +52,55 @@ void main() { }); }); + group('WatchAlertEnvelope', () { + test('round-trips every alert kind through JSON', () { + for (final kind in WatchAlertKind.values) { + final alert = WatchAlertEnvelope( + alertId: 'alert-${kind.name}', + sessionId: 'session-1', + revision: 7, + kind: kind, + timerKind: WatchTimerKind.step, + programIndex: 0, + exerciseIndex: 1, + setIndex: 2, + passageIndex: 3, + stepIndex: 4, + scheduledForEpochMs: 1710000000700, + expiresAtEpochMs: 1710000002700, + pattern: kind == WatchAlertKind.countdownTick + ? WatchAlertPattern.countdownTick + : WatchAlertPattern.timerFinished, + ); + + final decoded = WatchAlertEnvelope.fromJson( + jsonDecode(jsonEncode(alert.toJson())) as Map, + ); + + expect(decoded, alert); + } + }); + + test('falls back safely for absent fields', () { + final alert = WatchAlertEnvelope.fromJson({}); + + expect(alert.schemaVersion, watchBridgeSchemaVersion); + expect(alert.alertId, ''); + expect(alert.sessionId, ''); + expect(alert.revision, 0); + expect(alert.kind, WatchAlertKind.countdownTick); + expect(alert.timerKind, WatchTimerKind.step); + expect(alert.programIndex, isNull); + expect(alert.exerciseIndex, isNull); + expect(alert.setIndex, isNull); + expect(alert.passageIndex, isNull); + expect(alert.stepIndex, isNull); + expect(alert.scheduledForEpochMs, 0); + expect(alert.expiresAtEpochMs, 0); + expect(alert.pattern, WatchAlertPattern.countdownTick); + }); + }); + group('WatchSensorSummary', () { test('round-trips heart rate summary through JSON', () { const summary = WatchSensorSummary( diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index 5e8276e..bcbbdf4 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -512,6 +512,7 @@ void main() { expect(first.revision, 1); expect(second.revision, 1); expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs)); + expect(second.expiresAtEpochMs, greaterThan(first.expiresAtEpochMs)); expect( second.dominantTimer?.accumulatedMs, greaterThan(first.dominantTimer?.accumulatedMs ?? 0), diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index 11087a7..290de04 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -440,6 +440,7 @@ final class _BlockingCommandIngress implements WatchCommandIngress { final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { final published = []; + final alerts = []; final acks = <_SentAck>[]; final _commands = StreamController.broadcast(); final _sensorSummaries = StreamController.broadcast(); @@ -483,6 +484,11 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { published.add(projection); } + @override + Future publishAlert(WatchAlertEnvelope alert) async { + alerts.add(alert); + } + @override Future requestCapabilityRefresh() async { capabilityRefreshCount += 1; diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 6ecf485..0986616 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -7,6 +7,7 @@ import 'package:gametime/application/application.dart'; import 'package:gametime/application/app_bootstrap.dart'; import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/home_screen.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { testWidgets('l’entrée Progression est présente après Historique', ( @@ -292,7 +293,8 @@ final class _FakeBootstrap implements AppDependencies { repository: _FakeLocalDataBackupRepository(), mediaStore: _FakeLocalBackupMediaStore(), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), - ); + ), + watchAlertPublisher = _NoOpWatchAlertPublisher(); @override final AuthUseCases authUseCases; @@ -339,6 +341,9 @@ final class _FakeBootstrap implements AppDependencies { @override final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; + @override + final WatchAlertPublisher watchAlertPublisher; + @override final DataExportUseCase dataExportUseCase; @@ -346,6 +351,11 @@ final class _FakeBootstrap implements AppDependencies { final DataImportUseCase dataImportUseCase; } +final class _NoOpWatchAlertPublisher implements WatchAlertPublisher { + @override + Future publishAlert(WatchAlertEnvelope alert) async {} +} + String _sessionSnapshot() { return jsonEncode({ 'name': 'Séance jambes', diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 5c71526..7b4eece 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1962,6 +1962,7 @@ void main() { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository(); final audio = _FakeStepAudioCuePlayer(); + final alerts = _FakeWatchAlertPublisher(); final session = ActiveWorkoutSession( metadata: _metadata('session-1'), sourceWorkoutTemplateId: 'template-1', @@ -1991,6 +1992,7 @@ void main() { closeUseCase: _closeUseCase(repository, clock), historyUseCases: _historyUseCases(clock), workoutTemplateUseCases: _workoutTemplateUseCases(), + watchAlertPublisher: alerts, ), ), ); @@ -2004,24 +2006,31 @@ void main() { expect(find.text('00:03'), findsOneWidget); expect(audio.shortBeeps, 1); + expect(alerts.alerts, hasLength(1)); + expect(alerts.alerts.last.pattern, WatchAlertPattern.countdownTick); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 3); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('00:02'), findsOneWidget); expect(audio.shortBeeps, 2); + expect(alerts.alerts, hasLength(2)); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4); await tester.pump(const Duration(milliseconds: 100)); expect(find.text('00:01'), findsOneWidget); expect(audio.shortBeeps, 3); + expect(alerts.alerts, hasLength(3)); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200); await tester.pump(const Duration(milliseconds: 200)); await tester.pump(); expect(audio.longBeeps, 1); + expect(alerts.alerts, hasLength(4)); + expect(alerts.alerts.last.kind, WatchAlertKind.countdownFinished); + expect(alerts.alerts.last.pattern, WatchAlertPattern.timerFinished); }); testWidgets( @@ -2608,6 +2617,15 @@ final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer { } } +final class _FakeWatchAlertPublisher implements WatchAlertPublisher { + final alerts = []; + + @override + Future publishAlert(WatchAlertEnvelope alert) async { + alerts.add(alert); + } +} + final class _FakeExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { const _FakeExercisePerformanceReferenceRepository({this.last, this.record}); diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 38fbd1f..ffcca3b 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt index fce62b4..6956278 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt @@ -21,11 +21,11 @@ class WatchBridgeListenerService : WearableListenerService() { override fun onMessageReceived(messageEvent: MessageEvent) { WatchBridgePlugin.attachApplicationContext(applicationContext) - if (messageEvent.path != WatchBridgePlugin.ACK_PATH) { - return - } val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8)) - WatchBridgePlugin.emitAck(payload.toMap()) + when (messageEvent.path) { + WatchBridgePlugin.ACK_PATH -> WatchBridgePlugin.emitAck(payload.toMap()) + WatchBridgePlugin.ALERT_PATH -> WatchBridgePlugin.handleAlert(payload.toMap()) + } } override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) { diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 4844819..4f7d57c 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -7,6 +7,9 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import android.util.Log import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.DataEvent @@ -26,12 +29,14 @@ object WatchBridgePlugin { private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections" private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples" private const val ACK_CHANNEL = "gametime.watch_bridge/acks" + private const val ALERT_CHANNEL = "gametime.watch_bridge/alerts" private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection" const val COMMAND_PATH = "/gametime/watch/command" const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary" const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample" const val ACK_PATH = "/gametime/phone/ack" + const val ALERT_PATH = "/gametime/phone/alert" const val STATE_PATH = "/gametime/phone/projection" const val PHONE_CAPABILITY = "gametime_phone_companion" const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION" @@ -45,6 +50,7 @@ object WatchBridgePlugin { private var projectionSink: EventChannel.EventSink? = null private var sensorSampleSink: EventChannel.EventSink? = null private var ackSink: EventChannel.EventSink? = null + private var alertSink: EventChannel.EventSink? = null private var connectionSink: EventChannel.EventSink? = null private val mainHandler = Handler(Looper.getMainLooper()) private val heartRateCollector = WatchHeartRateCollector( @@ -94,6 +100,18 @@ object WatchBridgePlugin { } }, ) + EventChannel(flutterEngine.dartExecutor.binaryMessenger, ALERT_CHANNEL) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + alertSink = events + } + + override fun onCancel(arguments: Any?) { + alertSink = null + } + }, + ) EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL) .setStreamHandler( object : EventChannel.StreamHandler { @@ -194,6 +212,20 @@ object WatchBridgePlugin { return true } + fun emitAlert(payload: Map): Boolean { + val sink = alertSink ?: return false + mainHandler.post { + sink.success(payload) + } + return true + } + + fun handleAlert(payload: Map) { + if (!emitAlert(payload)) { + triggerNativeAlertHaptic(payload) + } + } + fun emitSensorSample(payload: Map): Boolean { val sink = sensorSampleSink ?: return false mainHandler.post { @@ -379,12 +411,7 @@ object WatchBridgePlugin { return } val now = System.currentTimeMillis() - val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L - val delayMs = if (expiresAt > 0L) { - (expiresAt - now).coerceAtLeast(0L) - } else { - 12000L - } + val delayMs = projectionTtlMs(projection) val appContext = context.applicationContext activeProjectionExpiryRunnable = Runnable { if (!hasFreshActiveProjection()) { @@ -397,12 +424,43 @@ object WatchBridgePlugin { private fun hasFreshActiveProjection(): Boolean { val projection = lastActiveProjection ?: return false - val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L val now = System.currentTimeMillis() - if (expiresAt > 0L) { - return now < expiresAt + return now - lastActiveProjectionReceivedAtEpochMs <= projectionTtlMs(projection) + } + + private fun projectionTtlMs(projection: Map): Long { + val projectedAt = (projection["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L + val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L + if (projectedAt > 0L && expiresAt > projectedAt) { + return expiresAt - projectedAt + } + return 12000L + } + + private fun triggerNativeAlertHaptic(payload: Map) { + if (payload["pattern"] != "timerFinished") { + return + } + val context = appContext ?: return + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } ?: return + val timings = longArrayOf(0L, 180L, 120L, 260L) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate( + VibrationEffect.createWaveform( + timings, + intArrayOf(0, 255, 0, 255), + -1, + ), + ) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(timings, -1) } - return now - lastActiveProjectionReceivedAtEpochMs <= 12000L } private fun updateHeartRateCollection(context: Context, projection: Map) { @@ -420,7 +478,7 @@ object WatchBridgePlugin { WatchHeartRateForegroundService.stop(context) heartRateCollector.noteActiveSession( sessionId, - shouldAggregate = false, + shouldAggregate = shouldAggregate, executionContext = telemetryContext(projection), ) pendingSensorPermissionRequest = true diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index ba604a9..ccde604 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -335,10 +335,10 @@ internal class WatchHeartRateCollector( capabilities: androidx.health.services.client.data.ExerciseCapabilities, ): ExerciseConfig? { val requestedTypes = listOf( - ExerciseType.WORKOUT, ExerciseType.RUNNING, ExerciseType.WALKING, ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, + ExerciseType.WORKOUT, ) for (exerciseType in requestedTypes) { if (exerciseType !in capabilities.supportedExerciseTypes) { diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt index 51517b9..db50b3d 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt @@ -8,6 +8,7 @@ import android.app.PendingIntent import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.wear.ongoing.OngoingActivity @@ -15,12 +16,11 @@ import androidx.wear.ongoing.Status import com.gametime.watch.R object WatchOngoingActivityController { + private const val TAG = "GTWatchOngoing" private const val CHANNEL_ID = "gametime_watch_session" private const val CHANNEL_NAME = "Séance GameTime" private const val NOTIFICATION_ID = 9101 private const val ONGOING_ACTIVITY_ID = 9101 - private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107 - private var postNotificationsPermissionRequested = false fun update(context: Context, projection: Map, activity: Activity?) { val phase = projection["phase"] as? String ?: "noActiveSession" @@ -30,7 +30,7 @@ object WatchOngoingActivityController { return } if (!hasPostNotificationsPermission(context)) { - requestPostNotificationsPermissionOnce(activity) + Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted") return } post(context, projection) @@ -104,18 +104,4 @@ object WatchOngoingActivityController { context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } - - private fun requestPostNotificationsPermissionOnce(activity: Activity?) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - activity == null || - postNotificationsPermissionRequested - ) { - return - } - postNotificationsPermissionRequested = true - activity.requestPermissions( - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - POST_NOTIFICATIONS_PERMISSION_REQUEST, - ) - } } diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index db83c58..badcf68 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -106,6 +106,7 @@ final class WatchSessionViewModel extends ValueNotifier { _subscriptions.add(_nativeClient.projections.listen(_handleProjection)); _subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample)); _subscriptions.add(_nativeClient.acks.listen(_handleAck)); + _subscriptions.add(_nativeClient.alerts.listen(_handleAlert)); _subscriptions.add( _nativeClient.connectionEvents.listen(_handleConnectionEvent), ); @@ -122,6 +123,7 @@ final class WatchSessionViewModel extends ValueNotifier { final Duration _staleProjectionThreshold; final Duration _connectionLostThreshold; final _subscriptions = >[]; + final _handledAlertIds = {}; Timer? _waitingTimer; Timer? _commandTimeoutTimer; @@ -379,6 +381,26 @@ final class WatchSessionViewModel extends ValueNotifier { } } + void _handleAlert(WatchAlertEnvelope alert) { + final alertId = alert.alertId.trim(); + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + if (alertId.isEmpty || + !_handledAlertIds.add(alertId) || + alert.sessionId != value.projection.deviceSessionId || + (alert.expiresAtEpochMs > 0 && nowMs > alert.expiresAtEpochMs)) { + return; + } + if (_handledAlertIds.length > 64) { + _handledAlertIds.remove(_handledAlertIds.first); + } + switch (alert.pattern) { + case WatchAlertPattern.countdownTick: + unawaited(HapticFeedback.selectionClick()); + case WatchAlertPattern.timerFinished: + _triggerTimerFinishedHaptic(); + } + } + void _handleConnectionEvent(WatchBridgeConnectionEvent event) { value = value.copyWith(connectionLost: !event.isReachable); if (event.isReachable || event.requestsResync) { @@ -392,15 +414,12 @@ final class WatchSessionViewModel extends ValueNotifier { return; } final now = DateTime.now(); - final expiresAtEpochMs = value.projection.expiresAtEpochMs; - final fallbackExpired = - expiresAtEpochMs <= 0 && - now.difference(receivedAt) >= const Duration(seconds: 12); + final expiryAge = Duration( + milliseconds: _projectionTtlMs(value.projection), + ); final expired = value.projection.deviceSessionId.isNotEmpty && - (fallbackExpired || - (expiresAtEpochMs > 0 && - now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) && + now.difference(receivedAt) >= expiryAge && _pendingCommand == null && _pendingScoreCommandIds.isEmpty; if (expired) { @@ -444,22 +463,15 @@ final class WatchSessionViewModel extends ValueNotifier { if (projection.deviceSessionId.isEmpty) { return; } - final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; - final expiresAtEpochMs = projection.expiresAtEpochMs > 0 - ? projection.expiresAtEpochMs - : nowMs + const Duration(seconds: 12).inMilliseconds; - final delayMs = expiresAtEpochMs - nowMs; - _projectionExpiryTimer = Timer( - Duration(milliseconds: delayMs <= 0 ? 0 : delayMs), - () { - if (value.projection.deviceSessionId.isEmpty || - _pendingCommand != null || - _pendingScoreCommandIds.isNotEmpty) { - return; - } - _invalidateExpiredProjection(); - }, - ); + final delayMs = _projectionTtlMs(projection); + _projectionExpiryTimer = Timer(Duration(milliseconds: delayMs), () { + if (value.projection.deviceSessionId.isEmpty || + _pendingCommand != null || + _pendingScoreCommandIds.isNotEmpty) { + return; + } + _invalidateExpiredProjection(); + }); } void _clearCommandTimers() { @@ -538,6 +550,29 @@ final class WatchSessionViewModel extends ValueNotifier { ); } } + + void _triggerTimerFinishedHaptic() { + unawaited(HapticFeedback.heavyImpact()); + unawaited( + Future.delayed(const Duration(milliseconds: 140), () { + return HapticFeedback.heavyImpact(); + }), + ); + unawaited( + Future.delayed(const Duration(milliseconds: 320), () { + return HapticFeedback.heavyImpact(); + }), + ); + } +} + +int _projectionTtlMs(WatchSessionProjection projection) { + final projectedAtEpochMs = projection.projectedAtEpochMs; + final expiresAtEpochMs = projection.expiresAtEpochMs; + if (projectedAtEpochMs > 0 && expiresAtEpochMs > projectedAtEpochMs) { + return expiresAtEpochMs - projectedAtEpochMs; + } + return const Duration(seconds: 12).inMilliseconds; } bool _isRejected(WatchCommandAck ack) { diff --git a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart index 2db538e..0287df1 100644 --- a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart +++ b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart @@ -34,6 +34,8 @@ abstract interface class NativeWatchBridgeClient { Stream get acks; + Stream get alerts; + Stream get connectionEvents; Future sendCommand(WatchCommandEnvelope command); @@ -54,11 +56,13 @@ final class MethodChannelNativeWatchBridgeClient _sensorSampleChannelName, ), EventChannel ackChannel = const EventChannel(_ackChannelName), + EventChannel alertChannel = const EventChannel(_alertChannelName), EventChannel connectionChannel = const EventChannel(_connectionChannelName), }) : _methodChannel = methodChannel, _projectionChannel = projectionChannel, _sensorSampleChannel = sensorSampleChannel, _ackChannel = ackChannel, + _alertChannel = alertChannel, _connectionChannel = connectionChannel; static const _methodChannelName = 'gametime.watch_bridge/methods'; @@ -66,12 +70,14 @@ final class MethodChannelNativeWatchBridgeClient static const _sensorSampleChannelName = 'gametime.watch_bridge/sensor_samples'; static const _ackChannelName = 'gametime.watch_bridge/acks'; + static const _alertChannelName = 'gametime.watch_bridge/alerts'; static const _connectionChannelName = 'gametime.watch_bridge/connection'; final MethodChannel _methodChannel; final EventChannel _projectionChannel; final EventChannel _sensorSampleChannel; final EventChannel _ackChannel; + final EventChannel _alertChannel; final EventChannel _connectionChannel; @override @@ -115,6 +121,16 @@ final class MethodChannelNativeWatchBridgeClient }); } + @override + Stream get alerts { + return _alertChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) { + return WatchAlertEnvelope.fromJson(_stringObjectMap(event)); + }); + } + @override Stream get connectionEvents { return _connectionChannel diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 98ed1d1..6cd21a3 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -809,6 +809,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { final _sensorSampleController = StreamController.broadcast(); final _ackController = StreamController.broadcast(); + final _alertController = StreamController.broadcast(); final _connectionController = StreamController.broadcast(); @@ -827,6 +828,9 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { @override Stream get acks => _ackController.stream; + @override + Stream get alerts => _alertController.stream; + @override Stream get connectionEvents => _connectionController.stream; @@ -843,6 +847,10 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { _ackController.add(ack); } + void emitAlert(WatchAlertEnvelope alert) { + _alertController.add(alert); + } + void emitConnection(WatchBridgeConnectionEvent event) { _connectionController.add(event); }