feat(watch): shared watch bridge contract package (#91-A)

This commit is contained in:
2026-07-25 17:42:57 +02:00
parent f140d7a014
commit cf68a72403
8 changed files with 1096 additions and 0 deletions

View File

@ -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<String, Object?> 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<String, Object?> 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<String, Object?> 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<WatchTimerProjection> secondaryTimers;
final WatchPrimaryAction primaryAction;
final List<WatchSecondaryAction> secondaryActions;
final String? nextExerciseName;
final String? statusLabel;
Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<T extends Enum>(Object? value, List<T> values, T fallback) {
if (value is String) {
for (final enumValue in values) {
if (enumValue.name == value) {
return enumValue;
}
}
}
return fallback;
}
List<T> _enumListFromJson<T extends Enum>(Object? value, List<T> 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<String, Object?>.from(value));
}
List<WatchTimerProjection> _timerListFromJson(Object? value) {
if (value is! List) {
return const [];
}
return [
for (final item in value)
if (item is Map)
WatchTimerProjection.fromJson(Map<String, Object?>.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<T>(List<T> left, List<T> 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;
}

View File

@ -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';