feat(watch): intégration Wear OS companion app (#91-A à #91-F)

Merge de la branche feature/ticket91-wear-os-watch-sync dans develop :
bridge de données partagé, service Android Data Layer, routage des
commandes montre vers les cas d'usage de session, et app compagnon
Wear OS (UX + client bridge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 11:59:56 +02:00
52 changed files with 6421 additions and 16 deletions

View File

@ -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")
}

View File

@ -1,8 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="GameTime"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
@ -30,6 +37,22 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".watch.WatchCompanionForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".watch.PhoneWatchBridgeListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<action android:name="com.google.android.gms.wearable.CAPABILITY_CHANGED" />
<data
android:host="*"
android:pathPrefix="/gametime"
android:scheme="wear" />
</intent-filter>
</service>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@ -1,5 +1,12 @@
package com.gametime.app
import com.gametime.app.watch.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
}
}

View File

@ -0,0 +1,63 @@
package com.gametime.app.watch
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.Wearable
import com.google.android.gms.wearable.WearableListenerService
import org.json.JSONObject
import java.nio.charset.StandardCharsets
class PhoneWatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WatchBridgePlugin.COMMAND_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
}
}
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
if (capabilityInfo.name != WatchBridgePlugin.WATCH_CAPABILITY) {
return
}
WatchBridgePlugin.emitConnection(
isReachable = capabilityInfo.nodes.isNotEmpty(),
requestsResync = capabilityInfo.nodes.isNotEmpty(),
)
}
override fun onCreate() {
super.onCreate()
WatchBridgePlugin.requestCapabilityRefresh(applicationContext)
}
private fun sendPhoneBusyAck(command: Map<String, Any?>, sourceNodeId: String) {
val ack = JSONObject(
mapOf(
"schemaVersion" to (command["schemaVersion"] ?: 1),
"commandId" to command["commandId"],
"sessionId" to command["sessionId"],
"expectedRevision" to command["expectedRevision"],
"status" to "rejectedPhoneBusy",
"ackedAtEpochMs" to System.currentTimeMillis(),
),
).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getMessageClient(this)
.sendMessage(sourceNodeId, WatchBridgePlugin.ACK_PATH, ack)
}
}
private fun JSONObject.toMap(): Map<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()
while (keys.hasNext()) {
val key = keys.next()
val value = get(key)
output[key] = if (value == JSONObject.NULL) null else value
}
return output
}

View File

@ -0,0 +1,214 @@
package com.gametime.app.watch
import android.content.Context
import android.content.Intent
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val COMMAND_CHANNEL = "gametime.watch_bridge/commands"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val WATCH_CAPABILITY = "gametime_watch_companion"
private val pendingCommandNodes = ConcurrentHashMap<String, String>()
private var appContext: Context? = null
private var commandSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, COMMAND_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
commandSink = events
}
override fun onCancel(arguments: Any?) {
commandSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
connectionSink = events
requestCapabilityRefresh(context.applicationContext)
}
override fun onCancel(arguments: Any?) {
connectionSink = null
}
},
)
}
fun emitCommand(payload: Map<String, Any?>, sourceNodeId: String): Boolean {
val sink = commandSink ?: return false
val commandId = payload["commandId"] as? String
if (commandId != null) {
pendingCommandNodes[commandId] = sourceNodeId
}
sink.success(payload)
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("watch_bridge_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"publishProjection" -> publishProjection(context, call.arguments, result)
"sendCommandAck" -> sendCommandAck(context, call.arguments, result)
"requestCapabilityRefresh" -> {
requestCapabilityRefresh(context)
result.success(null)
}
"startForegroundService" -> {
startForegroundService(context)
result.success(null)
}
"stopForegroundService" -> {
context.stopService(Intent(context, WatchCompanionForegroundService::class.java))
result.success(null)
}
else -> result.notImplemented()
}
}
private fun publishProjection(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_projection", "Projection payload must be a map.", null)
return
}
val projectionJson = JSONObject(map).toString()
val request = PutDataMapRequest.create(STATE_PATH).apply {
dataMap.putString("projectionJson", projectionJson)
dataMap.putInt("schemaVersion", (map["schemaVersion"] as? Number)?.toInt() ?: 1)
dataMap.putInt("revision", (map["revision"] as? Number)?.toInt() ?: 0)
dataMap.putLong(
"projectedAtEpochMs",
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
)
}.asPutDataRequest().setUrgent()
Wearable.getDataClient(context).putDataItem(request)
.addOnSuccessListener { result.success(null) }
.addOnFailureListener { error ->
result.error("publish_projection_failed", error.message, null)
}
}
private fun sendCommandAck(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_ack", "Ack payload must be a map.", null)
return
}
val commandId = map["commandId"] as? String
val targetNode = commandId?.let { pendingCommandNodes.remove(it) }
val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8)
if (targetNode != null) {
sendMessage(context, targetNode, payload, result)
return
}
Wearable.getCapabilityClient(context)
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
val nodes = capability.nodes.toList()
if (nodes.isEmpty()) {
result.success(null)
return@addOnSuccessListener
}
var remaining = nodes.size
var failed = false
for (node in nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, ACK_PATH, payload)
.addOnSuccessListener {
remaining -= 1
if (remaining == 0 && !failed) result.success(null)
}
.addOnFailureListener { error ->
failed = true
result.error("send_ack_failed", error.message, null)
}
}
}
.addOnFailureListener { error ->
result.error("capability_lookup_failed", error.message, null)
}
}
private fun sendMessage(
context: Context,
nodeId: String,
payload: ByteArray,
result: MethodChannel.Result,
) {
Wearable.getMessageClient(context)
.sendMessage(nodeId, ACK_PATH, payload)
.addOnSuccessListener { result.success(null) }
.addOnFailureListener { error ->
result.error("send_ack_failed", error.message, null)
}
}
fun requestCapabilityRefresh(context: Context) {
Wearable.getCapabilityClient(context)
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
emitConnection(
isReachable = capability.nodes.isNotEmpty(),
requestsResync = capability.nodes.isNotEmpty(),
)
}
.addOnFailureListener {
emitConnection(isReachable = false, requestsResync = false)
}
}
private fun startForegroundService(context: Context) {
val intent = Intent(context, WatchCompanionForegroundService::class.java)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}

View File

@ -0,0 +1,58 @@
package com.gametime.app.watch
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.gametime.app.R
class WatchCompanionForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
startForeground(NOTIFICATION_ID, notification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("GameTime")
.setContentText("Séance en cours")
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
"Synchronisation montre",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannel(channel)
}
private companion object {
const val CHANNEL_ID = "gametime_watch_companion"
const val NOTIFICATION_ID = 91
}
}

View File

@ -0,0 +1,5 @@
<resources>
<string-array name="android_wear_capabilities">
<item>gametime_phone_companion</item>
</string-array>
</resources>

View File

@ -1,6 +1,7 @@
import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import '../infrastructure/watch_bridge/watch_bridge.dart';
import 'application.dart';
abstract interface class AppDependencies {
@ -31,6 +32,9 @@ final class AppBootstrap implements AppDependencies {
required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases,
required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.watchWearDataLayerAdapter,
required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
required this.progressionStatsUseCase,
@ -57,6 +61,9 @@ final class AppBootstrap implements AppDependencies {
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
@ -103,6 +110,37 @@ 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,
);
final watchCompanionCommandHandler = WatchCompanionCommandHandler(
sessionRepository: activeSessionRepository,
activeSessionUseCases: activeWorkoutSessionUseCases,
stepUseCases: activeExerciseStepUseCases,
projectionSource: watchCompanionProjectionUseCases,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler,
projectionSource: watchCompanionProjectionUseCases,
);
await watchWearDataLayerAdapter.start();
await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
@ -158,19 +196,11 @@ final class AppBootstrap implements AppDependencies {
ids: ids,
originDeviceId: originDeviceId,
),
activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeSessionRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
activeExerciseStepUseCases: ActiveExerciseStepUseCases(
sessionRepository: activeSessionRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler,
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
@ -224,5 +254,9 @@ final class AppBootstrap implements AppDependencies {
);
}
Future<void> dispose() => database.close();
Future<void> dispose() async {
await watchWearDataLayerAdapter.stop();
await watchCompanionProjectionUseCases.dispose();
await database.close();
}
}

View File

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

View File

@ -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,865 @@ 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<WatchSessionProjection>.broadcast();
WatchSessionProjection? _latestProjection;
int _revision = 0;
@override
Stream<WatchSessionProjection> get projections => _controller.stream;
@override
Future<WatchSessionProjection> currentProjection() async {
return _latestProjection ?? _projectWithCurrentRevision();
}
@override
Future<WatchSessionProjection> emitCurrentProjection() async {
_revision += 1;
final projection = await _projector.project(revision: _revision);
_latestProjection = projection;
_controller.add(projection);
await _publisher?.publish(projection);
return projection;
}
Future<void> dispose() => _controller.close();
Future<WatchSessionProjection> _projectWithCurrentRevision() {
return _projector.project(revision: _revision);
}
}
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<void> _tail = Future<void>.value();
@override
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
final run = _tail.then(
(_) => _dispatch(command),
onError: (_) => _dispatch(command),
);
_tail = run.then((_) {}, onError: (_) {});
return run;
}
Future<WatchCommandAck> _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<WatchCommandAck> _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<WatchCommandAck> _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<WatchCommandAck> _pause(ActiveWorkoutSession session) async {
await _activeSessionUseCases.pause(session.metadata.id);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _resume(ActiveWorkoutSession session) async {
await _activeSessionUseCases.resume(session.metadata.id);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _startPreparedTimedStep(
ActiveWorkoutSession session,
) async {
await _stepUseCases.startTimer(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _skipCurrentStep(ActiveWorkoutSession session) async {
await _stepUseCases.skipCurrentStep(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _skipCurrentPassage(
ActiveWorkoutSession session,
) async {
await _stepUseCases.skipCurrentPassage(
sessionId: session.metadata.id,
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
return WatchCommandAck.accepted;
}
Future<WatchCommandAck> _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<int?> _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<void> _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<WatchCommandAck> _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<void> _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,
required this.clock,
required this.ids,
required this.originDeviceId,
});
final ActiveSessionRepository sessionRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
Future<WatchSessionProjection> 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 = <WatchTimerProjection>[
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 <WatchTimerProjection>[]
: 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<ActiveExerciseStepProgressView?> _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<ActiveRestState?> _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<WatchTimerProjection> 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<WatchSecondaryAction> _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,
@ -4466,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,
@ -4519,6 +5413,8 @@ _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,
);
@ -4586,6 +5482,7 @@ List<_SetPositionSnapshot> _listSetSnapshots(
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
exercise['scoreInputModeSnapshot'],
),
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
),
);
}
@ -4604,6 +5501,7 @@ final class _SetPositionSnapshot {
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.scoreInputModeSnapshot,
required this.restSeconds,
});
final String programSnapshotId;
@ -4614,6 +5512,7 @@ final class _SetPositionSnapshot {
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final ScoreInputMode scoreInputModeSnapshot;
final int restSeconds;
}
final class _StepSequenceContext {
@ -4730,6 +5629,8 @@ Map<String, _ResolvedExerciseSnapshot> _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?) ??
@ -4758,6 +5659,8 @@ final class _ResolvedExerciseSnapshot {
required this.scoreInputModeSnapshot,
this.scoreLabelSnapshot,
this.scoreUnitSnapshot,
required this.setsCount,
required this.restSeconds,
this.steps = const [],
this.autoStartNextTimedStepEffective = true,
});
@ -4777,6 +5680,8 @@ final class _ResolvedExerciseSnapshot {
final ScoreInputMode scoreInputModeSnapshot;
final String? scoreLabelSnapshot;
final String? scoreUnitSnapshot;
final int setsCount;
final int restSeconds;
final List<ExerciseStep> steps;
final bool autoStartNextTimedStepEffective;
}

View File

@ -0,0 +1,20 @@
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
abstract interface class WatchCommandIngress {
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command);
}
abstract interface class WatchProjectionPublisher {
Future<void> publish(WatchSessionProjection projection);
}
abstract interface class WatchProjectionSource {
Stream<WatchSessionProjection> get projections;
Future<WatchSessionProjection> currentProjection();
Future<WatchSessionProjection> emitCurrentProjection();
}
abstract interface class WatchCompanionUseCases
implements WatchCommandIngress, WatchProjectionSource {}

View File

@ -7,3 +7,4 @@ library;
export 'local/local.dart';
export 'remote/remote.dart';
export 'security/security.dart';
export 'watch_bridge/watch_bridge.dart';

View File

@ -0,0 +1,137 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
final class WatchBridgeConnectionEvent {
const WatchBridgeConnectionEvent({
required this.isReachable,
this.requestsResync = false,
});
final bool isReachable;
final bool requestsResync;
}
abstract interface class WatchBridgeNativeChannel {
Stream<WatchCommandEnvelope> get commands;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> publishProjection(WatchSessionProjection projection);
Future<void> sendCommandAck(
WatchCommandEnvelope command,
WatchCommandAck ack, {
int? revisionAtAck,
});
Future<void> requestCapabilityRefresh();
Future<void> startForegroundService();
Future<void> stopForegroundService();
}
final class MethodChannelWatchBridgeNativeChannel
implements WatchBridgeNativeChannel {
const MethodChannelWatchBridgeNativeChannel({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel commandChannel = const EventChannel(_commandChannelName),
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
}) : _methodChannel = methodChannel,
_commandChannel = commandChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _commandChannelName = 'gametime.watch_bridge/commands';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _commandChannel;
final EventChannel _connectionChannel;
@override
Stream<WatchCommandEnvelope> get commands {
return _commandChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
return WatchCommandEnvelope.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
final json = _stringObjectMap(event);
return WatchBridgeConnectionEvent(
isReachable: json['isReachable'] == true,
requestsResync: json['requestsResync'] == true,
);
});
}
@override
Future<void> publishProjection(WatchSessionProjection projection) {
return _invokeIgnoringMissingPlugin(
'publishProjection',
projection.toJson(),
);
}
@override
Future<void> requestCapabilityRefresh() {
return _invokeIgnoringMissingPlugin('requestCapabilityRefresh');
}
@override
Future<void> sendCommandAck(
WatchCommandEnvelope command,
WatchCommandAck ack, {
int? revisionAtAck,
}) {
return _invokeIgnoringMissingPlugin('sendCommandAck', {
'schemaVersion': watchBridgeSchemaVersion,
'commandId': command.commandId,
'sessionId': command.sessionId,
'expectedRevision': command.expectedRevision,
'status': ack.name,
'revisionAtAck': revisionAtAck,
'ackedAtEpochMs': DateTime.now().toUtc().millisecondsSinceEpoch,
});
}
@override
Future<void> startForegroundService() {
return _invokeIgnoringMissingPlugin('startForegroundService');
}
@override
Future<void> stopForegroundService() {
return _invokeIgnoringMissingPlugin('stopForegroundService');
}
Future<void> _invokeIgnoringMissingPlugin(
String method, [
Object? arguments,
]) {
return _methodChannel
.invokeMethod<void>(method, arguments)
.onError<MissingPluginException>((_, _) {});
}
}
Map<String, Object?> _stringObjectMap(Object? value) {
if (value is Map) {
return value.map((key, value) => MapEntry(key.toString(), value));
}
return const {};
}

View File

@ -0,0 +1,2 @@
export 'native_watch_bridge_channel.dart';
export 'wear_data_layer_adapter.dart';

View File

@ -0,0 +1,183 @@
import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../../application/watch_companion_use_cases.dart';
import 'native_watch_bridge_channel.dart';
final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
WatchWearDataLayerAdapter({
required WatchBridgeNativeChannel nativeChannel,
required WatchCommandIngress commandIngress,
required WatchProjectionSource projectionSource,
Duration heartbeatInterval = const Duration(seconds: 5),
}) : _nativeChannel = nativeChannel,
_commandIngress = commandIngress,
_projectionSource = projectionSource,
_heartbeatInterval = heartbeatInterval;
final WatchBridgeNativeChannel _nativeChannel;
final WatchCommandIngress _commandIngress;
final WatchProjectionSource _projectionSource;
final Duration _heartbeatInterval;
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
final _subscriptions = <StreamSubscription<dynamic>>[];
Future<void> _commandTail = Future<void>.value();
Timer? _heartbeatTimer;
WatchSessionProjection? _latestProjection;
bool _started = false;
bool _foregroundActive = false;
Future<void> start() async {
if (_started) {
return;
}
_started = true;
_subscriptions.add(
_projectionSource.projections.listen((projection) {
unawaited(publish(projection));
}),
);
_subscriptions.add(
_nativeChannel.commands.listen((command) {
unawaited(_enqueueCommand(command));
}),
);
_subscriptions.add(
_nativeChannel.connectionEvents.listen((event) {
if (event.isReachable || event.requestsResync) {
unawaited(_projectionSource.emitCurrentProjection());
}
}),
);
await _projectionSource.emitCurrentProjection();
await _nativeChannel.requestCapabilityRefresh();
}
Future<void> stop() async {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
_started = false;
}
@override
Future<void> publish(WatchSessionProjection projection) async {
_latestProjection = projection;
await _nativeChannel.publishProjection(projection);
await _syncForegroundService(projection);
_syncHeartbeat(projection);
}
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
final run = _commandTail.then(
(_) => _handleCommand(command),
onError: (_) => _handleCommand(command),
);
_commandTail = run.then((_) {}, onError: (_) {});
return run;
}
Future<void> _handleCommand(WatchCommandEnvelope command) async {
final key = _WatchAdapterCommandKey(command);
final cachedAck = _commandAcks[key];
if (cachedAck != null) {
await _sendAck(command, WatchCommandAck.acceptedNoOp);
return;
}
final ack = await _commandIngress.dispatch(command);
if (ack == WatchCommandAck.accepted ||
ack == WatchCommandAck.acceptedNoOp) {
_rememberAck(key, ack);
}
await _sendAck(command, ack);
}
Future<void> _sendAck(
WatchCommandEnvelope command,
WatchCommandAck ack,
) async {
int? revisionAtAck;
try {
revisionAtAck = (await _projectionSource.currentProjection()).revision;
} on Exception {
revisionAtAck = _latestProjection?.revision;
}
await _nativeChannel.sendCommandAck(
command,
ack,
revisionAtAck: revisionAtAck,
);
}
void _rememberAck(_WatchAdapterCommandKey key, WatchCommandAck ack) {
_commandAcks[key] = ack;
if (_commandAcks.length <= 128) {
return;
}
_commandAcks.remove(_commandAcks.keys.first);
}
Future<void> _syncForegroundService(WatchSessionProjection projection) async {
final shouldRun =
projection.phase != WatchSessionPhase.noActiveSession &&
projection.deviceSessionId.isNotEmpty;
if (shouldRun == _foregroundActive) {
return;
}
_foregroundActive = shouldRun;
if (shouldRun) {
await _nativeChannel.startForegroundService();
} else {
await _nativeChannel.stopForegroundService();
}
}
void _syncHeartbeat(WatchSessionProjection projection) {
if (!_hasRunningTimer(projection)) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return;
}
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
unawaited(_projectionSource.emitCurrentProjection());
});
}
}
bool _hasRunningTimer(WatchSessionProjection projection) {
final timers = [
if (projection.dominantTimer != null) projection.dominantTimer!,
...projection.secondaryTimers,
];
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
}
final class _WatchAdapterCommandKey {
_WatchAdapterCommandKey(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 _WatchAdapterCommandKey &&
sessionId == other.sessionId &&
expectedRevision == other.expectedRevision &&
commandId == other.commandId &&
type == other.type;
}
@override
int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type);
}

View File

@ -0,0 +1,5 @@
include: package:lints/recommended.yaml
linter:
rules:
prefer_single_quotes: true

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

View File

@ -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"

View File

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

View File

@ -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<String, Object?>,
);
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<String, Object?>,
);
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<String, Object?>,
);
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,
);
}

View File

@ -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:

View File

@ -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:

View File

@ -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<WatchCommandAck> 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<WatchSecondaryAction> 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<ExerciseStep> 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<WatchSessionProjection> get projections => const Stream.empty();
@override
Future<WatchSessionProjection> currentProjection() async => projection;
@override
Future<WatchSessionProjection> 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<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
scoreStopwatchStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveExerciseStepProgressState?> 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<ActiveRestState?> findRestStateById(String id) async {
return restStates[id];
}
@override
Future<ActiveScoreStopwatchState?> 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<ActiveSetTimerState?> 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<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.add(result);
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
}

View File

@ -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 = <WatchSessionProjection>[];
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
final second = await useCases.emitCurrentProjection();
await Future<void>.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<ExerciseStep> 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 = <WatchSessionProjection>[];
@override
Future<void> 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 = <ActiveSetResult>[];
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
scoreStopwatchStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveExerciseStepProgressState?> 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<ActiveRestState?> findRestStateById(String id) async {
return restStates[id];
}
@override
Future<ActiveScoreStopwatchState?> 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<ActiveSetTimerState?> 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<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async {
return stepProgressStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async {
return stepResults
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return restStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async {
return scoreStopwatchStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
.where((result) => result.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {
stepProgressStates[state.metadata.id] = state;
}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.add(result);
}
@override
Future<void> saveRestState(ActiveRestState restState) async {
restStates[restState.metadata.id] = restState;
}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates[state.metadata.id] = state;
}
}

View File

@ -0,0 +1,319 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/infrastructure/infrastructure.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('publishes every projection revision from the source stream', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_projection(revision: 0));
final adapter = _adapter(native: native, source: source);
await adapter.start();
native.published.clear();
source.emit(_projection(revision: 1));
source.emit(_projection(revision: 2));
await Future<void>.delayed(Duration.zero);
expect(native.published.map((projection) => projection.revision), [1, 2]);
await adapter.stop();
});
test('heartbeats while a timer is running', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_runningProjection(revision: 1));
final adapter = _adapter(
native: native,
source: source,
heartbeatInterval: const Duration(milliseconds: 10),
);
await adapter.start();
native.published.clear();
source.emitCount = 0;
await adapter.publish(_runningProjection(revision: 1));
await Future<void>.delayed(const Duration(milliseconds: 35));
expect(source.emitCount, greaterThanOrEqualTo(1));
expect(native.published.length, greaterThanOrEqualTo(2));
await adapter.stop();
});
test('dispatches watch command and sends ack back to native layer', () async {
final native = _FakeWatchBridgeNativeChannel();
final ingress = _FakeCommandIngress();
final source = _FakeProjectionSource(_projection(revision: 0));
final adapter = _adapter(native: native, ingress: ingress, source: source);
await adapter.start();
native.emitCommand(_command(WatchCommandType.pauseSession));
await Future<void>.delayed(Duration.zero);
expect(ingress.commands.single.type, WatchCommandType.pauseSession);
expect(native.acks.single.ack, WatchCommandAck.accepted);
expect(native.acks.single.revisionAtAck, 1);
await adapter.stop();
});
test('deduplicates retry before dispatching to ingress again', () async {
final native = _FakeWatchBridgeNativeChannel();
final ingress = _FakeCommandIngress();
final source = _FakeProjectionSource(_projection(revision: 0));
final adapter = _adapter(native: native, ingress: ingress, source: source);
await adapter.start();
final command = _command(WatchCommandType.skipCurrentSet);
native.emitCommand(command);
native.emitCommand(command);
await Future<void>.delayed(Duration.zero);
expect(ingress.commands, hasLength(1));
expect(native.acks.map((ack) => ack.ack), [
WatchCommandAck.accepted,
WatchCommandAck.acceptedNoOp,
]);
await adapter.stop();
});
test('emits a full resync when a watch node reconnects', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_projection(revision: 3));
final adapter = _adapter(native: native, source: source);
await adapter.start();
native.published.clear();
source.emitCount = 0;
native.emitConnection(
const WatchBridgeConnectionEvent(isReachable: true, requestsResync: true),
);
await Future<void>.delayed(Duration.zero);
expect(source.emitCount, 1);
expect(native.published.single.revision, 5);
await adapter.stop();
});
test('processes commands sequentially in receive order', () async {
final native = _FakeWatchBridgeNativeChannel();
final ingress = _BlockingCommandIngress();
final source = _FakeProjectionSource(_projection(revision: 0));
final adapter = _adapter(native: native, ingress: ingress, source: source);
await adapter.start();
native.emitCommand(_command(WatchCommandType.skipCurrentStep, id: 'first'));
native.emitCommand(_command(WatchCommandType.skipCurrentSet, id: 'second'));
await Future<void>.delayed(Duration.zero);
expect(ingress.started, ['first']);
ingress.completeNext();
await Future<void>.delayed(Duration.zero);
expect(ingress.started, ['first', 'second']);
ingress.completeNext();
await Future<void>.delayed(Duration.zero);
expect(native.acks.map((ack) => ack.command.commandId), [
'first',
'second',
]);
await adapter.stop();
});
}
WatchWearDataLayerAdapter _adapter({
required _FakeWatchBridgeNativeChannel native,
WatchCommandIngress? ingress,
required _FakeProjectionSource source,
Duration heartbeatInterval = const Duration(seconds: 5),
}) {
return WatchWearDataLayerAdapter(
nativeChannel: native,
commandIngress: ingress ?? _FakeCommandIngress(),
projectionSource: source,
heartbeatInterval: heartbeatInterval,
);
}
WatchCommandEnvelope _command(
WatchCommandType type, {
String id = 'command-1',
}) {
return WatchCommandEnvelope(
commandId: id,
type: type,
sessionId: 'session-1',
expectedRevision: 1,
sentAtEpochMs: _now.millisecondsSinceEpoch,
);
}
WatchSessionProjection _projection({required int revision}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: revision,
projectedAtEpochMs: _now.millisecondsSinceEpoch,
phase: WatchSessionPhase.ready,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 2,
exerciseName: 'Squat',
primaryAction: WatchPrimaryAction.startCurrentExercise,
);
}
WatchSessionProjection _runningProjection({required int revision}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: revision,
projectedAtEpochMs: _now.millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 2,
exerciseName: 'Squat',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
referenceEpochMs: _now.millisecondsSinceEpoch,
accumulatedMs: 0,
startedAtEpochMs: _now.millisecondsSinceEpoch,
targetMs: 30000,
),
);
}
final _now = DateTime.utc(2026, 7, 25, 12);
final class _FakeProjectionSource implements WatchProjectionSource {
_FakeProjectionSource(this.current);
WatchSessionProjection current;
var emitCount = 0;
final _controller = StreamController<WatchSessionProjection>.broadcast();
@override
Stream<WatchSessionProjection> get projections => _controller.stream;
void emit(WatchSessionProjection projection) {
current = projection;
_controller.add(projection);
}
@override
Future<WatchSessionProjection> currentProjection() async => current;
@override
Future<WatchSessionProjection> emitCurrentProjection() async {
emitCount += 1;
current = WatchSessionProjection(
deviceSessionId: current.deviceSessionId,
revision: current.revision + 1,
projectedAtEpochMs: current.projectedAtEpochMs,
phase: current.phase,
phoneReachable: current.phoneReachable,
seriesIndex: current.seriesIndex,
seriesTotal: current.seriesTotal,
exerciseName: current.exerciseName,
dominantTimer: current.dominantTimer,
secondaryTimers: current.secondaryTimers,
primaryAction: current.primaryAction,
secondaryActions: current.secondaryActions,
);
_controller.add(current);
return current;
}
}
final class _FakeCommandIngress implements WatchCommandIngress {
final commands = <WatchCommandEnvelope>[];
@override
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) async {
commands.add(command);
return WatchCommandAck.accepted;
}
}
final class _BlockingCommandIngress implements WatchCommandIngress {
final started = <String>[];
final _pending = <Completer<WatchCommandAck>>[];
@override
Future<WatchCommandAck> dispatch(WatchCommandEnvelope command) {
started.add(command.commandId);
final completer = Completer<WatchCommandAck>();
_pending.add(completer);
return completer.future;
}
void completeNext() {
_pending.removeAt(0).complete(WatchCommandAck.accepted);
}
}
final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
final published = <WatchSessionProjection>[];
final acks = <_SentAck>[];
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
var capabilityRefreshCount = 0;
var foregroundStartCount = 0;
var foregroundStopCount = 0;
@override
Stream<WatchCommandEnvelope> get commands => _commands.stream;
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connections.stream;
void emitCommand(WatchCommandEnvelope command) {
_commands.add(command);
}
void emitConnection(WatchBridgeConnectionEvent event) {
_connections.add(event);
}
@override
Future<void> publishProjection(WatchSessionProjection projection) async {
published.add(projection);
}
@override
Future<void> requestCapabilityRefresh() async {
capabilityRefreshCount += 1;
}
@override
Future<void> sendCommandAck(
WatchCommandEnvelope command,
WatchCommandAck ack, {
int? revisionAtAck,
}) async {
acks.add(_SentAck(command, ack, revisionAtAck));
}
@override
Future<void> startForegroundService() async {
foregroundStartCount += 1;
}
@override
Future<void> stopForegroundService() async {
foregroundStopCount += 1;
}
}
final class _SentAck {
const _SentAck(this.command, this.ack, this.revisionAtAck);
final WatchCommandEnvelope command;
final WatchCommandAck ack;
final int? revisionAtAck;
}

15
watch_app/.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/
**/GeneratedPluginRegistrant.java
**/generated_plugin_registrant.*
android/local.properties
android/.gradle/
android/captures/
android/app/debug/
android/app/profile/
android/app/release/
*.iml
.idea/
.DS_Store

View File

@ -0,0 +1 @@
include: ../analysis_options.yaml

View File

@ -0,0 +1,38 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.gametime.watch"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.gametime.watch"
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
dependencies {
implementation("com.google.android.gms:play-services-wearable:19.0.0")
}

View File

@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.type.watch"
android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="GameTime"
android:name="${applicationName}"
android:icon="@drawable/ic_launcher"
android:theme="@style/LaunchTheme"
android:usesCleartextTraffic="false">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".bridge.WatchBridgeListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<action android:name="com.google.android.gms.wearable.CAPABILITY_CHANGED" />
<data
android:host="*"
android:pathPrefix="/gametime"
android:scheme="wear" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,12 @@
package com.gametime.watch
import com.gametime.watch.bridge.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
}
}

View File

@ -0,0 +1,58 @@
package com.gametime.watch.bridge
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import org.json.JSONObject
import java.nio.charset.StandardCharsets
class WatchBridgeListenerService : WearableListenerService() {
override fun onDataChanged(dataEvents: DataEventBuffer) {
try {
for (event in dataEvents) {
WatchBridgePlugin.handleDataEvent(event)
}
} finally {
dataEvents.release()
}
}
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WatchBridgePlugin.ACK_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
WatchBridgePlugin.emitAck(payload.toMap())
}
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
if (capabilityInfo.name != WatchBridgePlugin.PHONE_CAPABILITY) {
return
}
WatchBridgePlugin.emitConnection(
isReachable = capabilityInfo.nodes.isNotEmpty(),
requestsResync = capabilityInfo.nodes.isNotEmpty(),
)
if (capabilityInfo.nodes.isNotEmpty()) {
WatchBridgePlugin.requestLatestProjection(applicationContext)
}
}
override fun onCreate() {
super.onCreate()
WatchBridgePlugin.requestCapabilityRefresh(applicationContext)
WatchBridgePlugin.requestLatestProjection(applicationContext)
}
}
private fun JSONObject.toMap(): Map<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()
while (keys.hasNext()) {
val key = keys.next()
val value = get(key)
output[key] = if (value == JSONObject.NULL) null else value
}
return output
}

View File

@ -0,0 +1,242 @@
package com.gametime.watch.bridge
import android.content.Context
import android.net.Uri
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.Wearable
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
import org.json.JSONObject
import java.nio.charset.StandardCharsets
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val ACK_CHANNEL = "gametime.watch_bridge/acks"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
private var appContext: Context? = null
private var projectionSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, PROJECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
projectionSink = events
requestLatestProjection(context.applicationContext)
}
override fun onCancel(arguments: Any?) {
projectionSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, ACK_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
ackSink = events
}
override fun onCancel(arguments: Any?) {
ackSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
connectionSink = events
requestCapabilityRefresh(context.applicationContext)
}
override fun onCancel(arguments: Any?) {
connectionSink = null
}
},
)
}
fun emitProjection(payload: Map<String, Any?>): Boolean {
val sink = projectionSink ?: return false
sink.success(payload)
return true
}
fun emitAck(payload: Map<String, Any?>): Boolean {
val sink = ackSink ?: return false
sink.success(payload)
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
fun handleDataEvent(event: DataEvent) {
if (event.type != DataEvent.TYPE_CHANGED ||
event.dataItem.uri.path != STATE_PATH
) {
return
}
val projectionJson = DataMapItem.fromDataItem(event.dataItem)
.dataMap
.getString("projectionJson")
?: return
emitProjection(JSONObject(projectionJson).toMap())
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("watch_bridge_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"sendCommand" -> sendCommand(context, call.arguments, result)
"requestCapabilityRefresh" -> {
requestCapabilityRefresh(context)
result.success(null)
}
"requestResync" -> {
requestLatestProjection(context)
requestCapabilityRefresh(context)
result.success(null)
}
else -> result.notImplemented()
}
}
private fun sendCommand(
context: Context,
arguments: Any?,
result: MethodChannel.Result,
) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_command", "Command payload must be a map.", null)
return
}
val payload = JSONObject(map).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
val nodes = capability.nodes.toList()
if (nodes.isEmpty()) {
emitConnection(isReachable = false, requestsResync = false)
result.error("phone_unreachable", "No reachable phone companion.", null)
return@addOnSuccessListener
}
var remaining = nodes.size
var failed = false
for (node in nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, COMMAND_PATH, payload)
.addOnSuccessListener {
remaining -= 1
if (remaining == 0 && !failed) {
emitConnection(isReachable = true, requestsResync = false)
result.success(null)
}
}
.addOnFailureListener { error ->
if (failed) {
return@addOnFailureListener
}
failed = true
emitConnection(isReachable = false, requestsResync = false)
result.error("send_command_failed", error.message, null)
}
}
}
.addOnFailureListener { error ->
emitConnection(isReachable = false, requestsResync = false)
result.error("capability_lookup_failed", error.message, null)
}
}
fun requestCapabilityRefresh(context: Context) {
Wearable.getCapabilityClient(context)
.getCapability(PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
emitConnection(
isReachable = capability.nodes.isNotEmpty(),
requestsResync = capability.nodes.isNotEmpty(),
)
}
.addOnFailureListener {
emitConnection(isReachable = false, requestsResync = false)
}
}
fun requestLatestProjection(context: Context) {
val uri = Uri.Builder()
.scheme("wear")
.path(STATE_PATH)
.build()
Wearable.getDataClient(context)
.getDataItems(uri)
.addOnSuccessListener { buffer ->
try {
for (item in buffer) {
val projectionJson = DataMapItem.fromDataItem(item)
.dataMap
.getString("projectionJson")
?: continue
emitProjection(JSONObject(projectionJson).toMap())
}
} finally {
buffer.release()
}
}
}
}
private fun JSONObject.toMap(): Map<String, Any?> {
val output = linkedMapOf<String, Any?>()
val keys = keys()
while (keys.hasNext()) {
val key = keys.next()
output[key] = unwrapJsonValue(get(key))
}
return output
}
private fun JSONArray.toList(): List<Any?> {
val output = mutableListOf<Any?>()
for (index in 0 until length()) {
output.add(unwrapJsonValue(get(index)))
}
return output
}
private fun unwrapJsonValue(value: Any?): Any? {
return when (value) {
JSONObject.NULL -> null
is JSONObject -> value.toMap()
is JSONArray -> value.toList()
else -> value
}
}

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#080A12"
android:pathData="M24,2a22,22 0,1 0,0.1 0z" />
<path
android:fillColor="#D72638"
android:pathData="M13,12h22v6H22v5h11v6H22v7h-9z" />
</vector>

View File

@ -0,0 +1,3 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black" />
</layer-list>

View File

@ -0,0 +1,11 @@
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowIsTranslucent">false</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">#080A12</item>
<item name="android:windowIsTranslucent">false</item>
</style>
</resources>

View File

@ -0,0 +1,9 @@
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowBackground">#080A12</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<resources>
<string-array name="android_wear_capabilities">
<item>gametime_watch_companion</item>
</string-array>
</resources>

View File

@ -0,0 +1,25 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build/watch_app")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

160
watch_app/android/gradlew vendored Executable file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
watch_app/android/gradlew.bat vendored Executable file
View File

@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

View File

@ -0,0 +1,285 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../infrastructure/watch_bridge/native_watch_bridge_client.dart';
final class WatchSessionUiState {
const WatchSessionUiState({
required this.projection,
this.commandPending = false,
this.waitingForPhone = false,
this.connectionLost = false,
this.staleProjection = false,
this.lastAck,
});
final WatchSessionProjection projection;
final bool commandPending;
final bool waitingForPhone;
final bool connectionLost;
final bool staleProjection;
final WatchCommandAckEvent? lastAck;
bool get actionsEnabled => !commandPending && !connectionLost;
WatchSessionUiState copyWith({
WatchSessionProjection? projection,
bool? commandPending,
bool? waitingForPhone,
bool? connectionLost,
bool? staleProjection,
WatchCommandAckEvent? lastAck,
}) {
return WatchSessionUiState(
projection: projection ?? this.projection,
commandPending: commandPending ?? this.commandPending,
waitingForPhone: waitingForPhone ?? this.waitingForPhone,
connectionLost: connectionLost ?? this.connectionLost,
staleProjection: staleProjection ?? this.staleProjection,
lastAck: lastAck ?? this.lastAck,
);
}
}
final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
WatchSessionViewModel({
NativeWatchBridgeClient nativeClient =
const MethodChannelNativeWatchBridgeClient(),
Duration waitingThreshold = const Duration(milliseconds: 500),
Duration commandTimeout = const Duration(seconds: 2),
Duration staleProjectionThreshold = const Duration(seconds: 6),
Duration connectionLostThreshold = const Duration(seconds: 10),
}) : _nativeClient = nativeClient,
_waitingThreshold = waitingThreshold,
_commandTimeout = commandTimeout,
_staleProjectionThreshold = staleProjectionThreshold,
_connectionLostThreshold = connectionLostThreshold,
super(WatchSessionUiState(projection: _initialProjection())) {
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
_subscriptions.add(
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
);
unawaited(_nativeClient.requestCapabilityRefresh());
unawaited(_nativeClient.requestResync());
_freshnessTimer = Timer.periodic(const Duration(seconds: 1), (_) {
_syncFreshnessState();
});
}
final NativeWatchBridgeClient _nativeClient;
final Duration _waitingThreshold;
final Duration _commandTimeout;
final Duration _staleProjectionThreshold;
final Duration _connectionLostThreshold;
final _subscriptions = <StreamSubscription<dynamic>>[];
Timer? _waitingTimer;
Timer? _commandTimeoutTimer;
Timer? _freshnessTimer;
WatchCommandEnvelope? _pendingCommand;
DateTime? _lastProjectionReceivedAt;
var _commandCounter = 0;
Future<void> refresh() async {
value = value.copyWith(connectionLost: false);
try {
await _nativeClient.requestCapabilityRefresh();
await _nativeClient.requestResync();
} on PlatformException {
value = value.copyWith(connectionLost: true);
unawaited(HapticFeedback.heavyImpact());
}
}
Future<void> sendPrimaryAction() async {
final action = value.projection.primaryAction;
final command = switch (action) {
WatchPrimaryAction.none => null,
WatchPrimaryAction.startCurrentExercise =>
WatchCommandType.startCurrentExercise,
WatchPrimaryAction.pauseSession => WatchCommandType.pauseSession,
WatchPrimaryAction.resumeSession => WatchCommandType.resumeSession,
WatchPrimaryAction.startPreparedTimedStep =>
WatchCommandType.startPreparedTimedStep,
WatchPrimaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest,
};
if (command == null) {
await refresh();
return;
}
await _sendCommand(command);
}
Future<void> sendSecondaryAction(WatchSecondaryAction action) {
final command = switch (action) {
WatchSecondaryAction.skipCurrentStep => WatchCommandType.skipCurrentStep,
WatchSecondaryAction.skipCurrentPassage =>
WatchCommandType.skipCurrentPassage,
WatchSecondaryAction.finishCurrentSet => WatchCommandType.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet => WatchCommandType.skipCurrentSet,
WatchSecondaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest,
};
return _sendCommand(command);
}
@override
void dispose() {
_waitingTimer?.cancel();
_commandTimeoutTimer?.cancel();
_freshnessTimer?.cancel();
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
super.dispose();
}
Future<void> _sendCommand(WatchCommandType type) async {
if (!value.actionsEnabled || value.projection.deviceSessionId.isEmpty) {
return;
}
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
final command = WatchCommandEnvelope(
commandId: 'watch-$nowMs-${_commandCounter++}',
type: type,
sessionId: value.projection.deviceSessionId,
expectedRevision: value.projection.revision,
sentAtEpochMs: nowMs,
);
_pendingCommand = command;
value = value.copyWith(
commandPending: true,
waitingForPhone: false,
connectionLost: false,
);
_waitingTimer?.cancel();
_commandTimeoutTimer?.cancel();
_waitingTimer = Timer(_waitingThreshold, () {
value = value.copyWith(waitingForPhone: true);
});
_commandTimeoutTimer = Timer(_commandTimeout, () {
_pendingCommand = null;
value = value.copyWith(
commandPending: false,
waitingForPhone: false,
connectionLost: true,
);
unawaited(HapticFeedback.heavyImpact());
});
try {
await _nativeClient.sendCommand(command);
} on PlatformException {
_pendingCommand = null;
_clearCommandTimers();
value = value.copyWith(
commandPending: false,
waitingForPhone: false,
connectionLost: true,
);
unawaited(HapticFeedback.heavyImpact());
}
}
void _handleProjection(WatchSessionProjection projection) {
final previousProjection = value.projection;
_lastProjectionReceivedAt = DateTime.now();
_pendingCommand = null;
_clearCommandTimers();
value = WatchSessionUiState(
projection: projection,
lastAck: value.lastAck,
);
_triggerProjectionHaptic(previousProjection, projection);
}
void _handleAck(WatchCommandAckEvent ack) {
if (_pendingCommand?.commandId != ack.commandId) {
value = value.copyWith(lastAck: ack);
return;
}
_waitingTimer?.cancel();
value = value.copyWith(
waitingForPhone: false,
connectionLost: false,
lastAck: ack,
);
unawaited(HapticFeedback.lightImpact());
if (_isRejected(ack.status)) {
_pendingCommand = null;
_clearCommandTimers();
value = value.copyWith(commandPending: false);
unawaited(_nativeClient.requestResync());
}
}
void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
value = value.copyWith(connectionLost: !event.isReachable);
if (event.isReachable || event.requestsResync) {
unawaited(_nativeClient.requestResync());
}
}
void _syncFreshnessState() {
final receivedAt = _lastProjectionReceivedAt;
if (receivedAt == null) {
return;
}
final age = DateTime.now().difference(receivedAt);
final stale = age >= _staleProjectionThreshold;
final lost = age >= _connectionLostThreshold;
if (stale != value.staleProjection || lost != value.connectionLost) {
value = value.copyWith(staleProjection: stale, connectionLost: lost);
}
}
void _clearCommandTimers() {
_waitingTimer?.cancel();
_waitingTimer = null;
_commandTimeoutTimer?.cancel();
_commandTimeoutTimer = null;
}
void _triggerProjectionHaptic(
WatchSessionProjection previous,
WatchSessionProjection current,
) {
final phaseChanged = previous.phase != current.phase;
final enteredReadyTimer = current.phase == WatchSessionPhase.nextTimerReady &&
previous.phase != WatchSessionPhase.nextTimerReady;
final enteredRestEnd =
previous.phase == WatchSessionPhase.restRunning &&
current.phase != WatchSessionPhase.restRunning &&
current.phase != WatchSessionPhase.restPaused;
if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) {
unawaited(HapticFeedback.mediumImpact());
unawaited(Future<void>.delayed(const Duration(milliseconds: 120), () {
return HapticFeedback.mediumImpact();
}));
}
}
}
bool _isRejected(WatchCommandAck ack) {
return switch (ack) {
WatchCommandAck.accepted || WatchCommandAck.acceptedNoOp => false,
_ => true,
};
}
WatchSessionProjection _initialProjection() {
return WatchSessionProjection(
deviceSessionId: '',
revision: 0,
projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
phase: WatchSessionPhase.noActiveSession,
phoneReachable: false,
seriesIndex: 0,
seriesTotal: 0,
exerciseName: '',
primaryAction: WatchPrimaryAction.none,
statusLabel: 'Téléphone indisponible',
);
}

View File

@ -0,0 +1,159 @@
import 'package:flutter/services.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
final class WatchBridgeConnectionEvent {
const WatchBridgeConnectionEvent({
required this.isReachable,
this.requestsResync = false,
});
final bool isReachable;
final bool requestsResync;
}
final class WatchCommandAckEvent {
const WatchCommandAckEvent({
required this.commandId,
required this.status,
required this.sessionId,
this.revisionAtAck,
this.reasonCode,
});
final String commandId;
final WatchCommandAck status;
final String sessionId;
final int? revisionAtAck;
final String? reasonCode;
}
abstract interface class NativeWatchBridgeClient {
Stream<WatchSessionProjection> get projections;
Stream<WatchCommandAckEvent> get acks;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> sendCommand(WatchCommandEnvelope command);
Future<void> requestResync();
Future<void> requestCapabilityRefresh();
}
final class MethodChannelNativeWatchBridgeClient
implements NativeWatchBridgeClient {
const MethodChannelNativeWatchBridgeClient({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel projectionChannel = const EventChannel(
_projectionChannelName,
),
EventChannel ackChannel = const EventChannel(_ackChannelName),
EventChannel connectionChannel = const EventChannel(
_connectionChannelName,
),
}) : _methodChannel = methodChannel,
_projectionChannel = projectionChannel,
_ackChannel = ackChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _projectionChannelName = 'gametime.watch_bridge/projections';
static const _ackChannelName = 'gametime.watch_bridge/acks';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _projectionChannel;
final EventChannel _ackChannel;
final EventChannel _connectionChannel;
@override
Stream<WatchSessionProjection> get projections {
return _projectionChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
return WatchSessionProjection.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchCommandAckEvent> get acks {
return _ackChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
final json = _stringObjectMap(event);
return WatchCommandAckEvent(
commandId: _stringFromJson(json['commandId']),
status: _enumFromJson(
json['status'],
WatchCommandAck.values,
WatchCommandAck.rejectedPhoneBusy,
),
sessionId: _stringFromJson(json['sessionId']),
revisionAtAck: _nullableIntFromJson(json['revisionAtAck']),
reasonCode: _nullableStringFromJson(json['reasonCode']),
);
});
}
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
final json = _stringObjectMap(event);
return WatchBridgeConnectionEvent(
isReachable: json['isReachable'] == true,
requestsResync: json['requestsResync'] == true,
);
});
}
@override
Future<void> sendCommand(WatchCommandEnvelope command) {
return _methodChannel.invokeMethod<void>('sendCommand', command.toJson());
}
@override
Future<void> requestCapabilityRefresh() {
return _methodChannel.invokeMethod<void>('requestCapabilityRefresh');
}
@override
Future<void> requestResync() {
return _methodChannel.invokeMethod<void>('requestResync');
}
}
Map<String, Object?> _stringObjectMap(Object? value) {
if (value is Map) {
return value.map((key, value) => MapEntry(key.toString(), value));
}
return const {};
}
String _stringFromJson(Object? value) {
return value is String ? value : '';
}
String? _nullableStringFromJson(Object? value) {
return value is String ? value : null;
}
int? _nullableIntFromJson(Object? value) {
return value is int ? value : value is num ? value.toInt() : null;
}
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;
}

7
watch_app/lib/main.dart Normal file
View File

@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
import 'presentation/watch_session_app.dart';
void main() {
runApp(const WatchSessionApp());
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../application/watch_session_view_model.dart';
import 'watch_session_screen.dart';
import 'watch_theme.dart';
final class WatchSessionApp extends StatefulWidget {
const WatchSessionApp({super.key});
@override
State<WatchSessionApp> createState() => _WatchSessionAppState();
}
final class _WatchSessionAppState extends State<WatchSessionApp> {
late final WatchSessionViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = WatchSessionViewModel();
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'GameTime',
theme: watchTheme(),
home: WatchSessionScreen(viewModel: _viewModel),
);
}
}

View File

@ -0,0 +1,549 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../application/watch_session_view_model.dart';
final class WatchSessionScreen extends StatefulWidget {
const WatchSessionScreen({required this.viewModel, super.key});
final WatchSessionViewModel viewModel;
@override
State<WatchSessionScreen> createState() => _WatchSessionScreenState();
}
final class _WatchSessionScreenState extends State<WatchSessionScreen> {
late final PageController _pageController;
Timer? _ticker;
@override
void initState() {
super.initState();
_pageController = PageController();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
_ticker?.cancel();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<WatchSessionUiState>(
valueListenable: widget.viewModel,
builder: (context, state, _) {
final projection = state.projection;
if (projection.phase == WatchSessionPhase.noActiveSession) {
return _RoundScaffold(
child: _NoSessionView(
projection: projection,
pending: state.commandPending,
onRefresh: widget.viewModel.refresh,
),
);
}
return PageView(
controller: _pageController,
children: [
_RoundScaffold(
child: _SessionMainView(
state: state,
onPrimary: widget.viewModel.sendPrimaryAction,
onRetry: widget.viewModel.refresh,
onActions: _showActions,
),
),
_RoundScaffold(
child: _ActionsView(
state: state,
onAction: _handleSecondaryAction,
onSession: _showSession,
),
),
],
);
},
);
}
void _showActions() {
_pageController.animateToPage(
1,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
void _showSession() {
_pageController.animateToPage(
0,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
Future<void> _handleSecondaryAction(WatchSecondaryAction action) async {
final confirmed = switch (action) {
WatchSecondaryAction.skipCurrentPassage => await _confirm(
title: 'Passer le passage ?',
message: "L'étape en cours sera ignorée.",
confirmLabel: 'Passer',
),
WatchSecondaryAction.skipCurrentSet => await _confirm(
title: 'Passer la série ?',
message: 'Le chrono en cours sera ignoré.',
confirmLabel: 'Passer',
),
_ => true,
};
if (confirmed && mounted) {
unawaited(widget.viewModel.sendSecondaryAction(action));
_showSession();
}
}
Future<bool> _confirm({
required String title,
required String message,
required String confirmLabel,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
},
);
return result ?? false;
}
}
final class _RoundScaffold extends StatelessWidget {
const _RoundScaffold({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
minimum: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210, maxHeight: 210),
child: child,
),
),
),
);
}
}
final class _NoSessionView extends StatelessWidget {
const _NoSessionView({
required this.projection,
required this.pending,
required this.onRefresh,
});
final WatchSessionProjection projection;
final bool pending;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final phoneReachable = projection.phoneReachable;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
phoneReachable ? 'Aucune séance en cours' : 'Téléphone indisponible',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 10),
Text(
phoneReachable
? 'Lance une séance sur le téléphone.'
: 'Rouvre GameTime sur le téléphone.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton(
onPressed: pending ? null : onRefresh,
child: Text(pending ? 'Envoi...' : 'Actualiser'),
),
],
);
}
}
final class _SessionMainView extends StatelessWidget {
const _SessionMainView({
required this.state,
required this.onPrimary,
required this.onRetry,
required this.onActions,
});
final WatchSessionUiState state;
final VoidCallback onPrimary;
final VoidCallback onRetry;
final VoidCallback onActions;
@override
Widget build(BuildContext context) {
final projection = state.projection;
final isRest = projection.phase == WatchSessionPhase.restRunning ||
projection.phase == WatchSessionPhase.restPaused;
if (state.connectionLost || !projection.phoneReachable) {
return _ConnectionLostView(onRetry: onRetry);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onActions,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
minimumSize: const Size(56, 26),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('Actions'),
),
),
Expanded(
child: isRest
? _RestContent(projection: projection)
: _ActiveContent(projection: projection),
),
if (state.staleProjection)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'Dernier état reçu',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
FilledButton(
onPressed: state.actionsEnabled ? onPrimary : null,
child: Text(_primaryLabel(state)),
),
],
);
}
}
final class _ActiveContent extends StatelessWidget {
const _ActiveContent({required this.projection});
final WatchSessionProjection projection;
@override
Widget build(BuildContext context) {
final timer = projection.dominantTimer;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'SÉRIE ${projection.seriesIndex} / ${projection.seriesTotal}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 3),
Text(
projection.exerciseName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
if (_contextLine(projection) case final contextLine?)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
contextLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
if (timer == null)
Text(
projection.statusLabel ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
)
else ...[
Text(
_timerText(timer),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
projection.statusLabel ?? timer.label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
if (projection.secondaryTimers.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 7),
child: Text(
projection.secondaryTimers.map(_compactTimerText).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
);
}
}
final class _RestContent extends StatelessWidget {
const _RestContent({required this.projection});
final WatchSessionProjection projection;
@override
Widget build(BuildContext context) {
final timer = projection.dominantTimer;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'REPOS',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 3),
Text(
'Après série ${projection.seriesIndex} / ${projection.seriesTotal}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Text(
timer == null ? '--:--' : _timerText(timer),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
projection.statusLabel ?? timer?.label ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
if (projection.nextExerciseName case final next?)
Padding(
padding: const EdgeInsets.only(top: 9),
child: Column(
children: [
Text(
'Exercice suivant',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
next,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
);
}
}
final class _ActionsView extends StatelessWidget {
const _ActionsView({
required this.state,
required this.onAction,
required this.onSession,
});
final WatchSessionUiState state;
final ValueChanged<WatchSecondaryAction> onAction;
final VoidCallback onSession;
@override
Widget build(BuildContext context) {
final actions = state.projection.secondaryActions;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'Actions',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
onPressed: onSession,
tooltip: 'Séance',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.chevron_left),
),
],
),
Expanded(
child: actions.isEmpty || !state.projection.phoneReachable ||
state.connectionLost
? Center(
child: Text(
state.projection.phoneReachable && !state.connectionLost
? 'Aucune action'
: 'Connexion perdue',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
)
: ListView.separated(
padding: const EdgeInsets.only(top: 4, bottom: 12),
itemBuilder: (context, index) {
final action = actions[index];
return OutlinedButton(
onPressed: state.actionsEnabled
? () => onAction(action)
: null,
child: Text(_secondaryLabel(action)),
);
},
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemCount: actions.length,
),
),
],
);
}
}
final class _ConnectionLostView extends StatelessWidget {
const _ConnectionLostView({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Connexion perdue',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'Dernier état reçu il y a quelques secondes',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Réessayer')),
],
);
}
}
String _primaryLabel(WatchSessionUiState state) {
if (state.commandPending) {
return state.waitingForPhone ? 'En attente du téléphone' : 'Envoi...';
}
return switch (state.projection.primaryAction) {
WatchPrimaryAction.none => 'Actualiser',
WatchPrimaryAction.startCurrentExercise => 'Démarrer lexercice',
WatchPrimaryAction.pauseSession => 'Pause',
WatchPrimaryAction.resumeSession => 'Reprendre',
WatchPrimaryAction.startPreparedTimedStep => 'Démarrer le chrono',
WatchPrimaryAction.skipCurrentRest => 'Passer le repos',
};
}
String _secondaryLabel(WatchSecondaryAction action) {
return switch (action) {
WatchSecondaryAction.skipCurrentStep => 'Passer létape',
WatchSecondaryAction.skipCurrentPassage => 'Passer le passage',
WatchSecondaryAction.finishCurrentSet => 'Terminer la série',
WatchSecondaryAction.skipCurrentSet => 'Passer la série',
WatchSecondaryAction.skipCurrentRest => 'Passer le repos',
};
}
String? _contextLine(WatchSessionProjection projection) {
final parts = [
if (projection.passageIndex != null && projection.passageTotal != null)
'Passage ${projection.passageIndex} / ${projection.passageTotal}',
if (projection.stepIndex != null && projection.stepTotal != null)
'Étape ${projection.stepIndex} / ${projection.stepTotal}',
];
if (parts.isEmpty) {
return null;
}
return parts.join(' · ');
}
String _timerText(WatchTimerProjection timer) {
final duration = _displayDuration(timer);
final totalSeconds = duration.inSeconds;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
String _compactTimerText(WatchTimerProjection timer) {
return '${timer.label} ${_timerText(timer)}';
}
Duration _displayDuration(WatchTimerProjection timer) {
final elapsedMs = _interpolatedElapsedMs(timer);
final displayMs = switch (timer.displayMode) {
WatchTimerDisplayMode.elapsed => elapsedMs,
WatchTimerDisplayMode.countdown => (timer.targetMs ?? 0) - elapsedMs,
};
return Duration(milliseconds: displayMs < 0 ? 0 : displayMs);
}
int _interpolatedElapsedMs(WatchTimerProjection timer) {
if (timer.runState != WatchTimerRunState.running ||
timer.startedAtEpochMs == null) {
return timer.accumulatedMs;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
return timer.accumulatedMs + nowMs - timer.startedAtEpochMs!;
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
ThemeData watchTheme() {
const background = Color(0xFF080A12);
const surface = Color(0xFF141824);
const text = Color(0xFFF5F1E8);
const muted = Color(0xFFA7ADBA);
const accent = Color(0xFFD72638);
final textTheme = Typography.whiteMountainView.copyWith(
labelSmall: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: muted,
),
bodySmall: const TextStyle(fontSize: 11, color: muted, height: 1.15),
bodyMedium: const TextStyle(fontSize: 13, color: text, height: 1.15),
titleSmall: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: text,
height: 1.05,
),
displayLarge: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w900,
color: text,
height: 0.95,
fontFeatures: [FontFeature.tabularFigures()],
),
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: background,
colorScheme: const ColorScheme.dark(
primary: accent,
onPrimary: Colors.white,
secondary: Color(0xFFC9A24A),
surface: surface,
onSurface: text,
onSurfaceVariant: muted,
error: Color(0xFFFF4D5E),
),
textTheme: textTheme,
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(38),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge?.copyWith(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(38),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
side: const BorderSide(color: Color(0xFF303748)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge?.copyWith(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
);
}

78
watch_app/pubspec.lock Normal file
View File

@ -0,0 +1,78 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
watch_bridge_contract:
dependency: "direct main"
description:
path: "../packages/watch_bridge_contract"
relative: true
source: path
version: "0.1.0"
sdks:
dart: ">=3.10.0 <4.0.0"

20
watch_app/pubspec.yaml Normal file
View File

@ -0,0 +1,20 @@
name: gametime_watch
description: Wear OS companion app for GameTime workout sessions.
publish_to: 'none'
version: 0.1.0+1
environment:
sdk: ^3.10.0
dependencies:
flutter:
sdk: flutter
watch_bridge_contract:
path: ../packages/watch_bridge_contract
dev_dependencies:
flutter_lints: ^6.0.0
flutter:
uses-material-design: true