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

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

View File

@ -26,6 +26,7 @@ object WatchBridgePlugin {
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary" const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample" const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack" const val ACK_PATH = "/gametime/phone/ack"
const val ALERT_PATH = "/gametime/phone/alert"
const val STATE_PATH = "/gametime/phone/projection" const val STATE_PATH = "/gametime/phone/projection"
const val WATCH_CAPABILITY = "gametime_watch_companion" const val WATCH_CAPABILITY = "gametime_watch_companion"
@ -140,6 +141,7 @@ object WatchBridgePlugin {
} }
when (call.method) { when (call.method) {
"publishProjection" -> publishProjection(context, call.arguments, result) "publishProjection" -> publishProjection(context, call.arguments, result)
"publishAlert" -> publishAlert(context, call.arguments, result)
"sendCommandAck" -> sendCommandAck(context, call.arguments, result) "sendCommandAck" -> sendCommandAck(context, call.arguments, result)
"requestCapabilityRefresh" -> { "requestCapabilityRefresh" -> {
requestCapabilityRefresh(context) requestCapabilityRefresh(context)
@ -202,7 +204,7 @@ object WatchBridgePlugin {
val targetNode = commandId?.let { pendingCommandNodes.remove(it) } val targetNode = commandId?.let { pendingCommandNodes.remove(it) }
val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8) val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8)
if (targetNode != null) { if (targetNode != null) {
sendMessage(context, targetNode, payload, result) sendMessage(context, targetNode, ACK_PATH, payload, result)
return return
} }
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
@ -236,14 +238,57 @@ object WatchBridgePlugin {
private fun sendMessage( private fun sendMessage(
context: Context, context: Context,
nodeId: String, nodeId: String,
path: String,
payload: ByteArray, payload: ByteArray,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
Wearable.getMessageClient(context) Wearable.getMessageClient(context)
.sendMessage(nodeId, ACK_PATH, payload) .sendMessage(nodeId, path, payload)
.addOnSuccessListener { result.success(null) } .addOnSuccessListener { result.success(null) }
.addOnFailureListener { error -> .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)
} }
} }

View File

@ -19,6 +19,7 @@ abstract interface class AppDependencies {
WorkoutHistoryUseCases get workoutHistoryUseCases; WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase; ProgressionStatsUseCase get progressionStatsUseCase;
ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase; ExercisePerformanceReferenceUseCase get exercisePerformanceReferenceUseCase;
WatchAlertPublisher get watchAlertPublisher;
DataExportUseCase get dataExportUseCase; DataExportUseCase get dataExportUseCase;
DataImportUseCase get dataImportUseCase; DataImportUseCase get dataImportUseCase;
SyncUseCases get syncUseCases; SyncUseCases get syncUseCases;
@ -74,6 +75,8 @@ final class AppBootstrap implements AppDependencies {
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter; final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
@override
WatchAlertPublisher get watchAlertPublisher => watchWearDataLayerAdapter;
final SessionNotificationCoordinator sessionNotificationCoordinator; final SessionNotificationCoordinator sessionNotificationCoordinator;
@override @override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;

View File

@ -3507,7 +3507,6 @@ bool _hasSameWatchCommandRevisionState(
left.deviceSessionId == right.deviceSessionId && left.deviceSessionId == right.deviceSessionId &&
left.phase == right.phase && left.phase == right.phase &&
left.phoneReachable == right.phoneReachable && left.phoneReachable == right.phoneReachable &&
left.expiresAtEpochMs == right.expiresAtEpochMs &&
left.seriesIndex == right.seriesIndex && left.seriesIndex == right.seriesIndex &&
left.seriesTotal == right.seriesTotal && left.seriesTotal == right.seriesTotal &&
left.exerciseName == right.exerciseName && left.exerciseName == right.exerciseName &&

View File

@ -8,6 +8,10 @@ abstract interface class WatchProjectionPublisher {
Future<void> publish(WatchSessionProjection projection); Future<void> publish(WatchSessionProjection projection);
} }
abstract interface class WatchAlertPublisher {
Future<void> publishAlert(WatchAlertEnvelope alert);
}
abstract interface class WatchProjectionSource { abstract interface class WatchProjectionSource {
Stream<WatchSessionProjection> get projections; Stream<WatchSessionProjection> get projections;

View File

@ -24,6 +24,8 @@ abstract interface class WatchBridgeNativeChannel {
Future<void> publishProjection(WatchSessionProjection projection); Future<void> publishProjection(WatchSessionProjection projection);
Future<void> publishAlert(WatchAlertEnvelope alert);
Future<void> sendCommandAck( Future<void> sendCommandAck(
WatchCommandEnvelope command, WatchCommandEnvelope command,
WatchCommandAck ack, { WatchCommandAck ack, {
@ -129,6 +131,11 @@ final class MethodChannelWatchBridgeNativeChannel
); );
} }
@override
Future<void> publishAlert(WatchAlertEnvelope alert) {
return _invokeIgnoringMissingPlugin('publishAlert', alert.toJson());
}
@override @override
Future<void> requestCapabilityRefresh() { Future<void> requestCapabilityRefresh() {
return _invokeIgnoringMissingPlugin('requestCapabilityRefresh'); return _invokeIgnoringMissingPlugin('requestCapabilityRefresh');

View File

@ -6,7 +6,8 @@ import '../../application/use_cases.dart';
import '../../application/watch_companion_use_cases.dart'; import '../../application/watch_companion_use_cases.dart';
import 'native_watch_bridge_channel.dart'; import 'native_watch_bridge_channel.dart';
final class WatchWearDataLayerAdapter implements WatchProjectionPublisher { final class WatchWearDataLayerAdapter
implements WatchProjectionPublisher, WatchAlertPublisher {
WatchWearDataLayerAdapter({ WatchWearDataLayerAdapter({
required WatchBridgeNativeChannel nativeChannel, required WatchBridgeNativeChannel nativeChannel,
required WatchCommandIngress commandIngress, required WatchCommandIngress commandIngress,
@ -108,6 +109,11 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
await _syncForegroundService(projection); await _syncForegroundService(projection);
} }
@override
Future<void> publishAlert(WatchAlertEnvelope alert) {
return _nativeChannel.publishAlert(alert);
}
Future<void> _enqueueCommand(WatchCommandEnvelope command) { Future<void> _enqueueCommand(WatchCommandEnvelope command) {
final run = _commandTail.then( final run = _commandTail.then(
(_) => _handleCommand(command), (_) => _handleCommand(command),

View File

@ -35,6 +35,9 @@ final class AudioplayersExerciseStepAudioCuePlayer
Future<void> _configureAudioContext() async { Future<void> _configureAudioContext() async {
try { try {
await _player.setAudioContext(_stepCueAudioContext); await _player.setAudioContext(_stepCueAudioContext);
await _player.setPlayerMode(PlayerMode.lowLatency);
await _player.setReleaseMode(ReleaseMode.stop);
await _player.setVolume(1);
} on Object catch (error) { } on Object catch (error) {
debugPrint('Audio cue context ignored: $error'); debugPrint('Audio cue context ignored: $error');
} }

View File

@ -18,6 +18,7 @@ final class HistoryListScreen extends StatefulWidget {
this.sensorUseCases, this.sensorUseCases,
this.performanceReferenceUseCase, this.performanceReferenceUseCase,
this.onOpenProgression, this.onOpenProgression,
this.watchAlertPublisher,
super.key, super.key,
}); });
@ -30,6 +31,7 @@ final class HistoryListScreen extends StatefulWidget {
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases; final MediaUseCases? mediaUseCases;
final VoidCallback? onOpenProgression; final VoidCallback? onOpenProgression;
final WatchAlertPublisher? watchAlertPublisher;
@override @override
State<HistoryListScreen> createState() => _HistoryListScreenState(); State<HistoryListScreen> createState() => _HistoryListScreenState();
@ -116,6 +118,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
performanceReferenceUseCase: widget.performanceReferenceUseCase, performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases, mediaUseCases: widget.mediaUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),
); );
@ -138,6 +141,7 @@ final class HistoryDetailScreen extends StatelessWidget {
this.stepUseCases, this.stepUseCases,
this.sensorUseCases, this.sensorUseCases,
this.performanceReferenceUseCase, this.performanceReferenceUseCase,
this.watchAlertPublisher,
super.key, super.key,
}); });
@ -150,6 +154,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases; final MediaUseCases? mediaUseCases;
final WatchAlertPublisher? watchAlertPublisher;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -265,6 +270,7 @@ final class HistoryDetailScreen extends StatelessWidget {
workoutTemplateUseCases: workoutTemplateUseCases, workoutTemplateUseCases: workoutTemplateUseCases,
performanceReferenceUseCase: performanceReferenceUseCase, performanceReferenceUseCase: performanceReferenceUseCase,
mediaUseCases: mediaUseCases, mediaUseCases: mediaUseCases,
watchAlertPublisher: watchAlertPublisher,
), ),
), ),
); );

View File

@ -170,6 +170,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
shareUseCases: widget.bootstrap.shareUseCases, shareUseCases: widget.bootstrap.shareUseCases,
authUseCases: widget.bootstrap.authUseCases, authUseCases: widget.bootstrap.authUseCases,
syncUseCases: widget.bootstrap.syncUseCases, syncUseCases: widget.bootstrap.syncUseCases,
watchAlertPublisher: widget.bootstrap.watchAlertPublisher,
), ),
), ),
), ),
@ -191,6 +192,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases, mediaUseCases: widget.bootstrap.mediaUseCases,
watchAlertPublisher: widget.bootstrap.watchAlertPublisher,
onOpenProgression: () => _openProgression(context), onOpenProgression: () => _openProgression(context),
), ),
), ),
@ -256,6 +258,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases, mediaUseCases: widget.bootstrap.mediaUseCases,
watchAlertPublisher: widget.bootstrap.watchAlertPublisher,
), ),
), ),
); );
@ -276,6 +279,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
performanceReferenceUseCase: performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases, mediaUseCases: widget.bootstrap.mediaUseCases,
watchAlertPublisher: widget.bootstrap.watchAlertPublisher,
), ),
), ),
); );

View File

@ -16,6 +16,7 @@ final class ProgressionScreen extends StatefulWidget {
this.mediaUseCases, this.mediaUseCases,
this.stepUseCases, this.stepUseCases,
this.performanceReferenceUseCase, this.performanceReferenceUseCase,
this.watchAlertPublisher,
super.key, super.key,
}); });
@ -27,6 +28,7 @@ final class ProgressionScreen extends StatefulWidget {
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases; final MediaUseCases? mediaUseCases;
final WatchAlertPublisher? watchAlertPublisher;
@override @override
State<ProgressionScreen> createState() => _ProgressionScreenState(); State<ProgressionScreen> createState() => _ProgressionScreenState();
@ -247,6 +249,7 @@ final class _ProgressionScreenState extends State<ProgressionScreen> {
performanceReferenceUseCase: widget.performanceReferenceUseCase, performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases, mediaUseCases: widget.mediaUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),
); );
@ -278,6 +281,7 @@ final class _ProgressionScreenState extends State<ProgressionScreen> {
performanceReferenceUseCase: widget.performanceReferenceUseCase, performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases, mediaUseCases: widget.mediaUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),
); );

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
@ -30,6 +31,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
this.stepUseCases, this.stepUseCases,
this.sensorUseCases, this.sensorUseCases,
this.stepAudioCuePlayer, this.stepAudioCuePlayer,
this.watchAlertPublisher,
super.key, super.key,
}); });
@ -45,6 +47,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
final VideoMediaBuilder? videoMediaBuilder; final VideoMediaBuilder? videoMediaBuilder;
final ActiveWorkoutSensorUseCases? sensorUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
final WatchAlertPublisher? watchAlertPublisher;
@override @override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState(); State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
@ -1077,6 +1080,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} }
Future<void> _playStepAudioCue({required bool short}) async { Future<void> _playStepAudioCue({required bool short}) async {
unawaited(_publishStepAlert(short: short));
try { try {
if (short) { if (short) {
await _stepAudioCuePlayer.playShortCountdownBeep(); await _stepAudioCuePlayer.playShortCountdownBeep();
@ -1088,6 +1092,52 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
} }
} }
Future<void> _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<void> _startCurrentStepTimer() async { Future<void> _startCurrentStepTimer() async {
if (_stepProgress?.currentStep?.type != ExerciseStepType.time) return; if (_stepProgress?.currentStep?.type != ExerciseStepType.time) return;
try { try {
@ -1818,6 +1868,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
mediaAssetLoader: widget.mediaAssetLoader, mediaAssetLoader: widget.mediaAssetLoader,
videoMediaBuilder: widget.videoMediaBuilder, videoMediaBuilder: widget.videoMediaBuilder,
sensorUseCases: widget.sensorUseCases, sensorUseCases: widget.sensorUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),
); );

View File

@ -24,6 +24,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
this.shareUseCases, this.shareUseCases,
this.authUseCases, this.authUseCases,
this.syncUseCases, this.syncUseCases,
this.watchAlertPublisher,
super.key, super.key,
}); });
@ -39,6 +40,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ShareUseCases? shareUseCases; final ShareUseCases? shareUseCases;
final AuthUseCases? authUseCases; final AuthUseCases? authUseCases;
final SyncUseCases? syncUseCases; final SyncUseCases? syncUseCases;
final WatchAlertPublisher? watchAlertPublisher;
@override @override
State<WorkoutTemplateListScreen> createState() => State<WorkoutTemplateListScreen> createState() =>
@ -311,6 +313,7 @@ final class _WorkoutTemplateListScreenState
performanceReferenceUseCase: widget.performanceReferenceUseCase, performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases, mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases, sensorUseCases: widget.sensorUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),
); );

View File

@ -63,6 +63,10 @@ enum WatchManualScoreScope { series, step }
enum WatchStepType { time, reps } enum WatchStepType { time, reps }
enum WatchAlertKind { countdownTick, countdownFinished }
enum WatchAlertPattern { countdownTick, timerFinished }
final class WatchCommandEnvelope { final class WatchCommandEnvelope {
const WatchCommandEnvelope({ const WatchCommandEnvelope({
this.schemaVersion = watchBridgeSchemaVersion, 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<String, Object?> 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<String, Object?> 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 { final class WatchSessionProjection {
const WatchSessionProjection({ const WatchSessionProjection({
this.schemaVersion = watchBridgeSchemaVersion, this.schemaVersion = watchBridgeSchemaVersion,

View File

@ -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<String, Object?>,
);
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', () { group('WatchSensorSummary', () {
test('round-trips heart rate summary through JSON', () { test('round-trips heart rate summary through JSON', () {
const summary = WatchSensorSummary( const summary = WatchSensorSummary(

View File

@ -512,6 +512,7 @@ void main() {
expect(first.revision, 1); expect(first.revision, 1);
expect(second.revision, 1); expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs)); expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(second.expiresAtEpochMs, greaterThan(first.expiresAtEpochMs));
expect( expect(
second.dominantTimer?.accumulatedMs, second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0), greaterThan(first.dominantTimer?.accumulatedMs ?? 0),

View File

@ -440,6 +440,7 @@ final class _BlockingCommandIngress implements WatchCommandIngress {
final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
final published = <WatchSessionProjection>[]; final published = <WatchSessionProjection>[];
final alerts = <WatchAlertEnvelope>[];
final acks = <_SentAck>[]; final acks = <_SentAck>[];
final _commands = StreamController<WatchCommandEnvelope>.broadcast(); final _commands = StreamController<WatchCommandEnvelope>.broadcast();
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast(); final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
@ -483,6 +484,11 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
published.add(projection); published.add(projection);
} }
@override
Future<void> publishAlert(WatchAlertEnvelope alert) async {
alerts.add(alert);
}
@override @override
Future<void> requestCapabilityRefresh() async { Future<void> requestCapabilityRefresh() async {
capabilityRefreshCount += 1; capabilityRefreshCount += 1;

View File

@ -7,6 +7,7 @@ import 'package:gametime/application/application.dart';
import 'package:gametime/application/app_bootstrap.dart'; import 'package:gametime/application/app_bootstrap.dart';
import 'package:gametime/domain/domain.dart'; import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/home_screen.dart'; import 'package:gametime/presentation/home_screen.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() { void main() {
testWidgets('lentrée Progression est présente après Historique', ( testWidgets('lentrée Progression est présente après Historique', (
@ -292,7 +293,8 @@ final class _FakeBootstrap implements AppDependencies {
repository: _FakeLocalDataBackupRepository(), repository: _FakeLocalDataBackupRepository(),
mediaStore: _FakeLocalBackupMediaStore(), mediaStore: _FakeLocalBackupMediaStore(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
); ),
watchAlertPublisher = _NoOpWatchAlertPublisher();
@override @override
final AuthUseCases authUseCases; final AuthUseCases authUseCases;
@ -339,6 +341,9 @@ final class _FakeBootstrap implements AppDependencies {
@override @override
final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase; final ExercisePerformanceReferenceUseCase exercisePerformanceReferenceUseCase;
@override
final WatchAlertPublisher watchAlertPublisher;
@override @override
final DataExportUseCase dataExportUseCase; final DataExportUseCase dataExportUseCase;
@ -346,6 +351,11 @@ final class _FakeBootstrap implements AppDependencies {
final DataImportUseCase dataImportUseCase; final DataImportUseCase dataImportUseCase;
} }
final class _NoOpWatchAlertPublisher implements WatchAlertPublisher {
@override
Future<void> publishAlert(WatchAlertEnvelope alert) async {}
}
String _sessionSnapshot() { String _sessionSnapshot() {
return jsonEncode({ return jsonEncode({
'name': 'Séance jambes', 'name': 'Séance jambes',

View File

@ -1962,6 +1962,7 @@ void main() {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository(); final repository = _FakeActiveSessionRepository();
final audio = _FakeStepAudioCuePlayer(); final audio = _FakeStepAudioCuePlayer();
final alerts = _FakeWatchAlertPublisher();
final session = ActiveWorkoutSession( final session = ActiveWorkoutSession(
metadata: _metadata('session-1'), metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1', sourceWorkoutTemplateId: 'template-1',
@ -1991,6 +1992,7 @@ void main() {
closeUseCase: _closeUseCase(repository, clock), closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock), historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(), workoutTemplateUseCases: _workoutTemplateUseCases(),
watchAlertPublisher: alerts,
), ),
), ),
); );
@ -2004,24 +2006,31 @@ void main() {
expect(find.text('00:03'), findsOneWidget); expect(find.text('00:03'), findsOneWidget);
expect(audio.shortBeeps, 1); 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); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 3);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:02'), findsOneWidget); expect(find.text('00:02'), findsOneWidget);
expect(audio.shortBeeps, 2); expect(audio.shortBeeps, 2);
expect(alerts.alerts, hasLength(2));
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 4);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(find.text('00:01'), findsOneWidget); expect(find.text('00:01'), findsOneWidget);
expect(audio.shortBeeps, 3); expect(audio.shortBeeps, 3);
expect(alerts.alerts, hasLength(3));
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200); clock.value = DateTime.utc(2026, 7, 17, 12, 0, 5, 200);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pump(); await tester.pump();
expect(audio.longBeeps, 1); expect(audio.longBeeps, 1);
expect(alerts.alerts, hasLength(4));
expect(alerts.alerts.last.kind, WatchAlertKind.countdownFinished);
expect(alerts.alerts.last.pattern, WatchAlertPattern.timerFinished);
}); });
testWidgets( testWidgets(
@ -2608,6 +2617,15 @@ final class _FakeStepAudioCuePlayer implements ExerciseStepAudioCuePlayer {
} }
} }
final class _FakeWatchAlertPublisher implements WatchAlertPublisher {
final alerts = <WatchAlertEnvelope>[];
@override
Future<void> publishAlert(WatchAlertEnvelope alert) async {
alerts.add(alert);
}
}
final class _FakeExercisePerformanceReferenceRepository final class _FakeExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository { implements ExercisePerformanceReferenceRepository {
const _FakeExercisePerformanceReferenceRepository({this.last, this.record}); const _FakeExercisePerformanceReferenceRepository({this.last, this.record});

View File

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

View File

@ -21,11 +21,11 @@ class WatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) { override fun onMessageReceived(messageEvent: MessageEvent) {
WatchBridgePlugin.attachApplicationContext(applicationContext) WatchBridgePlugin.attachApplicationContext(applicationContext)
if (messageEvent.path != WatchBridgePlugin.ACK_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8)) 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) { override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {

View File

@ -7,6 +7,9 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log import android.util.Log
import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent 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 PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples" private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
private const val ACK_CHANNEL = "gametime.watch_bridge/acks" 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" private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command" const val COMMAND_PATH = "/gametime/watch/command"
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary" const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample" const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack" const val ACK_PATH = "/gametime/phone/ack"
const val ALERT_PATH = "/gametime/phone/alert"
const val STATE_PATH = "/gametime/phone/projection" const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion" const val PHONE_CAPABILITY = "gametime_phone_companion"
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION" 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 projectionSink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null private var sensorSampleSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null private var ackSink: EventChannel.EventSink? = null
private var alertSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val heartRateCollector = WatchHeartRateCollector( 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) EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler( .setStreamHandler(
object : EventChannel.StreamHandler { object : EventChannel.StreamHandler {
@ -194,6 +212,20 @@ object WatchBridgePlugin {
return true return true
} }
fun emitAlert(payload: Map<String, Any?>): Boolean {
val sink = alertSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun handleAlert(payload: Map<String, Any?>) {
if (!emitAlert(payload)) {
triggerNativeAlertHaptic(payload)
}
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean { fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false val sink = sensorSampleSink ?: return false
mainHandler.post { mainHandler.post {
@ -379,12 +411,7 @@ object WatchBridgePlugin {
return return
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L val delayMs = projectionTtlMs(projection)
val delayMs = if (expiresAt > 0L) {
(expiresAt - now).coerceAtLeast(0L)
} else {
12000L
}
val appContext = context.applicationContext val appContext = context.applicationContext
activeProjectionExpiryRunnable = Runnable { activeProjectionExpiryRunnable = Runnable {
if (!hasFreshActiveProjection()) { if (!hasFreshActiveProjection()) {
@ -397,12 +424,43 @@ object WatchBridgePlugin {
private fun hasFreshActiveProjection(): Boolean { private fun hasFreshActiveProjection(): Boolean {
val projection = lastActiveProjection ?: return false val projection = lastActiveProjection ?: return false
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (expiresAt > 0L) { return now - lastActiveProjectionReceivedAtEpochMs <= projectionTtlMs(projection)
return now < expiresAt }
private fun projectionTtlMs(projection: Map<String, Any?>): 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<String, Any?>) {
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<String, Any?>) { private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
@ -420,7 +478,7 @@ object WatchBridgePlugin {
WatchHeartRateForegroundService.stop(context) WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession( heartRateCollector.noteActiveSession(
sessionId, sessionId,
shouldAggregate = false, shouldAggregate = shouldAggregate,
executionContext = telemetryContext(projection), executionContext = telemetryContext(projection),
) )
pendingSensorPermissionRequest = true pendingSensorPermissionRequest = true

View File

@ -335,10 +335,10 @@ internal class WatchHeartRateCollector(
capabilities: androidx.health.services.client.data.ExerciseCapabilities, capabilities: androidx.health.services.client.data.ExerciseCapabilities,
): ExerciseConfig? { ): ExerciseConfig? {
val requestedTypes = listOf( val requestedTypes = listOf(
ExerciseType.WORKOUT,
ExerciseType.RUNNING, ExerciseType.RUNNING,
ExerciseType.WALKING, ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
) )
for (exerciseType in requestedTypes) { for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) { if (exerciseType !in capabilities.supportedExerciseTypes) {

View File

@ -8,6 +8,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity import androidx.wear.ongoing.OngoingActivity
@ -15,12 +16,11 @@ import androidx.wear.ongoing.Status
import com.gametime.watch.R import com.gametime.watch.R
object WatchOngoingActivityController { object WatchOngoingActivityController {
private const val TAG = "GTWatchOngoing"
private const val CHANNEL_ID = "gametime_watch_session" private const val CHANNEL_ID = "gametime_watch_session"
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 POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107
private var postNotificationsPermissionRequested = false
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"
@ -30,7 +30,7 @@ object WatchOngoingActivityController {
return return
} }
if (!hasPostNotificationsPermission(context)) { if (!hasPostNotificationsPermission(context)) {
requestPostNotificationsPermissionOnce(activity) Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
return return
} }
post(context, projection) post(context, projection)
@ -104,18 +104,4 @@ object WatchOngoingActivityController {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED 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,
)
}
} }

View File

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

View File

@ -34,6 +34,8 @@ abstract interface class NativeWatchBridgeClient {
Stream<WatchCommandAckEvent> get acks; Stream<WatchCommandAckEvent> get acks;
Stream<WatchAlertEnvelope> get alerts;
Stream<WatchBridgeConnectionEvent> get connectionEvents; Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> sendCommand(WatchCommandEnvelope command); Future<void> sendCommand(WatchCommandEnvelope command);
@ -54,11 +56,13 @@ final class MethodChannelNativeWatchBridgeClient
_sensorSampleChannelName, _sensorSampleChannelName,
), ),
EventChannel ackChannel = const EventChannel(_ackChannelName), EventChannel ackChannel = const EventChannel(_ackChannelName),
EventChannel alertChannel = const EventChannel(_alertChannelName),
EventChannel connectionChannel = const EventChannel(_connectionChannelName), EventChannel connectionChannel = const EventChannel(_connectionChannelName),
}) : _methodChannel = methodChannel, }) : _methodChannel = methodChannel,
_projectionChannel = projectionChannel, _projectionChannel = projectionChannel,
_sensorSampleChannel = sensorSampleChannel, _sensorSampleChannel = sensorSampleChannel,
_ackChannel = ackChannel, _ackChannel = ackChannel,
_alertChannel = alertChannel,
_connectionChannel = connectionChannel; _connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods'; static const _methodChannelName = 'gametime.watch_bridge/methods';
@ -66,12 +70,14 @@ final class MethodChannelNativeWatchBridgeClient
static const _sensorSampleChannelName = static const _sensorSampleChannelName =
'gametime.watch_bridge/sensor_samples'; 'gametime.watch_bridge/sensor_samples';
static const _ackChannelName = 'gametime.watch_bridge/acks'; static const _ackChannelName = 'gametime.watch_bridge/acks';
static const _alertChannelName = 'gametime.watch_bridge/alerts';
static const _connectionChannelName = 'gametime.watch_bridge/connection'; static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel; final MethodChannel _methodChannel;
final EventChannel _projectionChannel; final EventChannel _projectionChannel;
final EventChannel _sensorSampleChannel; final EventChannel _sensorSampleChannel;
final EventChannel _ackChannel; final EventChannel _ackChannel;
final EventChannel _alertChannel;
final EventChannel _connectionChannel; final EventChannel _connectionChannel;
@override @override
@ -115,6 +121,16 @@ final class MethodChannelNativeWatchBridgeClient
}); });
} }
@override
Stream<WatchAlertEnvelope> get alerts {
return _alertChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
return WatchAlertEnvelope.fromJson(_stringObjectMap(event));
});
}
@override @override
Stream<WatchBridgeConnectionEvent> get connectionEvents { Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel return _connectionChannel

View File

@ -809,6 +809,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
final _sensorSampleController = final _sensorSampleController =
StreamController<WatchSensorSample>.broadcast(); StreamController<WatchSensorSample>.broadcast();
final _ackController = StreamController<WatchCommandAckEvent>.broadcast(); final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
final _alertController = StreamController<WatchAlertEnvelope>.broadcast();
final _connectionController = final _connectionController =
StreamController<WatchBridgeConnectionEvent>.broadcast(); StreamController<WatchBridgeConnectionEvent>.broadcast();
@ -827,6 +828,9 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
@override @override
Stream<WatchCommandAckEvent> get acks => _ackController.stream; Stream<WatchCommandAckEvent> get acks => _ackController.stream;
@override
Stream<WatchAlertEnvelope> get alerts => _alertController.stream;
@override @override
Stream<WatchBridgeConnectionEvent> get connectionEvents => Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connectionController.stream; _connectionController.stream;
@ -843,6 +847,10 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
_ackController.add(ack); _ackController.add(ack);
} }
void emitAlert(WatchAlertEnvelope alert) {
_alertController.add(alert);
}
void emitConnection(WatchBridgeConnectionEvent event) { void emitConnection(WatchBridgeConnectionEvent event) {
_connectionController.add(event); _connectionController.add(event);
} }