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