diff --git a/watch_app/.gitignore b/watch_app/.gitignore
new file mode 100644
index 0000000..ff464d3
--- /dev/null
+++ b/watch_app/.gitignore
@@ -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
diff --git a/watch_app/analysis_options.yaml b/watch_app/analysis_options.yaml
new file mode 100644
index 0000000..5e2133e
--- /dev/null
+++ b/watch_app/analysis_options.yaml
@@ -0,0 +1 @@
+include: ../analysis_options.yaml
diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts
new file mode 100644
index 0000000..4fcfa4f
--- /dev/null
+++ b/watch_app/android/app/build.gradle.kts
@@ -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")
+}
diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4cc8229
--- /dev/null
+++ b/watch_app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt
new file mode 100644
index 0000000..4ad6f1c
--- /dev/null
+++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt
@@ -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)
+ }
+}
diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt
new file mode 100644
index 0000000..3577e63
--- /dev/null
+++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgeListenerService.kt
@@ -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 {
+ val output = linkedMapOf()
+ 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
+}
diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt
new file mode 100644
index 0000000..9529700
--- /dev/null
+++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt
@@ -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): Boolean {
+ val sink = projectionSink ?: return false
+ sink.success(payload)
+ return true
+ }
+
+ fun emitAck(payload: Map): 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 {
+ val output = linkedMapOf()
+ val keys = keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ output[key] = unwrapJsonValue(get(key))
+ }
+ return output
+}
+
+private fun JSONArray.toList(): List {
+ val output = mutableListOf()
+ 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
+ }
+}
diff --git a/watch_app/android/app/src/main/res/drawable/ic_launcher.xml b/watch_app/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..c4043cc
--- /dev/null
+++ b/watch_app/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/watch_app/android/app/src/main/res/drawable/launch_background.xml b/watch_app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..95584a9
--- /dev/null
+++ b/watch_app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/watch_app/android/app/src/main/res/values-round/styles.xml b/watch_app/android/app/src/main/res/values-round/styles.xml
new file mode 100644
index 0000000..2ed69d5
--- /dev/null
+++ b/watch_app/android/app/src/main/res/values-round/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/watch_app/android/app/src/main/res/values/styles.xml b/watch_app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..15c53e4
--- /dev/null
+++ b/watch_app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/watch_app/android/app/src/main/res/values/wear.xml b/watch_app/android/app/src/main/res/values/wear.xml
new file mode 100644
index 0000000..d4cf88d
--- /dev/null
+++ b/watch_app/android/app/src/main/res/values/wear.xml
@@ -0,0 +1,5 @@
+
+
+ - gametime_watch_companion
+
+
diff --git a/watch_app/android/build.gradle.kts b/watch_app/android/build.gradle.kts
new file mode 100644
index 0000000..c410017
--- /dev/null
+++ b/watch_app/android/build.gradle.kts
@@ -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("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/watch_app/android/gradle/wrapper/gradle-wrapper.jar b/watch_app/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000..13372ae
Binary files /dev/null and b/watch_app/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/watch_app/android/gradle/wrapper/gradle-wrapper.properties b/watch_app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2d428bf
--- /dev/null
+++ b/watch_app/android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/watch_app/android/gradlew b/watch_app/android/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/watch_app/android/gradlew
@@ -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 "$@"
diff --git a/watch_app/android/gradlew.bat b/watch_app/android/gradlew.bat
new file mode 100755
index 0000000..aec9973
--- /dev/null
+++ b/watch_app/android/gradlew.bat
@@ -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
diff --git a/watch_app/android/settings.gradle.kts b/watch_app/android/settings.gradle.kts
new file mode 100644
index 0000000..c21f0c5
--- /dev/null
+++ b/watch_app/android/settings.gradle.kts
@@ -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")
diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart
new file mode 100644
index 0000000..011b4ea
--- /dev/null
+++ b/watch_app/lib/application/watch_session_view_model.dart
@@ -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 {
+ 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 = >[];
+
+ Timer? _waitingTimer;
+ Timer? _commandTimeoutTimer;
+ Timer? _freshnessTimer;
+ WatchCommandEnvelope? _pendingCommand;
+ DateTime? _lastProjectionReceivedAt;
+ var _commandCounter = 0;
+
+ Future 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 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 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 _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.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',
+ );
+}
diff --git a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart
new file mode 100644
index 0000000..a2b924b
--- /dev/null
+++ b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart
@@ -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 get projections;
+
+ Stream get acks;
+
+ Stream get connectionEvents;
+
+ Future sendCommand(WatchCommandEnvelope command);
+
+ Future requestResync();
+
+ Future 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 get projections {
+ return _projectionChannel
+ .receiveBroadcastStream()
+ .where((event) => event is Map)
+ .map((event) {
+ return WatchSessionProjection.fromJson(_stringObjectMap(event));
+ });
+ }
+
+ @override
+ Stream 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 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 sendCommand(WatchCommandEnvelope command) {
+ return _methodChannel.invokeMethod('sendCommand', command.toJson());
+ }
+
+ @override
+ Future requestCapabilityRefresh() {
+ return _methodChannel.invokeMethod('requestCapabilityRefresh');
+ }
+
+ @override
+ Future requestResync() {
+ return _methodChannel.invokeMethod('requestResync');
+ }
+}
+
+Map _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(Object? value, List values, T fallback) {
+ if (value is String) {
+ for (final enumValue in values) {
+ if (enumValue.name == value) {
+ return enumValue;
+ }
+ }
+ }
+ return fallback;
+}
diff --git a/watch_app/lib/main.dart b/watch_app/lib/main.dart
new file mode 100644
index 0000000..5e8d23b
--- /dev/null
+++ b/watch_app/lib/main.dart
@@ -0,0 +1,7 @@
+import 'package:flutter/material.dart';
+
+import 'presentation/watch_session_app.dart';
+
+void main() {
+ runApp(const WatchSessionApp());
+}
diff --git a/watch_app/lib/presentation/watch_session_app.dart b/watch_app/lib/presentation/watch_session_app.dart
new file mode 100644
index 0000000..6f1da18
--- /dev/null
+++ b/watch_app/lib/presentation/watch_session_app.dart
@@ -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 createState() => _WatchSessionAppState();
+}
+
+final class _WatchSessionAppState extends State {
+ 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),
+ );
+ }
+}
diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart
new file mode 100644
index 0000000..8f865f2
--- /dev/null
+++ b/watch_app/lib/presentation/watch_session_screen.dart
@@ -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 createState() => _WatchSessionScreenState();
+}
+
+final class _WatchSessionScreenState extends State {
+ 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(
+ 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 _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 _confirm({
+ required String title,
+ required String message,
+ required String confirmLabel,
+ }) async {
+ final result = await showDialog(
+ 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 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!;
+}
diff --git a/watch_app/lib/presentation/watch_theme.dart b/watch_app/lib/presentation/watch_theme.dart
new file mode 100644
index 0000000..e1154d6
--- /dev/null
+++ b/watch_app/lib/presentation/watch_theme.dart
@@ -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,
+ ),
+ ),
+ ),
+ );
+}
diff --git a/watch_app/pubspec.lock b/watch_app/pubspec.lock
new file mode 100644
index 0000000..33d9400
--- /dev/null
+++ b/watch_app/pubspec.lock
@@ -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"
diff --git a/watch_app/pubspec.yaml b/watch_app/pubspec.yaml
new file mode 100644
index 0000000..38b435c
--- /dev/null
+++ b/watch_app/pubspec.yaml
@@ -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