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:
@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
@ -40,3 +41,7 @@ kotlin {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:label="GameTime"
|
android:label="GameTime"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -30,6 +37,22 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
package com.gametime.app
|
package com.gametime.app
|
||||||
|
|
||||||
|
import com.gametime.app.watch.WatchBridgePlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
android/app/src/main/res/values/wear.xml
Normal file
5
android/app/src/main/res/values/wear.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string-array name="android_wear_capabilities">
|
||||||
|
<item>gametime_phone_companion</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import '../infrastructure/local/local.dart';
|
import '../infrastructure/local/local.dart';
|
||||||
import '../infrastructure/remote/remote.dart';
|
import '../infrastructure/remote/remote.dart';
|
||||||
import '../infrastructure/security/security.dart';
|
import '../infrastructure/security/security.dart';
|
||||||
|
import '../infrastructure/watch_bridge/watch_bridge.dart';
|
||||||
import 'application.dart';
|
import 'application.dart';
|
||||||
|
|
||||||
abstract interface class AppDependencies {
|
abstract interface class AppDependencies {
|
||||||
@ -31,6 +32,9 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
required this.workoutTemplateUseCases,
|
required this.workoutTemplateUseCases,
|
||||||
required this.activeWorkoutSessionUseCases,
|
required this.activeWorkoutSessionUseCases,
|
||||||
required this.activeExerciseStepUseCases,
|
required this.activeExerciseStepUseCases,
|
||||||
|
required this.watchCompanionProjectionUseCases,
|
||||||
|
required this.watchCompanionCommandHandler,
|
||||||
|
required this.watchWearDataLayerAdapter,
|
||||||
required this.closeWorkoutSessionUseCase,
|
required this.closeWorkoutSessionUseCase,
|
||||||
required this.workoutHistoryUseCases,
|
required this.workoutHistoryUseCases,
|
||||||
required this.progressionStatsUseCase,
|
required this.progressionStatsUseCase,
|
||||||
@ -57,6 +61,9 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||||
@override
|
@override
|
||||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||||
|
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
|
||||||
|
final WatchCompanionCommandHandler watchCompanionCommandHandler;
|
||||||
|
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
|
||||||
@override
|
@override
|
||||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||||
@override
|
@override
|
||||||
@ -103,6 +110,37 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
final ids = LocalIdGenerator();
|
final ids = LocalIdGenerator();
|
||||||
const clock = SystemClock();
|
const clock = SystemClock();
|
||||||
const originDeviceId = 'local-device';
|
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(
|
await SeedStarterContentUseCase(
|
||||||
seedStateRepository: starterSeedRepository,
|
seedStateRepository: starterSeedRepository,
|
||||||
contentRepository: starterSeedRepository,
|
contentRepository: starterSeedRepository,
|
||||||
@ -158,19 +196,11 @@ final class AppBootstrap implements AppDependencies {
|
|||||||
ids: ids,
|
ids: ids,
|
||||||
originDeviceId: originDeviceId,
|
originDeviceId: originDeviceId,
|
||||||
),
|
),
|
||||||
activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases(
|
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
|
||||||
sessionRepository: activeSessionRepository,
|
activeExerciseStepUseCases: activeExerciseStepUseCases,
|
||||||
templateRepository: templateRepository,
|
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
|
||||||
clock: clock,
|
watchCompanionCommandHandler: watchCompanionCommandHandler,
|
||||||
ids: ids,
|
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
|
||||||
originDeviceId: originDeviceId,
|
|
||||||
),
|
|
||||||
activeExerciseStepUseCases: ActiveExerciseStepUseCases(
|
|
||||||
sessionRepository: activeSessionRepository,
|
|
||||||
clock: clock,
|
|
||||||
ids: ids,
|
|
||||||
originDeviceId: originDeviceId,
|
|
||||||
),
|
|
||||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||||
sessionRepository: activeSessionRepository,
|
sessionRepository: activeSessionRepository,
|
||||||
historyRepository: historyRepository,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export 'ports.dart';
|
|||||||
export 'starter_content/basket_starter_seed_v1.dart';
|
export 'starter_content/basket_starter_seed_v1.dart';
|
||||||
export 'starter_content/starter_content.dart';
|
export 'starter_content/starter_content.dart';
|
||||||
export 'use_cases.dart';
|
export 'use_cases.dart';
|
||||||
|
export 'watch_companion_use_cases.dart';
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||||
|
|
||||||
import '../domain/domain.dart';
|
import '../domain/domain.dart';
|
||||||
import 'ports.dart';
|
import 'ports.dart';
|
||||||
import 'starter_content/basket_starter_seed_v1.dart';
|
import 'starter_content/basket_starter_seed_v1.dart';
|
||||||
import 'starter_content/starter_content.dart';
|
import 'starter_content/starter_content.dart';
|
||||||
|
import 'watch_companion_use_cases.dart';
|
||||||
|
|
||||||
const Object _useCaseUnchanged = Object();
|
const Object _useCaseUnchanged = Object();
|
||||||
|
|
||||||
@ -3084,6 +3088,865 @@ final class ActiveExerciseStepProgressView {
|
|||||||
: steps[state.currentStepIndex];
|
: 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 {
|
final class ActiveExerciseStepUseCases {
|
||||||
const ActiveExerciseStepUseCases({
|
const ActiveExerciseStepUseCases({
|
||||||
required this.sessionRepository,
|
required this.sessionRepository,
|
||||||
@ -4466,6 +5329,37 @@ _SetPositionSnapshot? _findSetSnapshot({
|
|||||||
return null;
|
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({
|
_ResolvedExerciseSnapshot? _findExerciseSnapshot({
|
||||||
required String resolvedTemplateSnapshotJson,
|
required String resolvedTemplateSnapshotJson,
|
||||||
required int programIndex,
|
required int programIndex,
|
||||||
@ -4519,6 +5413,8 @@ _ResolvedExerciseSnapshot? _findExerciseSnapshot({
|
|||||||
),
|
),
|
||||||
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
||||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||||
|
setsCount: exercise['setsCount'] as int? ?? 0,
|
||||||
|
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||||
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
|
autoStartNextTimedStepEffective: autoStartNextTimedStepEffective,
|
||||||
);
|
);
|
||||||
@ -4586,6 +5482,7 @@ List<_SetPositionSnapshot> _listSetSnapshots(
|
|||||||
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
||||||
exercise['scoreInputModeSnapshot'],
|
exercise['scoreInputModeSnapshot'],
|
||||||
),
|
),
|
||||||
|
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -4604,6 +5501,7 @@ final class _SetPositionSnapshot {
|
|||||||
this.scoreLabelSnapshot,
|
this.scoreLabelSnapshot,
|
||||||
this.scoreUnitSnapshot,
|
this.scoreUnitSnapshot,
|
||||||
required this.scoreInputModeSnapshot,
|
required this.scoreInputModeSnapshot,
|
||||||
|
required this.restSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String programSnapshotId;
|
final String programSnapshotId;
|
||||||
@ -4614,6 +5512,7 @@ final class _SetPositionSnapshot {
|
|||||||
final String? scoreLabelSnapshot;
|
final String? scoreLabelSnapshot;
|
||||||
final String? scoreUnitSnapshot;
|
final String? scoreUnitSnapshot;
|
||||||
final ScoreInputMode scoreInputModeSnapshot;
|
final ScoreInputMode scoreInputModeSnapshot;
|
||||||
|
final int restSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
final class _StepSequenceContext {
|
final class _StepSequenceContext {
|
||||||
@ -4730,6 +5629,8 @@ Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|||||||
),
|
),
|
||||||
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
||||||
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
||||||
|
setsCount: exercise['setsCount'] as int? ?? 0,
|
||||||
|
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||||||
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
||||||
autoStartNextTimedStepEffective:
|
autoStartNextTimedStepEffective:
|
||||||
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
|
(exercise['autoStartNextTimedStepOverride'] as bool?) ??
|
||||||
@ -4758,6 +5659,8 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
required this.scoreInputModeSnapshot,
|
required this.scoreInputModeSnapshot,
|
||||||
this.scoreLabelSnapshot,
|
this.scoreLabelSnapshot,
|
||||||
this.scoreUnitSnapshot,
|
this.scoreUnitSnapshot,
|
||||||
|
required this.setsCount,
|
||||||
|
required this.restSeconds,
|
||||||
this.steps = const [],
|
this.steps = const [],
|
||||||
this.autoStartNextTimedStepEffective = true,
|
this.autoStartNextTimedStepEffective = true,
|
||||||
});
|
});
|
||||||
@ -4777,6 +5680,8 @@ final class _ResolvedExerciseSnapshot {
|
|||||||
final ScoreInputMode scoreInputModeSnapshot;
|
final ScoreInputMode scoreInputModeSnapshot;
|
||||||
final String? scoreLabelSnapshot;
|
final String? scoreLabelSnapshot;
|
||||||
final String? scoreUnitSnapshot;
|
final String? scoreUnitSnapshot;
|
||||||
|
final int setsCount;
|
||||||
|
final int restSeconds;
|
||||||
final List<ExerciseStep> steps;
|
final List<ExerciseStep> steps;
|
||||||
final bool autoStartNextTimedStepEffective;
|
final bool autoStartNextTimedStepEffective;
|
||||||
}
|
}
|
||||||
|
|||||||
20
lib/application/watch_companion_use_cases.dart
Normal file
20
lib/application/watch_companion_use_cases.dart
Normal 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 {}
|
||||||
@ -7,3 +7,4 @@ library;
|
|||||||
export 'local/local.dart';
|
export 'local/local.dart';
|
||||||
export 'remote/remote.dart';
|
export 'remote/remote.dart';
|
||||||
export 'security/security.dart';
|
export 'security/security.dart';
|
||||||
|
export 'watch_bridge/watch_bridge.dart';
|
||||||
|
|||||||
137
lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart
Normal file
137
lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart
Normal 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 {};
|
||||||
|
}
|
||||||
2
lib/infrastructure/watch_bridge/watch_bridge.dart
Normal file
2
lib/infrastructure/watch_bridge/watch_bridge.dart
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export 'native_watch_bridge_channel.dart';
|
||||||
|
export 'wear_data_layer_adapter.dart';
|
||||||
183
lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart
Normal file
183
lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart
Normal 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);
|
||||||
|
}
|
||||||
5
packages/watch_bridge_contract/analysis_options.yaml
Normal file
5
packages/watch_bridge_contract/analysis_options.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_single_quotes: true
|
||||||
@ -0,0 +1,458 @@
|
|||||||
|
const int watchBridgeSchemaVersion = 1;
|
||||||
|
|
||||||
|
enum WatchCommandType {
|
||||||
|
startCurrentExercise,
|
||||||
|
pauseSession,
|
||||||
|
resumeSession,
|
||||||
|
startPreparedTimedStep,
|
||||||
|
skipCurrentStep,
|
||||||
|
skipCurrentPassage,
|
||||||
|
finishCurrentSet,
|
||||||
|
skipCurrentSet,
|
||||||
|
skipCurrentRest,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchCommandAck {
|
||||||
|
accepted,
|
||||||
|
acceptedNoOp,
|
||||||
|
rejectedStaleRevision,
|
||||||
|
rejectedNotApplicable,
|
||||||
|
rejectedNoActiveSession,
|
||||||
|
rejectedSessionMismatch,
|
||||||
|
rejectedPhoneBusy,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchSessionPhase {
|
||||||
|
noActiveSession,
|
||||||
|
ready,
|
||||||
|
running,
|
||||||
|
paused,
|
||||||
|
nextTimerReady,
|
||||||
|
restRunning,
|
||||||
|
restPaused,
|
||||||
|
betweenSetsReady,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchPrimaryAction {
|
||||||
|
none,
|
||||||
|
startCurrentExercise,
|
||||||
|
pauseSession,
|
||||||
|
resumeSession,
|
||||||
|
startPreparedTimedStep,
|
||||||
|
skipCurrentRest,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchSecondaryAction {
|
||||||
|
skipCurrentStep,
|
||||||
|
skipCurrentPassage,
|
||||||
|
finishCurrentSet,
|
||||||
|
skipCurrentSet,
|
||||||
|
skipCurrentRest,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WatchTimerKind { rest, step, scoreStopwatch, setTimer }
|
||||||
|
|
||||||
|
enum WatchTimerDisplayMode { countdown, elapsed }
|
||||||
|
|
||||||
|
enum WatchTimerRunState { stopped, running, paused }
|
||||||
|
|
||||||
|
final class WatchCommandEnvelope {
|
||||||
|
const WatchCommandEnvelope({
|
||||||
|
this.schemaVersion = watchBridgeSchemaVersion,
|
||||||
|
required this.commandId,
|
||||||
|
required this.type,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.expectedRevision,
|
||||||
|
required this.sentAtEpochMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WatchCommandEnvelope.fromJson(Map<String, Object?> json) {
|
||||||
|
return WatchCommandEnvelope(
|
||||||
|
schemaVersion: _intFromJson(
|
||||||
|
json['schemaVersion'],
|
||||||
|
watchBridgeSchemaVersion,
|
||||||
|
),
|
||||||
|
commandId: _stringFromJson(json['commandId']),
|
||||||
|
type: _enumFromJson(
|
||||||
|
json['type'],
|
||||||
|
WatchCommandType.values,
|
||||||
|
WatchCommandType.startCurrentExercise,
|
||||||
|
),
|
||||||
|
sessionId: _stringFromJson(json['sessionId']),
|
||||||
|
expectedRevision: _intFromJson(json['expectedRevision'], 0),
|
||||||
|
sentAtEpochMs: _intFromJson(json['sentAtEpochMs'], 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int schemaVersion;
|
||||||
|
final String commandId;
|
||||||
|
final WatchCommandType type;
|
||||||
|
final String sessionId;
|
||||||
|
final int expectedRevision;
|
||||||
|
final int sentAtEpochMs;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'schemaVersion': schemaVersion,
|
||||||
|
'commandId': commandId,
|
||||||
|
'type': type.name,
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'expectedRevision': expectedRevision,
|
||||||
|
'sentAtEpochMs': sentAtEpochMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
other is WatchCommandEnvelope &&
|
||||||
|
schemaVersion == other.schemaVersion &&
|
||||||
|
commandId == other.commandId &&
|
||||||
|
type == other.type &&
|
||||||
|
sessionId == other.sessionId &&
|
||||||
|
expectedRevision == other.expectedRevision &&
|
||||||
|
sentAtEpochMs == other.sentAtEpochMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
schemaVersion,
|
||||||
|
commandId,
|
||||||
|
type,
|
||||||
|
sessionId,
|
||||||
|
expectedRevision,
|
||||||
|
sentAtEpochMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WatchSessionProjection {
|
||||||
|
const WatchSessionProjection({
|
||||||
|
this.schemaVersion = watchBridgeSchemaVersion,
|
||||||
|
required this.deviceSessionId,
|
||||||
|
required this.revision,
|
||||||
|
required this.projectedAtEpochMs,
|
||||||
|
required this.phase,
|
||||||
|
required this.phoneReachable,
|
||||||
|
required this.seriesIndex,
|
||||||
|
required this.seriesTotal,
|
||||||
|
required this.exerciseName,
|
||||||
|
this.passageIndex,
|
||||||
|
this.passageTotal,
|
||||||
|
this.stepIndex,
|
||||||
|
this.stepTotal,
|
||||||
|
this.stepName,
|
||||||
|
this.dominantTimer,
|
||||||
|
this.secondaryTimers = const [],
|
||||||
|
required this.primaryAction,
|
||||||
|
this.secondaryActions = const [],
|
||||||
|
this.nextExerciseName,
|
||||||
|
this.statusLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WatchSessionProjection.fromJson(Map<String, Object?> json) {
|
||||||
|
return WatchSessionProjection(
|
||||||
|
schemaVersion: _intFromJson(
|
||||||
|
json['schemaVersion'],
|
||||||
|
watchBridgeSchemaVersion,
|
||||||
|
),
|
||||||
|
deviceSessionId: _stringFromJson(json['deviceSessionId']),
|
||||||
|
revision: _intFromJson(json['revision'], 0),
|
||||||
|
projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0),
|
||||||
|
phase: _enumFromJson(
|
||||||
|
json['phase'],
|
||||||
|
WatchSessionPhase.values,
|
||||||
|
WatchSessionPhase.noActiveSession,
|
||||||
|
),
|
||||||
|
phoneReachable: _boolFromJson(json['phoneReachable'], false),
|
||||||
|
seriesIndex: _intFromJson(json['seriesIndex'], 0),
|
||||||
|
seriesTotal: _intFromJson(json['seriesTotal'], 0),
|
||||||
|
exerciseName: _stringFromJson(json['exerciseName']),
|
||||||
|
passageIndex: _nullableIntFromJson(json['passageIndex']),
|
||||||
|
passageTotal: _nullableIntFromJson(json['passageTotal']),
|
||||||
|
stepIndex: _nullableIntFromJson(json['stepIndex']),
|
||||||
|
stepTotal: _nullableIntFromJson(json['stepTotal']),
|
||||||
|
stepName: _nullableStringFromJson(json['stepName']),
|
||||||
|
dominantTimer: _timerFromJson(json['dominantTimer']),
|
||||||
|
secondaryTimers: _timerListFromJson(json['secondaryTimers']),
|
||||||
|
primaryAction: _enumFromJson(
|
||||||
|
json['primaryAction'],
|
||||||
|
WatchPrimaryAction.values,
|
||||||
|
WatchPrimaryAction.none,
|
||||||
|
),
|
||||||
|
secondaryActions: _enumListFromJson(
|
||||||
|
json['secondaryActions'],
|
||||||
|
WatchSecondaryAction.values,
|
||||||
|
),
|
||||||
|
nextExerciseName: _nullableStringFromJson(json['nextExerciseName']),
|
||||||
|
statusLabel: _nullableStringFromJson(json['statusLabel']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int schemaVersion;
|
||||||
|
final String deviceSessionId;
|
||||||
|
final int revision;
|
||||||
|
final int projectedAtEpochMs;
|
||||||
|
final WatchSessionPhase phase;
|
||||||
|
final bool phoneReachable;
|
||||||
|
final int seriesIndex;
|
||||||
|
final int seriesTotal;
|
||||||
|
final String exerciseName;
|
||||||
|
final int? passageIndex;
|
||||||
|
final int? passageTotal;
|
||||||
|
final int? stepIndex;
|
||||||
|
final int? stepTotal;
|
||||||
|
final String? stepName;
|
||||||
|
final WatchTimerProjection? dominantTimer;
|
||||||
|
final List<WatchTimerProjection> secondaryTimers;
|
||||||
|
final WatchPrimaryAction primaryAction;
|
||||||
|
final List<WatchSecondaryAction> secondaryActions;
|
||||||
|
final String? nextExerciseName;
|
||||||
|
final String? statusLabel;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'schemaVersion': schemaVersion,
|
||||||
|
'deviceSessionId': deviceSessionId,
|
||||||
|
'revision': revision,
|
||||||
|
'projectedAtEpochMs': projectedAtEpochMs,
|
||||||
|
'phase': phase.name,
|
||||||
|
'phoneReachable': phoneReachable,
|
||||||
|
'seriesIndex': seriesIndex,
|
||||||
|
'seriesTotal': seriesTotal,
|
||||||
|
'exerciseName': exerciseName,
|
||||||
|
'passageIndex': passageIndex,
|
||||||
|
'passageTotal': passageTotal,
|
||||||
|
'stepIndex': stepIndex,
|
||||||
|
'stepTotal': stepTotal,
|
||||||
|
'stepName': stepName,
|
||||||
|
'dominantTimer': dominantTimer?.toJson(),
|
||||||
|
'secondaryTimers': secondaryTimers
|
||||||
|
.map((timer) => timer.toJson())
|
||||||
|
.toList(),
|
||||||
|
'primaryAction': primaryAction.name,
|
||||||
|
'secondaryActions': secondaryActions
|
||||||
|
.map((action) => action.name)
|
||||||
|
.toList(),
|
||||||
|
'nextExerciseName': nextExerciseName,
|
||||||
|
'statusLabel': statusLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
other is WatchSessionProjection &&
|
||||||
|
schemaVersion == other.schemaVersion &&
|
||||||
|
deviceSessionId == other.deviceSessionId &&
|
||||||
|
revision == other.revision &&
|
||||||
|
projectedAtEpochMs == other.projectedAtEpochMs &&
|
||||||
|
phase == other.phase &&
|
||||||
|
phoneReachable == other.phoneReachable &&
|
||||||
|
seriesIndex == other.seriesIndex &&
|
||||||
|
seriesTotal == other.seriesTotal &&
|
||||||
|
exerciseName == other.exerciseName &&
|
||||||
|
passageIndex == other.passageIndex &&
|
||||||
|
passageTotal == other.passageTotal &&
|
||||||
|
stepIndex == other.stepIndex &&
|
||||||
|
stepTotal == other.stepTotal &&
|
||||||
|
stepName == other.stepName &&
|
||||||
|
dominantTimer == other.dominantTimer &&
|
||||||
|
_listEquals(secondaryTimers, other.secondaryTimers) &&
|
||||||
|
primaryAction == other.primaryAction &&
|
||||||
|
_listEquals(secondaryActions, other.secondaryActions) &&
|
||||||
|
nextExerciseName == other.nextExerciseName &&
|
||||||
|
statusLabel == other.statusLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
schemaVersion,
|
||||||
|
deviceSessionId,
|
||||||
|
revision,
|
||||||
|
projectedAtEpochMs,
|
||||||
|
phase,
|
||||||
|
phoneReachable,
|
||||||
|
seriesIndex,
|
||||||
|
seriesTotal,
|
||||||
|
exerciseName,
|
||||||
|
passageIndex,
|
||||||
|
passageTotal,
|
||||||
|
stepIndex,
|
||||||
|
stepTotal,
|
||||||
|
stepName,
|
||||||
|
dominantTimer,
|
||||||
|
Object.hashAll(secondaryTimers),
|
||||||
|
primaryAction,
|
||||||
|
Object.hashAll(secondaryActions),
|
||||||
|
nextExerciseName,
|
||||||
|
statusLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WatchTimerProjection {
|
||||||
|
const WatchTimerProjection({
|
||||||
|
required this.kind,
|
||||||
|
required this.label,
|
||||||
|
required this.displayMode,
|
||||||
|
required this.runState,
|
||||||
|
required this.referenceEpochMs,
|
||||||
|
required this.accumulatedMs,
|
||||||
|
this.startedAtEpochMs,
|
||||||
|
this.targetMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WatchTimerProjection.fromJson(Map<String, Object?> json) {
|
||||||
|
return WatchTimerProjection(
|
||||||
|
kind: _enumFromJson(
|
||||||
|
json['kind'],
|
||||||
|
WatchTimerKind.values,
|
||||||
|
WatchTimerKind.setTimer,
|
||||||
|
),
|
||||||
|
label: _stringFromJson(json['label']),
|
||||||
|
displayMode: _enumFromJson(
|
||||||
|
json['displayMode'],
|
||||||
|
WatchTimerDisplayMode.values,
|
||||||
|
WatchTimerDisplayMode.elapsed,
|
||||||
|
),
|
||||||
|
runState: _enumFromJson(
|
||||||
|
json['runState'],
|
||||||
|
WatchTimerRunState.values,
|
||||||
|
WatchTimerRunState.stopped,
|
||||||
|
),
|
||||||
|
referenceEpochMs: _intFromJson(json['referenceEpochMs'], 0),
|
||||||
|
accumulatedMs: _intFromJson(json['accumulatedMs'], 0),
|
||||||
|
startedAtEpochMs: _nullableIntFromJson(json['startedAtEpochMs']),
|
||||||
|
targetMs: _nullableIntFromJson(json['targetMs']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final WatchTimerKind kind;
|
||||||
|
final String label;
|
||||||
|
final WatchTimerDisplayMode displayMode;
|
||||||
|
final WatchTimerRunState runState;
|
||||||
|
final int referenceEpochMs;
|
||||||
|
final int accumulatedMs;
|
||||||
|
final int? startedAtEpochMs;
|
||||||
|
final int? targetMs;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'kind': kind.name,
|
||||||
|
'label': label,
|
||||||
|
'displayMode': displayMode.name,
|
||||||
|
'runState': runState.name,
|
||||||
|
'referenceEpochMs': referenceEpochMs,
|
||||||
|
'accumulatedMs': accumulatedMs,
|
||||||
|
'startedAtEpochMs': startedAtEpochMs,
|
||||||
|
'targetMs': targetMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
other is WatchTimerProjection &&
|
||||||
|
kind == other.kind &&
|
||||||
|
label == other.label &&
|
||||||
|
displayMode == other.displayMode &&
|
||||||
|
runState == other.runState &&
|
||||||
|
referenceEpochMs == other.referenceEpochMs &&
|
||||||
|
accumulatedMs == other.accumulatedMs &&
|
||||||
|
startedAtEpochMs == other.startedAtEpochMs &&
|
||||||
|
targetMs == other.targetMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
displayMode,
|
||||||
|
runState,
|
||||||
|
referenceEpochMs,
|
||||||
|
accumulatedMs,
|
||||||
|
startedAtEpochMs,
|
||||||
|
targetMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
|
||||||
|
if (value is String) {
|
||||||
|
for (final enumValue in values) {
|
||||||
|
if (enumValue.name == value) {
|
||||||
|
return enumValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> _enumListFromJson<T extends Enum>(Object? value, List<T> values) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final item in value)
|
||||||
|
if (item is String)
|
||||||
|
for (final enumValue in values)
|
||||||
|
if (enumValue.name == item) enumValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
WatchTimerProjection? _timerFromJson(Object? value) {
|
||||||
|
if (value is! Map) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return WatchTimerProjection.fromJson(Map<String, Object?>.from(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WatchTimerProjection> _timerListFromJson(Object? value) {
|
||||||
|
if (value is! List) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final item in value)
|
||||||
|
if (item is Map)
|
||||||
|
WatchTimerProjection.fromJson(Map<String, Object?>.from(item)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringFromJson(Object? value) {
|
||||||
|
return value is String ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _nullableStringFromJson(Object? value) {
|
||||||
|
return value is String ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _intFromJson(Object? value, int fallback) {
|
||||||
|
return value is int ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _nullableIntFromJson(Object? value) {
|
||||||
|
return value is int ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _boolFromJson(Object? value, bool fallback) {
|
||||||
|
return value is bool ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEquals<T>(List<T> left, List<T> right) {
|
||||||
|
if (identical(left, right)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (left.length != right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var index = 0; index < left.length; index += 1) {
|
||||||
|
if (left[index] != right[index]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/// Shared transport contracts between the GameTime phone app and Wear OS app.
|
||||||
|
///
|
||||||
|
/// This package is pure Dart by design: no Flutter, Android, persistence, or
|
||||||
|
/// business logic belongs here.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/watch_bridge_contract.dart';
|
||||||
389
packages/watch_bridge_contract/pubspec.lock
Normal file
389
packages/watch_bridge_contract/pubspec.lock
Normal 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"
|
||||||
11
packages/watch_bridge_contract/pubspec.yaml
Normal file
11
packages/watch_bridge_contract/pubspec.yaml
Normal 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
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1085,6 +1085,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
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:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -24,6 +24,8 @@ dependencies:
|
|||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
file_picker: ^11.0.2
|
file_picker: ^11.0.2
|
||||||
share_plus: ^12.0.2
|
share_plus: ^12.0.2
|
||||||
|
watch_bridge_contract:
|
||||||
|
path: packages/watch_bridge_contract
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
686
test/application/watch_companion_command_handler_test.dart
Normal file
686
test/application/watch_companion_command_handler_test.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
673
test/application/watch_companion_projection_test.dart
Normal file
673
test/application/watch_companion_projection_test.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
15
watch_app/.gitignore
vendored
Normal 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
|
||||||
1
watch_app/analysis_options.yaml
Normal file
1
watch_app/analysis_options.yaml
Normal file
@ -0,0 +1 @@
|
|||||||
|
include: ../analysis_options.yaml
|
||||||
38
watch_app/android/app/build.gradle.kts
Normal file
38
watch_app/android/app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
54
watch_app/android/app/src/main/AndroidManifest.xml
Normal file
54
watch_app/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
watch_app/android/app/src/main/res/drawable/ic_launcher.xml
Normal file
12
watch_app/android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/black" />
|
||||||
|
</layer-list>
|
||||||
11
watch_app/android/app/src/main/res/values-round/styles.xml
Normal file
11
watch_app/android/app/src/main/res/values-round/styles.xml
Normal 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>
|
||||||
9
watch_app/android/app/src/main/res/values/styles.xml
Normal file
9
watch_app/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
5
watch_app/android/app/src/main/res/values/wear.xml
Normal file
5
watch_app/android/app/src/main/res/values/wear.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string-array name="android_wear_capabilities">
|
||||||
|
<item>gametime_watch_companion</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
25
watch_app/android/build.gradle.kts
Normal file
25
watch_app/android/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
BIN
watch_app/android/gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
BIN
watch_app/android/gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
Binary file not shown.
5
watch_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
watch_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
160
watch_app/android/gradlew
vendored
Executable 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
90
watch_app/android/gradlew.bat
vendored
Executable 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
|
||||||
26
watch_app/android/settings.gradle.kts
Normal file
26
watch_app/android/settings.gradle.kts
Normal 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")
|
||||||
285
watch_app/lib/application/watch_session_view_model.dart
Normal file
285
watch_app/lib/application/watch_session_view_model.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
7
watch_app/lib/main.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'presentation/watch_session_app.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const WatchSessionApp());
|
||||||
|
}
|
||||||
38
watch_app/lib/presentation/watch_session_app.dart
Normal file
38
watch_app/lib/presentation/watch_session_app.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
549
watch_app/lib/presentation/watch_session_screen.dart
Normal file
549
watch_app/lib/presentation/watch_session_screen.dart
Normal 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 l’exercice',
|
||||||
|
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!;
|
||||||
|
}
|
||||||
71
watch_app/lib/presentation/watch_theme.dart
Normal file
71
watch_app/lib/presentation/watch_theme.dart
Normal 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
78
watch_app/pubspec.lock
Normal 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
20
watch_app/pubspec.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user