merge(develop): integre lot watch critique/stats WIP sans validation QA, sur demande explicite (#157 #163 #164 #174 #175 #178)
This commit is contained in:
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 &&
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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('l’entrée Progression est présente après Historique', (
|
testWidgets('l’entré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',
|
||||||
|
|||||||
@ -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});
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
if (value.projection.deviceSessionId.isEmpty ||
|
||||||
: nowMs + const Duration(seconds: 12).inMilliseconds;
|
_pendingCommand != null ||
|
||||||
final delayMs = expiresAtEpochMs - nowMs;
|
_pendingScoreCommandIds.isNotEmpty) {
|
||||||
_projectionExpiryTimer = Timer(
|
return;
|
||||||
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
|
}
|
||||||
() {
|
_invalidateExpiredProjection();
|
||||||
if (value.projection.deviceSessionId.isEmpty ||
|
});
|
||||||
_pendingCommand != null ||
|
|
||||||
_pendingScoreCommandIds.isNotEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user