feat(watch): shared watch bridge contract package (#91-A)
This commit is contained in:
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
Reference in New Issue
Block a user