Compare commits

...

12 Commits

Author SHA1 Message Date
58272e354a merge(develop): déverrouille les demandes de permissions capteurs montre
Intègre le correctif de throttling des demandes de permissions
READ_HEART_RATE / ACTIVITY_RECOGNITION côté montre, qui restaient
bloquées à granted=false après une première tentative. QA verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:17:16 +02:00
f71a155156 fix(watch): déverrouille les demandes de permissions capteurs montre
READ_HEART_RATE et ACTIVITY_RECOGNITION restaient bloquées à granted=false
à cause d'un verrou permanent après une première tentative de demande
("sensor permission request already attempted" en boucle dans les logs).
Remplace le verrou par sensorPermissionRequestInFlight avec throttling et
reprise au callback de permission, pour permettre de redemander sans
relancer l'app.

QA verte sur ce périmètre ciblé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:17:10 +02:00
ab6fc81a37 merge(develop): fréquence cardiaque live montre/téléphone (ticket #155)
Intègre la mise en conformité avec le cadrage #155/#144/#142 (FC live
bridge montre -> téléphone, affichage écran de séance montre) ainsi que
la correction de la procédure de build/install montre. QA verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:03:50 +02:00
5ac3f6c695 docs(ideai): fige la procédure correcte de build/install APK montre
Corrige le script de build montre pour prioriser l'artefact canonique
watch_app/build/app/outputs/flutter-apk/app-release.apk et ne plus
recopier un fichier sur lui-même ; évite de réinstaller par erreur
l'artefact résiduel watch_app/build/watch_app/app/outputs/....

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:03:46 +02:00
592796915f feat(watch): fréquence cardiaque live montre/téléphone (ticket #155)
Met en conformité l'implémentation avec le cadrage produit acté
(#155, #144, #142) : diffusion de la FC live depuis la montre vers le
téléphone via le bridge, affichage sur l'écran de séance montre
(watch_session_screen) en plus de l'écran Stats existant.

Validé : bridge fonctionnel bout-en-bout, tests watch_session_screen OK.
Artefact canonique d'installation montre confirmé :
watch_app/build/app/outputs/flutter-apk/app-release.apk
(package com.gametime.app) ; ne pas utiliser l'artefact résiduel
watch_app/build/watch_app/app/outputs/flutter-apk/app-release.apk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:03:40 +02:00
8bb74df763 merge(develop): corrige applicationId et signature partagée de l'APK montre
Intègre le correctif de packaging/signature de watch_app (applicationId
com.gametime.watch, signing partagé nettoyé) qui répare la détection du
bridge côté montre après le reset/remerge de develop. QA verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:55 +02:00
2e40694de4 fix(watch): corrige applicationId et nettoie la config de build montre
Répare la régression de packaging introduite par le reset/remerge de
develop : l'APK montre était packagée avec com.gametime.app au lieu de
com.gametime.watch (namespace correct mais applicationId hérité du
téléphone), ce qui cassait la détection du bridge. Aligne applicationId
sur com.gametime.watch, nettoie la config de signing partagée sur les
deux modules android/app et watch_app/android/app, et ajuste
use_cases + watch_session_screen en conséquence.

Validé : APK montre release package = com.gametime.watch, même SHA-256
de certificat sur les deux APKs (70cc69e8...45c7815), tests use_cases et
watch_session_screen OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:48 +02:00
6e110a9fb9 merge(develop): intègre la résolution du lot #91 Wear OS après reset
Finalise l'intégration de feature/ticket91-wear-os-watch-sync (clôture
lot #91 : fréquence cardiaque live, notifications de séance, finitions
montre) sur develop@fda82d7, via la branche de résolution
integration/ticket91-into-develop. Conflits résolus sur entities.dart,
workout_execution_screen.dart et watch_companion_projection_test.dart
selon l'arbitrage Architect (base ticket91, invariants chronos réinjectés).
QA verte sur comportement et builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:35:31 +02:00
83f57b5640 merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre
Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
2026-07-28 11:35:22 +02:00
fda82d77d0 merge(develop): clarification chronos - Durée vs Temps réalisé
Intègre le lot de clarification des chronos (Durée / Temps réalisé,
renommage Durée par défaut (s), chrono unique visible à l'exécution).
QA verte sur les 6 fichiers de test couvrant use_cases, watch companion
projection, drift repositories, bibliothèque d'exercices, programme et
exécution de séance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:37:54 +02:00
e3c7ca0dab feat(exécution): clarifie Durée et Temps réalisé, renomme Durée par défaut (s)
Sépare la notion de Durée (paramètre configuré) de Temps réalisé (résultat
mesuré au chronomètre), renomme le champ « Temps par défaut (s) » en
« Durée par défaut (s) » dans la bibliothèque d'exercices, et limite
l'exécution à un seul chrono visible à la fois. Le mode Score chrono est
conservé tel quel comme mode du score.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:37:44 +02:00
65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00
83 changed files with 13017 additions and 947 deletions

View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
MODE="${1:-debug}"
if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then
echo "Usage: $0 [debug|release]" >&2
exit 2
fi
PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime"
WATCH_ROOT="$PROJECT_ROOT/watch_app"
BUILD_ENV_ROOT="$PROJECT_ROOT/.ideai/build-env"
SDK_COPY="$BUILD_ENV_ROOT/flutter-sdk"
HOME_DIR="$BUILD_ENV_ROOT/home"
GRADLE_DIR="$BUILD_ENV_ROOT/gradle"
PUB_CACHE_DIR="$BUILD_ENV_ROOT/pub-cache"
TMP_DIR="$BUILD_ENV_ROOT/tmp"
ANDROID_HOME="/opt/android-sdk"
JAVA_HOME="/usr/lib/jvm/java-21-openjdk"
FLUTTER_BIN="$SDK_COPY/bin/flutter"
mkdir -p "$BUILD_ENV_ROOT" "$HOME_DIR/.config" "$HOME_DIR/.local/share" "$HOME_DIR/.cache" "$GRADLE_DIR" "$PUB_CACHE_DIR" "$TMP_DIR"
if [[ ! -x "$FLUTTER_BIN" ]]; then
cp -a /opt/flutter "$SDK_COPY"
fi
export HOME="$HOME_DIR"
export XDG_CONFIG_HOME="$HOME_DIR/.config"
export XDG_DATA_HOME="$HOME_DIR/.local/share"
export XDG_CACHE_HOME="$HOME_DIR/.cache"
export GRADLE_USER_HOME="$GRADLE_DIR"
export PUB_CACHE="$PUB_CACHE_DIR"
export TMPDIR="$TMP_DIR"
export TMP="$TMP_DIR"
export TEMP="$TMP_DIR"
export JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$TMP_DIR"
export GRADLE_OPTS="-Djava.io.tmpdir=$TMP_DIR"
export ANDROID_HOME
export JAVA_HOME
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
cd "$WATCH_ROOT"
"$FLUTTER_BIN" --disable-analytics pub get
set +e
"$FLUTTER_BIN" build apk "--$MODE"
BUILD_STATUS=$?
set -e
APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk"
ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/flutter-apk/app-$MODE.apk"
LEGACY_ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/apk/$MODE/app-$MODE.apk"
CANONICAL_APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk"
copy_to_canonical_path() {
local source_apk="$1"
mkdir -p "$(dirname "$CANONICAL_APK_PATH")"
if [[ "$source_apk" == "$CANONICAL_APK_PATH" ]]; then
echo "APK already at canonical path: $CANONICAL_APK_PATH"
return 0
fi
cp "$source_apk" "$CANONICAL_APK_PATH"
echo "APK copied to canonical path: $CANONICAL_APK_PATH"
}
if [[ -f "$APK_PATH" ]]; then
copy_to_canonical_path "$APK_PATH"
echo "APK built at: $APK_PATH"
exit 0
fi
if [[ -f "$ALT_APK_PATH" ]]; then
copy_to_canonical_path "$ALT_APK_PATH"
echo "APK built at: $ALT_APK_PATH"
exit 0
fi
if [[ -f "$LEGACY_ALT_APK_PATH" ]]; then
copy_to_canonical_path "$LEGACY_ALT_APK_PATH"
echo "APK built at: $LEGACY_ALT_APK_PATH"
exit 0
fi
if [[ $BUILD_STATUS -ne 0 ]]; then
exit "$BUILD_STATUS"
fi
echo "APK built at: $APK_PATH"

View File

@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@ -5,6 +7,26 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val sharedSigningPropertiesFile = rootProject.file("key.properties")
val sharedSigningProperties =
Properties().also { properties ->
check(sharedSigningPropertiesFile.isFile) {
"Shared signing config missing: ${sharedSigningPropertiesFile.absolutePath}"
}
sharedSigningPropertiesFile.inputStream().use(properties::load)
}
fun requiredSharedSigningProperty(name: String): String =
sharedSigningProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("Shared signing property '$name' missing in ${sharedSigningPropertiesFile.absolutePath}")
val sharedSigningStoreFile =
sharedSigningPropertiesFile.parentFile.resolve(requiredSharedSigningProperty("storeFile"))
check(sharedSigningStoreFile.isFile) {
"Shared signing keystore missing: ${sharedSigningStoreFile.absolutePath}"
}
android {
namespace = "com.gametime.app"
compileSdk = flutter.compileSdkVersion
@ -23,11 +45,18 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("sharedLocal") {
storeFile = sharedSigningStoreFile
storePassword = requiredSharedSigningProperty("storePassword")
keyAlias = requiredSharedSigningProperty("keyAlias")
keyPassword = requiredSharedSigningProperty("keyPassword")
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("sharedLocal")
}
}
}

View File

@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
@ -10,6 +12,9 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<meta-data
android:name="com.google.android.wearable.capabilities"
android:resource="@array/android_wear_capabilities" />
<activity
android:name=".MainActivity"
android:exported="true"
@ -41,6 +46,14 @@
android:name=".watch.WatchCompanionForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".session.SessionStatusForegroundService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="workout-session-status" />
</service>
<service
android:name=".watch.PhoneWatchBridgeListenerService"
android:exported="true">

View File

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

View File

@ -0,0 +1,90 @@
package com.gametime.app.session
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.lang.ref.WeakReference
object SessionNotificationPlugin {
private const val METHOD_CHANNEL = "gametime.session_notification/methods"
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 9210
private var appContext: Context? = null
private var activityRef: WeakReference<Activity>? = null
private var notificationPermissionRequested = false
fun register(flutterEngine: FlutterEngine, activity: Activity) {
appContext = activity.applicationContext
activityRef = WeakReference(activity)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
.setMethodCallHandler(::handleMethodCall)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
val context = appContext
if (context == null) {
result.error("session_notification_unavailable", "Application context unavailable.", null)
return
}
when (call.method) {
"show" -> show(context, call.arguments, result)
"clear" -> {
context.stopService(Intent(context, SessionStatusForegroundService::class.java))
result.success(null)
}
else -> result.notImplemented()
}
}
private fun show(context: Context, arguments: Any?, result: MethodChannel.Result) {
val map = arguments as? Map<*, *>
if (map == null) {
result.error("invalid_notification", "Notification payload must be a map.", null)
return
}
requestPostNotificationsPermissionIfNeeded()
val intent = Intent(context, SessionStatusForegroundService::class.java).apply {
action = SessionStatusForegroundService.ACTION_SHOW
putExtra(SessionStatusForegroundService.EXTRA_TITLE, map["title"] as? String ?: "GameTime")
putExtra(
SessionStatusForegroundService.EXTRA_PRIMARY_LINE,
map["primaryLine"] as? String ?: "Séance en cours",
)
putExtra(
SessionStatusForegroundService.EXTRA_SECONDARY_LINE,
map["secondaryLine"] as? String,
)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
result.success(null)
}
private fun requestPostNotificationsPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
notificationPermissionRequested
) {
return
}
val activity = activityRef?.get() ?: return
if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionRequested = true
activity.requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
}

View File

@ -0,0 +1,100 @@
package com.gametime.app.session
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import com.gametime.app.MainActivity
import com.gametime.app.R
class SessionStatusForegroundService : Service() {
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_SHOW) {
stopSelf()
return START_NOT_STICKY
}
val title = intent.getStringExtra(EXTRA_TITLE) ?: "GameTime"
val primaryLine = intent.getStringExtra(EXTRA_PRIMARY_LINE) ?: "Séance en cours"
val secondaryLine = intent.getStringExtra(EXTRA_SECONDARY_LINE)
startForeground(
NOTIFICATION_ID,
notification(
title = title,
primaryLine = primaryLine,
secondaryLine = secondaryLine,
),
)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun notification(
title: String,
primaryLine: String,
secondaryLine: String?,
): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val contentIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val style = secondaryLine?.takeIf { it.isNotBlank() }?.let {
Notification.BigTextStyle().bigText("$primaryLine\n$it")
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(primaryLine)
.setSubText(secondaryLine)
.setStyle(style)
.setContentIntent(contentIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
"Séance en cours",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannel(channel)
}
companion object {
const val ACTION_SHOW = "com.gametime.app.session.SHOW"
const val EXTRA_TITLE = "title"
const val EXTRA_PRIMARY_LINE = "primaryLine"
const val EXTRA_SECONDARY_LINE = "secondaryLine"
private const val CHANNEL_ID = "gametime_session_status"
private const val NOTIFICATION_ID = 92
}
}

View File

@ -9,14 +9,21 @@ import java.nio.charset.StandardCharsets
class PhoneWatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WatchBridgePlugin.COMMAND_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
when (messageEvent.path) {
WatchBridgePlugin.COMMAND_PATH -> {
val command = payload.toMap()
val delivered = WatchBridgePlugin.emitCommand(command, messageEvent.sourceNodeId)
if (!delivered) {
sendPhoneBusyAck(command, messageEvent.sourceNodeId)
}
}
WatchBridgePlugin.SENSOR_SUMMARY_PATH -> {
WatchBridgePlugin.emitSensorSummary(payload.toMap())
}
WatchBridgePlugin.SENSOR_SAMPLE_PATH -> {
WatchBridgePlugin.emitSensorSample(payload.toMap())
}
}
}

View File

@ -2,6 +2,8 @@ package com.gametime.app.watch
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
@ -16,9 +18,13 @@ import java.util.concurrent.ConcurrentHashMap
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val COMMAND_CHANNEL = "gametime.watch_bridge/commands"
private const val SENSOR_SUMMARY_CHANNEL = "gametime.watch_bridge/sensor_summaries"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val WATCH_CAPABILITY = "gametime_watch_companion"
@ -26,7 +32,10 @@ object WatchBridgePlugin {
private val pendingCommandNodes = ConcurrentHashMap<String, String>()
private var appContext: Context? = null
private var commandSink: EventChannel.EventSink? = null
private var sensorSummarySink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -44,6 +53,30 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SUMMARY_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSummarySink = events
}
override fun onCancel(arguments: Any?) {
sensorSummarySink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSampleSink = events
}
override fun onCancel(arguments: Any?) {
sensorSampleSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
@ -65,17 +98,38 @@ object WatchBridgePlugin {
if (commandId != null) {
pendingCommandNodes[commandId] = sourceNodeId
}
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSummary(payload: Map<String, Any?>): Boolean {
val sink = sensorSummarySink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
val sink = connectionSink ?: return
mainHandler.post {
sink.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {

View File

@ -1,6 +1,7 @@
import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart';
import '../infrastructure/session_notification/session_notification.dart';
import '../infrastructure/watch_bridge/watch_bridge.dart';
import 'application.dart';
@ -12,6 +13,7 @@ abstract interface class AppDependencies {
WorkoutTemplateUseCases get workoutTemplateUseCases;
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase;
@ -32,9 +34,11 @@ final class AppBootstrap implements AppDependencies {
required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases,
required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.watchWearDataLayerAdapter,
required this.sessionNotificationCoordinator,
required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
required this.progressionStatsUseCase,
@ -61,9 +65,12 @@ final class AppBootstrap implements AppDependencies {
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
final SessionNotificationCoordinator sessionNotificationCoordinator;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@override
@ -122,6 +129,10 @@ final class AppBootstrap implements AppDependencies {
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
activeSessionUseCases: activeWorkoutSessionUseCases,
);
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock,
);
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
@ -135,12 +146,23 @@ final class AppBootstrap implements AppDependencies {
stepUseCases: activeExerciseStepUseCases,
projectionSource: watchCompanionProjectionUseCases,
);
final workoutHistoryUseCases = WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler,
projectionSource: watchCompanionProjectionUseCases,
workoutHistoryUseCases: workoutHistoryUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
);
final sessionNotificationCoordinator = SessionNotificationCoordinator(
projections: watchCompanionProjectionUseCases.projections,
gateway: const MethodChannelSessionNotificationGateway(),
);
await watchWearDataLayerAdapter.start();
sessionNotificationCoordinator.start();
await SeedStarterContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
@ -198,9 +220,11 @@ final class AppBootstrap implements AppDependencies {
),
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler,
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
sessionNotificationCoordinator: sessionNotificationCoordinator,
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
@ -208,10 +232,7 @@ final class AppBootstrap implements AppDependencies {
ids: ids,
originDeviceId: originDeviceId,
),
workoutHistoryUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
workoutHistoryUseCases: workoutHistoryUseCases,
progressionStatsUseCase: ProgressionStatsUseCase(
repository: progressionStatsRepository,
clock: clock,
@ -255,8 +276,10 @@ final class AppBootstrap implements AppDependencies {
}
Future<void> dispose() async {
await sessionNotificationCoordinator.dispose();
await watchWearDataLayerAdapter.stop();
await watchCompanionProjectionUseCases.dispose();
await activeWorkoutSensorUseCases.dispose();
await database.close();
}
}

View File

@ -5,6 +5,7 @@
library;
export 'ports.dart';
export 'session_notification_use_cases.dart';
export 'starter_content/basket_starter_seed_v1.dart';
export 'starter_content/starter_content.dart';
export 'use_cases.dart';

View File

@ -485,6 +485,7 @@ enum RemoteAuthFailure {
invalidCredentials,
emailAlreadyUsed,
network,
server,
unknown,
}
@ -857,6 +858,7 @@ abstract interface class ActiveSessionRepository {
Future<void> saveSetTimerState(ActiveSetTimerState state);
Future<void> saveRestState(ActiveRestState restState);
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
Future<void> saveManualScoreState(ActiveManualScoreState state);
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
);
@ -868,6 +870,13 @@ abstract interface class ActiveSessionRepository {
required int setIndex,
required DateTime deletedAt,
});
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
});
Future<ActiveRestState?> findRestStateById(String id);
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -875,6 +884,12 @@ abstract interface class ActiveSessionRepository {
required int exerciseIndex,
required int setIndex,
});
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
});
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
@ -899,12 +914,19 @@ abstract interface class ActiveSessionRepository {
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
);
Future<List<ActiveManualScoreState>> listManualScoreStates(String sessionId);
}
abstract interface class WorkoutHistoryRepository {
Future<WorkoutHistory?> findById(String id);
Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history);
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
});
Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> saveStepResult(WorkoutHistoryStepResult result);
Future<void> delete(String id, DateTime deletedAt);

View File

@ -0,0 +1,198 @@
import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
abstract interface class SessionNotificationGateway {
Future<void> show(SessionNotificationContent content);
Future<void> clear();
}
final class SessionNotificationContent {
const SessionNotificationContent({
required this.title,
required this.primaryLine,
this.secondaryLine,
});
final String title;
final String primaryLine;
final String? secondaryLine;
Map<String, Object?> toJson() {
return {
'title': title,
'primaryLine': primaryLine,
'secondaryLine': secondaryLine,
};
}
}
final class SessionNotificationCoordinator {
SessionNotificationCoordinator({
required Stream<WatchSessionProjection> projections,
required SessionNotificationGateway gateway,
Duration tickInterval = const Duration(seconds: 1),
}) : _projections = projections,
_gateway = gateway,
_tickInterval = tickInterval;
final Stream<WatchSessionProjection> _projections;
final SessionNotificationGateway _gateway;
final Duration _tickInterval;
StreamSubscription<WatchSessionProjection>? _subscription;
Timer? _timer;
WatchSessionProjection? _latestProjection;
void start() {
if (_subscription != null) {
return;
}
_subscription = _projections.listen(_handleProjection);
}
Future<void> dispose() async {
_timer?.cancel();
_timer = null;
await _subscription?.cancel();
_subscription = null;
}
void _handleProjection(WatchSessionProjection projection) {
_latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession ||
projection.deviceSessionId.isEmpty) {
_timer?.cancel();
_timer = null;
unawaited(_gateway.clear().catchError((_) {}));
return;
}
_show(projection);
if (_timerShouldRun(projection)) {
_timer ??= Timer.periodic(_tickInterval, (_) {
final latest = _latestProjection;
if (latest != null) {
_show(latest);
}
});
} else {
_timer?.cancel();
_timer = null;
}
}
void _show(WatchSessionProjection projection) {
unawaited(
_gateway
.show(buildSessionNotificationContent(projection))
.catchError((_) {}),
);
}
}
SessionNotificationContent buildSessionNotificationContent(
WatchSessionProjection projection, {
DateTime? now,
}) {
final phase = projection.phase;
final paused =
phase == WatchSessionPhase.paused ||
phase == WatchSessionPhase.restPaused;
final timer = projection.dominantTimer;
final title =
phase == WatchSessionPhase.restRunning ||
phase == WatchSessionPhase.restPaused
? 'Repos'
: projection.exerciseName.isEmpty
? 'Séance en cours'
: projection.exerciseName;
final primary = switch (phase) {
WatchSessionPhase.restRunning ||
WatchSessionPhase.restPaused => _restLine(projection, now: now),
_ when timer != null => _timerText(timer, now: now),
_ => _measureLine(projection),
};
return SessionNotificationContent(
title: title,
primaryLine: paused ? 'En pause · $primary' : primary,
secondaryLine: _secondaryLine(projection),
);
}
bool _timerShouldRun(WatchSessionProjection projection) {
final timer = projection.dominantTimer;
return timer != null && timer.runState == WatchTimerRunState.running;
}
String _restLine(WatchSessionProjection projection, {DateTime? now}) {
final timer = projection.dominantTimer;
final value = timer == null ? '--:--' : _timerText(timer, now: now);
final next = projection.nextExerciseName;
if (next == null || next.isEmpty) {
return '$value restant';
}
return '$value restant · Ensuite : $next';
}
String _measureLine(WatchSessionProjection projection) {
final score = projection.currentManualScoreValue;
if (projection.hasManualScore && score != null) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal} · ${_scoreText(score)}';
}
if (projection.stepName case final stepName? when stepName.isNotEmpty) {
return stepName;
}
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
return 'Série ${projection.seriesIndex}/${projection.seriesTotal}';
}
return projection.statusLabel ?? 'Séance en cours';
}
String? _secondaryLine(WatchSessionProjection projection) {
final parts = <String>[];
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
parts.add('Série ${projection.seriesIndex}/${projection.seriesTotal}');
}
if (projection.stepIndex != null && projection.stepTotal != null) {
parts.add('Étape ${projection.stepIndex}/${projection.stepTotal}');
}
return parts.isEmpty ? null : parts.join(' · ');
}
String _timerText(WatchTimerProjection timer, {DateTime? now}) {
final duration = _displayDuration(timer, now: now);
final totalSeconds = duration.inSeconds;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
Duration _displayDuration(WatchTimerProjection timer, {DateTime? now}) {
final elapsed = _elapsedMs(timer, now: now);
if (timer.displayMode == WatchTimerDisplayMode.countdown &&
timer.targetMs != null) {
return Duration(
milliseconds: (timer.targetMs! - elapsed).clamp(0, 1 << 31).toInt(),
);
}
return Duration(milliseconds: elapsed);
}
int _elapsedMs(WatchTimerProjection timer, {DateTime? now}) {
if (timer.runState != WatchTimerRunState.running ||
timer.startedAtEpochMs == null) {
return timer.accumulatedMs;
}
final reference =
now?.toUtc().millisecondsSinceEpoch ??
DateTime.now().toUtc().millisecondsSinceEpoch;
return timer.accumulatedMs +
(reference - timer.startedAtEpochMs!).clamp(0, 1 << 31).toInt();
}
String _scoreText(double value) {
if (value == value.roundToDouble()) {
return value.toInt().toString();
}
return value.toStringAsFixed(1);
}

File diff suppressed because it is too large Load Diff

View File

@ -375,6 +375,17 @@ final class Exercise {
defaultTargetScoreTimeMs,
'Default target score time ms',
);
_requireScoreTargetShape(
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
targetScore: defaultTargetScore,
targetScoreTimeMs: defaultTargetScoreTimeMs,
);
_requireLinkedStepSeriesScoreShape(
steps: steps,
scoreEnabled: hasScoreMeasure,
scoreInputMode: scoreInputMode,
);
}
final EntityMetadata metadata;
@ -508,6 +519,7 @@ final class ExerciseStep {
this.scoreUnit,
this.defaultTargetScore,
this.defaultTargetScoreTimeMs,
this.linkedToSeriesScore = false,
}) : id = _nonBlank(id, 'Exercise step id'),
name = _nonBlank(name, 'Exercise step name') {
_requireNonNegative(position, 'Exercise step position');
@ -519,6 +531,7 @@ final class ExerciseStep {
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -533,6 +546,7 @@ final class ExerciseStep {
final String? scoreUnit;
final double? defaultTargetScore;
final int? defaultTargetScoreTimeMs;
final bool linkedToSeriesScore;
Map<String, Object?> toSnapshotJson() => {
'id': id,
@ -546,6 +560,7 @@ final class ExerciseStep {
'scoreUnit': scoreUnit,
'defaultTargetScore': defaultTargetScore,
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
'linkedToSeriesScore': linkedToSeriesScore,
};
}
@ -658,6 +673,11 @@ final class ProgramExercise {
targetScore: targetScore,
targetScoreTimeMs: targetScoreTimeMs,
);
_requireLinkedStepSeriesScoreShape(
steps: exerciseStepsSnapshot,
scoreEnabled: scoreEnabled,
scoreInputMode: scoreInputModeSnapshot,
);
}
final EntityMetadata metadata;
@ -1100,6 +1120,47 @@ final class ActiveScoreStopwatchState {
}
}
final class ActiveManualScoreState {
ActiveManualScoreState({
required this.metadata,
required this.activeWorkoutSessionId,
required this.programIndex,
required this.exerciseIndex,
required this.setIndex,
required this.value,
required this.updatedAt,
}) {
_requireNonNegative(programIndex, 'Program index');
_requireNonNegative(exerciseIndex, 'Exercise index');
_requireNonNegative(setIndex, 'Set index');
_requireNullableNonNegativeDouble(value, 'Manual score value');
}
final EntityMetadata metadata;
final String activeWorkoutSessionId;
final int programIndex;
final int exerciseIndex;
final int setIndex;
final double value;
final DateTime updatedAt;
ActiveManualScoreState copyWith({
EntityMetadata? metadata,
double? value,
DateTime? updatedAt,
}) {
return ActiveManualScoreState(
metadata: metadata ?? this.metadata,
activeWorkoutSessionId: activeWorkoutSessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
value: value ?? this.value,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
final class ActiveSetTimerState {
ActiveSetTimerState({
required this.metadata,
@ -1405,7 +1466,7 @@ final class ActiveExerciseStepResult {
}
final class WorkoutHistory {
const WorkoutHistory({
WorkoutHistory({
required this.metadata,
this.sourceWorkoutTemplateId,
this.sourceActiveWorkoutSessionId,
@ -1415,9 +1476,17 @@ final class WorkoutHistory {
required this.totalActiveMs,
required this.completed,
required this.historySnapshotJson,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.results = const [],
this.stepResults = const [],
});
}) {
_requireNullablePositiveDouble(
averageHeartRateBpm,
'Average heart rate bpm',
);
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
}
final EntityMetadata metadata;
final String? sourceWorkoutTemplateId;
@ -1428,8 +1497,36 @@ final class WorkoutHistory {
final int totalActiveMs;
final bool completed;
final String historySnapshotJson;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final List<WorkoutHistorySetResult> results;
final List<WorkoutHistoryStepResult> stepResults;
WorkoutHistory copyWith({
EntityMetadata? metadata,
Object? averageHeartRateBpm = _unchanged,
Object? maxHeartRateBpm = _unchanged,
}) {
return WorkoutHistory(
metadata: metadata ?? this.metadata,
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
sourceActiveWorkoutSessionId: sourceActiveWorkoutSessionId,
nameSnapshot: nameSnapshot,
startedAt: startedAt,
endedAt: endedAt,
totalActiveMs: totalActiveMs,
completed: completed,
historySnapshotJson: historySnapshotJson,
averageHeartRateBpm: averageHeartRateBpm == _unchanged
? this.averageHeartRateBpm
: averageHeartRateBpm as double?,
maxHeartRateBpm: maxHeartRateBpm == _unchanged
? this.maxHeartRateBpm
: maxHeartRateBpm as int?,
results: results,
stepResults: stepResults,
);
}
}
final class WorkoutHistorySetResult {
@ -1721,6 +1818,12 @@ void _requireNullableNonNegativeDouble(double? value, String label) {
}
}
void _requireNullablePositiveDouble(double? value, String label) {
if (value != null && value <= 0) {
throw DomainException('$label must be positive.');
}
}
void _requireScoreTargetShape({
required bool scoreEnabled,
required ScoreInputMode scoreInputMode,
@ -1753,12 +1856,14 @@ void _validateExerciseStepScoreShape({
required String? scoreUnit,
required double? defaultTargetScore,
required int? defaultTargetScoreTimeMs,
required bool linkedToSeriesScore,
}) {
if (!hasScore) {
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null ||
defaultTargetScoreTimeMs != null) {
defaultTargetScoreTimeMs != null ||
linkedToSeriesScore) {
throw const DomainException(
'Disabled exercise step score must not define score values.',
);
@ -1784,6 +1889,11 @@ void _validateExerciseStepScoreShape({
);
}
case ScoreInputMode.stopwatch:
if (linkedToSeriesScore) {
throw const DomainException(
'Linked series score requires manual exercise step score.',
);
}
if (scoreLabel != null ||
scoreUnit != null ||
defaultTargetScore != null) {
@ -1820,6 +1930,19 @@ void _requireScoreResultShape({
}
}
void _requireLinkedStepSeriesScoreShape({
required List<ExerciseStep> steps,
required bool scoreEnabled,
required ScoreInputMode scoreInputMode,
}) {
if (steps.any((step) => step.linkedToSeriesScore) &&
(!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) {
throw const DomainException(
'Linked step scores require manual series score.',
);
}
}
void _validateExerciseStepResult({
required int programIndex,
required int exerciseIndex,

View File

@ -10,6 +10,7 @@ part 'app_database.g.dart';
ActiveExerciseStepProgressStates,
ActiveExerciseStepResults,
ActiveRestStates,
ActiveManualScoreStates,
ActiveScoreStopwatchStates,
ActiveSetTimerStates,
ActiveSetResults,
@ -48,7 +49,7 @@ final class AppDatabase extends _$AppDatabase {
}
@override
int get schemaVersion => 19;
int get schemaVersion => 22;
@override
MigrationStrategy get migration {
@ -119,6 +120,15 @@ final class AppDatabase extends _$AppDatabase {
if (from < 19) {
await _migrateToSchema19();
}
if (from < 20) {
await _migrateToSchema20(migrator);
}
if (from < 21) {
await _migrateToSchema21();
}
if (from < 22) {
await _migrateToSchema22();
}
await _createIndexes();
},
beforeOpen: (details) async {
@ -199,6 +209,10 @@ final class AppDatabase extends _$AppDatabase {
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
'ON active_set_results (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id '
'ON active_manual_score_states (active_workout_session_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
'ON active_score_stopwatch_states (active_workout_session_id)',
@ -288,6 +302,7 @@ final class AppDatabase extends _$AppDatabase {
const _syncableTableNames = [
'active_exercise_step_progress_states',
'active_exercise_step_results',
'active_manual_score_states',
'active_rest_states',
'active_score_stopwatch_states',
'active_set_timer_states',
@ -770,6 +785,37 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
);
}
Future<void> _migrateToSchema20(Migrator migrator) async {
await migrator.createTable(activeManualScoreStates);
}
Future<void> _migrateToSchema21() async {
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'average_heart_rate_bpm',
definition:
'average_heart_rate_bpm REAL CHECK '
'(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
);
await _addColumnIfMissing(
tableName: 'workout_history',
columnName: 'max_heart_rate_bpm',
definition:
'max_heart_rate_bpm INTEGER CHECK '
'(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
);
}
Future<void> _migrateToSchema22() async {
await _addColumnIfMissing(
tableName: 'exercise_steps',
columnName: 'linked_to_series_score',
definition:
'linked_to_series_score INTEGER NOT NULL DEFAULT 0 '
'CHECK (linked_to_series_score IN (0, 1))',
);
}
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
await customStatement(r'''
UPDATE workout_history_set_results AS result

File diff suppressed because it is too large Load Diff

View File

@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_manual_score_states',
entityType: 'ActiveManualScoreState',
metadata: state.metadata,
write: () => database
.into(database.activeManualScoreStates)
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
);
}
@override
Future<void> saveExerciseStepProgressState(
domain.ActiveExerciseStepProgressState state,
@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.delete(
database.activeManualScoreStates,
)..where((table) => table.id.equals(row.id))).go();
await _writeChangeLog(
database: database,
entityType: 'ActiveManualScoreState',
entityId: row.id,
operation: 'delete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
@override
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
}
@override
Future<domain.ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeManualScoreStateFromRow(row);
}
@override
Future<domain.ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
.get();
return rows.map(_activeScoreStopwatchStateFromRow).toList();
}
@override
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
final rows =
await (database.select(database.activeManualScoreStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeManualScoreStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
@ -1522,6 +1611,51 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
});
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
return;
}
final row =
await (database.select(database.workoutHistories)..where(
(table) =>
table.id.equals(historyId) &
table.deletedAt.isNull() &
table.averageHeartRateBpm.isNull() &
table.maxHeartRateBpm.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistories,
)..where((table) => table.id.equals(historyId))).write(
db.WorkoutHistoriesCompanion(
updatedAt: Value(patchedAt.toUtc()),
syncState: const Value('dirty'),
localRevision: Value(revision),
averageHeartRateBpm: Value(averageHeartRateBpm),
maxHeartRateBpm: Value(maxHeartRateBpm),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistory',
entityId: historyId,
operation: 'update',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: patchedAt,
);
}
@override
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
await _upsertWithChangeLog(
@ -2777,6 +2911,7 @@ Future<void> _replaceExerciseSteps(
scoreUnit: Value(step.scoreUnit),
defaultTargetScore: Value(step.defaultTargetScore),
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
linkedToSeriesScore: Value(step.linkedToSeriesScore),
),
),
);
@ -3252,6 +3387,7 @@ domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
scoreUnit: row.scoreUnit,
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
linkedToSeriesScore: row.linkedToSeriesScore,
);
}
@ -4013,6 +4149,45 @@ db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
);
}
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
domain.ActiveManualScoreState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveManualScoreStatesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
programIndex: Value(state.programIndex),
exerciseIndex: Value(state.exerciseIndex),
setIndex: Value(state.setIndex),
value: Value(state.value),
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
);
}
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
db.ActiveManualScoreState row,
) {
return domain.ActiveManualScoreState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
value: row.value,
updatedAt: _utc(row.scoreUpdatedAt),
);
}
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
db.ActiveScoreStopwatchState row,
) {
@ -4188,6 +4363,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
totalActiveMs: Value(history.totalActiveMs),
completed: Value(history.completed),
historySnapshotJson: Value(history.historySnapshotJson),
averageHeartRateBpm: Value(history.averageHeartRateBpm),
maxHeartRateBpm: Value(history.maxHeartRateBpm),
);
}
@ -4303,6 +4480,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
totalActiveMs: row.totalActiveMs,
completed: row.completed,
historySnapshotJson: row.historySnapshotJson,
averageHeartRateBpm: row.averageHeartRateBpm,
maxHeartRateBpm: row.maxHeartRateBpm,
results: results,
stepResults: stepResults,
);
@ -4560,6 +4739,8 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'totalActiveMs': history.totalActiveMs,
'completed': history.completed,
'historySnapshotJson': history.historySnapshotJson,
'averageHeartRateBpm': history.averageHeartRateBpm,
'maxHeartRateBpm': history.maxHeartRateBpm,
};
Map<String, Object?> _localWorkoutHistoryPayload(
@ -4714,6 +4895,8 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
@ -5142,6 +5325,7 @@ List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);
@ -5487,6 +5671,7 @@ List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
json,
'defaultTargetScoreTimeMs',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);

View File

@ -137,7 +137,7 @@ class PendingShareActions extends Table {
@override
List<String> get customConstraints => [
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
"CHECK (resource_type IS NULL OR resource_type IN "
'CHECK (resource_type IS NULL OR resource_type IN '
"('program', 'workoutTemplate'))",
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
];
@ -250,6 +250,8 @@ class ExerciseSteps extends SyncableTable {
TextColumn get scoreUnit => text().nullable()();
RealColumn get defaultTargetScore => real().nullable()();
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
BoolColumn get linkedToSeriesScore =>
boolean().withDefault(const Constant(false))();
@override
List<String> get customConstraints => [
@ -276,6 +278,8 @@ class ExerciseSteps extends SyncableTable {
'AND default_target_score IS NULL))',
'CHECK (default_target_score IS NULL OR '
'default_target_score_time_ms IS NULL)',
'CHECK (NOT linked_to_series_score OR '
"(has_score AND score_input_mode = 'manual'))",
];
}
@ -548,6 +552,29 @@ class ActiveScoreStopwatchStates extends SyncableTable {
];
}
class ActiveManualScoreStates extends SyncableTable {
@override
String get tableName => 'active_manual_score_states';
TextColumn get activeWorkoutSessionId =>
text().references(ActiveWorkoutSessions, #id)();
IntColumn get programIndex => integer()();
IntColumn get exerciseIndex => integer()();
IntColumn get setIndex => integer()();
RealColumn get value => real()();
DateTimeColumn get scoreUpdatedAt => dateTime()();
@override
List<String> get customConstraints => [
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
'set_index)',
'CHECK (program_index >= 0)',
'CHECK (exercise_index >= 0)',
'CHECK (set_index >= 0)',
'CHECK (value >= 0)',
];
}
class ActiveSetTimerStates extends SyncableTable {
@override
String get tableName => 'active_set_timer_states';
@ -723,9 +750,15 @@ class WorkoutHistories extends SyncableTable {
IntColumn get totalActiveMs => integer()();
BoolColumn get completed => boolean()();
TextColumn get historySnapshotJson => text().withLength(min: 1)();
RealColumn get averageHeartRateBpm => real().nullable()();
IntColumn get maxHeartRateBpm => integer().nullable()();
@override
List<String> get customConstraints => ['CHECK (total_active_ms >= 0)'];
List<String> get customConstraints => [
'CHECK (total_active_ms >= 0)',
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
];
}
class WorkoutHistorySetResults extends SyncableTable {

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@ -12,11 +13,28 @@ final class HttpApiClient {
this.timeout = const Duration(seconds: 10),
}) : client = client ?? http.Client();
static const defaultBaseUrl = String.fromEnvironment(
static const _configuredBaseUrl = String.fromEnvironment(
'GAMETIME_API_BASE_URL',
defaultValue: 'http://localhost:8080',
defaultValue: '',
);
static String get defaultBaseUrl =>
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
static String defaultBaseUrlFor({
required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl,
}) {
final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) {
return configured;
}
if (isAndroid) {
return 'http://10.0.2.2:8080';
}
return 'http://localhost:8080';
}
final Uri baseUrl;
final http.Client client;
final Duration timeout;
@ -142,7 +160,7 @@ final class HttpApiClient {
return switch (statusCode) {
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
>= 500 => RemoteAuthException(RemoteAuthFailure.server, message),
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
};
}

View File

@ -0,0 +1 @@
export 'session_notification_gateway.dart';

View File

@ -0,0 +1,33 @@
import 'package:flutter/services.dart';
import '../../application/application.dart';
final class MethodChannelSessionNotificationGateway
implements SessionNotificationGateway {
const MethodChannelSessionNotificationGateway({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
}) : _methodChannel = methodChannel;
static const _methodChannelName = 'gametime.session_notification/methods';
final MethodChannel _methodChannel;
@override
Future<void> show(SessionNotificationContent content) {
return _invokeIgnoringMissingPlugin('show', content.toJson());
}
@override
Future<void> clear() {
return _invokeIgnoringMissingPlugin('clear');
}
Future<void> _invokeIgnoringMissingPlugin(
String method, [
Object? arguments,
]) {
return _methodChannel
.invokeMethod<void>(method, arguments)
.onError<MissingPluginException>((_, _) {});
}
}

View File

@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent {
abstract interface class WatchBridgeNativeChannel {
Stream<WatchCommandEnvelope> get commands;
Stream<WatchSensorSummary> get sensorSummaries;
Stream<WatchSensorSample> get sensorSamples;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
Future<void> publishProjection(WatchSessionProjection projection);
@ -38,17 +42,31 @@ final class MethodChannelWatchBridgeNativeChannel
const MethodChannelWatchBridgeNativeChannel({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel commandChannel = const EventChannel(_commandChannelName),
EventChannel sensorSummaryChannel = const EventChannel(
_sensorSummaryChannelName,
),
EventChannel sensorSampleChannel = const EventChannel(
_sensorSampleChannelName,
),
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
}) : _methodChannel = methodChannel,
_commandChannel = commandChannel,
_sensorSummaryChannel = sensorSummaryChannel,
_sensorSampleChannel = sensorSampleChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _commandChannelName = 'gametime.watch_bridge/commands';
static const _sensorSummaryChannelName =
'gametime.watch_bridge/sensor_summaries';
static const _sensorSampleChannelName =
'gametime.watch_bridge/sensor_samples';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _commandChannel;
final EventChannel _sensorSummaryChannel;
final EventChannel _sensorSampleChannel;
final EventChannel _connectionChannel;
@override
@ -63,6 +81,30 @@ final class MethodChannelWatchBridgeNativeChannel
});
}
@override
Stream<WatchSensorSummary> get sensorSummaries {
return _sensorSummaryChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
return WatchSensorSummary.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchSensorSample> get sensorSamples {
return _sensorSampleChannel
.receiveBroadcastStream()
.where((event) {
return event is Map;
})
.map((event) {
return WatchSensorSample.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents {
return _connectionChannel

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
import '../../application/use_cases.dart';
import '../../application/watch_companion_use_cases.dart';
import 'native_watch_bridge_channel.dart';
@ -10,20 +11,26 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
required WatchBridgeNativeChannel nativeChannel,
required WatchCommandIngress commandIngress,
required WatchProjectionSource projectionSource,
Duration heartbeatInterval = const Duration(seconds: 5),
WorkoutHistoryUseCases? workoutHistoryUseCases,
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
Duration projectionRefreshInterval = const Duration(seconds: 2),
}) : _nativeChannel = nativeChannel,
_commandIngress = commandIngress,
_projectionSource = projectionSource,
_heartbeatInterval = heartbeatInterval;
_workoutHistoryUseCases = workoutHistoryUseCases,
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
_projectionRefreshInterval = projectionRefreshInterval;
final WatchBridgeNativeChannel _nativeChannel;
final WatchCommandIngress _commandIngress;
final WatchProjectionSource _projectionSource;
final Duration _heartbeatInterval;
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
final Duration _projectionRefreshInterval;
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
final _subscriptions = <StreamSubscription<dynamic>>[];
Future<void> _commandTail = Future<void>.value();
Timer? _heartbeatTimer;
Timer? _projectionRefreshTimer;
WatchSessionProjection? _latestProjection;
bool _started = false;
bool _foregroundActive = false;
@ -33,6 +40,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
return;
}
_started = true;
_ensureProjectionRefreshLoop();
_subscriptions.add(
_projectionSource.projections.listen((projection) {
unawaited(publish(projection));
@ -43,6 +51,22 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
unawaited(_enqueueCommand(command));
}),
);
final workoutHistoryUseCases = _workoutHistoryUseCases;
if (workoutHistoryUseCases != null) {
_subscriptions.add(
_nativeChannel.sensorSummaries.listen((summary) {
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
}),
);
}
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
if (activeWorkoutSensorUseCases != null) {
_subscriptions.add(
_nativeChannel.sensorSamples.listen((sample) {
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
}),
);
}
_subscriptions.add(
_nativeChannel.connectionEvents.listen((event) {
if (event.isReachable || event.requestsResync) {
@ -55,8 +79,8 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
}
Future<void> stop() async {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_projectionRefreshTimer?.cancel();
_projectionRefreshTimer = null;
for (final subscription in _subscriptions) {
await subscription.cancel();
}
@ -66,10 +90,16 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
@override
Future<void> publish(WatchSessionProjection projection) async {
final previousProjection = _latestProjection;
_latestProjection = projection;
if (projection.phase == WatchSessionPhase.noActiveSession) {
final previousSessionId = previousProjection?.deviceSessionId;
if (previousSessionId != null && previousSessionId.isNotEmpty) {
_activeWorkoutSensorUseCases?.clear(previousSessionId);
}
}
await _nativeChannel.publishProjection(projection);
await _syncForegroundService(projection);
_syncHeartbeat(projection);
}
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
@ -136,26 +166,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
}
}
void _syncHeartbeat(WatchSessionProjection projection) {
if (!_hasRunningTimer(projection)) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return;
}
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
void _ensureProjectionRefreshLoop() {
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
unawaited(_projectionSource.emitCurrentProjection());
});
}
}
bool _hasRunningTimer(WatchSessionProjection projection) {
final timers = [
if (projection.dominantTimer != null) projection.dominantTimer!,
...projection.secondaryTimers,
];
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
}
final class _WatchAdapterCommandKey {
_WatchAdapterCommandKey(WatchCommandEnvelope command)
: sessionId = command.sessionId,

View File

@ -628,7 +628,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: _defaultTimeController,
decoration: const InputDecoration(
labelText: 'Temps par défaut (s)',
labelText: 'Durée par défaut (s)',
),
keyboardType: TextInputType.number,
validator: (value) => _hasTime
@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
RadioGroup<ScoreInputMode>(
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
subtitle: Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
@ -758,7 +759,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: _defaultScoreTimeController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
labelText: 'Objectif chrono (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: _stepDrafts.length,
onReorder: _reorderStep,
onReorderItem: _reorderStep,
itemBuilder: (context, index) {
return _buildStepCard(context, index, _stepDrafts[index]);
},
@ -901,6 +902,13 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
int index,
_ExerciseStepDraft draft,
) {
final canLinkToSeriesScore =
_hasScore && _scoreInputMode == ScoreInputMode.manual;
if ((!canLinkToSeriesScore ||
draft.scoreInputMode != ScoreInputMode.manual) &&
draft.linkedToSeriesScore) {
draft.linkedToSeriesScore = false;
}
final targetLabel = draft.type == ExerciseStepType.time
? 'Durée par défaut (s)'
: 'Répétitions par défaut';
@ -979,25 +987,26 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
),
const SizedBox(height: 8),
Text('Type détape', style: Theme.of(context).textTheme.titleSmall),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Temps'),
value: ExerciseStepType.time,
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: const Text('Répétitions'),
value: ExerciseStepType.reps,
RadioGroup<ExerciseStepType>(
groupValue: draft.type,
onChanged: (value) {
if (value == null) return;
setState(() => draft.type = value);
},
child: const Column(
children: [
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Temps'),
value: ExerciseStepType.time,
),
RadioListTile<ExerciseStepType>(
contentPadding: EdgeInsets.zero,
title: Text('Répétitions'),
value: ExerciseStepType.reps,
),
],
),
),
TextFormField(
controller: draft.targetController,
@ -1021,79 +1030,103 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
'Mode de score',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
RadioGroup<ScoreInputMode>(
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
groupValue: draft.scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => draft.scoreInputMode = value);
setState(() {
draft.scoreInputMode = value;
draft.linkedToSeriesScore = false;
});
},
child: const Column(
children: [
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Saisie libre'),
value: ScoreInputMode.manual,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: Text('Chrono intégré'),
value: ScoreInputMode.stopwatch,
),
],
),
),
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
if (canLinkToSeriesScore) ...[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Utiliser le score de la série'),
subtitle: const Text(
'Le score de cette étape alimente directement le score '
'de la série.',
),
value: draft.linkedToSeriesScore,
onChanged: (value) {
setState(() => draft.linkedToSeriesScore = value);
},
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
const SizedBox(height: 8),
],
if (!draft.linkedToSeriesScore) ...[
TextFormField(
controller: draft.scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_stepsEnabled || !draft.hasScore) return null;
if (draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
const SizedBox(height: 8),
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_stepsEnabled ||
!draft.hasScore ||
draft.scoreInputMode != ScoreInputMode.manual ||
draft.linkedToSeriesScore ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _nonNegativeDoubleValidator(
value,
'Saisis un score supérieur ou égal à 0.',
);
},
),
],
] else ...[
if (draft.type == ExerciseStepType.time) ...[
const SizedBox(height: 8),
@ -1107,7 +1140,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
TextFormField(
controller: draft.scoreTargetController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
labelText: 'Objectif chrono (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
void _reorderStep(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final draft = _stepDrafts.removeAt(oldIndex);
_stepDrafts.insert(newIndex, draft);
});
@ -1254,6 +1284,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
scoreInputMode: step.scoreInputMode,
scoreLabel: step.scoreLabel,
scoreUnit: step.scoreUnit,
linkedToSeriesScore: step.linkedToSeriesScore,
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
? _optionalDoubleText(
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
@ -1268,6 +1299,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
for (var index = 0; index < _stepDrafts.length; index++)
_stepDrafts[index].toExerciseStep(
position: index,
seriesScoreLabel: _scoreLabelController.text.trim(),
seriesScoreUnit: _scoreUnitController.text.trim(),
canLinkToSeriesScore:
_hasScore && _scoreInputMode == ScoreInputMode.manual,
defaultTargetScoreTimeMs:
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(
@ -1386,32 +1421,34 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
try {
final scoreInputMode = _hasScore
? _scoreInputMode
: ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
final steps = _buildSteps();
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
@ -1462,8 +1499,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} on Exception {
_showSnackBar(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
);
} finally {
if (mounted) {
setState(() => _saving = false);
@ -1556,6 +1595,7 @@ final class _ExerciseStepDraft {
required String defaultTargetValue,
this.hasScore = false,
this.scoreInputMode = ScoreInputMode.manual,
this.linkedToSeriesScore = false,
String? scoreLabel,
String? scoreUnit,
String? scoreTarget,
@ -1569,6 +1609,7 @@ final class _ExerciseStepDraft {
ExerciseStepType type;
bool hasScore;
ScoreInputMode scoreInputMode;
bool linkedToSeriesScore;
final TextEditingController nameController;
final TextEditingController targetController;
final TextEditingController scoreLabelController;
@ -1584,6 +1625,7 @@ final class _ExerciseStepDraft {
return nameController.text.trim().isNotEmpty ||
targetController.text.trim().isNotEmpty ||
hasScore ||
linkedToSeriesScore ||
scoreLabelController.text.trim().isNotEmpty ||
scoreUnitController.text.trim().isNotEmpty ||
scoreTargetController.text.trim().isNotEmpty;
@ -1597,6 +1639,7 @@ final class _ExerciseStepDraft {
defaultTargetValue: targetController.text,
hasScore: hasScore,
scoreInputMode: scoreInputMode,
linkedToSeriesScore: linkedToSeriesScore,
scoreLabel: scoreLabelController.text,
scoreUnit: scoreUnitController.text,
scoreTarget: scoreTargetController.text,
@ -1605,9 +1648,17 @@ final class _ExerciseStepDraft {
ExerciseStep toExerciseStep({
required int position,
required String seriesScoreLabel,
required String seriesScoreUnit,
required bool canLinkToSeriesScore,
required int? defaultTargetScoreTimeMs,
}) {
final scoreTargetText = scoreTargetController.text.trim();
final linksToSeries =
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
canLinkToSeriesScore &&
linkedToSeriesScore;
return ExerciseStep(
id: id,
position: position,
@ -1617,14 +1668,19 @@ final class _ExerciseStepDraft {
hasScore: hasScore,
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreLabelController.text.trim()
? linksToSeries
? seriesScoreLabel
: scoreLabelController.text.trim()
: null,
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
? scoreUnitController.text.trim()
? linksToSeries
? seriesScoreUnit
: scoreUnitController.text.trim()
: null,
defaultTargetScore:
hasScore &&
scoreInputMode == ScoreInputMode.manual &&
!linksToSeries &&
scoreTargetText.isNotEmpty
? double.parse(scoreTargetText)
: null,
@ -1632,6 +1688,7 @@ final class _ExerciseStepDraft {
hasScore && scoreInputMode == ScoreInputMode.stopwatch
? defaultTargetScoreTimeMs
: null,
linkedToSeriesScore: linksToSeries,
);
}

View File

@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
super.key,
@ -24,6 +25,7 @@ final class HistoryListScreen extends StatefulWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -110,6 +112,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sensorUseCases: widget.sensorUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
@ -133,6 +136,7 @@ final class HistoryDetailScreen extends StatelessWidget {
required this.closeUseCase,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
super.key,
});
@ -142,6 +146,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -157,6 +162,14 @@ final class HistoryDetailScreen extends StatelessWidget {
Text(_formatDateTime(history.startedAt)),
const SizedBox(height: 4),
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (history.averageHeartRateBpm != null &&
history.maxHeartRateBpm != null) ...[
const SizedBox(height: 16),
_HistoryHeartRateSummary(
averageHeartRateBpm: history.averageHeartRateBpm!,
maxHeartRateBpm: history.maxHeartRateBpm!,
),
],
const SizedBox(height: 16),
for (final program in detail.programs) ...[
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
@ -202,13 +215,28 @@ final class HistoryDetailScreen extends StatelessWidget {
}
Future<void> _restart(BuildContext context) async {
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!context.mounted) return;
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
'den lancer une nouvelle.',
),
),
);
return;
}
ActiveWorkoutSession session;
final sourceId = history.sourceWorkoutTemplateId;
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
if (context.mounted) {
try {
if (sourceId != null &&
await workoutTemplateUseCases.findById(sourceId) != null) {
session = await activeUseCases.startFromTemplate(sourceId);
} else {
session = await activeUseCases.startFromHistory(history);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -217,7 +245,16 @@ final class HistoryDetailScreen extends StatelessWidget {
),
);
}
session = await activeUseCases.startFromHistory(history);
} on DomainException {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
),
);
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
@ -226,6 +263,7 @@ final class HistoryDetailScreen extends StatelessWidget {
initialSession: session,
activeUseCases: activeUseCases,
stepUseCases: stepUseCases,
sensorUseCases: sensorUseCases,
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
@ -236,6 +274,22 @@ final class HistoryDetailScreen extends StatelessWidget {
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a restart.
}
return false;
}
Future<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
@ -261,6 +315,79 @@ final class HistoryDetailScreen extends StatelessWidget {
}
}
final class _HistoryHeartRateSummary extends StatelessWidget {
const _HistoryHeartRateSummary({
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Fréquence cardiaque',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _HistoryHeartRateMetric(
label: 'Moyenne',
value: '${averageHeartRateBpm.round()}',
),
),
const SizedBox(width: 12),
Expanded(
child: _HistoryHeartRateMetric(
label: 'Max',
value: '$maxHeartRateBpm',
),
),
],
),
],
),
);
}
}
final class _HistoryHeartRateMetric extends StatelessWidget {
const _HistoryHeartRateMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
RichText(
text: TextSpan(
style: AppTextStyles.scoreNumber(context),
children: [
TextSpan(text: value),
TextSpan(
text: ' bpm',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
);
}
}
final class HistoryDetailData {
const HistoryDetailData({required this.programs});

View File

@ -93,7 +93,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
if (session == null) {
return const SizedBox.shrink();
}
final plan = WorkoutExecutionPlan.fromSession(session);
final plan = WorkoutExecutionPlan.tryFromSession(session);
if (plan == null) {
return const SizedBox.shrink();
}
final position = ExecutionPosition(
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
@ -158,6 +161,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -182,6 +186,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.workoutTemplateUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -257,21 +262,33 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
}
Future<void> _resume(ActiveWorkoutSession session) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases,
),
),
),
);
);
} on DomainException {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
),
);
}
if (!mounted) return;
_reloadOpenSession();
}

View File

@ -89,14 +89,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _openRegister(BuildContext context) async {
@ -109,14 +108,13 @@ final class _ProfileScreenState extends State<ProfileScreen> {
),
),
);
if (connected == true && mounted) {
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
if (connected != true || !mounted || !context.mounted) return;
_reloadSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte connecté. Synchronisation en arrière-plan.'),
),
);
}
Future<void> _confirmLogout(BuildContext context) async {
@ -189,13 +187,12 @@ final class _SignedOutProfile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Compte optionnel',
'Compte GameTime',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text(
'GameTime fonctionne entièrement sans compte. Connecte-toi '
'seulement si tu veux sauvegarder tes données en ligne ou '
'Connecte-toi pour sauvegarder tes données en ligne et '
'partager des programmes et séances.',
),
const SizedBox(height: 16),
@ -481,7 +478,8 @@ abstract interface class LocalBackupFileExporter {
});
}
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
final class SharePlusLocalBackupFileExporter
implements LocalBackupFileExporter {
const SharePlusLocalBackupFileExporter();
@override
@ -950,11 +948,6 @@ final class _LoginScreenState extends State<LoginScreen> {
},
child: const Text('Créer un compte'),
),
const SizedBox(height: 16),
Text(
'Tu peux continuer à utiliser GameTime sans compte.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
@ -1103,7 +1096,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
const SizedBox(height: 16),
Text(
'Le compte sert à synchroniser tes données et partager tes '
"contenus. L'app reste utilisable sans compte.",
'contenus.',
style: Theme.of(context).textTheme.bodySmall,
),
],
@ -1211,6 +1204,8 @@ String _loginErrorMessage(RemoteAuthFailure failure) {
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
RemoteAuthFailure.network =>
'Connexion impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
};
}
@ -1221,6 +1216,8 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
'Un compte existe déjà avec cet email.',
RemoteAuthFailure.network =>
'Création impossible pour le moment. Réessaie plus tard.',
RemoteAuthFailure.server =>
'Serveur indisponible pour le moment. Réessaie plus tard.',
_ => 'Création impossible pour le moment. Réessaie plus tard.',
};
}

View File

@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
@override
Widget build(BuildContext context) {
final canSave = !_saving && _exercises.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(
@ -436,15 +437,21 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -495,7 +502,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
child: _exercises.isEmpty
? const _CenteredMessage(
title: 'Aucun exercice ajouté',
message: 'Ajoute un exercice depuis la bibliothèque.',
message:
'Ajoute au moins un exercice pour enregistrer ce programme.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
@ -595,6 +603,9 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
}
Future<void> _save() async {
if (_exercises.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -967,7 +978,7 @@ final class _ProgramExerciseCustomizationScreenState
if (_draft.enabledMeasures.contains(WorkoutMeasure.score))
if (hasStopwatchScore)
_NumberField(
label: 'Objectif de chrono',
label: 'Objectif chrono (optionnel)',
helperText: 'Le résultat réel sera mesuré pendant la série.',
initialValue: _millisecondsToSeconds(
_draft.targetScoreTimeMs,
@ -1402,7 +1413,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@ -172,7 +172,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
const SizedBox(height: 8),
Text(
'Connecte-toi pour envoyer $targetLabel à un autre compte '
'GameTime. Le reste de lapp reste utilisable sans compte.',
'GameTime.',
),
const SizedBox(height: 16),
FilledButton(

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.historyUseCases,
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.performanceReferenceUseCase,
this.shareUseCases,
this.authUseCases,
@ -30,6 +31,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
@ -264,9 +266,16 @@ final class _WorkoutTemplateListScreenState
}
Future<void> _start(WorkoutTemplate template) async {
final openSession = await widget.activeUseCases.findOpen();
if (_templateExerciseCount(template) == 0) {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
final hasBlockingOpenSession = await _hasBlockingOpenSession();
if (!mounted) return;
if (openSession != null) {
if (hasBlockingOpenSession) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
@ -277,9 +286,18 @@ final class _WorkoutTemplateListScreenState
);
return;
}
final session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
final ActiveWorkoutSession session;
try {
session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
} on DomainException {
_showSnackBar(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
);
return;
}
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
@ -292,11 +310,28 @@ final class _WorkoutTemplateListScreenState
workoutTemplateUseCases: widget.workoutTemplateUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases,
),
),
);
}
Future<bool> _hasBlockingOpenSession() async {
final openSession = await widget.activeUseCases.findOpen();
if (openSession == null) {
return false;
}
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
return true;
}
try {
await widget.activeUseCases.abandon(openSession.metadata.id);
} on DomainException {
// Legacy invalid sessions must not block a new start.
}
return false;
}
Future<void> _confirmDelete(WorkoutTemplate template) async {
final confirmed = await showDialog<bool>(
context: context,
@ -458,6 +493,7 @@ final class _WorkoutTemplateFormScreenState
@override
Widget build(BuildContext context) {
final canSave = !_saving && _programs.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
@ -472,15 +508,21 @@ final class _WorkoutTemplateFormScreenState
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: canSave ? _save : null,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
body: Column(
@ -521,13 +563,14 @@ final class _WorkoutTemplateFormScreenState
child: _programs.isEmpty
? const _CenteredMessage(
title: 'Aucun programme ajouté',
message: 'Ajoute un programme pour composer la séance.',
message:
'Ajoute au moins un programme pour enregistrer cette séance.',
)
: ReorderableListView.builder(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _programs.length,
onReorder: _reorderProgram,
onReorderItem: _reorderProgram,
itemBuilder: (context, index) {
final program = _programs[index];
return Card(
@ -579,9 +622,6 @@ final class _WorkoutTemplateFormScreenState
void _reorderProgram(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _programs.removeAt(oldIndex);
_programs.insert(newIndex, item);
});
@ -599,6 +639,9 @@ final class _WorkoutTemplateFormScreenState
}
Future<void> _save() async {
if (_programs.isEmpty) {
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@ -1050,6 +1093,7 @@ List<ExerciseStep> _parseExerciseSteps(Object? value) {
scoreUnit: step['scoreUnit'] as String?,
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
);
}).toList();
}
@ -1062,12 +1106,16 @@ String _autoStartHelpText(bool active) {
String _templateSummary(WorkoutTemplate template) {
final programCount = template.programs.length;
final exerciseCount = template.programs.fold<int>(
final exerciseCount = _templateExerciseCount(template);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
int _templateExerciseCount(WorkoutTemplate template) {
return template.programs.fold<int>(
0,
(total, program) => total + _exerciseCount(program.programSnapshotJson),
);
return '$programCount programme${programCount > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
@ -1099,8 +1147,12 @@ String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
}
int _exerciseCount(String programSnapshotJson) {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
try {
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
} on Exception {
return 0;
}
}
final class _CenteredMessage extends StatelessWidget {
@ -1119,7 +1171,7 @@ final class _CenteredMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@ -1,4 +1,4 @@
const int watchBridgeSchemaVersion = 1;
const int watchBridgeSchemaVersion = 4;
enum WatchCommandType {
startCurrentExercise,
@ -10,6 +10,8 @@ enum WatchCommandType {
finishCurrentSet,
skipCurrentSet,
skipCurrentRest,
incrementScore,
decrementScore,
}
enum WatchCommandAck {
@ -56,6 +58,8 @@ enum WatchTimerDisplayMode { countdown, elapsed }
enum WatchTimerRunState { stopped, running, paused }
enum WatchManualScoreScope { series, step }
final class WatchCommandEnvelope {
const WatchCommandEnvelope({
this.schemaVersion = watchBridgeSchemaVersion,
@ -138,6 +142,9 @@ final class WatchSessionProjection {
required this.seriesIndex,
required this.seriesTotal,
required this.exerciseName,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.passageTotal,
this.stepIndex,
@ -149,6 +156,12 @@ final class WatchSessionProjection {
this.secondaryActions = const [],
this.nextExerciseName,
this.statusLabel,
this.hasManualScore = false,
this.currentManualScoreValue,
this.canDecrementScore = false,
this.manualScoreTargetValue,
this.manualScoreTargetLabel,
this.manualScoreScope,
});
factory WatchSessionProjection.fromJson(Map<String, Object?> json) {
@ -169,6 +182,9 @@ final class WatchSessionProjection {
seriesIndex: _intFromJson(json['seriesIndex'], 0),
seriesTotal: _intFromJson(json['seriesTotal'], 0),
exerciseName: _stringFromJson(json['exerciseName']),
programIndex: _nullableIntFromJson(json['programIndex']),
exerciseIndex: _nullableIntFromJson(json['exerciseIndex']),
setIndex: _nullableIntFromJson(json['setIndex']),
passageIndex: _nullableIntFromJson(json['passageIndex']),
passageTotal: _nullableIntFromJson(json['passageTotal']),
stepIndex: _nullableIntFromJson(json['stepIndex']),
@ -187,6 +203,21 @@ final class WatchSessionProjection {
),
nextExerciseName: _nullableStringFromJson(json['nextExerciseName']),
statusLabel: _nullableStringFromJson(json['statusLabel']),
hasManualScore: _boolFromJson(json['hasManualScore'], false),
currentManualScoreValue: _nullableDoubleFromJson(
json['currentManualScoreValue'],
),
canDecrementScore: _boolFromJson(json['canDecrementScore'], false),
manualScoreTargetValue: _nullableDoubleFromJson(
json['manualScoreTargetValue'],
),
manualScoreTargetLabel: _nullableStringFromJson(
json['manualScoreTargetLabel'],
),
manualScoreScope: _nullableEnumFromJson(
json['manualScoreScope'],
WatchManualScoreScope.values,
),
);
}
@ -199,6 +230,9 @@ final class WatchSessionProjection {
final int seriesIndex;
final int seriesTotal;
final String exerciseName;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? passageTotal;
final int? stepIndex;
@ -210,6 +244,12 @@ final class WatchSessionProjection {
final List<WatchSecondaryAction> secondaryActions;
final String? nextExerciseName;
final String? statusLabel;
final bool hasManualScore;
final double? currentManualScoreValue;
final bool canDecrementScore;
final double? manualScoreTargetValue;
final String? manualScoreTargetLabel;
final WatchManualScoreScope? manualScoreScope;
Map<String, Object?> toJson() {
return {
@ -222,6 +262,9 @@ final class WatchSessionProjection {
'seriesIndex': seriesIndex,
'seriesTotal': seriesTotal,
'exerciseName': exerciseName,
'programIndex': programIndex,
'exerciseIndex': exerciseIndex,
'setIndex': setIndex,
'passageIndex': passageIndex,
'passageTotal': passageTotal,
'stepIndex': stepIndex,
@ -237,6 +280,12 @@ final class WatchSessionProjection {
.toList(),
'nextExerciseName': nextExerciseName,
'statusLabel': statusLabel,
'hasManualScore': hasManualScore,
'currentManualScoreValue': currentManualScoreValue,
'canDecrementScore': canDecrementScore,
'manualScoreTargetValue': manualScoreTargetValue,
'manualScoreTargetLabel': manualScoreTargetLabel,
'manualScoreScope': manualScoreScope?.name,
};
}
@ -253,6 +302,9 @@ final class WatchSessionProjection {
seriesIndex == other.seriesIndex &&
seriesTotal == other.seriesTotal &&
exerciseName == other.exerciseName &&
programIndex == other.programIndex &&
exerciseIndex == other.exerciseIndex &&
setIndex == other.setIndex &&
passageIndex == other.passageIndex &&
passageTotal == other.passageTotal &&
stepIndex == other.stepIndex &&
@ -263,12 +315,18 @@ final class WatchSessionProjection {
primaryAction == other.primaryAction &&
_listEquals(secondaryActions, other.secondaryActions) &&
nextExerciseName == other.nextExerciseName &&
statusLabel == other.statusLabel;
statusLabel == other.statusLabel &&
hasManualScore == other.hasManualScore &&
currentManualScoreValue == other.currentManualScoreValue &&
canDecrementScore == other.canDecrementScore &&
manualScoreTargetValue == other.manualScoreTargetValue &&
manualScoreTargetLabel == other.manualScoreTargetLabel &&
manualScoreScope == other.manualScoreScope;
}
@override
int get hashCode {
return Object.hash(
return Object.hashAll([
schemaVersion,
deviceSessionId,
revision,
@ -278,6 +336,9 @@ final class WatchSessionProjection {
seriesIndex,
seriesTotal,
exerciseName,
programIndex,
exerciseIndex,
setIndex,
passageIndex,
passageTotal,
stepIndex,
@ -289,10 +350,226 @@ final class WatchSessionProjection {
Object.hashAll(secondaryActions),
nextExerciseName,
statusLabel,
hasManualScore,
currentManualScoreValue,
canDecrementScore,
manualScoreTargetValue,
manualScoreTargetLabel,
manualScoreScope,
]);
}
}
final class WatchSensorSummary {
const WatchSensorSummary({
this.schemaVersion = watchBridgeSchemaVersion,
required this.sessionId,
required this.sampleCount,
this.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.totalDistanceMeters,
this.totalCaloriesKcal,
});
factory WatchSensorSummary.fromJson(Map<String, Object?> json) {
return WatchSensorSummary(
schemaVersion: _intFromJson(
json['schemaVersion'],
watchBridgeSchemaVersion,
),
sessionId: _stringFromJson(json['sessionId']),
sampleCount: _intFromJson(json['sampleCount'], 0),
minHeartRateBpm: _nullableIntFromJson(json['minHeartRateBpm']),
averageHeartRateBpm: _nullableDoubleFromJson(json['averageHeartRateBpm']),
maxHeartRateBpm: _nullableIntFromJson(json['maxHeartRateBpm']),
totalDistanceMeters: _nullableDoubleFromJson(json['totalDistanceMeters']),
totalCaloriesKcal: _nullableDoubleFromJson(json['totalCaloriesKcal']),
);
}
final int schemaVersion;
final String sessionId;
final int sampleCount;
final int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final double? totalDistanceMeters;
final double? totalCaloriesKcal;
Map<String, Object?> toJson() {
return {
'schemaVersion': schemaVersion,
'sessionId': sessionId,
'sampleCount': sampleCount,
'minHeartRateBpm': minHeartRateBpm,
'averageHeartRateBpm': averageHeartRateBpm,
'maxHeartRateBpm': maxHeartRateBpm,
'totalDistanceMeters': totalDistanceMeters,
'totalCaloriesKcal': totalCaloriesKcal,
};
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is WatchSensorSummary &&
schemaVersion == other.schemaVersion &&
sessionId == other.sessionId &&
sampleCount == other.sampleCount &&
minHeartRateBpm == other.minHeartRateBpm &&
averageHeartRateBpm == other.averageHeartRateBpm &&
maxHeartRateBpm == other.maxHeartRateBpm &&
totalDistanceMeters == other.totalDistanceMeters &&
totalCaloriesKcal == other.totalCaloriesKcal;
}
@override
int get hashCode {
return Object.hash(
schemaVersion,
sessionId,
sampleCount,
minHeartRateBpm,
averageHeartRateBpm,
maxHeartRateBpm,
totalDistanceMeters,
totalCaloriesKcal,
);
}
}
final class WatchSensorSample {
const WatchSensorSample({
this.schemaVersion = watchBridgeSchemaVersion,
this.sampleId,
required this.sessionId,
int? capturedAtEpochMs,
int? recordedAtEpochMs,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
this.programSnapshotId,
this.exerciseSnapshotId,
this.stepSnapshotId,
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
}) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0;
factory WatchSensorSample.fromJson(Map<String, Object?> json) {
return WatchSensorSample(
schemaVersion: _intFromJson(
json['schemaVersion'],
watchBridgeSchemaVersion,
),
sampleId: _nullableStringFromJson(json['sampleId']),
sessionId: _stringFromJson(json['sessionId']),
capturedAtEpochMs: _intFromJson(
json['capturedAtEpochMs'],
_intFromJson(json['recordedAtEpochMs'], 0),
),
programIndex: _nullableIntFromJson(json['programIndex']),
exerciseIndex: _nullableIntFromJson(json['exerciseIndex']),
setIndex: _nullableIntFromJson(json['setIndex']),
passageIndex: _nullableIntFromJson(json['passageIndex']),
stepIndex: _nullableIntFromJson(json['stepIndex']),
programSnapshotId: _nullableStringFromJson(json['programSnapshotId']),
exerciseSnapshotId: _nullableStringFromJson(json['exerciseSnapshotId']),
stepSnapshotId: _nullableStringFromJson(json['stepSnapshotId']),
heartRateBpm: _nullableIntFromJson(json['heartRateBpm']),
distanceMeters: _nullableDoubleFromJson(json['distanceMeters']),
caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']),
);
}
final int schemaVersion;
final String? sampleId;
final String sessionId;
final int capturedAtEpochMs;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
final String? programSnapshotId;
final String? exerciseSnapshotId;
final String? stepSnapshotId;
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
int get recordedAtEpochMs => capturedAtEpochMs;
Map<String, Object?> toJson() {
return {
'schemaVersion': schemaVersion,
'sampleId': sampleId,
'sessionId': sessionId,
'capturedAtEpochMs': capturedAtEpochMs,
'recordedAtEpochMs': capturedAtEpochMs,
'programIndex': programIndex,
'exerciseIndex': exerciseIndex,
'setIndex': setIndex,
'passageIndex': passageIndex,
'stepIndex': stepIndex,
'programSnapshotId': programSnapshotId,
'exerciseSnapshotId': exerciseSnapshotId,
'stepSnapshotId': stepSnapshotId,
'heartRateBpm': heartRateBpm,
'distanceMeters': distanceMeters,
'caloriesKcal': caloriesKcal,
};
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is WatchSensorSample &&
schemaVersion == other.schemaVersion &&
sampleId == other.sampleId &&
sessionId == other.sessionId &&
capturedAtEpochMs == other.capturedAtEpochMs &&
programIndex == other.programIndex &&
exerciseIndex == other.exerciseIndex &&
setIndex == other.setIndex &&
passageIndex == other.passageIndex &&
stepIndex == other.stepIndex &&
programSnapshotId == other.programSnapshotId &&
exerciseSnapshotId == other.exerciseSnapshotId &&
stepSnapshotId == other.stepSnapshotId &&
heartRateBpm == other.heartRateBpm &&
distanceMeters == other.distanceMeters &&
caloriesKcal == other.caloriesKcal;
}
@override
int get hashCode {
return Object.hash(
schemaVersion,
sampleId,
sessionId,
capturedAtEpochMs,
programIndex,
exerciseIndex,
setIndex,
passageIndex,
stepIndex,
programSnapshotId,
exerciseSnapshotId,
stepSnapshotId,
heartRateBpm,
distanceMeters,
caloriesKcal,
);
}
}
typedef WatchTelemetrySummary = WatchSensorSummary;
typedef WatchTelemetrySample = WatchSensorSample;
final class WatchTimerProjection {
const WatchTimerProjection({
required this.kind,
@ -392,6 +669,17 @@ T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
return fallback;
}
T? _nullableEnumFromJson<T extends Enum>(Object? value, List<T> values) {
if (value is String) {
for (final enumValue in values) {
if (enumValue.name == value) {
return enumValue;
}
}
}
return null;
}
List<T> _enumListFromJson<T extends Enum>(Object? value, List<T> values) {
if (value is! List) {
return const [];
@ -438,6 +726,14 @@ int? _nullableIntFromJson(Object? value) {
return value is int ? value : null;
}
double? _nullableDoubleFromJson(Object? value) {
return switch (value) {
double() => value,
int() => value.toDouble(),
_ => null,
};
}
bool _boolFromJson(Object? value, bool fallback) {
return value is bool ? value : fallback;
}

View File

@ -52,6 +52,94 @@ void main() {
});
});
group('WatchSensorSummary', () {
test('round-trips heart rate summary through JSON', () {
const summary = WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
minHeartRateBpm: 91,
averageHeartRateBpm: 128.5,
maxHeartRateBpm: 174,
totalDistanceMeters: 842.4,
totalCaloriesKcal: 186.2,
);
final decoded = WatchSensorSummary.fromJson(
jsonDecode(jsonEncode(summary.toJson())) as Map<String, Object?>,
);
expect(decoded, summary);
});
test('falls back safely for absent fields', () {
final summary = WatchSensorSummary.fromJson({});
expect(summary.schemaVersion, watchBridgeSchemaVersion);
expect(summary.sessionId, '');
expect(summary.sampleCount, 0);
expect(summary.minHeartRateBpm, isNull);
expect(summary.averageHeartRateBpm, isNull);
expect(summary.maxHeartRateBpm, isNull);
expect(summary.totalDistanceMeters, isNull);
expect(summary.totalCaloriesKcal, isNull);
});
});
group('WatchSensorSample', () {
test('round-trips live telemetry sample through JSON', () {
const sample = WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: 1710000000300,
programIndex: 0,
exerciseIndex: 1,
setIndex: 2,
passageIndex: 3,
stepIndex: 4,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
stepSnapshotId: 'step-snapshot-1',
heartRateBpm: 142,
distanceMeters: 840.5,
caloriesKcal: 184.2,
);
final decoded = WatchSensorSample.fromJson(
jsonDecode(jsonEncode(sample.toJson())) as Map<String, Object?>,
);
expect(decoded, sample);
});
test('falls back safely for absent fields', () {
final sample = WatchSensorSample.fromJson({});
expect(sample.schemaVersion, watchBridgeSchemaVersion);
expect(sample.sampleId, isNull);
expect(sample.sessionId, '');
expect(sample.recordedAtEpochMs, 0);
expect(sample.capturedAtEpochMs, 0);
expect(sample.programIndex, isNull);
expect(sample.exerciseIndex, isNull);
expect(sample.setIndex, isNull);
expect(sample.passageIndex, isNull);
expect(sample.stepIndex, isNull);
expect(sample.heartRateBpm, isNull);
expect(sample.distanceMeters, isNull);
expect(sample.caloriesKcal, isNull);
});
test('accepts legacy recordedAtEpochMs as captured timestamp', () {
final sample = WatchSensorSample.fromJson({
'sessionId': 'session-1',
'recordedAtEpochMs': 1710000000300,
});
expect(sample.capturedAtEpochMs, 1710000000300);
expect(sample.recordedAtEpochMs, 1710000000300);
});
});
group('WatchSessionProjection', () {
test('round-trips every phase, primary action, and secondary action', () {
for (final phase in WatchSessionPhase.values) {
@ -65,6 +153,9 @@ void main() {
seriesIndex: 2,
seriesTotal: 5,
exerciseName: 'Pompes tempo',
programIndex: 0,
exerciseIndex: 1,
setIndex: 1,
passageIndex: 1,
passageTotal: 3,
stepIndex: 2,
@ -76,6 +167,12 @@ void main() {
secondaryActions: WatchSecondaryAction.values,
nextExerciseName: 'Fentes sautees',
statusLabel: 'Chrono etape',
hasManualScore: true,
currentManualScoreValue: 7.5,
canDecrementScore: true,
manualScoreTargetValue: 10,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.step,
);
final decoded = WatchSessionProjection.fromJson(
@ -114,6 +211,12 @@ void main() {
]);
expect(projection.nextExerciseName, isNull);
expect(projection.statusLabel, isNull);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
test('falls back to neutral values for absent required fields', () {
@ -130,6 +233,12 @@ void main() {
expect(projection.exerciseName, '');
expect(projection.primaryAction, WatchPrimaryAction.none);
expect(projection.secondaryActions, isEmpty);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreScope, isNull);
});
});

View File

@ -62,6 +62,24 @@ void main() {
expect(response.statusCode, 409);
});
test('register endpoint returns 400 for malformed json payload', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/register'),
body: jsonEncode(['not-an-object']),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Request body must be a JSON object.'},
);
});
test('login endpoint returns a token and expiration', () async {
final users = _FakeUserRepository();
await users.insert(
@ -121,6 +139,24 @@ void main() {
expect(response.statusCode, 401);
});
test('login endpoint returns 400 when email is blank', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/auth/login'),
body: jsonEncode({'email': ' ', 'password': 'password123'}),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'email must be a non-empty string.'},
);
});
test('logout endpoint revokes the bearer session', () async {
final users = _FakeUserRepository();
await users.insert(
@ -157,6 +193,20 @@ void main() {
expect(response.statusCode, 204);
expect(sessions.revokedSessionIds, ['session-1']);
});
test('logout endpoint returns 401 without bearer token', () async {
final handler = buildApiHandler(authApi: _authApi());
final response = await handler(
Request('POST', Uri.parse('http://localhost/auth/logout')),
);
expect(response.statusCode, 401);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Missing bearer token.'},
);
});
}
AuthApi _authApi({

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/api/share_api.dart';
import 'package:gametime_server/application/application.dart';
@ -22,20 +24,342 @@ void main() {
expect(response.statusCode, 401, reason: request.url.path);
}
});
test('create share returns share id, resolved recipients and unresolved emails',
() async {
final shares = _FakeShareRepository();
final users = _FakeUserRepository([
_user('user-1', 'user@example.com'),
_user('user-2', 'friend@example.com'),
]);
final handler = buildApiHandler(
shareApi: _shareApi(users: users, shares: shares),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'resourceType': 'program',
'payload': {'schemaVersion': 2, 'name': 'Programme été'},
'recipientEmails': [
' friend@example.com ',
'missing@example.com',
'user@example.com',
],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 201);
expect(body['shareId'], 'id-1');
expect(body['recipientUserIds'], ['user-2']);
expect(body['unresolvedEmails'], ['missing@example.com']);
expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program);
});
test('create share returns 400 for invalid resource type', () async {
final handler = buildApiHandler(shareApi: _shareApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'resourceType': 'exercise',
'payload': {'name': 'Squat'},
'recipientEmails': ['friend@example.com'],
}),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'resourceType must be program or workoutTemplate.'},
);
});
test('inbox returns serialized shares for the authenticated recipient',
() async {
final shares = _FakeShareRepository()
..seedInbox(
recipientUserId: 'user-1',
items: [
ShareInboxItem(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipient: ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'GET',
Uri.parse('http://localhost/shares/inbox'),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(items, hasLength(1));
expect((items.single as Map)['shareId'], 'share-1');
expect((items.single as Map)['resourceType'], 'program');
expect((items.single as Map)['status'], 'pending');
});
test('accept share returns the copied synced resource', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.workoutTemplate,
payloadJson: {'schemaVersion': 3, 'name': 'Template A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
],
);
final resources = _FakeSyncedResourceRepository();
final handler = buildApiHandler(
shareApi: _shareApi(shares: shares, resources: resources),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final created = body['createdResource'] as Map;
expect(response.statusCode, 200);
expect(created['resourceType'], 'workoutTemplate');
expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'});
expect(shares.recipientById['recipient-1']?.status,
ShareRecipientStatus.accepted);
expect(resources.items.single.ownerUserId, 'user-1');
});
test('accept share returns 404 when share is missing', () async {
final handler = buildApiHandler(shareApi: _shareApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/missing/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 404);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Share not found.'},
);
});
test('accept share returns 409 when share was already answered', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
status: ShareRecipientStatus.accepted,
respondedAt: DateTime.utc(2026, 7, 19, 11),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 409);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Share has already been answered.'},
);
});
test('decline share returns 204 and updates recipient status', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/decline'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 204);
expect(shares.recipientById['recipient-1']?.status,
ShareRecipientStatus.declined);
});
test('decline share returns 409 when share was already answered', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
status: ShareRecipientStatus.declined,
respondedAt: DateTime.utc(2026, 7, 19, 11),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/decline'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 409);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Share has already been answered.'},
);
});
test('revoke share returns 204 and marks share as revoked', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'user-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: const [],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/revoke'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 204);
expect(shares.shareById['share-1']?.isRevoked, isTrue);
});
test('revoke share returns 404 when requester is not the sender', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'other-user',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: const [],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/revoke'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 404);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Share not found.'},
);
});
}
ShareApi _shareApi() {
ShareApi _shareApi({
_FakeUserRepository? users,
_FakeShareRepository? shares,
_FakeSyncedResourceRepository? resources,
}) {
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final users = _FakeUserRepository(
UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: clock.now(),
updatedAt: clock.now(),
),
);
final userRepository =
users ??
_FakeUserRepository([_user('user-1', 'user@example.com')]);
final sessions = _FakeAuthSessionRepository(
AuthSession(
id: 'session-1',
@ -47,46 +371,49 @@ ShareApi _shareApi() {
);
final tokens = _FakeTokenService();
final authenticate = AuthenticateRequestUseCase(
users: users,
users: userRepository,
sessions: sessions,
tokens: tokens,
clock: clock,
);
final shares = _FakeShareRepository();
final resources = _FakeSyncedResourceRepository();
final shareRepository = shares ?? _FakeShareRepository();
final resourceRepository = resources ?? _FakeSyncedResourceRepository();
return ShareApi(
createShare: CreateShareUseCase(
users: users,
shares: shares,
users: userRepository,
shares: shareRepository,
clock: clock,
ids: ids,
),
listInbox: ListInboxUseCase(shares: shares),
listInbox: ListInboxUseCase(shares: shareRepository),
acceptShare: AcceptShareUseCase(
shares: shares,
resources: resources,
shares: shareRepository,
resources: resourceRepository,
clock: clock,
ids: ids,
),
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
declineShare: DeclineShareUseCase(shares: shareRepository, clock: clock),
revokeShare: RevokeShareUseCase(shares: shareRepository, clock: clock),
authenticateRequest: authenticate,
);
}
final class _FakeUserRepository implements UserRepository {
const _FakeUserRepository(this.user);
_FakeUserRepository(List<UserAccount> users)
: _byId = {for (final user in users) user.id: user},
_byEmail = {for (final user in users) user.email: user};
final UserAccount user;
final Map<String, UserAccount> _byId;
final Map<String, UserAccount> _byEmail;
@override
Future<UserAccount?> findByEmail(String email) async {
return user.email == email.toLowerCase() ? user : null;
return _byEmail[email.trim().toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async {
return id == user.id ? user : null;
return _byId[id];
}
@override
@ -121,20 +448,49 @@ final class _FakeAuthSessionRepository implements AuthSessionRepository {
}
final class _FakeShareRepository implements ShareRepository {
final shareById = <String, Share>{};
final recipientById = <String, ShareRecipient>{};
final recipientByKey = <String, ShareRecipient>{};
final inboxByRecipient = <String, List<ShareInboxItem>>{};
void seedShare({
required Share share,
required List<ShareRecipient> recipients,
}) {
shareById[share.id] = share;
for (final recipient in recipients) {
recipientById[recipient.id] = recipient;
recipientByKey['${recipient.shareId}:${recipient.recipientUserId}'] =
recipient;
}
}
void seedInbox({
required String recipientUserId,
required List<ShareInboxItem> items,
}) {
inboxByRecipient[recipientUserId] = items;
for (final item in items) {
seedShare(share: item.share, recipients: [item.recipient]);
}
}
@override
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
}) async {}
}) async {
seedShare(share: share, recipients: recipients);
}
@override
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
return const [];
return inboxByRecipient[recipientUserId] ?? const [];
}
@override
Future<Share?> findShareById(String shareId) async {
return null;
return shareById[shareId];
}
@override
@ -142,7 +498,7 @@ final class _FakeShareRepository implements ShareRepository {
required String shareId,
required String recipientUserId,
}) async {
return null;
return recipientByKey['$shareId:$recipientUserId'];
}
@override
@ -150,18 +506,60 @@ final class _FakeShareRepository implements ShareRepository {
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
}) async {}
}) async {
final existing = recipientById[recipientId];
if (existing == null) {
return;
}
final updated = ShareRecipient(
id: existing.id,
shareId: existing.shareId,
recipientUserId: existing.recipientUserId,
status: status,
respondedAt: respondedAt,
);
recipientById[recipientId] = updated;
recipientByKey['${existing.shareId}:${existing.recipientUserId}'] = updated;
final inbox = inboxByRecipient[existing.recipientUserId];
if (inbox != null) {
inboxByRecipient[existing.recipientUserId] = [
for (final item in inbox)
if (item.recipient.id == recipientId)
ShareInboxItem(share: item.share, recipient: updated)
else
item,
];
}
}
@override
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
}) async {}
}) async {
final existing = shareById[shareId];
if (existing == null) {
return;
}
final updated = Share(
id: existing.id,
senderUserId: existing.senderUserId,
resourceType: existing.resourceType,
payloadJson: existing.payloadJson,
createdAt: existing.createdAt,
revokedAt: revokedAt,
);
shareById[shareId] = updated;
}
}
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
final items = <SyncedResource>[];
@override
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
items.add(resource);
return SyncWriteResult(
status: SyncWriteStatus.accepted,
resource: resource,
@ -173,7 +571,10 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
required String ownerUserId,
DateTime? since,
}) async {
return const [];
return items
.where((item) => item.ownerUserId == ownerUserId)
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
.toList();
}
}
@ -203,3 +604,13 @@ final class _FakeIds implements IdGenerator {
return 'id-$_next';
}
}
UserAccount _user(String id, String email) {
return UserAccount(
id: id,
email: email,
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19, 12),
updatedAt: DateTime.utc(2026, 7, 19, 12),
);
}

View File

@ -62,6 +62,140 @@ void main() {
]);
expect(repository.items.single.clientId, 'exercise-1');
});
test('pull returns synced items and filters them with since query', () async {
final repository = _FakeSyncedResourceRepository()
..items.addAll([
SyncedResource(
serverId: 'resource-1',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.exercise,
clientId: 'exercise-1',
payloadJson: {'name': 'Squat'},
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
),
SyncedResource(
serverId: 'resource-2',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.program,
clientId: 'program-1',
payloadJson: {'name': 'Programme A'},
schemaVersion: 2,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
),
]);
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
final response = await handler(
Request(
'GET',
Uri.parse(
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
expect(items, hasLength(1));
expect((items.single as Map)['clientId'], 'program-1');
expect((items.single as Map)['resourceType'], 'program');
});
test('pull returns 400 for invalid since query parameter', () async {
final handler = buildApiHandler(syncApi: _syncApi());
final response = await handler(
Request(
'GET',
Uri.parse('http://localhost/sync/pull?since=not-a-date'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Invalid date format'},
);
});
test('exchange returns push results followed by pulled items', () async {
final repository = _FakeSyncedResourceRepository()
..items.add(
SyncedResource(
serverId: 'existing-1',
ownerUserId: 'user-1',
resourceType: SyncedResourceType.workoutHistory,
clientId: 'history-1',
payloadJson: {'score': 12},
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
),
);
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/sync/exchange'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'deviceId': 'device-1',
'since': '2026-07-19T08:30:00Z',
'items': [
{
'resourceType': 'exercise',
'clientId': 'exercise-2',
'schemaVersion': 1,
'clientUpdatedAt': '2026-07-19T10:00:00Z',
'payload': {'name': 'Lunge'},
},
],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final pushResults = body['pushResults'] as List;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(pushResults, hasLength(1));
expect((pushResults.single as Map)['status'], 'accepted');
expect(items, hasLength(2));
expect(
items.map((item) => (item as Map)['clientId']),
containsAll(['history-1', 'exercise-2']),
);
});
test('exchange returns 400 when items is not a json array', () async {
final handler = buildApiHandler(syncApi: _syncApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/sync/exchange'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
),
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'items must be a JSON array.'},
);
});
}
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {

View File

@ -0,0 +1,126 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('builds active timer notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Squats bulgares',
dominantTimer: _timer(label: 'Chrono étape', accumulatedMs: 134000),
),
);
expect(content.title, 'Squats bulgares');
expect(content.primaryLine, '02:14');
expect(content.secondaryLine, 'Série 2/4');
});
test('builds manual score notification content', () {
final content = buildSessionNotificationContent(
_projection(
exerciseName: 'Lancers francs',
hasManualScore: true,
currentManualScoreValue: 8,
),
);
expect(content.title, 'Lancers francs');
expect(content.primaryLine, 'Série 2/4 · 8');
});
test('builds rest notification content with next exercise', () {
final now = DateTime.utc(2026, 7, 26, 10);
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.restRunning,
exerciseName: 'Squats',
nextExerciseName: 'Fentes',
dominantTimer: _timer(
kind: WatchTimerKind.rest,
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.running,
accumulatedMs: 10000,
targetMs: 60000,
startedAtEpochMs: now.millisecondsSinceEpoch,
),
),
now: now.add(const Duration(seconds: 5)),
);
expect(content.title, 'Repos');
expect(content.primaryLine, '00:45 restant · Ensuite : Fentes');
});
test('prefixes paused content without ticking', () {
final content = buildSessionNotificationContent(
_projection(
phase: WatchSessionPhase.paused,
dominantTimer: _timer(
runState: WatchTimerRunState.paused,
accumulatedMs: 42000,
),
),
now: DateTime.utc(2026, 7, 26, 10),
);
expect(content.primaryLine, 'En pause · 00:42');
});
test('falls back to current step name without timer or score', () {
final content = buildSessionNotificationContent(
_projection(stepName: 'Gainage'),
);
expect(content.primaryLine, 'Gainage');
});
}
WatchSessionProjection _projection({
WatchSessionPhase phase = WatchSessionPhase.running,
String exerciseName = 'Pompes',
String? stepName,
WatchTimerProjection? dominantTimer,
bool hasManualScore = false,
double? currentManualScoreValue,
String? nextExerciseName,
}) {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
phase: phase,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: exerciseName,
stepName: stepName,
dominantTimer: dominantTimer,
primaryAction: WatchPrimaryAction.pauseSession,
nextExerciseName: nextExerciseName,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: (currentManualScoreValue ?? 0) > 0,
);
}
WatchTimerProjection _timer({
WatchTimerKind kind = WatchTimerKind.step,
String label = 'Chrono',
WatchTimerDisplayMode displayMode = WatchTimerDisplayMode.elapsed,
WatchTimerRunState runState = WatchTimerRunState.stopped,
int accumulatedMs = 0,
int? startedAtEpochMs,
int? targetMs,
}) {
return WatchTimerProjection(
kind: kind,
label: label,
displayMode: displayMode,
runState: runState,
referenceEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
accumulatedMs: accumulatedMs,
startedAtEpochMs: startedAtEpochMs,
targetMs: targetMs,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -213,6 +213,189 @@ void main() {
expect(env.repository.session?.currentSetIndex, 1);
});
test(
'accepts skipCurrentRest after a refresh that keeps the same revision',
() async {
final session = _session(setsCount: 2);
final repository = _FakeActiveSessionRepository()..session = session;
repository.restStates['rest'] = ActiveRestState(
metadata: _metadata('rest'),
activeWorkoutSessionId: session.metadata.id,
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: _now,
);
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final projectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final handler = WatchCompanionCommandHandler(
sessionRepository: repository,
activeSessionUseCases: activeUseCases,
stepUseCases: ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCases,
),
projectionSource: projectionUseCases,
);
final firstProjection = await projectionUseCases.emitCurrentProjection();
final refreshedProjection = await projectionUseCases
.emitCurrentProjection();
final ack = await handler.dispatch(
_command(
WatchCommandType.skipCurrentRest,
expectedRevision: firstProjection.revision,
),
);
expect(refreshedProjection.revision, firstProjection.revision);
expect(ack, WatchCommandAck.accepted);
expect(repository.restStates['rest']?.skippedAt, isNotNull);
expect(repository.session?.currentSetIndex, 1);
await projectionUseCases.dispose();
},
);
test(
'routes incrementScore and decrementScore to manual score use cases',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 0);
},
);
test('routes score commands to independent current step score', () async {
final session = _session(
scoreEnabled: true,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final env = _env(
session: session,
projection: _projection(
hasManualScore: true,
manualScoreScope: WatchManualScoreScope.step,
),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.single.actualScore, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.last.actualScore, 0);
});
test('decrementScore at zero is accepted no-op', () async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
final ack = await env.dispatch(WatchCommandType.decrementScore);
expect(ack, WatchCommandAck.acceptedNoOp);
expect(env.repository.manualScoreStates.values.single.value, 0);
expect(env.projections.emitCount, 0);
});
test('rejects score commands outside manual score mode', () async {
final env = _env(
session: _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
),
projection: _projection(hasManualScore: false),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.rejectedNotApplicable,
);
expect(env.repository.manualScoreStates, isEmpty);
});
test(
'accepts repeated score commands with stale expected revisions',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(revision: 3, hasManualScore: true),
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-1',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-2',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 2);
},
);
test(
'rejects stale revision, non applicable, missing and mismatch',
() async {
@ -256,6 +439,22 @@ void main() {
);
},
);
test('rejects commands when no session is active', () async {
final env = _env(
projection: _projection(
phase: WatchSessionPhase.noActiveSession,
deviceSessionId: '',
primaryAction: WatchPrimaryAction.none,
),
);
expect(
await env.dispatch(WatchCommandType.startCurrentExercise),
WatchCommandAck.rejectedNoActiveSession,
);
expect(env.repository.session, isNull);
});
}
final _now = DateTime.utc(2026, 7, 25, 12);
@ -263,13 +462,14 @@ final _now = DateTime.utc(2026, 7, 25, 12);
_Harness _env({
ActiveWorkoutSession? session,
required WatchSessionProjection projection,
_FakeWorkoutTemplateRepository? templateRepository,
}) {
final repository = _FakeActiveSessionRepository()..session = session;
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
@ -304,20 +504,27 @@ final class _Harness {
final _FakeProjectionSource projections;
final WatchCompanionCommandHandler handler;
Future<WatchCommandAck> dispatch(WatchCommandType type) {
return handler.dispatch(_command(type));
Future<WatchCommandAck> dispatch(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return handler.dispatch(
_command(type, commandId: commandId, expectedRevision: expectedRevision),
);
}
}
WatchCommandEnvelope _command(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return WatchCommandEnvelope(
commandId: commandId,
type: type,
sessionId: 'session-1',
expectedRevision: 1,
expectedRevision: expectedRevision,
sentAtEpochMs: _now.millisecondsSinceEpoch,
);
}
@ -331,6 +538,10 @@ WatchSessionProjection _projection({
WatchSecondaryAction.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet,
],
bool hasManualScore = false,
double? currentManualScoreValue,
bool canDecrementScore = false,
WatchManualScoreScope? manualScoreScope,
}) {
return WatchSessionProjection(
deviceSessionId: deviceSessionId,
@ -343,6 +554,12 @@ WatchSessionProjection _projection({
exerciseName: 'Squat',
primaryAction: primaryAction,
secondaryActions: secondaryActions,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: canDecrementScore,
manualScoreScope:
manualScoreScope ??
(hasManualScore ? WatchManualScoreScope.series : null),
);
}
@ -354,6 +571,8 @@ ActiveWorkoutSession _session({
bool timeEnabled = false,
int? targetReps = 10,
int restSeconds = 0,
bool scoreEnabled = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<ExerciseStep> steps = const [],
}) {
final exerciseSnapshot = {
@ -362,9 +581,9 @@ ActiveWorkoutSession _session({
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': true,
'scoreEnabled': false,
'scoreEnabled': scoreEnabled,
'targetReps': targetReps,
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
'scoreInputModeSnapshot': scoreInputMode.name,
'restSecondsOverride': restSeconds,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -395,13 +614,24 @@ ActiveWorkoutSession _session({
);
}
ExerciseStep _step({String id = 'step-1', int position = 0}) {
ExerciseStep _step({
String id = 'step-1',
int position = 0,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
position: position,
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: 1,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -473,6 +703,10 @@ final class _FakeProjectionSource implements WatchProjectionSource {
exerciseName: projection.exerciseName,
primaryAction: projection.primaryAction,
secondaryActions: projection.secondaryActions,
hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore,
manualScoreScope: projection.manualScoreScope,
);
return projection;
}
@ -499,11 +733,24 @@ final class _FakeIds implements IdGenerator {
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
final templates = <WorkoutTemplate>[];
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
Future<WorkoutTemplate?> findById(String id) async {
for (final template in templates) {
if (template.metadata.id == id && template.metadata.deletedAt == null) {
return template;
}
}
return null;
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
Future<void> save(WorkoutTemplate template) async {
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@ -527,6 +777,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -541,6 +792,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -584,6 +846,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -633,6 +910,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.removeWhere((item) => item.metadata.id == result.metadata.id);
stepResults.add(result);
}
@ -674,6 +961,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

@ -40,23 +40,61 @@ void main() {
expect(projection.primaryAction, WatchPrimaryAction.startCurrentExercise);
});
test('projects running with duration timer before stopwatch score', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
timeEnabled: true,
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
steps: [_step(defaultTargetValue: 30)],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now.subtract(const Duration(seconds: 5)),
)
..scoreStopwatchStates['score'] = _scoreStopwatch(
sessionId: session.metadata.id,
startedAt: now.subtract(const Duration(seconds: 4)),
)
..setTimerStates['set'] = _setTimer(
sessionId: session.metadata.id,
startedAt: now.subtract(const Duration(seconds: 6)),
);
final projector = _projector(repository, _clock(now));
final projection = await projector.project(revision: 1);
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
expect(projection.dominantTimer?.accumulatedMs, 5000);
expect(
projection.dominantTimer?.startedAtEpochMs,
now.millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.setTimer,
]);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
projection.secondaryActions,
contains(WatchSecondaryAction.finishCurrentSet),
);
});
test(
'projects running with dominant step timer and secondary timers',
'projects duration and stopwatch score with only duration timer visible',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
timeEnabled: true,
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
steps: [_step(defaultTargetValue: 30)],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now.subtract(const Duration(seconds: 5)),
)
..scoreStopwatchStates['score'] = _scoreStopwatch(
sessionId: session.metadata.id,
startedAt: now.subtract(const Duration(seconds: 4)),
@ -70,16 +108,8 @@ void main() {
final projection = await projector.project(revision: 1);
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
expect(projection.dominantTimer?.accumulatedMs, 0);
expect(
projection.dominantTimer?.startedAtEpochMs,
now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.scoreStopwatch,
WatchTimerKind.setTimer,
]);
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
projection.secondaryActions,
@ -88,6 +118,103 @@ void main() {
},
);
test('projects manual score state for the current set', () async {
final session = _session(scoreEnabled: true, targetScore: 8);
final repository = _FakeActiveSessionRepository()
..session = session
..manualScoreStates['manual-score'] = ActiveManualScoreState(
metadata: _metadata('manual-score'),
activeWorkoutSessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 3,
updatedAt: DateTime.utc(2026, 7, 25, 12),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 3);
expect(projection.canDecrementScore, isTrue);
expect(projection.manualScoreTargetValue, 8);
expect(projection.manualScoreTargetLabel, 'Cible');
expect(projection.manualScoreScope, WatchManualScoreScope.series);
});
test(
'projects independent current step manual score before series score',
() async {
final session = _session(
scoreEnabled: true,
targetScore: 8,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.waitingManual,
)
..stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-score'),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Step 1',
stepTypeSnapshot: ExerciseStepType.time,
targetValueSnapshot: 1,
hasScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Réussites',
scoreUnitSnapshot: 'pts',
targetScoreSnapshot: 5,
status: SetResultStatus.completed,
actualScore: 2,
),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 2);
expect(projection.manualScoreTargetValue, 5);
expect(projection.manualScoreScope, WatchManualScoreScope.step);
},
);
test('does not project manual score controls for stopwatch score', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
});
test('projects paused after a running session is paused', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -282,14 +409,22 @@ void main() {
);
test(
'emits projections through stream and publisher with incremented revision',
'keeps revision stable when refresh only changes volatile timing fields',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final clock = _clock(now);
final session = _session(steps: [_step(defaultTargetValue: 30)]);
final repository = _FakeActiveSessionRepository()
..session = _session(steps: [_step()]);
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
@ -298,21 +433,54 @@ void main() {
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
clock.value = now.add(const Duration(seconds: 2));
final second = await useCases.emitCurrentProjection();
await Future<void>.delayed(Duration.zero);
expect(first.revision, 1);
expect(second.revision, 2);
expect(emitted.map((projection) => projection.revision), [1, 2]);
expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(
second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
);
expect(emitted.map((projection) => projection.revision), [1, 1]);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
1,
]);
await subscription.cancel();
await useCases.dispose();
},
);
test('increments revision when session command state changes', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(setsCount: 2);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
);
final first = await useCases.emitCurrentProjection();
repository.session = repository.session!.copyWith(currentSetIndex: 1);
final second = await useCases.emitCurrentProjection();
expect(first.revision, 1);
expect(second.revision, 2);
expect(second.seriesIndex, 2);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
]);
await useCases.dispose();
});
}
WatchSessionProjectionProjector _projector(
@ -341,6 +509,7 @@ ActiveWorkoutSession _session({
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
double? targetScore,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
@ -355,6 +524,7 @@ ActiveWorkoutSession _session({
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? setsCount : null,
'targetScore': targetScore,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -403,6 +573,10 @@ ExerciseStep _step({
String id = 'step-1',
int position = 0,
int defaultTargetValue = 1,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
@ -410,6 +584,10 @@ ExerciseStep _step({
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -514,6 +692,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -528,6 +707,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -571,6 +761,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -620,6 +825,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +875,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

@ -1532,6 +1532,53 @@ void main() {
},
);
test(
'active set result persists duration and stopwatch score separately',
() async {
final now = DateTime.utc(2026, 7, 17, 12);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('session-chrono', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
),
);
await activeRepository.saveSetResult(
ActiveSetResult(
metadata: _metadata('active-result-chrono', now),
activeWorkoutSessionId: 'session-chrono',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualTimeMs: 30000,
actualScoreTimeMs: 12000,
scoreInputModeSnapshot: ScoreInputMode.stopwatch,
completedAt: now,
),
);
final afterAppKillRepository = local.DriftActiveSessionRepository(
database,
);
final restored = await afterAppKillRepository.listSetResults(
'session-chrono',
);
expect(restored, hasLength(1));
expect(restored.single.actualTimeMs, 30000);
expect(restored.single.actualScoreTimeMs, 12000);
expect(restored.single.scoreInputModeSnapshot, ScoreInputMode.stopwatch);
},
);
test(
'closing a session stores autonomous history rows with set snapshots',
() async {

View File

@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/infrastructure/remote/remote.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'defaultBaseUrl keeps localhost outside Android without env override',
() {
expect(
HttpApiClient.defaultBaseUrlFor(isAndroid: false),
'http://localhost:8080',
);
},
);
test('defaultBaseUrl uses Android emulator host without env override', () {
expect(
HttpApiClient.defaultBaseUrlFor(isAndroid: true),
'http://10.0.2.2:8080',
);
});
test('defaultBaseUrl keeps env override on Android', () {
expect(
HttpApiClient.defaultBaseUrlFor(
isAndroid: true,
configuredBaseUrl: ' http://192.168.1.42:8080 ',
),
'http://192.168.1.42:8080',
);
});
test('postJson maps server errors to server failure', () async {
final client = HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient(
(_) async => http.Response('{"error":"internal"}', 500),
),
);
await expectLater(
client.postJson('/auth/login'),
throwsA(
isA<RemoteAuthException>()
.having(
(error) => error.failure,
'failure',
RemoteAuthFailure.server,
)
.having((error) => error.message, 'message', 'internal'),
),
);
});
test('postJson maps transport failures to network failure', () async {
final client = HttpApiClient(
baseUrl: Uri.parse('http://api.example.test'),
client: MockClient((_) async => throw http.ClientException('refused')),
);
await expectLater(
client.postJson('/auth/login'),
throwsA(
isA<RemoteAuthException>()
.having(
(error) => error.failure,
'failure',
RemoteAuthFailure.network,
)
.having((error) => error.message, 'message', 'refused'),
),
);
});
}

View File

@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/infrastructure/infrastructure.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/infrastructure/infrastructure.dart'
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
@ -120,19 +122,122 @@ void main() {
]);
await adapter.stop();
});
test('patches workout history when a sensor summary arrives', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_projection(revision: 0));
final historyRepository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: _now.subtract(const Duration(hours: 1)),
endedAt: _now,
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final adapter = _adapter(
native: native,
source: source,
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: _FakeClock(_now),
),
);
await adapter.start();
native.emitSensorSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 8,
averageHeartRateBpm: 121.5,
maxHeartRateBpm: 168,
),
);
await Future<void>.delayed(Duration.zero);
expect(historyRepository.histories.single.averageHeartRateBpm, 121.5);
expect(historyRepository.histories.single.maxHeartRateBpm, 168);
await adapter.stop();
});
test('records live telemetry samples in active sensor state', () async {
final native = _FakeWatchBridgeNativeChannel();
final source = _FakeProjectionSource(_runningProjection(revision: 1));
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now));
final adapter = _adapter(
native: native,
source: source,
sensorUseCases: sensorUseCases,
);
await adapter.start();
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-1',
sessionId: 'session-1',
recordedAtEpochMs: _now.millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
),
);
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-2',
sessionId: 'session-1',
recordedAtEpochMs: _now
.add(const Duration(minutes: 30))
.millisecondsSinceEpoch,
heartRateBpm: 150,
distanceMeters: 900,
caloriesKcal: 120,
),
);
native.emitSensorSample(
WatchSensorSample(
sampleId: 'sample-2',
sessionId: 'session-1',
recordedAtEpochMs: _now
.add(const Duration(minutes: 31))
.millisecondsSinceEpoch,
heartRateBpm: 170,
distanceMeters: 100,
caloriesKcal: 10,
),
);
await Future<void>.delayed(Duration.zero);
final state = sensorUseCases.current('session-1');
expect(state?.latestHeartRateBpm, 150);
expect(state?.sampleCount, 2);
expect(state?.averageHeartRateBpm, 135);
expect(state?.maxHeartRateBpm, 150);
expect(state?.latestDistanceMeters, 900);
expect(state?.latestCaloriesKcal, 120);
expect(state?.estimatedCaloriesKcal, 187.5);
await adapter.stop();
await sensorUseCases.dispose();
});
}
WatchWearDataLayerAdapter _adapter({
required _FakeWatchBridgeNativeChannel native,
WatchCommandIngress? ingress,
required _FakeProjectionSource source,
WorkoutHistoryUseCases? historyUseCases,
ActiveWorkoutSensorUseCases? sensorUseCases,
Duration heartbeatInterval = const Duration(seconds: 5),
}) {
return WatchWearDataLayerAdapter(
nativeChannel: native,
commandIngress: ingress ?? _FakeCommandIngress(),
projectionSource: source,
heartbeatInterval: heartbeatInterval,
workoutHistoryUseCases: historyUseCases,
activeWorkoutSensorUseCases: sensorUseCases,
projectionRefreshInterval: heartbeatInterval,
);
}
@ -189,6 +294,24 @@ WatchSessionProjection _runningProjection({required int revision}) {
final _now = DateTime.utc(2026, 7, 25, 12);
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: _now,
updatedAt: _now,
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeProjectionSource implements WatchProjectionSource {
_FakeProjectionSource(this.current);
@ -260,6 +383,8 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
final published = <WatchSessionProjection>[];
final acks = <_SentAck>[];
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
var capabilityRefreshCount = 0;
var foregroundStartCount = 0;
@ -268,6 +393,12 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
@override
Stream<WatchCommandEnvelope> get commands => _commands.stream;
@override
Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSamples.stream;
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connections.stream;
@ -276,6 +407,14 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
_commands.add(command);
}
void emitSensorSummary(WatchSensorSummary summary) {
_sensorSummaries.add(summary);
}
void emitSensorSample(WatchSensorSample sample) {
_sensorSamples.add(sample);
}
void emitConnection(WatchBridgeConnectionEvent event) {
_connections.add(event);
}
@ -310,6 +449,54 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async => histories;
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
(history) =>
history.metadata.id == historyId &&
history.averageHeartRateBpm == null &&
history.maxHeartRateBpm == null,
);
if (index == -1) {
return;
}
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {}
}
final class _SentAck {
const _SentAck(this.command, this.ack, this.revisionAtAck);

View File

@ -107,13 +107,13 @@ void main() {
expect(find.widgetWithText(Chip, 'match'), findsOneWidget);
await tester.dragUntilVisible(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
find.byType(ListView),
const Offset(0, -200),
);
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.dragUntilVisible(
@ -195,7 +195,7 @@ void main() {
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Run');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
@ -405,6 +405,66 @@ void main() {
expect(find.text('Mode de score'), findsOneWidget);
});
testWidgets('une étape peut utiliser le score de la série', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 4200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Score à saisir').first,
'Paniers',
);
await tester.enterText(find.widgetWithText(TextFormField, 'Unité'), 'pts');
await tester.enterText(
find.widgetWithText(TextFormField, 'Score par défaut'),
'0',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.tap(_stepTypeRadio('Répétitions').last);
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Tirs main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
'10',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score détape'));
await tester.pump();
await tester.tap(
find.widgetWithText(SwitchListTile, 'Utiliser le score de la série'),
);
await tester.pump();
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final step = exerciseRepository.saved.single.steps.single;
expect(step.linkedToSeriesScore, isTrue);
expect(step.scoreLabel, 'Paniers');
expect(step.scoreUnit, 'pts');
expect(step.defaultTargetScore, isNull);
});
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
@ -510,7 +570,7 @@ void main() {
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(
@ -543,6 +603,82 @@ void main() {
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets('une étape invalide affiche une erreur inline sans sauvegarder', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'abc',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(find.text('Saisis une durée supérieure à 0.'), findsOneWidget);
expect(exerciseRepository.saved, isEmpty);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('un échec inattendu de sauvegarde libère le bouton', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()..throwOnSave = true;
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(
find.text(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNotNull,
);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(tester) async {
@ -557,7 +693,7 @@ void main() {
'Combo',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.tap(
@ -621,7 +757,7 @@ void main() {
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Squat');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'45',
);
await tester.tap(find.text('Ajouter une image'));
@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
final exercises = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
var throwOnSave = false;
@override
Future<Exercise?> findById(String id) async {
@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<void> save(Exercise exercise) async {
if (throwOnSave) {
throw Exception('save failed');
}
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);

View File

@ -71,6 +71,32 @@ void main() {
);
});
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(
id: 'history-1',
averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171,
),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsOneWidget);
expect(find.text('126 bpm', findRichText: true), findsOneWidget);
expect(find.text('171 bpm', findRichText: true), findsOneWidget);
});
testWidgets('le détail affiche le score chrono comme temps réalisé', (
tester,
) async {
@ -156,6 +182,72 @@ void main() {
expect(repository.deletedIds, ['history-1']);
});
testWidgets('relancer un historique vide affiche une erreur sans navigation', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-empty', emptySnapshot: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pump();
expect(
find.text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
findsOneWidget,
);
expect(find.byType(WorkoutExecutionScreen), findsNothing);
});
testWidgets('une session legacy invalide ne bloque pas une relance valide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1'),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, isNull);
});
}
WorkoutHistory _history({
@ -163,6 +255,9 @@ WorkoutHistory _history({
DateTime? startedAt,
bool stopwatchScore = false,
bool withStepResults = false,
bool emptySnapshot = false,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
}) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory(
@ -173,13 +268,17 @@ WorkoutHistory _history({
startedAt: start,
endedAt: start.add(const Duration(minutes: 30)),
totalActiveMs: 1800000,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
completed: true,
historySnapshotJson: jsonEncode({
'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'resolvedTemplateSnapshotJson': emptySnapshot
? _emptyResolvedSnapshot()
: _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'results': [
{
'programSnapshotId': 'program-snapshot-1',
@ -308,6 +407,20 @@ String _resolvedSnapshot({
});
}
String _emptyResolvedSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'program-snapshot-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -336,9 +449,11 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
);
}
ActiveWorkoutSessionUseCases _activeUseCases() {
ActiveWorkoutSessionUseCases _activeUseCases([
_FakeActiveSessionRepository? repository,
]) {
return ActiveWorkoutSessionUseCases(
sessionRepository: _FakeActiveSessionRepository(),
sessionRepository: repository ?? _FakeActiveSessionRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
@ -392,6 +507,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository {
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
ActiveWorkoutSession? session;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}

View File

@ -102,6 +102,37 @@ void main() {
);
});
testWidgets('le bandeau de reprise masque une séance invalide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
tester,
) async {
@ -196,6 +227,9 @@ final class _FakeBootstrap implements AppDependencies {
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
@ -282,6 +316,9 @@ final class _FakeBootstrap implements AppDependencies {
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@ -327,6 +364,20 @@ String _sessionSnapshot() {
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -553,6 +604,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -585,6 +646,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return scoreStopwatchStates;
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates;
@ -624,6 +692,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.add(state);
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates.add(state);
@ -646,6 +717,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
@ -808,6 +888,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -27,7 +27,7 @@ void main() {
);
await tester.pump();
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
expect(find.text('Créer un compte'), findsOneWidget);
expect(find.text('Se connecter'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
@ -52,6 +52,43 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Tu peux revenir à GameTime.'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'a@b.fr',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('une erreur serveur affiche le message dédié', (tester) async {
final harness = _AuthHarness()
..remote.loginFailure = RemoteAuthFailure.server;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
await tester.enterText(
@ -65,7 +102,10 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(
find.text('Serveur indisponible pour le moment. Réessaie plus tard.'),
findsOneWidget,
);
expect(find.byType(AlertDialog), findsNothing);
});
@ -142,7 +182,7 @@ void main() {
expect(harness.tokenStore.token, isNull);
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
});
testWidgets(
@ -295,36 +335,33 @@ void main() {
},
);
testWidgets(
'export réussi affiche le message de sauvegarde exportée',
(tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(
status: ShareResultStatus.success,
);
testWidgets('export réussi affiche le message de sauvegarde exportée', (
tester,
) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(status: ShareResultStatus.success);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
);
await tester.pump();
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
},
);
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
});
testWidgets('un échec dexport affiche un message neutre', (tester) async {
final harness = _AuthHarness();
@ -404,74 +441,70 @@ void main() {
]);
});
testWidgets(
'remplacer tout exige une seconde confirmation destructive',
(tester) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
testWidgets('remplacer tout exige une seconde confirmation destructive', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
),
);
await tester.pump();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Remplacer toutes les données locales ?'), findsOneWidget);
expect(harness.backupRepository.appliedModes, isEmpty);
expect(
find.text('Remplacer toutes les données locales ?'),
findsOneWidget,
);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
expect(harness.backupRepository.appliedModes, isEmpty);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
},
);
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
});
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
final harness = _AuthHarness();

View File

@ -15,6 +15,22 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository();
@ -65,6 +81,44 @@ void main() {
);
});
testWidgets('un programme sans exercice ne peut pas être enregistré', (
tester,
) async {
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
expect(
find.text('Ajoute au moins un exercice pour enregistrer ce programme.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(programRepository.saved, isEmpty);
});
testWidgets('filtre les programmes par tag et peut effacer les filtres', (
tester,
) async {
@ -125,9 +179,36 @@ void main() {
name: 'Jambes',
defaultRestSeconds: 60,
tags: const ['match'],
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
@ -167,9 +248,36 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
@ -707,12 +815,12 @@ void main() {
findsOneWidget,
);
await tester.dragUntilVisible(
find.text('Objectif de chrono'),
find.text('Objectif chrono (optionnel)'),
find.byType(ListView),
const Offset(0, -200),
);
expect(
find.widgetWithText(TextFormField, 'Objectif de chrono'),
find.widgetWithText(TextFormField, 'Objectif chrono (optionnel)'),
findsOneWidget,
);
expect(find.widgetWithText(TextFormField, 'Cible score'), findsNothing);

View File

@ -456,6 +456,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -539,6 +547,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -567,6 +583,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
String sessionId,
) async => const [];
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async => const [];
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -594,9 +615,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
@ -41,10 +42,52 @@ void main() {
);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:45'), findsOneWidget);
expect(find.text('Durée de série : 00:45'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
});
testWidgets('une séance invalide est abandonnée avec un message clair', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Séance indisponible'), findsOneWidget);
expect(
find.text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
findsOneWidget,
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
});
testWidgets('les répétitions sont initialisées avec la cible', (
tester,
) async {
@ -81,6 +124,214 @@ void main() {
expect(find.text('8'), findsOneWidget);
});
testWidgets('affiche les statistiques montre live disponibles', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
distanceMeters: 840,
caloriesKcal: 186,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
});
testWidgets('naffiche aucun indicateur capteur sans donnée FC', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.textContaining('bpm'), findsNothing);
expect(find.textContaining('kcal'), findsNothing);
expect(find.byIcon(Icons.favorite), findsNothing);
expect(find.byIcon(Icons.local_fire_department), findsNothing);
});
testWidgets('ne fabrique pas de calories sans mesure montre native', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing);
});
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(seconds: 30))
.millisecondsSinceEpoch,
heartRateBpm: 118,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 118 bpm'), findsOneWidget);
expect(
find.ancestor(
of: find.text('FC 118 bpm'),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.opacity == 0.45,
),
),
findsOneWidget,
);
});
testWidgets('les références de temps sont formatées sans valeurs brutes', (
tester,
) async {
@ -141,7 +392,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(find.text('Temps de série : 05:00'), findsOneWidget);
expect(find.text('Durée de série : 05:00'), findsOneWidget);
expect(find.text('1:12 · 00:45.1'), findsOneWidget);
expect(find.text('00:42.8'), findsOneWidget);
expect(find.textContaining('72000'), findsNothing);
@ -152,7 +403,9 @@ void main() {
tester,
) async {
final scoreController = TextEditingController();
final scoreFocusNode = FocusNode();
addTearDown(scoreController.dispose);
addTearDown(scoreFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
@ -170,13 +423,15 @@ void main() {
),
reps: 0,
scoreController: scoreController,
scoreFocusNode: scoreFocusNode,
onScoreSubmitted: (_) {},
onRepsChanged: (_) {},
),
),
),
);
final timeY = tester.getTopLeft(find.text('Temps')).dy;
final timeY = tester.getTopLeft(find.text('Durée')).dy;
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
@ -426,6 +681,7 @@ void main() {
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -492,6 +748,7 @@ void main() {
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -512,12 +769,12 @@ void main() {
await tester.tap(find.text('Terminer la série'));
await tester.pumpAndSettle();
expect(find.text('Chrono non lancé'), findsOneWidget);
expect(find.text('Aucun temps chronométré'), findsOneWidget);
expect(find.text('Tu nas pas démarré le chrono score.'), findsOneWidget);
expect(
find.text('Tu nas pas démarré le chrono de cette série.'),
find.widgetWithText(TextButton, 'Démarrer le chrono'),
findsOneWidget,
);
expect(find.widgetWithText(TextButton, 'Démarrer'), findsOneWidget);
expect(find.text('Terminer sans chrono'), findsOneWidget);
expect(repository.results, isEmpty);
});
@ -542,6 +799,7 @@ void main() {
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: false,
scoreInputMode: ScoreInputMode.stopwatch,
),
);
@ -736,6 +994,65 @@ void main() {
expect(repository.results.single.actualTimeMs, 2000);
});
testWidgets(
'durée et score chrono utilisent un seul chrono visible en exécution',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 2,
timeEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
targetTimeSeconds: 30,
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
clock.value = DateTime.utc(2026, 7, 17, 12, 0, 2);
await tester.pump(const Duration(seconds: 2));
expect(find.text('Chrono score'), findsNothing);
expect(find.text('Durée de série : 00:02'), findsOneWidget);
await tester.tap(find.text('Terminer la série'));
await tester.pump();
final result = repository.results.single;
expect(result.actualTimeMs, 2000);
expect(result.actualScoreTimeMs, 2000);
},
);
testWidgets(
'terminer la série 2 avec temps répétitions et score manuel avance',
(tester) async {
@ -780,7 +1097,7 @@ void main() {
expect(find.text('Programme jambes'), findsOneWidget);
expect(find.text('2 / 3'), findsOneWidget);
expect(find.text('Squat'), findsOneWidget);
expect(find.text('Temps de série : 00:30'), findsOneWidget);
expect(find.text('Durée de série : 00:30'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Démarrer').first);
await tester.pump();
@ -808,6 +1125,63 @@ void main() {
},
);
testWidgets(
'la saisie du score téléphone alimente le score vivant de la montre',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final activeUseCases = _activeUseCases(repository, clock);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
restSeconds: 0,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'7.5',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(repository.manualScoreStates.single.value, 7.5);
final incremented = await activeUseCases.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
},
);
testWidgets('la flèche de retour met la séance active en pause', (
tester,
) async {
@ -1186,11 +1560,75 @@ void main() {
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Dribble main droite'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
});
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
targetReps: 20,
scoreEnabled: true,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Tirs après dribble',
ExerciseStepType.time,
10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'paniers',
defaultTargetScore: 8,
),
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Tirs après dribble'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('Passer létape'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsWidgets);
});
testWidgets('lécran actif avec séquence longue reste sans scroll', (
tester,
) async {
@ -1465,7 +1903,7 @@ void main() {
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.completed);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(
@ -1526,7 +1964,7 @@ void main() {
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono prêt'), findsOneWidget);
@ -1847,6 +2285,20 @@ String _sessionSnapshot({
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
Map<String, Object?> _step(
String id,
int position,
@ -1960,6 +2412,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final manualScoreStates = <ActiveManualScoreState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -2001,6 +2454,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in manualScoreStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -2055,6 +2527,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -2114,6 +2595,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
final index = manualScoreStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
manualScoreStates.add(state);
} else {
manualScoreStates[index] = state;
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
@ -2183,6 +2676,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(
@ -2237,6 +2747,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -126,6 +126,134 @@ void main() {
);
});
testWidgets('une séance sans programme ne peut pas être enregistrée', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository(const []);
final programRepository = _FakeProgramRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
),
),
);
expect(
find.text('Ajoute au moins un programme pour enregistrer cette séance.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(templateRepository.saved, isEmpty);
});
testWidgets('le lancement est bloqué si aucun exercice ne peut être joué', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
WorkoutTemplate(
metadata: _metadata('template-empty'),
name: 'Séance vide',
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-empty'),
workoutTemplateId: 'template-empty',
position: 0,
programNameSnapshot: 'Programme vide',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pump();
expect(
find.text(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
),
findsOneWidget,
);
expect(activeRepository.session, isNull);
});
testWidgets('filtre les séances par tag et peut effacer les filtres', (
tester,
) async {
@ -517,6 +645,89 @@ void main() {
},
);
testWidgets('une session legacy invalide ne bloque pas le lancement', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
}),
);
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, 'template-1');
});
testWidgets(
'supprimer une séance demande confirmation puis la retire de la liste',
(tester) async {
@ -828,6 +1039,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -860,6 +1081,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -896,6 +1124,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -916,6 +1147,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}
@ -964,6 +1204,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -1,9 +1,32 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.flutter.flutter-gradle-plugin")
}
val sharedSigningPropertiesFile =
rootProject.projectDir.parentFile.parentFile.resolve("android/key.properties")
val sharedSigningProperties =
Properties().also { properties ->
check(sharedSigningPropertiesFile.isFile) {
"Shared signing config missing: ${sharedSigningPropertiesFile.absolutePath}"
}
sharedSigningPropertiesFile.inputStream().use(properties::load)
}
fun requiredSharedSigningProperty(name: String): String =
sharedSigningProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: error("Shared signing property '$name' missing in ${sharedSigningPropertiesFile.absolutePath}")
val sharedSigningStoreFile =
sharedSigningPropertiesFile.parentFile.resolve(requiredSharedSigningProperty("storeFile"))
check(sharedSigningStoreFile.isFile) {
"Shared signing keystore missing: ${sharedSigningStoreFile.absolutePath}"
}
android {
namespace = "com.gametime.watch"
compileSdk = flutter.compileSdkVersion
@ -15,24 +38,46 @@ android {
}
defaultConfig {
applicationId = "com.gametime.watch"
minSdk = 26
// Wear OS Data Layer requires matching package names and signatures
// across phone and watch APKs.
applicationId = "com.gametime.app"
minSdk = 30
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("sharedLocal") {
storeFile = sharedSigningStoreFile
storePassword = requiredSharedSigningProperty("storePassword")
keyAlias = requiredSharedSigningProperty("keyAlias")
keyPassword = requiredSharedSigningProperty("keyPassword")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("sharedLocal")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.health:health-services-client:1.1.0-rc02")
implementation("androidx.wear:wear:1.4.0")
implementation("androidx.wear:wear-ongoing:1.0.0")
implementation("com.google.guava:guava:33.6.0-android")
implementation("com.google.android.gms:play-services-wearable:19.0.0")
}

View File

@ -4,16 +4,29 @@
android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission
android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" />
<uses-permission
android:name="android.permission.BODY_SENSORS_BACKGROUND"
android:maxSdkVersion="35" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<application
android:label="GameTime"
android:name="${applicationName}"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/LaunchTheme"
android:usesCleartextTraffic="false">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<meta-data
android:name="com.google.android.wearable.capabilities"
android:resource="@array/android_wear_capabilities" />
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />

View File

@ -1,12 +1,40 @@
package com.gametime.watch
import android.os.Bundle
import androidx.wear.ambient.AmbientModeSupport
import com.gametime.watch.bridge.WatchBridgePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
class MainActivity :
FlutterFragmentActivity(),
AmbientModeSupport.AmbientCallbackProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AmbientModeSupport.attach(this)
WatchBridgePlugin.attachActivity(this)
}
override fun onDestroy() {
WatchBridgePlugin.detachActivity(this)
super.onDestroy()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
WatchBridgePlugin.register(flutterEngine, applicationContext)
}
override fun getAmbientCallback(): AmbientModeSupport.AmbientCallback {
return object : AmbientModeSupport.AmbientCallback() {}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
WatchBridgePlugin.handlePermissionResult(requestCode, permissions, grantResults)
}
}

View File

@ -1,7 +1,13 @@
package com.gametime.watch.bridge
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem
@ -15,20 +21,40 @@ import org.json.JSONObject
import java.nio.charset.StandardCharsets
object WatchBridgePlugin {
private const val TAG = "GTWatchBridgeWatch"
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
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 SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
private const val SENSOR_PERMISSION_REQUEST = 4106
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
private const val READ_HEART_RATE_PERMISSION =
"android.permission.health.READ_HEART_RATE"
private var appContext: Context? = null
private var activity: Activity? = null
private var projectionSink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val heartRateCollector = WatchHeartRateCollector(
phoneCapability = PHONE_CAPABILITY,
sensorSummaryPath = SENSOR_SUMMARY_PATH,
sensorSamplePath = SENSOR_SAMPLE_PATH,
onLocalSample = ::emitSensorSample,
)
private var sensorPermissionRequestInFlight = false
private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -59,6 +85,18 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSampleSink = events
}
override fun onCancel(arguments: Any?) {
sensorSampleSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
@ -72,27 +110,81 @@ object WatchBridgePlugin {
}
},
)
requestPendingSensorPermissionIfPossible()
}
fun attachActivity(activity: Activity) {
this.activity = activity
requestPendingSensorPermissionIfPossible()
}
fun detachActivity(activity: Activity) {
if (this.activity === activity) {
this.activity = null
}
}
fun handlePermissionResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
if (requestCode != SENSOR_PERMISSION_REQUEST) {
return
}
sensorPermissionRequestInFlight = false
val granted = appContext?.let(::hasRequiredSensorPermissions) == true ||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
} else {
val denied = permissions.filterIndexed { index, _ ->
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
}
Log.w(TAG, "sensor permissions denied=${denied.joinToString()}")
}
}
fun emitProjection(payload: Map<String, Any?>): Boolean {
appContext?.let {
WatchOngoingActivityController.update(it, payload, activity)
updateHeartRateCollection(it, payload)
}
val sink = projectionSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitAck(payload: Map<String, Any?>): Boolean {
val sink = ackSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
val sink = connectionSink ?: return
mainHandler.post {
sink.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
}
fun handleDataEvent(event: DataEvent) {
@ -105,7 +197,8 @@ object WatchBridgePlugin {
.dataMap
.getString("projectionJson")
?: return
emitProjection(JSONObject(projectionJson).toMap())
val projection = JSONObject(projectionJson).toMap()
emitProjection(projection)
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
@ -194,6 +287,7 @@ object WatchBridgePlugin {
fun requestLatestProjection(context: Context) {
val uri = Uri.Builder()
.scheme("wear")
.authority("*")
.path(STATE_PATH)
.build()
Wearable.getDataClient(context)
@ -212,6 +306,116 @@ object WatchBridgePlugin {
}
}
}
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
heartRateCollector.finishCurrentSession(context)
return
}
val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) {
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
executionContext = telemetryContext(projection),
)
pendingSensorPermissionRequest = true
requestSensorPermissionsOnce()
return
}
pendingSensorPermissionRequest = false
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate,
executionContext = telemetryContext(projection),
)
if (shouldAggregate) {
heartRateCollector.start(context)
} else {
heartRateCollector.pause(context)
}
}
private fun hasRequiredSensorPermissions(context: Context): Boolean {
return requiredSensorPermissions().all { permission ->
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestSensorPermissionsOnce() {
val activity = activity ?: run {
Log.d(TAG, "sensor permission request pending: activity unavailable")
return
}
val permissions = requiredSensorPermissions()
.filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
.toTypedArray()
if (permissions.isEmpty()) {
pendingSensorPermissionRequest = false
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
return
}
val now = System.currentTimeMillis()
if (sensorPermissionRequestInFlight &&
now - lastSensorPermissionRequestEpochMs < SENSOR_PERMISSION_RETRY_DELAY_MS
) {
Log.d(
TAG,
"sensor permission request in flight missing=${permissions.joinToString()}",
)
return
}
if (!sensorPermissionRequestInFlight &&
now - lastSensorPermissionRequestEpochMs < SENSOR_PERMISSION_RETRY_DELAY_MS
) {
Log.d(
TAG,
"sensor permission request throttled missing=${permissions.joinToString()}",
)
return
}
sensorPermissionRequestInFlight = true
lastSensorPermissionRequestEpochMs = now
Log.d(TAG, "request sensor permissions=${permissions.joinToString()}")
activity.requestPermissions(permissions, SENSOR_PERMISSION_REQUEST)
}
private fun requestPendingSensorPermissionIfPossible() {
val context = appContext ?: return
if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) {
return
}
requestSensorPermissionsOnce()
}
private fun requiredSensorPermissions(): List<String> {
val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) {
READ_HEART_RATE_PERMISSION
} else {
android.Manifest.permission.BODY_SENSORS
}
return listOf(
heartRatePermission,
android.Manifest.permission.ACTIVITY_RECOGNITION,
)
}
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
return mapOf(
"programIndex" to projection["programIndex"],
"exerciseIndex" to projection["exerciseIndex"],
"setIndex" to projection["setIndex"],
"passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]),
"stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]),
)
}
private fun zeroBasedNullableIndex(value: Any?): Int? {
val index = (value as? Number)?.toInt() ?: return null
return if (index > 0) index - 1 else null
}
}
private fun JSONObject.toMap(): Map<String, Any?> {

View File

@ -0,0 +1,250 @@
package com.gametime.watch.bridge
import android.content.Context
import android.util.Log
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
import androidx.health.services.client.MeasureCallback
import androidx.health.services.client.data.Availability
import androidx.health.services.client.data.DataPointContainer
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.DeltaDataType
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import kotlin.math.roundToInt
internal class WatchHeartRateCollector(
private val phoneCapability: String,
private val sensorSummaryPath: String,
private val sensorSamplePath: String,
private val onLocalSample: (Map<String, Any?>) -> Unit = {},
) {
private companion object {
const val TAG = "GTWatchHeartRate"
}
private var sessionId: String? = null
private var sampleCount = 0
private var sampleSum = 0.0
private var minBpm: Int? = null
private var maxBpm: Int? = null
private var distanceMeters: Double? = null
private var caloriesKcal: Double? = null
private var sampleSequence = 0
private var executionContext: Map<String, Any?> = emptyMap()
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false
private var appContext: Context? = null
private val callback = object : MeasureCallback {
override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>,
availability: Availability,
) {
Log.d(TAG, "availability dataType=$dataType availability=$availability")
}
override fun onDataReceived(data: DataPointContainer) {
if (!shouldAggregate) {
return
}
var latestHeartRateBpm: Int? = null
for (point in data.getData(DataType.HEART_RATE_BPM)) {
latestHeartRateBpm = recordHeartRate(point.value)
}
var updatedDistance = false
for (point in data.getData(DataType.DISTANCE)) {
if (point.value > 0) {
distanceMeters = (distanceMeters ?: 0.0) + point.value
updatedDistance = true
}
}
var updatedCalories = false
for (point in data.getData(DataType.CALORIES)) {
if (point.value > 0) {
caloriesKcal = (caloriesKcal ?: 0.0) + point.value
updatedCalories = true
}
}
if (latestHeartRateBpm != null || updatedDistance || updatedCalories) {
sendSample(latestHeartRateBpm)
}
}
override fun onRegistrationFailed(throwable: Throwable) {
Log.w(TAG, "measure callback registration failed", throwable)
}
}
fun noteActiveSession(
nextSessionId: String,
shouldAggregate: Boolean,
executionContext: Map<String, Any?> = emptyMap(),
) {
if (sessionId != nextSessionId) {
reset(nextSessionId)
}
this.executionContext = executionContext
this.shouldAggregate = shouldAggregate
}
fun onBodySensorsGranted(context: Context) {
if (shouldAggregate) {
start(context)
}
}
fun start(context: Context) {
if (sessionId.isNullOrBlank()) {
return
}
appContext = context.applicationContext
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
}
fun pause(context: Context) {
shouldAggregate = false
unregister(context)
}
fun finishCurrentSession(context: Context) {
unregister(context)
val completedSessionId = sessionId
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
sendSummary(context, completedSessionId)
} else {
Log.d(
TAG,
"skip summary sessionId=$completedSessionId sampleCount=$sampleCount",
)
}
reset(null)
}
private fun recordHeartRate(bpm: Double): Int? {
if (bpm <= 0) {
return null
}
sampleCount += 1
sampleSum += bpm
val rounded = bpm.roundToInt()
minBpm = minOf(minBpm ?: rounded, rounded)
maxBpm = maxOf(maxBpm ?: rounded, rounded)
return rounded
}
private fun sendSample(bpm: Int?) {
val activeSessionId = sessionId ?: return
val context = appContext ?: return
sampleSequence += 1
val capturedAt = System.currentTimeMillis()
val sample = mapOf(
"schemaVersion" to 4,
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
"sessionId" to activeSessionId,
"capturedAtEpochMs" to capturedAt,
"recordedAtEpochMs" to capturedAt,
"programIndex" to executionContext["programIndex"],
"exerciseIndex" to executionContext["exerciseIndex"],
"setIndex" to executionContext["setIndex"],
"passageIndex" to executionContext["passageIndex"],
"stepIndex" to executionContext["stepIndex"],
"heartRateBpm" to bpm,
"distanceMeters" to distanceMeters,
"caloriesKcal" to caloriesKcal,
)
onLocalSample(sample)
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
Log.d(
TAG,
"send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}",
)
for (node in capability.nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSamplePath, payload)
}
}
.addOnFailureListener { error ->
Log.w(TAG, "sample capability lookup failed", error)
}
}
private fun sendSummary(context: Context, completedSessionId: String) {
val min = minBpm ?: return
val max = maxBpm ?: return
val payload = JSONObject(
mapOf(
"schemaVersion" to 4,
"sessionId" to completedSessionId,
"sampleCount" to sampleCount,
"minHeartRateBpm" to min,
"averageHeartRateBpm" to sampleSum / sampleCount,
"maxHeartRateBpm" to max,
),
).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context)
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener { capability ->
Log.d(
TAG,
"send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}",
)
for (node in capability.nodes) {
Wearable.getMessageClient(context)
.sendMessage(node.id, sensorSummaryPath, payload)
}
}
.addOnFailureListener { error ->
Log.w(TAG, "summary capability lookup failed", error)
}
}
private fun unregister(context: Context) {
if (registeredDataTypes.isEmpty()) {
return
}
val measureClient = HealthServices.getClient(context).measureClient
for (dataType in registeredDataTypes.toList()) {
measureClient.unregisterMeasureCallbackAsync(dataType, callback)
}
registeredDataTypes.clear()
appContext = null
}
private fun registerMeasureCallbackIfNeeded(
measureClient: MeasureClient,
dataType: DeltaDataType<*, *>,
) {
if (registeredDataTypes.contains(dataType)) {
return
}
try {
measureClient.registerMeasureCallback(dataType, callback)
registeredDataTypes.add(dataType)
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
} catch (error: RuntimeException) {
Log.w(TAG, "measure callback registration threw dataType=$dataType", error)
}
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0
sampleSum = 0.0
minBpm = null
maxBpm = null
distanceMeters = null
caloriesKcal = null
sampleSequence = 0
executionContext = emptyMap()
shouldAggregate = false
}
}

View File

@ -0,0 +1,125 @@
package com.gametime.watch.bridge
import android.Manifest
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity
import androidx.wear.ongoing.Status
import com.gametime.watch.MainActivity
import com.gametime.watch.R
object WatchOngoingActivityController {
private const val CHANNEL_ID = "gametime_watch_session"
private const val CHANNEL_NAME = "Séance GameTime"
private const val NOTIFICATION_ID = 9101
private const val ONGOING_ACTIVITY_ID = 9101
private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107
private var postNotificationsPermissionRequested = false
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
cancel(context)
return
}
if (!hasPostNotificationsPermission(context)) {
requestPostNotificationsPermissionOnce(activity)
return
}
post(context, projection)
}
fun cancel(context: Context) {
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun post(context: Context, projection: Map<String, Any?>) {
ensureNotificationChannel(context)
val touchIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
val status = Status.Builder()
.addPart("activity", Status.TextPart(exerciseName))
.addTemplate("#activity#")
.build()
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime")
.setContentText(exerciseName)
.setContentIntent(touchIntent)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
OngoingActivity.Builder(context, NOTIFICATION_ID, notificationBuilder)
.setOngoingActivityId(ONGOING_ACTIVITY_ID)
.setStaticIcon(R.drawable.ic_ongoing_gt)
.setTouchIntent(touchIntent)
.setTitle("GameTime")
.setStatus(status)
.build()
.apply(context)
NotificationManagerCompat.from(context).notify(
NOTIFICATION_ID,
notificationBuilder.build(),
)
}
private fun ensureNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(NotificationManager::class.java)
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun hasPostNotificationsPermission(context: Context): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
private fun requestPostNotificationsPermissionOnce(activity: Activity?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
activity == null ||
postNotificationsPermissionRequested
) {
return
}
postNotificationsPermissionRequested = true
activity.requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
POST_NOTIFICATIONS_PERMISSION_REQUEST,
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,4h14v3H11v3h7v3h-7v7H5z" />
</vector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#080A12</color>
</resources>

View File

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

View File

@ -6,9 +6,8 @@ allprojects {
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build/watch_app")
.get()
rootProject.layout.projectDirectory
.dir("../build")
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {

View File

@ -0,0 +1,4 @@
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

Binary file not shown.

Binary file not shown.

View File

@ -11,17 +11,31 @@ final class WatchSessionUiState {
required this.projection,
this.commandPending = false,
this.waitingForPhone = false,
this.timerTogglePending = false,
this.connectionLost = false,
this.staleProjection = false,
this.scoreCommandPending = false,
this.scoreWaitingForPhone = false,
this.optimisticManualScoreValue,
this.commandFailureMessage,
this.commandFailureSerial = 0,
this.lastAck,
this.sensorSample,
});
final WatchSessionProjection projection;
final bool commandPending;
final bool waitingForPhone;
final bool timerTogglePending;
final bool connectionLost;
final bool staleProjection;
final bool scoreCommandPending;
final bool scoreWaitingForPhone;
final double? optimisticManualScoreValue;
final String? commandFailureMessage;
final int commandFailureSerial;
final WatchCommandAckEvent? lastAck;
final WatchSensorSample? sensorSample;
bool get actionsEnabled => !commandPending && !connectionLost;
@ -29,19 +43,50 @@ final class WatchSessionUiState {
WatchSessionProjection? projection,
bool? commandPending,
bool? waitingForPhone,
bool? timerTogglePending,
bool? connectionLost,
bool? staleProjection,
bool? scoreCommandPending,
bool? scoreWaitingForPhone,
double? optimisticManualScoreValue,
bool clearOptimisticManualScoreValue = false,
String? commandFailureMessage,
bool clearCommandFailureMessage = false,
int? commandFailureSerial,
WatchCommandAckEvent? lastAck,
WatchSensorSample? sensorSample,
bool clearSensorSample = false,
}) {
return WatchSessionUiState(
projection: projection ?? this.projection,
commandPending: commandPending ?? this.commandPending,
waitingForPhone: waitingForPhone ?? this.waitingForPhone,
timerTogglePending: timerTogglePending ?? this.timerTogglePending,
connectionLost: connectionLost ?? this.connectionLost,
staleProjection: staleProjection ?? this.staleProjection,
scoreCommandPending: scoreCommandPending ?? this.scoreCommandPending,
scoreWaitingForPhone: scoreWaitingForPhone ?? this.scoreWaitingForPhone,
optimisticManualScoreValue: clearOptimisticManualScoreValue
? null
: optimisticManualScoreValue ?? this.optimisticManualScoreValue,
commandFailureMessage: clearCommandFailureMessage
? null
: commandFailureMessage ?? this.commandFailureMessage,
commandFailureSerial: commandFailureSerial ?? this.commandFailureSerial,
lastAck: lastAck ?? this.lastAck,
sensorSample: clearSensorSample
? null
: sensorSample ?? this.sensorSample,
);
}
bool get hasLiveSensors {
final sample = sensorSample;
return sample != null &&
(sample.heartRateBpm != null ||
sample.distanceMeters != null ||
sample.caloriesKcal != null);
}
}
final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
@ -59,6 +104,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_connectionLostThreshold = connectionLostThreshold,
super(WatchSessionUiState(projection: _initialProjection())) {
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
_subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample));
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
_subscriptions.add(
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
@ -79,10 +125,16 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Timer? _waitingTimer;
Timer? _commandTimeoutTimer;
Timer? _scoreWaitingTimer;
Timer? _scoreCommandTimeoutTimer;
Timer? _freshnessTimer;
Timer? _commandFailureClearTimer;
WatchCommandEnvelope? _pendingCommand;
final _pendingScoreCommandIds = <String>{};
double? _optimisticManualScoreValue;
DateTime? _lastProjectionReceivedAt;
var _commandCounter = 0;
var _commandFailureSerial = 0;
Future<void> refresh() async {
value = value.copyWith(connectionLost: false);
@ -119,18 +171,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
WatchSecondaryAction.skipCurrentStep => WatchCommandType.skipCurrentStep,
WatchSecondaryAction.skipCurrentPassage =>
WatchCommandType.skipCurrentPassage,
WatchSecondaryAction.finishCurrentSet => WatchCommandType.finishCurrentSet,
WatchSecondaryAction.finishCurrentSet =>
WatchCommandType.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet => WatchCommandType.skipCurrentSet,
WatchSecondaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest,
};
return _sendCommand(command);
}
Future<void> incrementScore() {
return _sendScoreCommand(WatchCommandType.incrementScore, 1);
}
Future<void> decrementScore() {
return _sendScoreCommand(WatchCommandType.decrementScore, -1);
}
@override
void dispose() {
_waitingTimer?.cancel();
_commandTimeoutTimer?.cancel();
_scoreWaitingTimer?.cancel();
_scoreCommandTimeoutTimer?.cancel();
_freshnessTimer?.cancel();
_commandFailureClearTimer?.cancel();
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
@ -138,7 +202,9 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
}
Future<void> _sendCommand(WatchCommandType type) async {
if (!value.actionsEnabled || value.projection.deviceSessionId.isEmpty) {
if (!value.actionsEnabled ||
(_requiresActiveSession(type) &&
value.projection.deviceSessionId.isEmpty)) {
return;
}
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
@ -153,6 +219,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
value = value.copyWith(
commandPending: true,
waitingForPhone: false,
timerTogglePending: _isTimerToggleCommand(type),
connectionLost: false,
);
_waitingTimer?.cancel();
@ -165,6 +232,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
value = value.copyWith(
commandPending: false,
waitingForPhone: false,
timerTogglePending: false,
connectionLost: true,
);
unawaited(HapticFeedback.heavyImpact());
@ -177,25 +245,114 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
value = value.copyWith(
commandPending: false,
waitingForPhone: false,
timerTogglePending: false,
connectionLost: true,
);
unawaited(HapticFeedback.heavyImpact());
}
}
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
final projection = value.projection;
if (value.connectionLost ||
!projection.phoneReachable ||
!projection.hasManualScore ||
projection.deviceSessionId.isEmpty) {
return;
}
final current =
_optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0;
final next = (current + delta).clamp(0, double.infinity).toDouble();
if (next == current) {
return;
}
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
final command = WatchCommandEnvelope(
commandId: 'watch-$nowMs-${_commandCounter++}',
type: type,
sessionId: projection.deviceSessionId,
expectedRevision: projection.revision,
sentAtEpochMs: nowMs,
);
_pendingScoreCommandIds.add(command.commandId);
_optimisticManualScoreValue = next;
value = value.copyWith(
scoreCommandPending: true,
scoreWaitingForPhone: false,
optimisticManualScoreValue: next,
connectionLost: false,
);
_scoreWaitingTimer?.cancel();
_scoreCommandTimeoutTimer?.cancel();
_scoreWaitingTimer = Timer(_waitingThreshold, () {
value = value.copyWith(scoreWaitingForPhone: true);
});
_scoreCommandTimeoutTimer = Timer(_commandTimeout, () {
_clearScorePending(recalibrate: true);
unawaited(HapticFeedback.heavyImpact());
unawaited(_nativeClient.requestResync());
});
try {
await _nativeClient.sendCommand(command);
unawaited(HapticFeedback.selectionClick());
} on PlatformException {
_pendingScoreCommandIds.remove(command.commandId);
_clearScorePending(recalibrate: true);
value = value.copyWith(connectionLost: true);
unawaited(HapticFeedback.heavyImpact());
}
}
void _handleProjection(WatchSessionProjection projection) {
final previousProjection = value.projection;
_lastProjectionReceivedAt = DateTime.now();
_pendingCommand = null;
_clearCommandTimers();
_syncScorePendingFromProjection(projection);
value = WatchSessionUiState(
projection: projection,
timerTogglePending: false,
scoreCommandPending: _pendingScoreCommandIds.isNotEmpty,
scoreWaitingForPhone:
_pendingScoreCommandIds.isNotEmpty && value.scoreWaitingForPhone,
optimisticManualScoreValue: _optimisticManualScoreValue,
commandFailureMessage: value.commandFailureMessage,
commandFailureSerial: value.commandFailureSerial,
lastAck: value.lastAck,
sensorSample: projection.deviceSessionId.isEmpty
? null
: value.sensorSample?.sessionId == projection.deviceSessionId
? value.sensorSample
: null,
);
_triggerProjectionHaptic(previousProjection, projection);
}
void _handleSensorSample(WatchSensorSample sample) {
final sessionId = sample.sessionId.trim();
if (sessionId.isEmpty || sessionId != value.projection.deviceSessionId) {
return;
}
final hasMetric =
sample.heartRateBpm != null ||
sample.distanceMeters != null ||
sample.caloriesKcal != null;
if (!hasMetric) {
return;
}
value = value.copyWith(sensorSample: sample);
}
void _handleAck(WatchCommandAckEvent ack) {
if (_pendingScoreCommandIds.remove(ack.commandId)) {
value = value.copyWith(lastAck: ack);
unawaited(HapticFeedback.lightImpact());
if (_isRejected(ack.status)) {
_clearScorePending(recalibrate: true);
unawaited(_nativeClient.requestResync());
}
return;
}
if (_pendingCommand?.commandId != ack.commandId) {
value = value.copyWith(lastAck: ack);
return;
@ -208,9 +365,10 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
);
unawaited(HapticFeedback.lightImpact());
if (_isRejected(ack.status)) {
final failedType = _pendingCommand?.type;
_pendingCommand = null;
_clearCommandTimers();
value = value.copyWith(commandPending: false);
_publishCommandFailure(_commandFailureMessageFor(failedType));
unawaited(_nativeClient.requestResync());
}
}
@ -242,12 +400,61 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_commandTimeoutTimer = null;
}
void _publishCommandFailure(String message) {
_commandFailureClearTimer?.cancel();
value = value.copyWith(
commandPending: false,
timerTogglePending: false,
commandFailureMessage: message,
commandFailureSerial: ++_commandFailureSerial,
);
_commandFailureClearTimer = Timer(const Duration(seconds: 2), () {
value = value.copyWith(clearCommandFailureMessage: true);
});
}
void _clearScorePending({required bool recalibrate}) {
_pendingScoreCommandIds.clear();
_scoreWaitingTimer?.cancel();
_scoreWaitingTimer = null;
_scoreCommandTimeoutTimer?.cancel();
_scoreCommandTimeoutTimer = null;
_optimisticManualScoreValue = null;
value = value.copyWith(
scoreCommandPending: false,
scoreWaitingForPhone: false,
clearOptimisticManualScoreValue: recalibrate,
);
}
void _syncScorePendingFromProjection(WatchSessionProjection projection) {
if (_pendingScoreCommandIds.isEmpty) {
_optimisticManualScoreValue = null;
_scoreWaitingTimer?.cancel();
_scoreWaitingTimer = null;
_scoreCommandTimeoutTimer?.cancel();
_scoreCommandTimeoutTimer = null;
return;
}
final optimistic = _optimisticManualScoreValue;
final confirmed = projection.currentManualScoreValue;
if (optimistic != null && confirmed == optimistic) {
_pendingScoreCommandIds.clear();
_optimisticManualScoreValue = null;
_scoreWaitingTimer?.cancel();
_scoreWaitingTimer = null;
_scoreCommandTimeoutTimer?.cancel();
_scoreCommandTimeoutTimer = null;
}
}
void _triggerProjectionHaptic(
WatchSessionProjection previous,
WatchSessionProjection current,
) {
final phaseChanged = previous.phase != current.phase;
final enteredReadyTimer = current.phase == WatchSessionPhase.nextTimerReady &&
final enteredReadyTimer =
current.phase == WatchSessionPhase.nextTimerReady &&
previous.phase != WatchSessionPhase.nextTimerReady;
final enteredRestEnd =
previous.phase == WatchSessionPhase.restRunning &&
@ -255,9 +462,11 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
current.phase != WatchSessionPhase.restPaused;
if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) {
unawaited(HapticFeedback.mediumImpact());
unawaited(Future<void>.delayed(const Duration(milliseconds: 120), () {
return HapticFeedback.mediumImpact();
}));
unawaited(
Future<void>.delayed(const Duration(milliseconds: 120), () {
return HapticFeedback.mediumImpact();
}),
);
}
}
}
@ -269,6 +478,28 @@ bool _isRejected(WatchCommandAck ack) {
};
}
String _commandFailureMessageFor(WatchCommandType? type) {
return switch (type) {
WatchCommandType.finishCurrentSet ||
WatchCommandType.skipCurrentSet => 'Série non modifiée',
_ => 'Commande non appliquée',
};
}
bool _isTimerToggleCommand(WatchCommandType type) {
return switch (type) {
WatchCommandType.startCurrentExercise ||
WatchCommandType.startPreparedTimedStep ||
WatchCommandType.pauseSession ||
WatchCommandType.resumeSession => true,
_ => false,
};
}
bool _requiresActiveSession(WatchCommandType type) {
return true;
}
WatchSessionProjection _initialProjection() {
return WatchSessionProjection(
deviceSessionId: '',

View File

@ -30,6 +30,8 @@ final class WatchCommandAckEvent {
abstract interface class NativeWatchBridgeClient {
Stream<WatchSessionProjection> get projections;
Stream<WatchSensorSample> get sensorSamples;
Stream<WatchCommandAckEvent> get acks;
Stream<WatchBridgeConnectionEvent> get connectionEvents;
@ -45,25 +47,28 @@ final class MethodChannelNativeWatchBridgeClient
implements NativeWatchBridgeClient {
const MethodChannelNativeWatchBridgeClient({
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
EventChannel projectionChannel = const EventChannel(
_projectionChannelName,
EventChannel projectionChannel = const EventChannel(_projectionChannelName),
EventChannel sensorSampleChannel = const EventChannel(
_sensorSampleChannelName,
),
EventChannel ackChannel = const EventChannel(_ackChannelName),
EventChannel connectionChannel = const EventChannel(
_connectionChannelName,
),
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
}) : _methodChannel = methodChannel,
_projectionChannel = projectionChannel,
_sensorSampleChannel = sensorSampleChannel,
_ackChannel = ackChannel,
_connectionChannel = connectionChannel;
static const _methodChannelName = 'gametime.watch_bridge/methods';
static const _projectionChannelName = 'gametime.watch_bridge/projections';
static const _sensorSampleChannelName =
'gametime.watch_bridge/sensor_samples';
static const _ackChannelName = 'gametime.watch_bridge/acks';
static const _connectionChannelName = 'gametime.watch_bridge/connection';
final MethodChannel _methodChannel;
final EventChannel _projectionChannel;
final EventChannel _sensorSampleChannel;
final EventChannel _ackChannel;
final EventChannel _connectionChannel;
@ -77,6 +82,16 @@ final class MethodChannelNativeWatchBridgeClient
});
}
@override
Stream<WatchSensorSample> get sensorSamples {
return _sensorSampleChannel
.receiveBroadcastStream()
.where((event) => event is Map)
.map((event) {
return WatchSensorSample.fromJson(_stringObjectMap(event));
});
}
@override
Stream<WatchCommandAckEvent> get acks {
return _ackChannel
@ -144,7 +159,11 @@ String? _nullableStringFromJson(Object? value) {
}
int? _nullableIntFromJson(Object? value) {
return value is int ? value : value is num ? value.toInt() : null;
return value is int
? value
: value is num
? value.toInt()
: null;
}
T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {

File diff suppressed because it is too large Load Diff

View File

@ -9,23 +9,41 @@ ThemeData watchTheme() {
final textTheme = Typography.whiteMountainView.copyWith(
labelSmall: const TextStyle(
fontFamily: 'Archivo',
fontSize: 10,
fontWeight: FontWeight.w800,
color: muted,
letterSpacing: 0,
),
bodySmall: const TextStyle(
fontFamily: 'Archivo',
fontSize: 11,
color: muted,
height: 1.15,
letterSpacing: 0,
),
bodyMedium: const TextStyle(
fontFamily: 'Archivo',
fontSize: 13,
color: text,
height: 1.15,
letterSpacing: 0,
),
bodySmall: const TextStyle(fontSize: 11, color: muted, height: 1.15),
bodyMedium: const TextStyle(fontSize: 13, color: text, height: 1.15),
titleSmall: const TextStyle(
fontFamily: 'Archivo',
fontSize: 16,
fontWeight: FontWeight.w800,
color: text,
height: 1.05,
letterSpacing: 0,
),
displayLarge: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w900,
color: text,
fontFamily: 'Anton',
fontSize: 42,
fontWeight: FontWeight.w400,
color: Color(0xFFC9A24A),
height: 0.95,
letterSpacing: 0,
fontFeatures: [FontFeature.tabularFigures()],
),
);
@ -50,8 +68,10 @@ ThemeData watchTheme() {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge?.copyWith(
fontFamily: 'Archivo',
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
),
@ -62,8 +82,10 @@ ThemeData watchTheme() {
side: const BorderSide(color: Color(0xFF303748)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: textTheme.labelLarge?.copyWith(
fontFamily: 'Archivo',
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
),

View File

@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
@ -9,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
@ -17,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
@ -30,6 +62,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
@ -38,6 +99,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@ -54,11 +123,67 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
vector_math:
dependency: transitive
description:
@ -67,6 +192,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watch_bridge_contract:
dependency: "direct main"
description:
@ -76,3 +209,4 @@ packages:
version: "0.1.0"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@ -14,7 +14,16 @@ dependencies:
path: ../packages/watch_bridge_contract
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
fonts:
- family: Anton
fonts:
- asset: assets/fonts/Anton-Regular.ttf
- family: Archivo
fonts:
- asset: assets/fonts/Archivo-Variable.ttf

View File

@ -0,0 +1,698 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime_watch/application/watch_session_view_model.dart';
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
import 'package:gametime_watch/presentation/watch_session_screen.dart';
import 'package:gametime_watch/presentation/watch_theme.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'renders an active session on a compact round-sized viewport without overflow',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Squat jump'), findsOneWidget);
expect(find.text('02:14'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'transitions from no-session to manual score controls without rendering a black screen',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_noSessionStartProjection());
await tester.pump();
expect(find.text('Aucune séance en cours'), findsOneWidget);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Lancers francs'), findsOneWidget);
expect(find.text('Routine de tir'), findsOneWidget);
expect(find.text('Cible : 8'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
expect(find.byTooltip('Ajouter'), findsOneWidget);
expect(find.byTooltip('Retirer'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('does not offer a start action from the no-session screen', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_noSessionStartProjection());
await tester.pump();
expect(find.text('Aucune séance en cours'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
expect(client.sentCommands, isEmpty);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('starts a stopped exercise timer from the timer button', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_readyProjection());
await tester.pump();
expect(find.text('Routine de tir'), findsOneWidget);
await tester.tap(find.byTooltip('Démarrer'));
await tester.pump();
expect(
client.sentCommands.single.type,
WatchCommandType.startCurrentExercise,
);
expect(
find.byKey(const ValueKey('timer-toggle-pending-dot')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('keeps the no-session start action disabled without phone', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
client.emitProjection(_noSessionStartProjection(phoneReachable: false));
await tester.pump();
expect(find.text('Téléphone indisponible'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets('shows compact timer controls with a manual score step', (
tester,
) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_manualScoreProjectionWithTimer());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('3'), findsOneWidget);
expect(find.text('Routine de tir'), findsOneWidget);
expect(find.text('Chrono étape 02:14'), findsOneWidget);
expect(find.byTooltip('Pause'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'shows live heart rate on main page and full telemetry on stats',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
expect(find.byTooltip('Stats'), findsNothing);
client.emitSensorSample(
WatchSensorSample(
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
27,
10,
).millisecondsSinceEpoch,
heartRateBpm: 142,
distanceMeters: 840,
caloriesKcal: 186,
),
);
await tester.pump();
expect(find.text('FC 142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsNothing);
expect(find.text('186 kcal'), findsNothing);
expect(find.byTooltip('Stats'), findsOneWidget);
await tester.drag(
find.byKey(const ValueKey('watch-session-page')),
const Offset(-220, 0),
);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('watch-actions-page')),
const Offset(-220, 0),
);
await tester.pumpAndSettle();
expect(find.text('Stats'), findsOneWidget);
expect(find.text('FC'), findsOneWidget);
expect(find.text('142 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'sends pause command from the icon button when a timer dominates',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Pause'));
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('shows pending feedback inside the pause button', (tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Pause'));
await tester.pump();
expect(
find.byKey(const ValueKey('timer-toggle-pending-dot')),
findsOneWidget,
);
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'returns to session and shows a discreet notice when a set command is rejected',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Terminer la série'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(
client.sentCommands.single.type,
WatchCommandType.finishCurrentSet,
);
expect(find.text('Squat jump'), findsOneWidget);
client.emitAck(
WatchCommandAckEvent(
commandId: client.sentCommands.single.commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
await tester.pump();
expect(find.text('Série non modifiée'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1900));
expect(find.text('Série non modifiée'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'keeps rest skip secondary action open until the session projection updates',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_secondaryRestActionProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
_expectActionsPageVisible(tester);
await tester.tap(find.text('Passer le repos'));
await tester.pump();
expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest);
_expectActionsPageVisible(tester);
client.emitProjection(_runningProjection());
await tester.pump();
await tester.pumpAndSettle();
_expectSessionPageVisible(tester);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'keeps rest skip secondary action open and shows feedback when rejected',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(192, 192);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_secondaryRestActionProjection());
await tester.pump();
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Passer le repos'));
await tester.pump();
client.emitAck(
WatchCommandAckEvent(
commandId: client.sentCommands.single.commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
await tester.pump();
_expectActionsPageVisible(tester);
expect(find.text('Commande non appliquée'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
}
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
final _projectionController =
StreamController<WatchSessionProjection>.broadcast();
final _sensorSampleController =
StreamController<WatchSensorSample>.broadcast();
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
final _connectionController =
StreamController<WatchBridgeConnectionEvent>.broadcast();
var resyncRequests = 0;
var capabilityRefreshRequests = 0;
final sentCommands = <WatchCommandEnvelope>[];
@override
Stream<WatchSessionProjection> get projections =>
_projectionController.stream;
@override
Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;
@override
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
@override
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
_connectionController.stream;
void emitProjection(WatchSessionProjection projection) {
_projectionController.add(projection);
}
void emitSensorSample(WatchSensorSample sample) {
_sensorSampleController.add(sample);
}
void emitAck(WatchCommandAckEvent ack) {
_ackController.add(ack);
}
void emitConnection(WatchBridgeConnectionEvent event) {
_connectionController.add(event);
}
@override
Future<void> requestCapabilityRefresh() async {
capabilityRefreshRequests += 1;
}
@override
Future<void> requestResync() async {
resyncRequests += 1;
}
@override
Future<void> sendCommand(WatchCommandEnvelope command) async {
sentCommands.add(command);
}
}
WatchSessionProjection _runningProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: _runningStepTimer(),
secondaryTimers: const [
WatchTimerProjection(
kind: WatchTimerKind.setTimer,
label: 'Série',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: 0,
accumulatedMs: 45000,
),
],
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
);
}
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
return WatchSessionProjection(
deviceSessionId: '',
revision: 0,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.noActiveSession,
phoneReachable: phoneReachable,
seriesIndex: 0,
seriesTotal: 0,
exerciseName: '',
statusLabel: 'Aucune séance',
primaryAction: WatchPrimaryAction.none,
);
}
WatchSessionProjection _readyProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.ready,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Prêt à démarrer',
primaryAction: WatchPrimaryAction.startCurrentExercise,
dominantTimer: const WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.countdown,
runState: WatchTimerRunState.stopped,
referenceEpochMs: 0,
accumulatedMs: 0,
targetMs: 30000,
),
);
}
WatchSessionProjection _secondaryRestActionProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 4,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 2,
seriesTotal: 4,
exerciseName: 'Squat jump',
statusLabel: 'Séance active',
primaryAction: WatchPrimaryAction.pauseSession,
secondaryActions: const [WatchSecondaryAction.skipCurrentRest],
);
}
void _expectActionsPageVisible(WidgetTester tester) {
expect(
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
lessThan(96),
);
}
void _expectSessionPageVisible(WidgetTester tester) {
expect(
tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx,
lessThan(96),
);
}
WatchSessionProjection _manualScoreProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 2,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Score manuel',
primaryAction: WatchPrimaryAction.pauseSession,
hasManualScore: true,
currentManualScoreValue: 3,
canDecrementScore: true,
manualScoreTargetValue: 8,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.series,
);
}
WatchSessionProjection _manualScoreProjectionWithTimer() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 3,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Lancers francs',
stepName: 'Routine de tir',
statusLabel: 'Score manuel',
primaryAction: WatchPrimaryAction.pauseSession,
dominantTimer: _runningStepTimer(),
hasManualScore: true,
currentManualScoreValue: 3,
canDecrementScore: true,
manualScoreTargetValue: 8,
manualScoreTargetLabel: 'Cible',
manualScoreScope: WatchManualScoreScope.step,
);
}
WatchTimerProjection _runningStepTimer() {
return WatchTimerProjection(
kind: WatchTimerKind.step,
label: 'Chrono étape',
displayMode: WatchTimerDisplayMode.elapsed,
runState: WatchTimerRunState.running,
referenceEpochMs: DateTime.now()
.toUtc()
.add(const Duration(minutes: 1))
.millisecondsSinceEpoch,
accumulatedMs: 134000,
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
targetMs: 180000,
);
}