From cf68a72403f67b94cbc1d43c12844dd264ad32dc Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 25 Jul 2026 17:42:57 +0200 Subject: [PATCH] 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: