From cf68a72403f67b94cbc1d43c12844dd264ad32dc Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 17:42:57 +0200 Subject: [PATCH 1/6] feat(watch): shared watch bridge contract package (#91-A) --- .../analysis_options.yaml | 5 + .../lib/src/watch_bridge_contract.dart | 458 ++++++++++++++++++ .../lib/watch_bridge_contract.dart | 7 + packages/watch_bridge_contract/pubspec.lock | 389 +++++++++++++++ packages/watch_bridge_contract/pubspec.yaml | 11 + .../test/watch_bridge_contract_test.dart | 217 +++++++++ pubspec.lock | 7 + pubspec.yaml | 2 + 8 files changed, 1096 insertions(+) create mode 100644 packages/watch_bridge_contract/analysis_options.yaml create mode 100644 packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart create mode 100644 packages/watch_bridge_contract/lib/watch_bridge_contract.dart create mode 100644 packages/watch_bridge_contract/pubspec.lock create mode 100644 packages/watch_bridge_contract/pubspec.yaml create mode 100644 packages/watch_bridge_contract/test/watch_bridge_contract_test.dart diff --git a/packages/watch_bridge_contract/analysis_options.yaml b/packages/watch_bridge_contract/analysis_options.yaml new file mode 100644 index 0000000..1eb34bb --- /dev/null +++ b/packages/watch_bridge_contract/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_single_quotes: true diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart new file mode 100644 index 0000000..a378749 --- /dev/null +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -0,0 +1,458 @@ +const int watchBridgeSchemaVersion = 1; + +enum WatchCommandType { + startCurrentExercise, + pauseSession, + resumeSession, + startPreparedTimedStep, + skipCurrentStep, + skipCurrentPassage, + finishCurrentSet, + skipCurrentSet, + skipCurrentRest, +} + +enum WatchCommandAck { + accepted, + acceptedNoOp, + rejectedStaleRevision, + rejectedNotApplicable, + rejectedNoActiveSession, + rejectedSessionMismatch, + rejectedPhoneBusy, +} + +enum WatchSessionPhase { + noActiveSession, + ready, + running, + paused, + nextTimerReady, + restRunning, + restPaused, + betweenSetsReady, +} + +enum WatchPrimaryAction { + none, + startCurrentExercise, + pauseSession, + resumeSession, + startPreparedTimedStep, + skipCurrentRest, +} + +enum WatchSecondaryAction { + skipCurrentStep, + skipCurrentPassage, + finishCurrentSet, + skipCurrentSet, + skipCurrentRest, +} + +enum WatchTimerKind { rest, step, scoreStopwatch, setTimer } + +enum WatchTimerDisplayMode { countdown, elapsed } + +enum WatchTimerRunState { stopped, running, paused } + +final class WatchCommandEnvelope { + const WatchCommandEnvelope({ + this.schemaVersion = watchBridgeSchemaVersion, + required this.commandId, + required this.type, + required this.sessionId, + required this.expectedRevision, + required this.sentAtEpochMs, + }); + + factory WatchCommandEnvelope.fromJson(Map json) { + return WatchCommandEnvelope( + schemaVersion: _intFromJson( + json['schemaVersion'], + watchBridgeSchemaVersion, + ), + commandId: _stringFromJson(json['commandId']), + type: _enumFromJson( + json['type'], + WatchCommandType.values, + WatchCommandType.startCurrentExercise, + ), + sessionId: _stringFromJson(json['sessionId']), + expectedRevision: _intFromJson(json['expectedRevision'], 0), + sentAtEpochMs: _intFromJson(json['sentAtEpochMs'], 0), + ); + } + + final int schemaVersion; + final String commandId; + final WatchCommandType type; + final String sessionId; + final int expectedRevision; + final int sentAtEpochMs; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'commandId': commandId, + 'type': type.name, + 'sessionId': sessionId, + 'expectedRevision': expectedRevision, + 'sentAtEpochMs': sentAtEpochMs, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WatchCommandEnvelope && + schemaVersion == other.schemaVersion && + commandId == other.commandId && + type == other.type && + sessionId == other.sessionId && + expectedRevision == other.expectedRevision && + sentAtEpochMs == other.sentAtEpochMs; + } + + @override + int get hashCode { + return Object.hash( + schemaVersion, + commandId, + type, + sessionId, + expectedRevision, + sentAtEpochMs, + ); + } +} + +final class WatchSessionProjection { + const WatchSessionProjection({ + this.schemaVersion = watchBridgeSchemaVersion, + required this.deviceSessionId, + required this.revision, + required this.projectedAtEpochMs, + required this.phase, + required this.phoneReachable, + required this.seriesIndex, + required this.seriesTotal, + required this.exerciseName, + this.passageIndex, + this.passageTotal, + this.stepIndex, + this.stepTotal, + this.stepName, + this.dominantTimer, + this.secondaryTimers = const [], + required this.primaryAction, + this.secondaryActions = const [], + this.nextExerciseName, + this.statusLabel, + }); + + factory WatchSessionProjection.fromJson(Map json) { + return WatchSessionProjection( + schemaVersion: _intFromJson( + json['schemaVersion'], + watchBridgeSchemaVersion, + ), + deviceSessionId: _stringFromJson(json['deviceSessionId']), + revision: _intFromJson(json['revision'], 0), + projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0), + phase: _enumFromJson( + json['phase'], + WatchSessionPhase.values, + WatchSessionPhase.noActiveSession, + ), + phoneReachable: _boolFromJson(json['phoneReachable'], false), + seriesIndex: _intFromJson(json['seriesIndex'], 0), + seriesTotal: _intFromJson(json['seriesTotal'], 0), + exerciseName: _stringFromJson(json['exerciseName']), + passageIndex: _nullableIntFromJson(json['passageIndex']), + passageTotal: _nullableIntFromJson(json['passageTotal']), + stepIndex: _nullableIntFromJson(json['stepIndex']), + stepTotal: _nullableIntFromJson(json['stepTotal']), + stepName: _nullableStringFromJson(json['stepName']), + dominantTimer: _timerFromJson(json['dominantTimer']), + secondaryTimers: _timerListFromJson(json['secondaryTimers']), + primaryAction: _enumFromJson( + json['primaryAction'], + WatchPrimaryAction.values, + WatchPrimaryAction.none, + ), + secondaryActions: _enumListFromJson( + json['secondaryActions'], + WatchSecondaryAction.values, + ), + nextExerciseName: _nullableStringFromJson(json['nextExerciseName']), + statusLabel: _nullableStringFromJson(json['statusLabel']), + ); + } + + final int schemaVersion; + final String deviceSessionId; + final int revision; + final int projectedAtEpochMs; + final WatchSessionPhase phase; + final bool phoneReachable; + final int seriesIndex; + final int seriesTotal; + final String exerciseName; + final int? passageIndex; + final int? passageTotal; + final int? stepIndex; + final int? stepTotal; + final String? stepName; + final WatchTimerProjection? dominantTimer; + final List secondaryTimers; + final WatchPrimaryAction primaryAction; + final List secondaryActions; + final String? nextExerciseName; + final String? statusLabel; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'deviceSessionId': deviceSessionId, + 'revision': revision, + 'projectedAtEpochMs': projectedAtEpochMs, + 'phase': phase.name, + 'phoneReachable': phoneReachable, + 'seriesIndex': seriesIndex, + 'seriesTotal': seriesTotal, + 'exerciseName': exerciseName, + 'passageIndex': passageIndex, + 'passageTotal': passageTotal, + 'stepIndex': stepIndex, + 'stepTotal': stepTotal, + 'stepName': stepName, + 'dominantTimer': dominantTimer?.toJson(), + 'secondaryTimers': secondaryTimers + .map((timer) => timer.toJson()) + .toList(), + 'primaryAction': primaryAction.name, + 'secondaryActions': secondaryActions + .map((action) => action.name) + .toList(), + 'nextExerciseName': nextExerciseName, + 'statusLabel': statusLabel, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WatchSessionProjection && + schemaVersion == other.schemaVersion && + deviceSessionId == other.deviceSessionId && + revision == other.revision && + projectedAtEpochMs == other.projectedAtEpochMs && + phase == other.phase && + phoneReachable == other.phoneReachable && + seriesIndex == other.seriesIndex && + seriesTotal == other.seriesTotal && + exerciseName == other.exerciseName && + passageIndex == other.passageIndex && + passageTotal == other.passageTotal && + stepIndex == other.stepIndex && + stepTotal == other.stepTotal && + stepName == other.stepName && + dominantTimer == other.dominantTimer && + _listEquals(secondaryTimers, other.secondaryTimers) && + primaryAction == other.primaryAction && + _listEquals(secondaryActions, other.secondaryActions) && + nextExerciseName == other.nextExerciseName && + statusLabel == other.statusLabel; + } + + @override + int get hashCode { + return Object.hash( + schemaVersion, + deviceSessionId, + revision, + projectedAtEpochMs, + phase, + phoneReachable, + seriesIndex, + seriesTotal, + exerciseName, + passageIndex, + passageTotal, + stepIndex, + stepTotal, + stepName, + dominantTimer, + Object.hashAll(secondaryTimers), + primaryAction, + Object.hashAll(secondaryActions), + nextExerciseName, + statusLabel, + ); + } +} + +final class WatchTimerProjection { + const WatchTimerProjection({ + required this.kind, + required this.label, + required this.displayMode, + required this.runState, + required this.referenceEpochMs, + required this.accumulatedMs, + this.startedAtEpochMs, + this.targetMs, + }); + + factory WatchTimerProjection.fromJson(Map json) { + return WatchTimerProjection( + kind: _enumFromJson( + json['kind'], + WatchTimerKind.values, + WatchTimerKind.setTimer, + ), + label: _stringFromJson(json['label']), + displayMode: _enumFromJson( + json['displayMode'], + WatchTimerDisplayMode.values, + WatchTimerDisplayMode.elapsed, + ), + runState: _enumFromJson( + json['runState'], + WatchTimerRunState.values, + WatchTimerRunState.stopped, + ), + referenceEpochMs: _intFromJson(json['referenceEpochMs'], 0), + accumulatedMs: _intFromJson(json['accumulatedMs'], 0), + startedAtEpochMs: _nullableIntFromJson(json['startedAtEpochMs']), + targetMs: _nullableIntFromJson(json['targetMs']), + ); + } + + final WatchTimerKind kind; + final String label; + final WatchTimerDisplayMode displayMode; + final WatchTimerRunState runState; + final int referenceEpochMs; + final int accumulatedMs; + final int? startedAtEpochMs; + final int? targetMs; + + Map toJson() { + return { + 'kind': kind.name, + 'label': label, + 'displayMode': displayMode.name, + 'runState': runState.name, + 'referenceEpochMs': referenceEpochMs, + 'accumulatedMs': accumulatedMs, + 'startedAtEpochMs': startedAtEpochMs, + 'targetMs': targetMs, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WatchTimerProjection && + kind == other.kind && + label == other.label && + displayMode == other.displayMode && + runState == other.runState && + referenceEpochMs == other.referenceEpochMs && + accumulatedMs == other.accumulatedMs && + startedAtEpochMs == other.startedAtEpochMs && + targetMs == other.targetMs; + } + + @override + int get hashCode { + return Object.hash( + kind, + label, + displayMode, + runState, + referenceEpochMs, + accumulatedMs, + startedAtEpochMs, + targetMs, + ); + } +} + +T _enumFromJson(Object? value, List values, T fallback) { + if (value is String) { + for (final enumValue in values) { + if (enumValue.name == value) { + return enumValue; + } + } + } + return fallback; +} + +List _enumListFromJson(Object? value, List values) { + if (value is! List) { + return const []; + } + return [ + for (final item in value) + if (item is String) + for (final enumValue in values) + if (enumValue.name == item) enumValue, + ]; +} + +WatchTimerProjection? _timerFromJson(Object? value) { + if (value is! Map) { + return null; + } + return WatchTimerProjection.fromJson(Map.from(value)); +} + +List _timerListFromJson(Object? value) { + if (value is! List) { + return const []; + } + return [ + for (final item in value) + if (item is Map) + WatchTimerProjection.fromJson(Map.from(item)), + ]; +} + +String _stringFromJson(Object? value) { + return value is String ? value : ''; +} + +String? _nullableStringFromJson(Object? value) { + return value is String ? value : null; +} + +int _intFromJson(Object? value, int fallback) { + return value is int ? value : fallback; +} + +int? _nullableIntFromJson(Object? value) { + return value is int ? value : null; +} + +bool _boolFromJson(Object? value, bool fallback) { + return value is bool ? value : fallback; +} + +bool _listEquals(List left, List right) { + if (identical(left, right)) { + return true; + } + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} diff --git a/packages/watch_bridge_contract/lib/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/watch_bridge_contract.dart new file mode 100644 index 0000000..19fef40 --- /dev/null +++ b/packages/watch_bridge_contract/lib/watch_bridge_contract.dart @@ -0,0 +1,7 @@ +/// Shared transport contracts between the GameTime phone app and Wear OS app. +/// +/// This package is pure Dart by design: no Flutter, Android, persistence, or +/// business logic belongs here. +library; + +export 'src/watch_bridge_contract.dart'; diff --git a/packages/watch_bridge_contract/pubspec.lock b/packages/watch_bridge_contract/pubspec.lock new file mode 100644 index 0000000..6643f26 --- /dev/null +++ b/packages/watch_bridge_contract/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19" + url: "https://pub.dev" + source: hosted + version: "105.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9" + url: "https://pub.dev" + source: hosted + version: "14.1.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30" + url: "https://pub.dev" + source: hosted + version: "1.31.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7" + url: "https://pub.dev" + source: hosted + version: "0.7.13" + test_core: + dependency: transitive + description: + name: test_core + sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132 + url: "https://pub.dev" + source: hosted + version: "0.6.19" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/watch_bridge_contract/pubspec.yaml b/packages/watch_bridge_contract/pubspec.yaml new file mode 100644 index 0000000..7314039 --- /dev/null +++ b/packages/watch_bridge_contract/pubspec.yaml @@ -0,0 +1,11 @@ +name: watch_bridge_contract +description: Shared Dart contracts for the GameTime phone/watch bridge. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.10.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.0 diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart new file mode 100644 index 0000000..e58483f --- /dev/null +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +void main() { + group('WatchCommandEnvelope', () { + test('round-trips every command type through JSON', () { + for (final type in WatchCommandType.values) { + final command = WatchCommandEnvelope( + commandId: 'command-${type.name}', + type: type, + sessionId: 'session-1', + expectedRevision: 12, + sentAtEpochMs: 1710000000000, + ); + + final decoded = WatchCommandEnvelope.fromJson( + jsonDecode(jsonEncode(command.toJson())) as Map, + ); + + expect(decoded, command); + } + }); + + test('ignores unknown fields and falls back for missing fields', () { + final command = WatchCommandEnvelope.fromJson({ + 'type': 'pauseSession', + 'unknown': 'ignored', + }); + + expect(command.schemaVersion, watchBridgeSchemaVersion); + expect(command.commandId, ''); + expect(command.type, WatchCommandType.pauseSession); + expect(command.sessionId, ''); + expect(command.expectedRevision, 0); + expect(command.sentAtEpochMs, 0); + }); + }); + + group('WatchCommandAck', () { + test('round-trips every ack enum value by stable JSON name', () { + for (final ack in WatchCommandAck.values) { + final encoded = jsonEncode(ack.name); + final decodedName = jsonDecode(encoded) as String; + final decoded = WatchCommandAck.values.singleWhere( + (value) => value.name == decodedName, + ); + + expect(decoded, ack); + } + }); + }); + + group('WatchSessionProjection', () { + test('round-trips every phase, primary action, and secondary action', () { + for (final phase in WatchSessionPhase.values) { + for (final primaryAction in WatchPrimaryAction.values) { + final projection = WatchSessionProjection( + deviceSessionId: 'session-${phase.name}-${primaryAction.name}', + revision: 4, + projectedAtEpochMs: 1710000000100, + phase: phase, + phoneReachable: true, + seriesIndex: 2, + seriesTotal: 5, + exerciseName: 'Pompes tempo', + passageIndex: 1, + passageTotal: 3, + stepIndex: 2, + stepTotal: 4, + stepName: 'Descente', + dominantTimer: _stepTimer(), + secondaryTimers: [_scoreStopwatchTimer(), _setTimer()], + primaryAction: primaryAction, + secondaryActions: WatchSecondaryAction.values, + nextExerciseName: 'Fentes sautees', + statusLabel: 'Chrono etape', + ); + + final decoded = WatchSessionProjection.fromJson( + jsonDecode(jsonEncode(projection.toJson())) as Map, + ); + + expect(decoded, projection); + } + } + }); + + test('ignores unknown fields and accepts absent optional fields', () { + final projection = WatchSessionProjection.fromJson({ + 'schemaVersion': 1, + 'deviceSessionId': 'session-1', + 'revision': 9, + 'projectedAtEpochMs': 1710000000200, + 'phase': 'restPaused', + 'phoneReachable': true, + 'seriesIndex': 3, + 'seriesTotal': 5, + 'exerciseName': 'Burpees', + 'primaryAction': 'resumeSession', + 'secondaryActions': ['skipCurrentRest', 'futureAction'], + 'extra': {'ignored': true}, + }); + + expect(projection.phase, WatchSessionPhase.restPaused); + expect(projection.passageIndex, isNull); + expect(projection.stepIndex, isNull); + expect(projection.dominantTimer, isNull); + expect(projection.secondaryTimers, isEmpty); + expect(projection.primaryAction, WatchPrimaryAction.resumeSession); + expect(projection.secondaryActions, [ + WatchSecondaryAction.skipCurrentRest, + ]); + expect(projection.nextExerciseName, isNull); + expect(projection.statusLabel, isNull); + }); + + test('falls back to neutral values for absent required fields', () { + final projection = WatchSessionProjection.fromJson({}); + + expect(projection.schemaVersion, watchBridgeSchemaVersion); + expect(projection.deviceSessionId, ''); + expect(projection.revision, 0); + expect(projection.projectedAtEpochMs, 0); + expect(projection.phase, WatchSessionPhase.noActiveSession); + expect(projection.phoneReachable, false); + expect(projection.seriesIndex, 0); + expect(projection.seriesTotal, 0); + expect(projection.exerciseName, ''); + expect(projection.primaryAction, WatchPrimaryAction.none); + expect(projection.secondaryActions, isEmpty); + }); + }); + + group('WatchTimerProjection', () { + test('round-trips every timer enum combination through JSON', () { + for (final kind in WatchTimerKind.values) { + for (final displayMode in WatchTimerDisplayMode.values) { + for (final runState in WatchTimerRunState.values) { + final timer = WatchTimerProjection( + kind: kind, + label: 'timer-${kind.name}', + displayMode: displayMode, + runState: runState, + referenceEpochMs: 1710000000300, + accumulatedMs: 42000, + startedAtEpochMs: 1710000000000, + targetMs: 90000, + ); + + final decoded = WatchTimerProjection.fromJson( + jsonDecode(jsonEncode(timer.toJson())) as Map, + ); + + expect(decoded, timer); + } + } + } + }); + + test('ignores unknown fields and falls back for missing fields', () { + final timer = WatchTimerProjection.fromJson({ + 'kind': 'rest', + 'displayMode': 'countdown', + 'runState': 'paused', + 'unknown': 'ignored', + }); + + expect(timer.kind, WatchTimerKind.rest); + expect(timer.label, ''); + expect(timer.displayMode, WatchTimerDisplayMode.countdown); + expect(timer.runState, WatchTimerRunState.paused); + expect(timer.referenceEpochMs, 0); + expect(timer.accumulatedMs, 0); + expect(timer.startedAtEpochMs, isNull); + expect(timer.targetMs, isNull); + }); + }); +} + +WatchTimerProjection _stepTimer() { + return const WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono etape', + displayMode: WatchTimerDisplayMode.countdown, + runState: WatchTimerRunState.running, + referenceEpochMs: 1710000000000, + accumulatedMs: 18000, + startedAtEpochMs: 1710000000000, + targetMs: 20000, + ); +} + +WatchTimerProjection _scoreStopwatchTimer() { + return const WatchTimerProjection( + kind: WatchTimerKind.scoreStopwatch, + label: 'Score chrono', + displayMode: WatchTimerDisplayMode.elapsed, + runState: WatchTimerRunState.running, + referenceEpochMs: 1710000000000, + accumulatedMs: 51000, + startedAtEpochMs: 1710000000000, + ); +} + +WatchTimerProjection _setTimer() { + return const WatchTimerProjection( + kind: WatchTimerKind.setTimer, + label: 'Temps de serie', + displayMode: WatchTimerDisplayMode.elapsed, + runState: WatchTimerRunState.running, + referenceEpochMs: 1710000000000, + accumulatedMs: 102000, + startedAtEpochMs: 1710000000000, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 0176e37..8b5f3d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1085,6 +1085,13 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + watch_bridge_contract: + dependency: "direct main" + description: + path: "packages/watch_bridge_contract" + relative: true + source: path + version: "0.1.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index efedffc..08d852f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: video_player: ^2.11.1 file_picker: ^11.0.2 share_plus: ^12.0.2 + watch_bridge_contract: + path: packages/watch_bridge_contract dev_dependencies: flutter_test: From d1c6076899a9957e1183cbf9cb787f045baf45b6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 17:43:09 +0200 Subject: [PATCH 2/6] feat(watch): phone session projection to watch (#91-B) --- lib/application/app_bootstrap.dart | 8 + lib/application/application.dart | 1 + lib/application/use_cases.dart | 515 ++++++++++++++ .../watch_companion_use_cases.dart | 20 + .../watch_companion_projection_test.dart | 673 ++++++++++++++++++ 5 files changed, 1217 insertions(+) create mode 100644 lib/application/watch_companion_use_cases.dart create mode 100644 test/application/watch_companion_projection_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 2678ffe..250625d 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -31,6 +31,7 @@ final class AppBootstrap implements AppDependencies { required this.workoutTemplateUseCases, required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, + required this.watchCompanionProjectionUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, required this.progressionStatsUseCase, @@ -57,6 +58,7 @@ final class AppBootstrap implements AppDependencies { final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; @override final ActiveExerciseStepUseCases activeExerciseStepUseCases; + final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override @@ -171,6 +173,12 @@ final class AppBootstrap implements AppDependencies { ids: ids, originDeviceId: originDeviceId, ), + watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, historyRepository: historyRepository, diff --git a/lib/application/application.dart b/lib/application/application.dart index c115371..f07094e 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -8,3 +8,4 @@ export 'ports.dart'; export 'starter_content/basket_starter_seed_v1.dart'; export 'starter_content/starter_content.dart'; export 'use_cases.dart'; +export 'watch_companion_use_cases.dart'; diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 17992da..16298fc 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + import '../domain/domain.dart'; import 'ports.dart'; import 'starter_content/basket_starter_seed_v1.dart'; import 'starter_content/starter_content.dart'; +import 'watch_companion_use_cases.dart'; const Object _useCaseUnchanged = Object(); @@ -3084,6 +3088,513 @@ final class ActiveExerciseStepProgressView { : steps[state.currentStepIndex]; } +final class WatchCompanionProjectionUseCases implements WatchProjectionSource { + WatchCompanionProjectionUseCases({ + required ActiveSessionRepository sessionRepository, + required Clock clock, + required IdGenerator ids, + required String originDeviceId, + WatchProjectionPublisher? publisher, + }) : _projector = WatchSessionProjectionProjector( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ), + _publisher = publisher; + + final WatchSessionProjectionProjector _projector; + final WatchProjectionPublisher? _publisher; + final _controller = StreamController.broadcast(); + WatchSessionProjection? _latestProjection; + int _revision = 0; + + @override + Stream get projections => _controller.stream; + + @override + Future currentProjection() async { + return _latestProjection ?? _projectWithCurrentRevision(); + } + + @override + Future emitCurrentProjection() async { + _revision += 1; + final projection = await _projector.project(revision: _revision); + _latestProjection = projection; + _controller.add(projection); + await _publisher?.publish(projection); + return projection; + } + + Future dispose() => _controller.close(); + + Future _projectWithCurrentRevision() { + return _projector.project(revision: _revision); + } +} + +final class WatchSessionProjectionProjector { + const WatchSessionProjectionProjector({ + required this.sessionRepository, + required this.clock, + required this.ids, + required this.originDeviceId, + }); + + final ActiveSessionRepository sessionRepository; + final Clock clock; + final IdGenerator ids; + final String originDeviceId; + + Future project({required int revision}) async { + final now = clock.now(); + final session = await sessionRepository.findOpen(); + if (session == null || + session.status == ActiveWorkoutStatus.completed || + session.status == ActiveWorkoutStatus.abandoned || + session.status == ActiveWorkoutStatus.savedExit) { + return WatchSessionProjection( + deviceSessionId: '', + revision: revision, + projectedAtEpochMs: _epochMs(now), + phase: WatchSessionPhase.noActiveSession, + phoneReachable: true, + seriesIndex: 0, + seriesTotal: 0, + exerciseName: '', + primaryAction: WatchPrimaryAction.none, + statusLabel: 'Aucune séance en cours', + ); + } + + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + ); + if (snapshot == null) { + return WatchSessionProjection( + deviceSessionId: session.metadata.id, + revision: revision, + projectedAtEpochMs: _epochMs(now), + phase: WatchSessionPhase.noActiveSession, + phoneReachable: true, + seriesIndex: session.currentSetIndex + 1, + seriesTotal: 0, + exerciseName: '', + primaryAction: WatchPrimaryAction.none, + statusLabel: 'Séance indisponible', + ); + } + + final activeRest = await _findActiveRest(session.metadata.id); + final setTimer = await sessionRepository.findSetTimerState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final scoreStopwatch = await sessionRepository.findScoreStopwatchState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final stepView = await _readStepViewIfStarted(session, snapshot); + final stepState = stepView?.state; + final currentStep = stepView?.currentStep ?? _initialStep(snapshot); + final expectedPassages = _expectedPassages(snapshot); + final projectedAtEpochMs = _epochMs(now); + + final timers = [ + if (activeRest != null) _restTimerProjection(activeRest, now), + if (currentStep != null && currentStep.type == ExerciseStepType.time) + _stepTimerProjection(stepState, currentStep, now), + if (scoreStopwatch != null) + ?_scoreStopwatchTimerProjection(scoreStopwatch, now), + if (setTimer != null) ?_setTimerProjection(setTimer, now), + ]; + final dominantTimer = timers.isEmpty ? null : timers.first; + final secondaryTimers = dominantTimer == null + ? const [] + : timers.skip(1).toList(growable: false); + final phase = _phase( + session: session, + snapshot: snapshot, + activeRest: activeRest, + stepState: stepState, + currentStep: currentStep, + timers: timers, + ); + + return WatchSessionProjection( + deviceSessionId: session.metadata.id, + revision: revision, + projectedAtEpochMs: projectedAtEpochMs, + phase: phase, + phoneReachable: true, + seriesIndex: session.currentSetIndex + 1, + seriesTotal: snapshot.setsCount, + exerciseName: snapshot.exerciseNameSnapshot, + passageIndex: expectedPassages > 1 && stepState != null + ? stepState.currentPassageIndex + 1 + : null, + passageTotal: expectedPassages > 1 ? expectedPassages : null, + stepIndex: currentStep == null + ? null + : (stepState?.currentStepIndex ?? 0) + 1, + stepTotal: snapshot.steps.isEmpty ? null : snapshot.steps.length, + stepName: currentStep?.name, + dominantTimer: dominantTimer, + secondaryTimers: secondaryTimers, + primaryAction: _primaryAction(phase), + secondaryActions: _secondaryActions( + phase: phase, + snapshot: snapshot, + stepState: stepState, + expectedPassages: expectedPassages, + ), + nextExerciseName: activeRest != null + ? _restNextExerciseName( + session.resolvedTemplateSnapshotJson, + session, + activeRest, + snapshot, + ) + : phase == WatchSessionPhase.betweenSetsReady + ? _betweenSetsNextExerciseName( + session.resolvedTemplateSnapshotJson, + session, + snapshot, + ) + : null, + statusLabel: _statusLabel(phase, dominantTimer), + ); + } + + Future _readStepViewIfStarted( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + if (snapshot.steps.isEmpty) { + return null; + } + final state = await sessionRepository.findExerciseStepProgressState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + if (state == null) { + return null; + } + if (state.status == ActiveExerciseStepProgressStatus.runningTimer) { + return ActiveExerciseStepUseCases( + sessionRepository: sessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ).readProgress( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + } + return ActiveExerciseStepProgressView( + state: state, + steps: snapshot.steps, + expectedPassages: _expectedPassages(snapshot), + results: const [], + ); + } + + Future _findActiveRest(String sessionId) async { + final active = + (await sessionRepository.listRestStates(sessionId)) + .where((rest) => rest.endedAt == null && rest.skippedAt == null) + .toList() + ..sort((left, right) => right.startedAt.compareTo(left.startedAt)); + return active.isEmpty ? null : active.first; + } +} + +WatchSessionPhase _phase({ + required ActiveWorkoutSession session, + required _ResolvedExerciseSnapshot snapshot, + required ActiveRestState? activeRest, + required ActiveExerciseStepProgressState? stepState, + required ExerciseStep? currentStep, + required List timers, +}) { + if (activeRest != null) { + return activeRest.pausedAt == null + ? WatchSessionPhase.restRunning + : WatchSessionPhase.restPaused; + } + if (session.status == ActiveWorkoutStatus.paused) { + return WatchSessionPhase.paused; + } + if (_isNextTimerReady(snapshot, stepState, currentStep)) { + return WatchSessionPhase.nextTimerReady; + } + if (timers.any((timer) => timer.runState == WatchTimerRunState.running)) { + return WatchSessionPhase.running; + } + return session.currentProgramIndex == 0 && + session.currentExerciseIndex == 0 && + session.currentSetIndex == 0 + ? WatchSessionPhase.ready + : WatchSessionPhase.betweenSetsReady; +} + +WatchPrimaryAction _primaryAction(WatchSessionPhase phase) { + return switch (phase) { + WatchSessionPhase.noActiveSession => WatchPrimaryAction.none, + WatchSessionPhase.ready => WatchPrimaryAction.startCurrentExercise, + WatchSessionPhase.running => WatchPrimaryAction.pauseSession, + WatchSessionPhase.paused => WatchPrimaryAction.resumeSession, + WatchSessionPhase.nextTimerReady => + WatchPrimaryAction.startPreparedTimedStep, + WatchSessionPhase.restRunning => WatchPrimaryAction.pauseSession, + WatchSessionPhase.restPaused => WatchPrimaryAction.resumeSession, + WatchSessionPhase.betweenSetsReady => + WatchPrimaryAction.startCurrentExercise, + }; +} + +List _secondaryActions({ + required WatchSessionPhase phase, + required _ResolvedExerciseSnapshot snapshot, + required ActiveExerciseStepProgressState? stepState, + required int expectedPassages, +}) { + if (phase == WatchSessionPhase.noActiveSession) { + return const []; + } + if (phase == WatchSessionPhase.restRunning || + phase == WatchSessionPhase.restPaused) { + return const [WatchSecondaryAction.skipCurrentRest]; + } + final hasCurrentStep = + snapshot.steps.isNotEmpty && + stepState?.status != ActiveExerciseStepProgressStatus.sequenceComplete; + final hasPassageToSkip = + hasCurrentStep && + expectedPassages > 1 && + stepState != null && + stepState.currentPassageIndex < expectedPassages - 1; + return [ + if (hasCurrentStep) WatchSecondaryAction.skipCurrentStep, + if (hasPassageToSkip) WatchSecondaryAction.skipCurrentPassage, + WatchSecondaryAction.finishCurrentSet, + WatchSecondaryAction.skipCurrentSet, + ]; +} + +String _statusLabel( + WatchSessionPhase phase, + WatchTimerProjection? dominantTimer, +) { + return switch (phase) { + WatchSessionPhase.noActiveSession => 'Aucune séance en cours', + WatchSessionPhase.ready => 'Prêt à démarrer', + WatchSessionPhase.running => dominantTimer?.label ?? 'En cours', + WatchSessionPhase.paused => 'Séance en pause', + WatchSessionPhase.nextTimerReady => 'Chrono suivant prêt', + WatchSessionPhase.restRunning => 'Repos en cours', + WatchSessionPhase.restPaused => 'Repos en pause', + WatchSessionPhase.betweenSetsReady => 'Prêt pour la série suivante', + }; +} + +WatchTimerProjection _restTimerProjection(ActiveRestState rest, DateTime now) { + final paused = rest.pausedAt != null; + return WatchTimerProjection( + kind: WatchTimerKind.rest, + label: 'Repos', + displayMode: WatchTimerDisplayMode.countdown, + runState: paused ? WatchTimerRunState.paused : WatchTimerRunState.running, + referenceEpochMs: _epochMs(now), + accumulatedMs: rest.elapsedMillisecondsAt(now), + startedAtEpochMs: paused ? null : _epochMs(now), + targetMs: rest.adjustedRestSeconds * 1000, + ); +} + +WatchTimerProjection _stepTimerProjection( + ActiveExerciseStepProgressState? state, + ExerciseStep step, + DateTime now, +) { + return WatchTimerProjection( + kind: WatchTimerKind.step, + label: 'Chrono étape', + displayMode: WatchTimerDisplayMode.countdown, + runState: switch (state?.status) { + ActiveExerciseStepProgressStatus.runningTimer => + WatchTimerRunState.running, + ActiveExerciseStepProgressStatus.pausedTimer => WatchTimerRunState.paused, + _ => WatchTimerRunState.stopped, + }, + referenceEpochMs: _epochMs(now), + accumulatedMs: state?.accumulatedMs ?? 0, + startedAtEpochMs: + state?.status == ActiveExerciseStepProgressStatus.runningTimer + ? _epochMs(state!.startedAt!) + : null, + targetMs: step.defaultTargetValue * 1000, + ); +} + +WatchTimerProjection? _scoreStopwatchTimerProjection( + ActiveScoreStopwatchState state, + DateTime now, +) { + if (state.status == ActiveScoreStopwatchStatus.stopped) { + return null; + } + return WatchTimerProjection( + kind: WatchTimerKind.scoreStopwatch, + label: 'Score chrono', + displayMode: WatchTimerDisplayMode.elapsed, + runState: state.status == ActiveScoreStopwatchStatus.running + ? WatchTimerRunState.running + : WatchTimerRunState.paused, + referenceEpochMs: _epochMs(now), + accumulatedMs: state.accumulatedMs, + startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running + ? _epochMs(state.startedAt) + : null, + ); +} + +WatchTimerProjection? _setTimerProjection( + ActiveSetTimerState state, + DateTime now, +) { + if (state.status == ActiveSetTimerStatus.stopped || + state.status == ActiveSetTimerStatus.skipped) { + return null; + } + return WatchTimerProjection( + kind: WatchTimerKind.setTimer, + label: 'Temps de série', + displayMode: WatchTimerDisplayMode.elapsed, + runState: state.status == ActiveSetTimerStatus.running + ? WatchTimerRunState.running + : WatchTimerRunState.paused, + referenceEpochMs: _epochMs(now), + accumulatedMs: state.accumulatedMs, + startedAtEpochMs: + state.status == ActiveSetTimerStatus.running && state.startedAt != null + ? _epochMs(state.startedAt!) + : null, + ); +} + +ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) { + return snapshot.steps.isEmpty ? null : snapshot.steps.first; +} + +bool _isNextTimerReady( + _ResolvedExerciseSnapshot snapshot, + ActiveExerciseStepProgressState? state, + ExerciseStep? currentStep, +) { + if (state == null || + currentStep == null || + state.status != ActiveExerciseStepProgressStatus.stoppedTimer || + currentStep.type != ExerciseStepType.time || + snapshot.autoStartNextTimedStepEffective) { + return false; + } + final previous = _previousStep(snapshot, state); + return previous?.type == ExerciseStepType.time; +} + +ExerciseStep? _previousStep( + _ResolvedExerciseSnapshot snapshot, + ActiveExerciseStepProgressState state, +) { + if (snapshot.steps.isEmpty) { + return null; + } + if (state.currentStepIndex > 0) { + return snapshot.steps[state.currentStepIndex - 1]; + } + if (state.currentPassageIndex > 0) { + return snapshot.steps.last; + } + return null; +} + +int _expectedPassages(_ResolvedExerciseSnapshot snapshot) { + return snapshot.repsEnabled + ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31) + : 1; +} + +String? _restNextExerciseName( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, + ActiveRestState rest, + _ResolvedExerciseSnapshot currentSnapshot, +) { + final sessionIsAfterRestSource = + _comparePositions( + session.currentProgramIndex, + session.currentExerciseIndex, + session.currentSetIndex, + rest.afterProgramIndex, + rest.afterExerciseIndex, + rest.afterSetIndex, + ) > + 0; + if (sessionIsAfterRestSource) { + return currentSnapshot.exerciseNameSnapshot; + } + final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson); + final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = setSnapshots.indexWhere( + (snapshot) => + snapshot.programIndex == rest.afterProgramIndex && + snapshot.exerciseIndex == rest.afterExerciseIndex && + snapshot.setIndex == rest.afterSetIndex, + ); + if (currentIndex == -1 || currentIndex + 1 >= setSnapshots.length) { + return null; + } + final next = setSnapshots[currentIndex + 1]; + return snapshots[next.exerciseSnapshotId]?.exerciseNameSnapshot; +} + +String? _betweenSetsNextExerciseName( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot currentSnapshot, +) { + final setSnapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = setSnapshots.indexWhere( + (snapshot) => + snapshot.programIndex == session.currentProgramIndex && + snapshot.exerciseIndex == session.currentExerciseIndex && + snapshot.setIndex == session.currentSetIndex, + ); + if (currentIndex <= 0) { + return null; + } + final previous = setSnapshots[currentIndex - 1]; + final current = setSnapshots[currentIndex]; + if (previous.exerciseSnapshotId == current.exerciseSnapshotId) { + return null; + } + return currentSnapshot.exerciseNameSnapshot; +} + +int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch; + final class ActiveExerciseStepUseCases { const ActiveExerciseStepUseCases({ required this.sessionRepository, @@ -4519,6 +5030,7 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ ), scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + setsCount: exercise['setsCount'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, ); @@ -4730,6 +5242,7 @@ Map _exerciseSnapshotsById( ), scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, + setsCount: exercise['setsCount'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: (exercise['autoStartNextTimedStepOverride'] as bool?) ?? @@ -4758,6 +5271,7 @@ final class _ResolvedExerciseSnapshot { required this.scoreInputModeSnapshot, this.scoreLabelSnapshot, this.scoreUnitSnapshot, + required this.setsCount, this.steps = const [], this.autoStartNextTimedStepEffective = true, }); @@ -4777,6 +5291,7 @@ final class _ResolvedExerciseSnapshot { final ScoreInputMode scoreInputModeSnapshot; final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; + final int setsCount; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/lib/application/watch_companion_use_cases.dart b/lib/application/watch_companion_use_cases.dart new file mode 100644 index 0000000..dd8803b --- /dev/null +++ b/lib/application/watch_companion_use_cases.dart @@ -0,0 +1,20 @@ +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +abstract interface class WatchCommandIngress { + Future dispatch(WatchCommandEnvelope command); +} + +abstract interface class WatchProjectionPublisher { + Future publish(WatchSessionProjection projection); +} + +abstract interface class WatchProjectionSource { + Stream get projections; + + Future currentProjection(); + + Future emitCurrentProjection(); +} + +abstract interface class WatchCompanionUseCases + implements WatchCommandIngress, WatchProjectionSource {} diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart new file mode 100644 index 0000000..629fc6a --- /dev/null +++ b/test/application/watch_companion_projection_test.dart @@ -0,0 +1,673 @@ +import 'dart:convert'; + +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +void main() { + test('projects noActiveSession without an open session', () async { + final projector = _projector(_FakeActiveSessionRepository(), _clock()); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.noActiveSession); + expect(projection.primaryAction, WatchPrimaryAction.none); + expect(projection.phoneReachable, isTrue); + }); + + test('projects ready at the first set before timers start', () async { + final repository = _FakeActiveSessionRepository() + ..session = _session( + currentSetIndex: 0, + timeEnabled: true, + targetTimeSeconds: 20, + steps: [_step(defaultTargetValue: 20)], + ); + final projector = _projector(repository, _clock()); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.ready); + expect(projection.seriesIndex, 1); + expect(projection.seriesTotal, 2); + expect(projection.exerciseName, 'Squat'); + expect(projection.stepIndex, 1); + expect(projection.stepTotal, 1); + expect(projection.dominantTimer?.kind, WatchTimerKind.step); + expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped); + expect(projection.dominantTimer?.targetMs, 20000); + expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); + }); + + test( + 'projects running with dominant step timer and secondary timers', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + timeEnabled: true, + scoreEnabled: true, + scoreInputMode: ScoreInputMode.stopwatch, + steps: [_step(defaultTargetValue: 30)], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + status: ActiveExerciseStepProgressStatus.runningTimer, + startedAt: now.subtract(const Duration(seconds: 5)), + ) + ..scoreStopwatchStates['score'] = _scoreStopwatch( + sessionId: session.metadata.id, + startedAt: now.subtract(const Duration(seconds: 4)), + ) + ..setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + startedAt: now.subtract(const Duration(seconds: 6)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 1); + + expect(projection.phase, WatchSessionPhase.running); + expect(projection.dominantTimer?.kind, WatchTimerKind.step); + expect(projection.dominantTimer?.accumulatedMs, 0); + expect( + projection.dominantTimer?.startedAtEpochMs, + now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch, + ); + expect(projection.secondaryTimers.map((timer) => timer.kind), [ + WatchTimerKind.scoreStopwatch, + WatchTimerKind.setTimer, + ]); + expect(projection.primaryAction, WatchPrimaryAction.pauseSession); + expect( + projection.secondaryActions, + contains(WatchSecondaryAction.finishCurrentSet), + ); + }, + ); + + test('projects paused after a running session is paused', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + status: ActiveWorkoutStatus.paused, + pausedAt: now, + timeEnabled: true, + steps: [_step()], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + status: ActiveExerciseStepProgressStatus.pausedTimer, + accumulatedMs: 5000, + lastTransitionAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 2); + + expect(projection.phase, WatchSessionPhase.paused); + expect(projection.primaryAction, WatchPrimaryAction.resumeSession); + expect(projection.statusLabel, 'Séance en pause'); + expect(projection.dominantTimer?.runState, WatchTimerRunState.paused); + }); + + test( + 'projects nextTimerReady after an elapsed timer with chaining disabled', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + autoStartNextTimedStepSnapshot: false, + steps: [ + _step(id: 'step-1'), + _step(id: 'step-2', position: 1), + ], + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + stepId: 'step-1', + status: ActiveExerciseStepProgressStatus.runningTimer, + startedAt: now.subtract(const Duration(milliseconds: 1500)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 3); + + expect(projection.phase, WatchSessionPhase.nextTimerReady); + expect(projection.stepIndex, 2); + expect(projection.stepName, 'Step 2'); + expect(projection.statusLabel, 'Chrono suivant prêt'); + expect( + projection.primaryAction, + WatchPrimaryAction.startPreparedTimedStep, + ); + expect(projection.dominantTimer?.runState, WatchTimerRunState.stopped); + expect(repository.stepResults, hasLength(1)); + }, + ); + + test('projects restRunning after finishing a set with rest', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(currentSetIndex: 1); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 10)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 4); + + expect(projection.phase, WatchSessionPhase.restRunning); + expect(projection.primaryAction, WatchPrimaryAction.pauseSession); + expect(projection.secondaryActions, [WatchSecondaryAction.skipCurrentRest]); + expect(projection.dominantTimer?.kind, WatchTimerKind.rest); + expect(projection.dominantTimer?.targetMs, 60000); + expect(projection.dominantTimer?.accumulatedMs, 10000); + expect(projection.nextExerciseName, 'Squat'); + }); + + test( + 'projects restRunning next exercise when rest precedes another exercise', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session( + currentExerciseIndex: 1, + currentSetIndex: 0, + secondExerciseName: 'Fentes', + ); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 1, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 10)), + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 4); + + expect(projection.phase, WatchSessionPhase.restRunning); + expect(projection.exerciseName, 'Fentes'); + expect(projection.nextExerciseName, 'Fentes'); + }, + ); + + test('projects restPaused', () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(status: ActiveWorkoutStatus.paused, pausedAt: now); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 15)), + pausedAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 5); + + expect(projection.phase, WatchSessionPhase.restPaused); + expect(projection.primaryAction, WatchPrimaryAction.resumeSession); + expect(projection.dominantTimer?.runState, WatchTimerRunState.paused); + }); + + test( + 'projects betweenSetsReady after rest ends before the next set', + () async { + final now = DateTime.utc(2026, 7, 25, 12); + final session = _session(currentSetIndex: 1); + final repository = _FakeActiveSessionRepository() + ..session = session + ..restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: now.subtract(const Duration(seconds: 60)), + endedAt: now, + ); + final projector = _projector(repository, _clock(now)); + + final projection = await projector.project(revision: 6); + + expect(projection.phase, WatchSessionPhase.betweenSetsReady); + expect(projection.statusLabel, 'Prêt pour la série suivante'); + expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise); + }, + ); + + test( + 'projects nextExerciseName between sets when exercise changes', + () async { + final session = _session( + currentExerciseIndex: 1, + currentSetIndex: 0, + secondExerciseName: 'Fentes', + ); + final repository = _FakeActiveSessionRepository()..session = session; + final projector = _projector(repository, _clock()); + + final projection = await projector.project(revision: 7); + + expect(projection.phase, WatchSessionPhase.betweenSetsReady); + expect(projection.exerciseName, 'Fentes'); + expect(projection.nextExerciseName, 'Fentes'); + }, + ); + + test( + 'emits projections through stream and publisher with incremented revision', + () async { + final repository = _FakeActiveSessionRepository() + ..session = _session(steps: [_step()]); + final publisher = _FakeWatchProjectionPublisher(); + final useCases = WatchCompanionProjectionUseCases( + sessionRepository: repository, + clock: _clock(), + ids: _FakeIds(), + originDeviceId: 'device-1', + publisher: publisher, + ); + final emitted = []; + final subscription = useCases.projections.listen(emitted.add); + + final first = await useCases.emitCurrentProjection(); + final second = await useCases.emitCurrentProjection(); + await Future.delayed(Duration.zero); + + expect(first.revision, 1); + expect(second.revision, 2); + expect(emitted.map((projection) => projection.revision), [1, 2]); + expect(publisher.published.map((projection) => projection.revision), [ + 1, + 2, + ]); + + await subscription.cancel(); + await useCases.dispose(); + }, + ); +} + +WatchSessionProjectionProjector _projector( + _FakeActiveSessionRepository repository, + _FakeClock clock, +) { + return WatchSessionProjectionProjector( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +_FakeClock _clock([DateTime? now]) { + return _FakeClock(now ?? DateTime.utc(2026, 7, 25, 12)); +} + +ActiveWorkoutSession _session({ + ActiveWorkoutStatus status = ActiveWorkoutStatus.running, + DateTime? pausedAt, + int currentExerciseIndex = 0, + int currentSetIndex = 0, + int setsCount = 2, + bool timeEnabled = false, + bool repsEnabled = true, + bool scoreEnabled = false, + int? targetTimeSeconds, + ScoreInputMode scoreInputMode = ScoreInputMode.manual, + bool? autoStartNextTimedStepSnapshot = true, + List steps = const [], + String? secondExerciseName, +}) { + final exerciseSnapshot = { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': timeEnabled, + 'repsEnabled': repsEnabled, + 'scoreEnabled': scoreEnabled, + 'targetTimeSeconds': targetTimeSeconds, + 'targetReps': repsEnabled ? setsCount : null, + 'scoreInputModeSnapshot': scoreInputMode.name, + 'exerciseStepsSnapshot': steps + .map((step) => step.toSnapshotJson()) + .toList(), + 'autoStartNextTimedStepSnapshot': ?autoStartNextTimedStepSnapshot, + }; + final secondExerciseSnapshot = secondExerciseName == null + ? null + : { + 'id': 'exercise-snapshot-2', + 'exerciseNameSnapshot': secondExerciseName, + 'setsCount': 1, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 1, + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'exerciseStepsSnapshot': const [], + 'autoStartNextTimedStepSnapshot': true, + }; + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: status, + startedAt: DateTime.utc(2026, 7, 25, 12), + pausedAt: pausedAt, + lastPersistedAt: DateTime.utc(2026, 7, 25, 12), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: currentExerciseIndex, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [exerciseSnapshot, ?secondExerciseSnapshot], + }), + }, + ], + }), + ); +} + +ExerciseStep _step({ + String id = 'step-1', + int position = 0, + int defaultTargetValue = 1, +}) { + return ExerciseStep( + id: id, + position: position, + name: 'Step ${position + 1}', + type: ExerciseStepType.time, + defaultTargetValue: defaultTargetValue, + ); +} + +ActiveExerciseStepProgressState _stepState({ + required String sessionId, + String stepId = 'step-1', + int stepIndex = 0, + ActiveExerciseStepProgressStatus status = + ActiveExerciseStepProgressStatus.stoppedTimer, + DateTime? startedAt, + int accumulatedMs = 0, + DateTime? lastTransitionAt, +}) { + return ActiveExerciseStepProgressState( + metadata: _metadata('step-state'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + currentPassageIndex: 0, + currentStepIndex: stepIndex, + currentStepSnapshotId: stepId, + status: status, + startedAt: startedAt, + accumulatedMs: accumulatedMs, + lastTransitionAt: lastTransitionAt ?? DateTime.utc(2026, 7, 25, 12), + ); +} + +ActiveScoreStopwatchState _scoreStopwatch({ + required String sessionId, + required DateTime startedAt, +}) { + return ActiveScoreStopwatchState( + metadata: _metadata('score'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveScoreStopwatchStatus.running, + startedAt: startedAt, + accumulatedMs: 0, + ); +} + +ActiveSetTimerState _setTimer({ + required String sessionId, + required DateTime startedAt, +}) { + return ActiveSetTimerState( + metadata: _metadata('set'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveSetTimerStatus.running, + startedAt: startedAt, + accumulatedMs: 0, + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: DateTime.utc(2026, 7, 25, 12), + updatedAt: DateTime.utc(2026, 7, 25, 12), + originDeviceId: 'device-1', + ); +} + +final class _FakeWatchProjectionPublisher implements WatchProjectionPublisher { + final published = []; + + @override + Future publish(WatchSessionProjection projection) async { + published.add(projection); + } +} + +final class _FakeClock implements Clock { + _FakeClock(this.value); + + DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + ActiveWorkoutSession? session; + final results = []; + final restStates = {}; + final setTimerStates = {}; + final scoreStopwatchStates = {}; + final stepProgressStates = {}; + final stepResults = []; + + @override + Future deleteScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + scoreStopwatchStates.clear(); + } + + @override + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; + + @override + Future findExerciseStepProgressState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return stepProgressStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findRestStateById(String id) async { + return restStates[id]; + } + + @override + Future findScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return scoreStopwatchStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return setTimerStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future> listExerciseStepProgressStates( + String sessionId, + ) async { + return stepProgressStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listExerciseStepResults( + String sessionId, + ) async { + return stepResults + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listRestStates(String sessionId) async { + return restStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listScoreStopwatchStates( + String sessionId, + ) async { + return scoreStopwatchStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetResults(String sessionId) async { + return results + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future save(ActiveWorkoutSession session) async { + this.session = session; + } + + @override + Future saveExerciseStepProgressState( + ActiveExerciseStepProgressState state, + ) async { + stepProgressStates[state.metadata.id] = state; + } + + @override + Future saveExerciseStepResult(ActiveExerciseStepResult result) async { + stepResults.add(result); + } + + @override + Future saveRestState(ActiveRestState restState) async { + restStates[restState.metadata.id] = restState; + } + + @override + Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { + scoreStopwatchStates[state.metadata.id] = state; + } + + @override + Future saveSetResult(ActiveSetResult result) async { + results.add(result); + } + + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + setTimerStates[state.metadata.id] = state; + } +} From 6c177de6f85e11ded6b70e359f276affc3fcf03a Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 17:53:11 +0200 Subject: [PATCH 3/6] feat(watch): route watch commands to existing session use cases (#91-C) --- lib/application/app_bootstrap.dart | 45 +- lib/application/use_cases.dart | 390 ++++++++++ .../watch_companion_command_handler_test.dart | 686 ++++++++++++++++++ 3 files changed, 1104 insertions(+), 17 deletions(-) create mode 100644 test/application/watch_companion_command_handler_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 250625d..d9c8096 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -32,6 +32,7 @@ final class AppBootstrap implements AppDependencies { required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, required this.watchCompanionProjectionUseCases, + required this.watchCompanionCommandHandler, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, required this.progressionStatsUseCase, @@ -59,6 +60,7 @@ final class AppBootstrap implements AppDependencies { @override final ActiveExerciseStepUseCases activeExerciseStepUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; + final WatchCompanionCommandHandler watchCompanionCommandHandler; @override final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override @@ -105,6 +107,25 @@ final class AppBootstrap implements AppDependencies { final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; + final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( + sessionRepository: activeSessionRepository, + templateRepository: templateRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); + final activeExerciseStepUseCases = ActiveExerciseStepUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); + final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases( + sessionRepository: activeSessionRepository, + clock: clock, + ids: ids, + originDeviceId: originDeviceId, + ); await SeedStarterContentUseCase( seedStateRepository: starterSeedRepository, contentRepository: starterSeedRepository, @@ -160,24 +181,14 @@ final class AppBootstrap implements AppDependencies { ids: ids, originDeviceId: originDeviceId, ), - activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases( + activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, + activeExerciseStepUseCases: activeExerciseStepUseCases, + watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, + watchCompanionCommandHandler: WatchCompanionCommandHandler( sessionRepository: activeSessionRepository, - templateRepository: templateRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, - ), - activeExerciseStepUseCases: ActiveExerciseStepUseCases( - sessionRepository: activeSessionRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, - ), - watchCompanionProjectionUseCases: WatchCompanionProjectionUseCases( - sessionRepository: activeSessionRepository, - clock: clock, - ids: ids, - originDeviceId: originDeviceId, + activeSessionUseCases: activeWorkoutSessionUseCases, + stepUseCases: activeExerciseStepUseCases, + projectionSource: watchCompanionProjectionUseCases, ), closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase( sessionRepository: activeSessionRepository, diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 16298fc..ba3ccb0 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -3134,6 +3134,358 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource { } } +final class WatchCompanionCommandHandler implements WatchCommandIngress { + WatchCompanionCommandHandler({ + required ActiveSessionRepository sessionRepository, + required ActiveWorkoutSessionUseCases activeSessionUseCases, + required ActiveExerciseStepUseCases stepUseCases, + required WatchProjectionSource projectionSource, + }) : _sessionRepository = sessionRepository, + _activeSessionUseCases = activeSessionUseCases, + _stepUseCases = stepUseCases, + _projectionSource = projectionSource; + + final ActiveSessionRepository _sessionRepository; + final ActiveWorkoutSessionUseCases _activeSessionUseCases; + final ActiveExerciseStepUseCases _stepUseCases; + final WatchProjectionSource _projectionSource; + final _handledCommands = <_WatchCommandKey, WatchCommandAck>{}; + Future _tail = Future.value(); + + @override + Future dispatch(WatchCommandEnvelope command) { + final run = _tail.then( + (_) => _dispatch(command), + onError: (_) => _dispatch(command), + ); + _tail = run.then((_) {}, onError: (_) {}); + return run; + } + + Future _dispatch(WatchCommandEnvelope command) async { + final key = _WatchCommandKey(command); + final previousAck = _handledCommands[key]; + if (previousAck == WatchCommandAck.accepted || + previousAck == WatchCommandAck.acceptedNoOp) { + return WatchCommandAck.acceptedNoOp; + } + + try { + final projection = await _projectionSource.currentProjection(); + if (projection.phase == WatchSessionPhase.noActiveSession || + projection.deviceSessionId.isEmpty) { + return WatchCommandAck.rejectedNoActiveSession; + } + if (command.sessionId != projection.deviceSessionId) { + return WatchCommandAck.rejectedSessionMismatch; + } + if (command.expectedRevision != projection.revision) { + return WatchCommandAck.rejectedStaleRevision; + } + if (!_isApplicable(command.type, projection)) { + return WatchCommandAck.rejectedNotApplicable; + } + + final session = await _sessionRepository.findOpen(); + if (session == null || + session.status == ActiveWorkoutStatus.completed || + session.status == ActiveWorkoutStatus.abandoned || + session.status == ActiveWorkoutStatus.savedExit) { + return WatchCommandAck.rejectedNoActiveSession; + } + if (session.metadata.id != command.sessionId) { + return WatchCommandAck.rejectedSessionMismatch; + } + + final ack = await _route(command.type, session); + if (ack == WatchCommandAck.accepted || + ack == WatchCommandAck.acceptedNoOp) { + _handledCommands[key] = ack; + } + if (ack == WatchCommandAck.accepted) { + await _emitProjectionAfterCommand(); + } + return ack; + } on DomainException { + return WatchCommandAck.rejectedNotApplicable; + } on StateError { + return WatchCommandAck.rejectedNotApplicable; + } on Exception { + return WatchCommandAck.rejectedPhoneBusy; + } + } + + bool _isApplicable(WatchCommandType type, WatchSessionProjection projection) { + return switch (type) { + WatchCommandType.startCurrentExercise => + projection.primaryAction == WatchPrimaryAction.startCurrentExercise, + WatchCommandType.pauseSession => + projection.primaryAction == WatchPrimaryAction.pauseSession, + WatchCommandType.resumeSession => + projection.primaryAction == WatchPrimaryAction.resumeSession, + WatchCommandType.startPreparedTimedStep => + projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, + WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentStep, + ), + WatchCommandType.skipCurrentPassage => + projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentPassage, + ), + WatchCommandType.finishCurrentSet => projection.secondaryActions.contains( + WatchSecondaryAction.finishCurrentSet, + ), + WatchCommandType.skipCurrentSet => projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentSet, + ), + WatchCommandType.skipCurrentRest => + projection.primaryAction == WatchPrimaryAction.skipCurrentRest || + projection.secondaryActions.contains( + WatchSecondaryAction.skipCurrentRest, + ), + }; + } + + Future _route( + WatchCommandType type, + ActiveWorkoutSession session, + ) { + return switch (type) { + WatchCommandType.startCurrentExercise => _startCurrentExercise(session), + WatchCommandType.pauseSession => _pause(session), + WatchCommandType.resumeSession => _resume(session), + WatchCommandType.startPreparedTimedStep => _startPreparedTimedStep( + session, + ), + WatchCommandType.skipCurrentStep => _skipCurrentStep(session), + WatchCommandType.skipCurrentPassage => _skipCurrentPassage(session), + WatchCommandType.finishCurrentSet => _finishCurrentSet( + session, + skipped: false, + ), + WatchCommandType.skipCurrentSet => _finishCurrentSet( + session, + skipped: true, + ), + WatchCommandType.skipCurrentRest => _skipCurrentRest(session), + }; + } + + Future _startCurrentExercise( + ActiveWorkoutSession session, + ) async { + final result = await _activeSessionUseCases.startCurrentExerciseTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final changed = + result.setTimer != null || + result.scoreStopwatch != null || + result.stepProgress != null; + return changed ? WatchCommandAck.accepted : WatchCommandAck.acceptedNoOp; + } + + Future _pause(ActiveWorkoutSession session) async { + await _activeSessionUseCases.pause(session.metadata.id); + return WatchCommandAck.accepted; + } + + Future _resume(ActiveWorkoutSession session) async { + await _activeSessionUseCases.resume(session.metadata.id); + return WatchCommandAck.accepted; + } + + Future _startPreparedTimedStep( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.startTimer( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _skipCurrentStep(ActiveWorkoutSession session) async { + await _stepUseCases.skipCurrentStep( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _skipCurrentPassage( + ActiveWorkoutSession session, + ) async { + await _stepUseCases.skipCurrentPassage( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return WatchCommandAck.accepted; + } + + Future _finishCurrentSet( + ActiveWorkoutSession session, { + required bool skipped, + }) async { + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + ); + if (snapshot == null) { + return WatchCommandAck.rejectedNotApplicable; + } + final setTimer = skipped + ? await _activeSessionUseCases.skipSetExecutionTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ) + : await _activeSessionUseCases.stopSetExecutionTimers( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + final actualScoreTimeMs = skipped + ? null + : await _scoreStopwatchMsIfNeeded(session, snapshot); + await _activeSessionUseCases.recordCurrentSetResult( + sessionId: session.metadata.id, + programSnapshotId: snapshot.programSnapshotId, + exerciseSnapshotId: snapshot.exerciseSnapshotId, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + actualTimeMs: skipped || !snapshot.timeEnabled + ? null + : setTimer?.accumulatedMs, + actualReps: skipped || !snapshot.repsEnabled ? null : snapshot.targetReps, + actualScoreTimeMs: actualScoreTimeMs, + scoreInputModeSnapshot: snapshot.scoreInputModeSnapshot, + scoreLabelSnapshot: snapshot.scoreLabelSnapshot, + scoreUnitSnapshot: snapshot.scoreUnitSnapshot, + ); + await _advanceAfterSet(session, snapshot); + return WatchCommandAck.accepted; + } + + Future _scoreStopwatchMsIfNeeded( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + if (!snapshot.scoreEnabled || + snapshot.scoreInputModeSnapshot != ScoreInputMode.stopwatch) { + return null; + } + final state = await _sessionRepository.findScoreStopwatchState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return state?.accumulatedMs; + } + + Future _advanceAfterSet( + ActiveWorkoutSession session, + _ResolvedExerciseSnapshot snapshot, + ) async { + final next = _nextPosition(session.resolvedTemplateSnapshotJson, session); + if (next == null) { + await _activeSessionUseCases.complete(session.metadata.id); + return; + } + if (snapshot.restSeconds > 0) { + await _activeSessionUseCases.startRestAfterSet( + sessionId: session.metadata.id, + afterProgramIndex: session.currentProgramIndex, + afterExerciseIndex: session.currentExerciseIndex, + afterSetIndex: session.currentSetIndex, + plannedRestSeconds: snapshot.restSeconds, + ); + return; + } + await _activeSessionUseCases.updateProgress( + sessionId: session.metadata.id, + programIndex: next.programIndex, + exerciseIndex: next.exerciseIndex, + setIndex: next.setIndex, + ); + } + + Future _skipCurrentRest(ActiveWorkoutSession session) async { + final rest = await _activeSessionUseCases.findActiveRest( + sessionId: session.metadata.id, + ); + if (rest == null) { + return WatchCommandAck.acceptedNoOp; + } + await _activeSessionUseCases.skipRest(restStateId: rest.metadata.id); + final next = _nextPositionAfter( + session.resolvedTemplateSnapshotJson, + programIndex: rest.afterProgramIndex, + exerciseIndex: rest.afterExerciseIndex, + setIndex: rest.afterSetIndex, + ); + if (next == null) { + await _activeSessionUseCases.complete(session.metadata.id); + } else { + await _activeSessionUseCases.updateProgress( + sessionId: session.metadata.id, + programIndex: next.programIndex, + exerciseIndex: next.exerciseIndex, + setIndex: next.setIndex, + ); + } + return WatchCommandAck.accepted; + } + + Future _emitProjectionAfterCommand() async { + try { + await _projectionSource.emitCurrentProjection(); + } on Exception { + // The command has already been applied; a publish failure must not turn + // the watch retry path into a second mutation. + } + } +} + +final class _WatchCommandKey { + _WatchCommandKey(WatchCommandEnvelope command) + : sessionId = command.sessionId, + expectedRevision = command.expectedRevision, + commandId = command.commandId, + type = command.type; + + final String sessionId; + final int expectedRevision; + final String commandId; + final WatchCommandType type; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _WatchCommandKey && + sessionId == other.sessionId && + expectedRevision == other.expectedRevision && + commandId == other.commandId && + type == other.type; + } + + @override + int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type); +} + final class WatchSessionProjectionProjector { const WatchSessionProjectionProjector({ required this.sessionRepository, @@ -4977,6 +5329,37 @@ _SetPositionSnapshot? _findSetSnapshot({ return null; } +_SetPositionSnapshot? _nextPosition( + String resolvedTemplateSnapshotJson, + ActiveWorkoutSession session, +) { + return _nextPositionAfter( + resolvedTemplateSnapshotJson, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); +} + +_SetPositionSnapshot? _nextPositionAfter( + String resolvedTemplateSnapshotJson, { + required int programIndex, + required int exerciseIndex, + required int setIndex, +}) { + final snapshots = _listSetSnapshots(resolvedTemplateSnapshotJson); + final currentIndex = snapshots.indexWhere( + (snapshot) => + snapshot.programIndex == programIndex && + snapshot.exerciseIndex == exerciseIndex && + snapshot.setIndex == setIndex, + ); + if (currentIndex == -1 || currentIndex + 1 >= snapshots.length) { + return null; + } + return snapshots[currentIndex + 1]; +} + _ResolvedExerciseSnapshot? _findExerciseSnapshot({ required String resolvedTemplateSnapshotJson, required int programIndex, @@ -5031,6 +5414,7 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({ scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: autoStartNextTimedStepEffective, ); @@ -5098,6 +5482,7 @@ List<_SetPositionSnapshot> _listSetSnapshots( scoreInputModeSnapshot: _scoreInputModeFromSnapshot( exercise['scoreInputModeSnapshot'], ), + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, ), ); } @@ -5116,6 +5501,7 @@ final class _SetPositionSnapshot { this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.scoreInputModeSnapshot, + required this.restSeconds, }); final String programSnapshotId; @@ -5126,6 +5512,7 @@ final class _SetPositionSnapshot { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final ScoreInputMode scoreInputModeSnapshot; + final int restSeconds; } final class _StepSequenceContext { @@ -5243,6 +5630,7 @@ Map _exerciseSnapshotsById( scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?, setsCount: exercise['setsCount'] as int? ?? 0, + restSeconds: exercise['restSecondsOverride'] as int? ?? 0, steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']), autoStartNextTimedStepEffective: (exercise['autoStartNextTimedStepOverride'] as bool?) ?? @@ -5272,6 +5660,7 @@ final class _ResolvedExerciseSnapshot { this.scoreLabelSnapshot, this.scoreUnitSnapshot, required this.setsCount, + required this.restSeconds, this.steps = const [], this.autoStartNextTimedStepEffective = true, }); @@ -5292,6 +5681,7 @@ final class _ResolvedExerciseSnapshot { final String? scoreLabelSnapshot; final String? scoreUnitSnapshot; final int setsCount; + final int restSeconds; final List steps; final bool autoStartNextTimedStepEffective; } diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart new file mode 100644 index 0000000..07b99a9 --- /dev/null +++ b/test/application/watch_companion_command_handler_test.dart @@ -0,0 +1,686 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +void main() { + test('routes startCurrentExercise to active execution timers', () async { + final env = _env( + session: _session(timeEnabled: true), + projection: _projection( + primaryAction: WatchPrimaryAction.startCurrentExercise, + ), + ); + + final ack = await env.dispatch(WatchCommandType.startCurrentExercise); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.setTimerStates, hasLength(1)); + expect(env.projections.emitCount, 1); + }); + + test('routes pauseSession and resumeSession to session use cases', () async { + final pauseEnv = _env( + session: _session(timeEnabled: true), + projection: _projection( + phase: WatchSessionPhase.running, + primaryAction: WatchPrimaryAction.pauseSession, + ), + ); + + expect( + await pauseEnv.dispatch(WatchCommandType.pauseSession), + WatchCommandAck.accepted, + ); + expect(pauseEnv.repository.session?.status, ActiveWorkoutStatus.paused); + + final resumeEnv = _env( + session: _session(status: ActiveWorkoutStatus.paused, pausedAt: _now), + projection: _projection( + phase: WatchSessionPhase.paused, + primaryAction: WatchPrimaryAction.resumeSession, + ), + ); + + expect( + await resumeEnv.dispatch(WatchCommandType.resumeSession), + WatchCommandAck.accepted, + ); + expect(resumeEnv.repository.session?.status, ActiveWorkoutStatus.running); + }); + + test('routes startPreparedTimedStep to step timer start', () async { + final session = _session( + steps: [ + _step(), + _step(id: 'step-2', position: 1), + ], + ); + final env = _env( + session: session, + projection: _projection( + phase: WatchSessionPhase.nextTimerReady, + primaryAction: WatchPrimaryAction.startPreparedTimedStep, + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + stepId: 'step-2', + stepIndex: 1, + ); + + final ack = await env.dispatch(WatchCommandType.startPreparedTimedStep); + + expect(ack, WatchCommandAck.accepted); + expect( + env.repository.stepProgressStates['step-state']?.status, + ActiveExerciseStepProgressStatus.runningTimer, + ); + }); + + test('routes skipCurrentStep to step use case', () async { + final session = _session(steps: [_step()]); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentStep], + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentStep); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.stepResults, hasLength(1)); + }); + + test('routes skipCurrentPassage to step use case', () async { + final session = _session( + targetReps: 2, + steps: [ + _step(), + _step(id: 'step-2', position: 1), + ], + ); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentPassage], + ), + ); + env.repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentPassage); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.stepResults, hasLength(2)); + }); + + test( + 'routes finishCurrentSet and advances to next set without rest', + () async { + final session = _session(timeEnabled: true, setsCount: 2); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.finishCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.finishCurrentSet); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.results, hasLength(1)); + expect(env.repository.session?.currentSetIndex, 1); + }, + ); + + test('routes finishCurrentSet and starts rest before next set', () async { + final session = _session(timeEnabled: true, setsCount: 2, restSeconds: 60); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.finishCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + + final ack = await env.dispatch(WatchCommandType.finishCurrentSet); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.restStates.values.single.plannedRestSeconds, 60); + expect(env.repository.session?.currentSetIndex, 0); + }); + + test('routes skipCurrentSet and advances once on retry duplicate', () async { + final session = _session(timeEnabled: true, setsCount: 3); + final env = _env( + session: session, + projection: _projection( + secondaryActions: [WatchSecondaryAction.skipCurrentSet], + ), + ); + env.repository.setTimerStates['set'] = _setTimer( + sessionId: session.metadata.id, + ); + final command = _command(WatchCommandType.skipCurrentSet); + + final firstAck = await env.handler.dispatch(command); + final retryAck = await env.handler.dispatch(command); + + expect(firstAck, WatchCommandAck.accepted); + expect(retryAck, WatchCommandAck.acceptedNoOp); + expect(env.repository.results, hasLength(1)); + expect(env.repository.session?.currentSetIndex, 1); + }); + + test('routes skipCurrentRest to rest skip and next position', () async { + final session = _session(setsCount: 2); + final env = _env( + session: session, + projection: _projection( + phase: WatchSessionPhase.restRunning, + primaryAction: WatchPrimaryAction.pauseSession, + secondaryActions: [WatchSecondaryAction.skipCurrentRest], + ), + ); + env.repository.restStates['rest'] = ActiveRestState( + metadata: _metadata('rest'), + activeWorkoutSessionId: session.metadata.id, + afterProgramIndex: 0, + afterExerciseIndex: 0, + afterSetIndex: 0, + plannedRestSeconds: 60, + adjustedRestSeconds: 60, + startedAt: _now, + ); + + final ack = await env.dispatch(WatchCommandType.skipCurrentRest); + + expect(ack, WatchCommandAck.accepted); + expect(env.repository.restStates['rest']?.skippedAt, isNotNull); + expect(env.repository.session?.currentSetIndex, 1); + }); + + test( + 'rejects stale revision, non applicable, missing and mismatch', + () async { + final stale = _env( + session: _session(), + projection: _projection(revision: 2), + ); + expect( + await stale.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedStaleRevision, + ); + + final nonApplicable = _env( + session: _session(), + projection: _projection(), + ); + expect( + await nonApplicable.dispatch(WatchCommandType.pauseSession), + WatchCommandAck.rejectedNotApplicable, + ); + + final missing = _env( + projection: _projection( + phase: WatchSessionPhase.noActiveSession, + deviceSessionId: '', + primaryAction: WatchPrimaryAction.none, + ), + ); + expect( + await missing.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedNoActiveSession, + ); + + final mismatch = _env( + session: _session(), + projection: _projection(deviceSessionId: 'other-session'), + ); + expect( + await mismatch.dispatch(WatchCommandType.startCurrentExercise), + WatchCommandAck.rejectedSessionMismatch, + ); + }, + ); +} + +final _now = DateTime.utc(2026, 7, 25, 12); + +_Harness _env({ + ActiveWorkoutSession? session, + required WatchSessionProjection projection, +}) { + final repository = _FakeActiveSessionRepository()..session = session; + final clock = _FakeClock(_now); + final ids = _FakeIds(); + final activeUseCases = ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: _FakeWorkoutTemplateRepository(), + clock: clock, + ids: ids, + originDeviceId: 'device-1', + ); + final stepUseCases = ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: ids, + originDeviceId: 'device-1', + ); + final projections = _FakeProjectionSource(projection); + return _Harness( + repository: repository, + projections: projections, + handler: WatchCompanionCommandHandler( + sessionRepository: repository, + activeSessionUseCases: activeUseCases, + stepUseCases: stepUseCases, + projectionSource: projections, + ), + ); +} + +final class _Harness { + const _Harness({ + required this.repository, + required this.projections, + required this.handler, + }); + + final _FakeActiveSessionRepository repository; + final _FakeProjectionSource projections; + final WatchCompanionCommandHandler handler; + + Future dispatch(WatchCommandType type) { + return handler.dispatch(_command(type)); + } +} + +WatchCommandEnvelope _command( + WatchCommandType type, { + String commandId = 'command-1', +}) { + return WatchCommandEnvelope( + commandId: commandId, + type: type, + sessionId: 'session-1', + expectedRevision: 1, + sentAtEpochMs: _now.millisecondsSinceEpoch, + ); +} + +WatchSessionProjection _projection({ + WatchSessionPhase phase = WatchSessionPhase.ready, + String deviceSessionId = 'session-1', + int revision = 1, + WatchPrimaryAction primaryAction = WatchPrimaryAction.startCurrentExercise, + List secondaryActions = const [ + WatchSecondaryAction.finishCurrentSet, + WatchSecondaryAction.skipCurrentSet, + ], +}) { + return WatchSessionProjection( + deviceSessionId: deviceSessionId, + revision: revision, + projectedAtEpochMs: _now.millisecondsSinceEpoch, + phase: phase, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 2, + exerciseName: 'Squat', + primaryAction: primaryAction, + secondaryActions: secondaryActions, + ); +} + +ActiveWorkoutSession _session({ + ActiveWorkoutStatus status = ActiveWorkoutStatus.running, + DateTime? pausedAt, + int currentSetIndex = 0, + int setsCount = 2, + bool timeEnabled = false, + int? targetReps = 10, + int restSeconds = 0, + List steps = const [], +}) { + final exerciseSnapshot = { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': setsCount, + 'timeEnabled': timeEnabled, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': targetReps, + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'restSecondsOverride': restSeconds, + 'exerciseStepsSnapshot': steps + .map((step) => step.toSnapshotJson()) + .toList(), + 'autoStartNextTimedStepSnapshot': false, + }; + return ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: status, + startedAt: _now, + pausedAt: pausedAt, + lastPersistedAt: _now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: currentSetIndex, + resolvedTemplateSnapshotJson: jsonEncode({ + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme', + 'programSnapshotJson': jsonEncode({ + 'exercises': [exerciseSnapshot], + }), + }, + ], + }), + ); +} + +ExerciseStep _step({String id = 'step-1', int position = 0}) { + return ExerciseStep( + id: id, + position: position, + name: 'Step ${position + 1}', + type: ExerciseStepType.time, + defaultTargetValue: 1, + ); +} + +ActiveExerciseStepProgressState _stepState({ + required String sessionId, + String stepId = 'step-1', + int stepIndex = 0, +}) { + return ActiveExerciseStepProgressState( + metadata: _metadata('step-state'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + currentPassageIndex: 0, + currentStepIndex: stepIndex, + currentStepSnapshotId: stepId, + status: ActiveExerciseStepProgressStatus.stoppedTimer, + accumulatedMs: 0, + lastTransitionAt: _now, + ); +} + +ActiveSetTimerState _setTimer({required String sessionId}) { + return ActiveSetTimerState( + metadata: _metadata('set'), + activeWorkoutSessionId: sessionId, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + status: ActiveSetTimerStatus.running, + startedAt: _now, + accumulatedMs: 0, + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: _now, + updatedAt: _now, + originDeviceId: 'device-1', + ); +} + +final class _FakeProjectionSource implements WatchProjectionSource { + _FakeProjectionSource(this.projection); + + WatchSessionProjection projection; + var emitCount = 0; + + @override + Stream get projections => const Stream.empty(); + + @override + Future currentProjection() async => projection; + + @override + Future emitCurrentProjection() async { + emitCount += 1; + projection = WatchSessionProjection( + deviceSessionId: projection.deviceSessionId, + revision: projection.revision + 1, + projectedAtEpochMs: projection.projectedAtEpochMs, + phase: projection.phase, + phoneReachable: projection.phoneReachable, + seriesIndex: projection.seriesIndex, + seriesTotal: projection.seriesTotal, + exerciseName: projection.exerciseName, + primaryAction: projection.primaryAction, + secondaryActions: projection.secondaryActions, + ); + return projection; + } +} + +final class _FakeClock implements Clock { + const _FakeClock(this.value); + + final DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var next = 0; + + @override + String newId() { + next += 1; + return 'id-$next'; + } +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + ActiveWorkoutSession? session; + final results = []; + final restStates = {}; + final setTimerStates = {}; + final scoreStopwatchStates = {}; + final stepProgressStates = {}; + final stepResults = []; + + @override + Future deleteScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + scoreStopwatchStates.clear(); + } + + @override + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; + + @override + Future findExerciseStepProgressState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return stepProgressStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findRestStateById(String id) async { + return restStates[id]; + } + + @override + Future findScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return scoreStopwatchStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return setTimerStates.values.where((state) { + return state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex; + }).firstOrNull; + } + + @override + Future> listExerciseStepProgressStates( + String sessionId, + ) async { + return stepProgressStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listExerciseStepResults( + String sessionId, + ) async { + return stepResults + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listRestStates(String sessionId) async { + return restStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listScoreStopwatchStates( + String sessionId, + ) async { + return scoreStopwatchStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetResults(String sessionId) async { + return results + .where((result) => result.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future> listSetTimerStates(String sessionId) async { + return setTimerStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + + @override + Future save(ActiveWorkoutSession session) async { + this.session = session; + } + + @override + Future saveExerciseStepProgressState( + ActiveExerciseStepProgressState state, + ) async { + stepProgressStates[state.metadata.id] = state; + } + + @override + Future saveExerciseStepResult(ActiveExerciseStepResult result) async { + stepResults.add(result); + } + + @override + Future saveRestState(ActiveRestState restState) async { + restStates[restState.metadata.id] = restState; + } + + @override + Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async { + scoreStopwatchStates[state.metadata.id] = state; + } + + @override + Future saveSetResult(ActiveSetResult result) async { + results.add(result); + } + + @override + Future saveSetTimerState(ActiveSetTimerState state) async { + setTimerStates[state.metadata.id] = state; + } +} From 40a2d5eddcd6077a492dec990699e555e850b0ae Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 19:51:17 +0200 Subject: [PATCH 4/6] feat(android): local server networking (INTERNET + cleartext) --- android/app/src/main/AndroidManifest.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bc7d6d5..00d050e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> Date: Sat, 25 Jul 2026 19:51:43 +0200 Subject: [PATCH 5/6] feat(watch): Android Wear Data Layer adapter + foreground service (#91-D) --- android/app/build.gradle.kts | 5 + android/app/src/main/AndroidManifest.xml | 20 ++ .../kotlin/com/gametime/app/MainActivity.kt | 9 +- .../watch/PhoneWatchBridgeListenerService.kt | 63 ++++ .../gametime/app/watch/WatchBridgePlugin.kt | 214 ++++++++++++ .../watch/WatchCompanionForegroundService.kt | 58 ++++ android/app/src/main/res/values/wear.xml | 5 + lib/application/app_bootstrap.dart | 29 +- lib/infrastructure/infrastructure.dart | 1 + .../native_watch_bridge_channel.dart | 137 ++++++++ .../watch_bridge/watch_bridge.dart | 2 + .../watch_bridge/wear_data_layer_adapter.dart | 183 ++++++++++ .../wear_data_layer_adapter_test.dart | 319 ++++++++++++++++++ 13 files changed, 1037 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/kotlin/com/gametime/app/watch/PhoneWatchBridgeListenerService.kt create mode 100644 android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt create mode 100644 android/app/src/main/kotlin/com/gametime/app/watch/WatchCompanionForegroundService.kt create mode 100644 android/app/src/main/res/values/wear.xml create mode 100644 lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart create mode 100644 lib/infrastructure/watch_bridge/watch_bridge.dart create mode 100644 lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart create mode 100644 test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9994ece..694f359 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("com.android.application") + id("org.jetbrains.kotlin.android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } @@ -40,3 +41,7 @@ kotlin { flutter { source = "../.." } + +dependencies { + implementation("com.google.android.gms:play-services-wearable:19.0.0") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 00d050e..43248fa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,9 @@ + + + + + + + + + + + +