diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 43248fa..c2344a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + + + diff --git a/android/app/src/main/kotlin/com/gametime/app/MainActivity.kt b/android/app/src/main/kotlin/com/gametime/app/MainActivity.kt index 4a55248..89dbc3a 100644 --- a/android/app/src/main/kotlin/com/gametime/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/gametime/app/MainActivity.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt b/android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt new file mode 100644 index 0000000..d6bc950 --- /dev/null +++ b/android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt @@ -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? = 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, + ) + } +} diff --git a/android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt b/android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt new file mode 100644 index 0000000..f399f6b --- /dev/null +++ b/android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/PhoneWatchBridgeListenerService.kt b/android/app/src/main/kotlin/com/gametime/app/watch/PhoneWatchBridgeListenerService.kt index d995c1d..6dceee7 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/PhoneWatchBridgeListenerService.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/PhoneWatchBridgeListenerService.kt @@ -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()) + } } } diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt index 02aa3f4..f500875 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt @@ -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() 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): Boolean { + val sink = sensorSummarySink ?: return false + mainHandler.post { + sink.success(payload) + } + return true + } + + fun emitSensorSample(payload: Map): 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) { diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 3f9227f..7e502d0 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -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 dispose() async { + await sessionNotificationCoordinator.dispose(); await watchWearDataLayerAdapter.stop(); await watchCompanionProjectionUseCases.dispose(); + await activeWorkoutSensorUseCases.dispose(); await database.close(); } } diff --git a/lib/application/application.dart b/lib/application/application.dart index f07094e..7e739bc 100644 --- a/lib/application/application.dart +++ b/lib/application/application.dart @@ -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'; diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 7be22f9..14768cf 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -485,6 +485,7 @@ enum RemoteAuthFailure { invalidCredentials, emailAlreadyUsed, network, + server, unknown, } @@ -857,6 +858,7 @@ abstract interface class ActiveSessionRepository { Future saveSetTimerState(ActiveSetTimerState state); Future saveRestState(ActiveRestState restState); Future saveScoreStopwatchState(ActiveScoreStopwatchState state); + Future saveManualScoreState(ActiveManualScoreState state); Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, ); @@ -868,6 +870,13 @@ abstract interface class ActiveSessionRepository { required int setIndex, required DateTime deletedAt, }); + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }); Future findRestStateById(String id); Future findScoreStopwatchState({ required String sessionId, @@ -875,6 +884,12 @@ abstract interface class ActiveSessionRepository { required int exerciseIndex, required int setIndex, }); + Future findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }); Future findSetTimerState({ required String sessionId, required int programIndex, @@ -899,12 +914,19 @@ abstract interface class ActiveSessionRepository { Future> listScoreStopwatchStates( String sessionId, ); + Future> listManualScoreStates(String sessionId); } abstract interface class WorkoutHistoryRepository { Future findById(String id); Future> listActive(); Future save(WorkoutHistory history); + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }); Future saveSetResult(WorkoutHistorySetResult result); Future saveStepResult(WorkoutHistoryStepResult result); Future delete(String id, DateTime deletedAt); diff --git a/lib/application/session_notification_use_cases.dart b/lib/application/session_notification_use_cases.dart new file mode 100644 index 0000000..76f2bbc --- /dev/null +++ b/lib/application/session_notification_use_cases.dart @@ -0,0 +1,198 @@ +import 'dart:async'; + +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; + +abstract interface class SessionNotificationGateway { + Future show(SessionNotificationContent content); + + Future clear(); +} + +final class SessionNotificationContent { + const SessionNotificationContent({ + required this.title, + required this.primaryLine, + this.secondaryLine, + }); + + final String title; + final String primaryLine; + final String? secondaryLine; + + Map toJson() { + return { + 'title': title, + 'primaryLine': primaryLine, + 'secondaryLine': secondaryLine, + }; + } +} + +final class SessionNotificationCoordinator { + SessionNotificationCoordinator({ + required Stream projections, + required SessionNotificationGateway gateway, + Duration tickInterval = const Duration(seconds: 1), + }) : _projections = projections, + _gateway = gateway, + _tickInterval = tickInterval; + + final Stream _projections; + final SessionNotificationGateway _gateway; + final Duration _tickInterval; + StreamSubscription? _subscription; + Timer? _timer; + WatchSessionProjection? _latestProjection; + + void start() { + if (_subscription != null) { + return; + } + _subscription = _projections.listen(_handleProjection); + } + + Future 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 = []; + 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); +} diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index ba3ccb0..604fc13 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -1479,6 +1479,11 @@ final class ProgramUseCases { List tags = const [], required List exercises, }) async { + if (exercises.isEmpty) { + throw const DomainException( + 'Program must contain at least one exercise.', + ); + } final now = clock.now(); final existing = id == null ? null : await programRepository.findById(id); if (id != null && existing == null) { @@ -1781,6 +1786,11 @@ final class WorkoutTemplateUseCases { required List programs, required List overrides, }) async { + if (programs.isEmpty) { + throw const DomainException( + 'Workout template must contain at least one program.', + ); + } final now = clock.now(); final existing = id == null ? null : await templateRepository.findById(id); if (id != null && existing == null) { @@ -1853,6 +1863,9 @@ final class WorkoutTemplateUseCases { programs: templatePrograms, overrides: templateOverrides, ); + _ensurePlayableResolvedTemplateSnapshot( + _resolvedTemplateSnapshotJson(template), + ); await templateRepository.replaceComposition(template, now); return template; } @@ -2038,6 +2051,13 @@ final class SetExecutionTimerStartResult { final ActiveExerciseStepProgressState? stepProgress; } +final class ManualScoreUpdateResult { + const ManualScoreUpdateResult({required this.state, required this.changed}); + + final ActiveManualScoreState state; + final bool changed; +} + final class ActiveWorkoutSessionUseCases { const ActiveWorkoutSessionUseCases({ required this.sessionRepository, @@ -2053,17 +2073,72 @@ final class ActiveWorkoutSessionUseCases { final IdGenerator ids; final String originDeviceId; - Future findOpen() => sessionRepository.findOpen(); + Future findOpen() async { + final session = await sessionRepository.findOpen(); + if (session == null) { + return null; + } + if (_isPlayableResolvedTemplateSnapshot( + session.resolvedTemplateSnapshotJson, + )) { + return session; + } + await abandon(session.metadata.id); + return null; + } Future startFromTemplate(String templateId) async { final template = await templateRepository.findById(templateId); if (template == null) { throw const DomainException('Workout template not found.'); } + return _startFromTemplate(template); + } + + Future startFromLastTemplate() async { + final open = await findOpen(); + if (open != null) { + throw const DomainException('Active workout session already open.'); + } + final templates = await templateRepository.listActive(); + final playable = [ + for (final template in templates) + if (_isPlayableResolvedTemplateSnapshot( + _resolvedTemplateSnapshotJson(template), + )) + template, + ]; + if (playable.isEmpty) { + throw const DomainException('No playable workout template found.'); + } + playable.sort((left, right) { + final leftStarted = left.lastStartedAt; + final rightStarted = right.lastStartedAt; + if (leftStarted != null && rightStarted != null) { + return rightStarted.compareTo(leftStarted); + } + if (leftStarted != null) { + return -1; + } + if (rightStarted != null) { + return 1; + } + return left.name.compareTo(right.name); + }); + return _startFromTemplate(playable.first); + } + + Future _startFromTemplate( + WorkoutTemplate template, + ) async { + final resolvedTemplateSnapshotJson = _resolvedTemplateSnapshotJson( + template, + ); + _ensurePlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson); final now = clock.now(); final session = ActiveWorkoutSession( metadata: _newMetadata(ids, originDeviceId, now), - sourceWorkoutTemplateId: templateId, + sourceWorkoutTemplateId: template.metadata.id, status: ActiveWorkoutStatus.running, startedAt: now, lastPersistedAt: now, @@ -2071,44 +2146,36 @@ final class ActiveWorkoutSessionUseCases { currentProgramIndex: 0, currentExerciseIndex: 0, currentSetIndex: 0, - resolvedTemplateSnapshotJson: jsonEncode({ - 'templateId': template.metadata.id, - 'name': template.name, - 'programs': template.programs - .map( - (program) => { - 'id': program.metadata.id, - 'programNameSnapshot': program.programNameSnapshot, - 'programSnapshotJson': program.programSnapshotJson, - }, - ) - .toList(), - 'overrides': template.overrides - .map( - (override) => { - 'workoutTemplateProgramId': override.workoutTemplateProgramId, - 'snapshotProgramExerciseId': override.snapshotProgramExerciseId, - 'setsCountOverride': override.setsCountOverride, - 'targetTimeSecondsOverride': override.targetTimeSecondsOverride, - 'targetRepsOverride': override.targetRepsOverride, - 'targetScoreOverride': override.targetScoreOverride, - 'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride, - 'autoStartNextTimedStepOverride': - override.autoStartNextTimedStepOverride, - }, - ) - .toList(), - }), + resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson, ); await sessionRepository.save(session); + await _markTemplateStarted(template, now); return session; } + Future _markTemplateStarted( + WorkoutTemplate template, + DateTime startedAt, + ) async { + await templateRepository.save( + WorkoutTemplate( + metadata: template.metadata.touch(startedAt), + name: template.name, + lastStartedAt: startedAt, + isExample: template.isExample, + tags: template.tags, + programs: template.programs, + overrides: template.overrides, + ), + ); + } + Future startFromHistory(WorkoutHistory history) async { final snapshot = jsonDecode(history.historySnapshotJson) as Map; final resolvedTemplateSnapshotJson = snapshot['resolvedTemplateSnapshotJson'] as String; + _ensurePlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson); final now = clock.now(); final session = ActiveWorkoutSession( metadata: _newMetadata(ids, originDeviceId, now), @@ -2190,6 +2257,14 @@ final class ActiveWorkoutSessionUseCases { Future resume(String sessionId) async { final session = await _requiredSession(sessionId); + if (!_isPlayableResolvedTemplateSnapshot( + session.resolvedTemplateSnapshotJson, + )) { + await abandon(sessionId); + throw const DomainException( + 'Workout session does not contain any exercise.', + ); + } final now = clock.now(); final pausedSetTimers = (await sessionRepository.listSetTimerStates( sessionId, @@ -2466,18 +2541,39 @@ final class ActiveWorkoutSessionUseCases { explicitActualScoreTimeMs: actualScoreTimeMs, now: now, ); + final resolvedActualScore = await _resolveManualScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + scoreInputMode: scoreInputModeSnapshot, + explicitActualScore: actualScore, + ); + final existingResults = await sessionRepository.listSetResults(sessionId); + ActiveSetResult? existing; + for (final result in existingResults) { + if (result.programIndex == programIndex && + result.exerciseIndex == exerciseIndex && + result.setIndex == setIndex) { + existing = result; + break; + } + } final result = ActiveSetResult( - metadata: _newMetadata(ids, originDeviceId, now), + metadata: existing == null + ? _newMetadata(ids, originDeviceId, now) + : existing.metadata.touch(now), activeWorkoutSessionId: sessionId, programSnapshotId: programSnapshotId, exerciseSnapshotId: exerciseSnapshotId, programIndex: programIndex, exerciseIndex: exerciseIndex, setIndex: setIndex, + startedAt: existing?.startedAt, completedAt: now, actualTimeMs: actualTimeMs, actualReps: actualReps, - actualScore: actualScore, + actualScore: resolvedActualScore, actualScoreTimeMs: resolvedActualScoreTimeMs, scoreInputModeSnapshot: scoreInputModeSnapshot, scoreLabelSnapshot: scoreLabelSnapshot, @@ -2485,6 +2581,13 @@ final class ActiveWorkoutSessionUseCases { status: SetResultStatus.completed, ); await sessionRepository.saveSetResult(result); + await sessionRepository.deleteManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + deletedAt: now, + ); return result; } @@ -2675,6 +2778,66 @@ final class ActiveWorkoutSessionUseCases { ); } + Future incrementManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateManualScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + delta: 1, + ); + } + + Future decrementManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateManualScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + delta: -1, + ); + } + + Future setManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required double value, + }) { + return _writeManualScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + value: value.clamp(0, double.infinity).toDouble(), + ); + } + + Future findManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return sessionRepository.findManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + } + int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) { return state.elapsedMillisecondsAt(clock.now()); } @@ -3008,6 +3171,104 @@ final class ActiveWorkoutSessionUseCases { ); } + Future _updateManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required int delta, + }) async { + await _ensureManualScoreActive( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + ); + final existing = await sessionRepository.findManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final currentValue = existing?.value ?? 0; + final nextValue = (currentValue + delta).clamp(0, double.infinity); + return _writeManualScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + value: nextValue.toDouble(), + existing: existing, + validate: false, + ); + } + + Future _ensureManualScoreActive({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + }) async { + final session = await _requiredSession(sessionId); + final snapshot = _findExerciseSnapshot( + resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + ); + if (snapshot == null || + !snapshot.scoreEnabled || + snapshot.scoreInputModeSnapshot != ScoreInputMode.manual) { + throw const DomainException('Manual score is not active for this set.'); + } + } + + Future _writeManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required double value, + ActiveManualScoreState? existing, + bool validate = true, + }) async { + if (validate) { + await _ensureManualScoreActive( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + ); + } + final now = clock.now(); + existing ??= await sessionRepository.findManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final changed = existing == null ? value != 0 : value != existing.value; + if (existing == null) { + final state = ActiveManualScoreState( + metadata: _newMetadata(ids, originDeviceId, now), + activeWorkoutSessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + value: value, + updatedAt: now, + ); + await sessionRepository.saveManualScoreState(state); + return ManualScoreUpdateResult(state: state, changed: changed); + } + if (!changed) { + return ManualScoreUpdateResult(state: existing, changed: false); + } + final updated = existing.copyWith( + metadata: existing.metadata.touch(now), + value: value, + updatedAt: now, + ); + await sessionRepository.saveManualScoreState(updated); + return ManualScoreUpdateResult(state: updated, changed: true); + } + Future _requiredSession(String sessionId) async { final session = await sessionRepository.findById(sessionId); if (session == null) { @@ -3067,6 +3328,27 @@ final class ActiveWorkoutSessionUseCases { await sessionRepository.saveScoreStopwatchState(stopped); return explicitActualScoreTimeMs ?? stopped.accumulatedMs; } + + Future _resolveManualScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required ScoreInputMode scoreInputMode, + required double? explicitActualScore, + }) async { + if (scoreInputMode != ScoreInputMode.manual || + explicitActualScore != null) { + return explicitActualScore; + } + final state = await sessionRepository.findManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + return state?.value; + } } final class ActiveExerciseStepProgressView { @@ -3119,8 +3401,12 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource { @override Future emitCurrentProjection() async { - _revision += 1; - final projection = await _projector.project(revision: _revision); + var projection = await _projector.project(revision: _revision); + if (_latestProjection == null || + !_hasSameWatchCommandRevisionState(_latestProjection!, projection)) { + _revision += 1; + projection = await _projector.project(revision: _revision); + } _latestProjection = projection; _controller.add(projection); await _publisher?.publish(projection); @@ -3134,6 +3420,86 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource { } } +bool _hasSameWatchCommandRevisionState( + WatchSessionProjection left, + WatchSessionProjection right, +) { + return left.schemaVersion == right.schemaVersion && + left.deviceSessionId == right.deviceSessionId && + left.phase == right.phase && + left.phoneReachable == right.phoneReachable && + left.seriesIndex == right.seriesIndex && + left.seriesTotal == right.seriesTotal && + left.exerciseName == right.exerciseName && + left.programIndex == right.programIndex && + left.exerciseIndex == right.exerciseIndex && + left.setIndex == right.setIndex && + left.passageIndex == right.passageIndex && + left.passageTotal == right.passageTotal && + left.stepIndex == right.stepIndex && + left.stepTotal == right.stepTotal && + left.stepName == right.stepName && + _hasSameWatchCommandRevisionTimerState( + left.dominantTimer, + right.dominantTimer, + ) && + _hasSameWatchCommandRevisionTimerListState( + left.secondaryTimers, + right.secondaryTimers, + ) && + left.primaryAction == right.primaryAction && + _listEquals(left.secondaryActions, right.secondaryActions) && + left.nextExerciseName == right.nextExerciseName && + left.statusLabel == right.statusLabel && + left.hasManualScore == right.hasManualScore && + left.currentManualScoreValue == right.currentManualScoreValue && + left.canDecrementScore == right.canDecrementScore && + left.manualScoreTargetValue == right.manualScoreTargetValue && + left.manualScoreTargetLabel == right.manualScoreTargetLabel && + left.manualScoreScope == right.manualScoreScope; +} + +bool _hasSameWatchCommandRevisionTimerListState( + List left, + List right, +) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (!_hasSameWatchCommandRevisionTimerState(left[index], right[index])) { + return false; + } + } + return true; +} + +bool _hasSameWatchCommandRevisionTimerState( + WatchTimerProjection? left, + WatchTimerProjection? right, +) { + if (left == null || right == null) { + return left == right; + } + return left.kind == right.kind && + left.label == right.label && + left.displayMode == right.displayMode && + left.runState == right.runState && + left.targetMs == right.targetMs; +} + +bool _listEquals(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + final class WatchCompanionCommandHandler implements WatchCommandIngress { WatchCompanionCommandHandler({ required ActiveSessionRepository sessionRepository, @@ -3179,10 +3545,12 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { if (command.sessionId != projection.deviceSessionId) { return WatchCommandAck.rejectedSessionMismatch; } - if (command.expectedRevision != projection.revision) { + final applicable = _isApplicable(command.type, projection); + if (!_allowsStaleRevision(command.type) && + command.expectedRevision != projection.revision) { return WatchCommandAck.rejectedStaleRevision; } - if (!_isApplicable(command.type, projection)) { + if (!applicable) { return WatchCommandAck.rejectedNotApplicable; } @@ -3197,7 +3565,7 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { return WatchCommandAck.rejectedSessionMismatch; } - final ack = await _route(command.type, session); + final ack = await _route(command.type, session, projection); if (ack == WatchCommandAck.accepted || ack == WatchCommandAck.acceptedNoOp) { _handledCommands[key] = ack; @@ -3243,12 +3611,22 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { projection.secondaryActions.contains( WatchSecondaryAction.skipCurrentRest, ), + WatchCommandType.incrementScore => + projection.hasManualScore && projection.manualScoreScope != null, + WatchCommandType.decrementScore => + projection.hasManualScore && projection.manualScoreScope != null, }; } + bool _allowsStaleRevision(WatchCommandType type) { + return type == WatchCommandType.incrementScore || + type == WatchCommandType.decrementScore; + } + Future _route( WatchCommandType type, ActiveWorkoutSession session, + WatchSessionProjection projection, ) { return switch (type) { WatchCommandType.startCurrentExercise => _startCurrentExercise(session), @@ -3268,6 +3646,8 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { skipped: true, ), WatchCommandType.skipCurrentRest => _skipCurrentRest(session), + WatchCommandType.incrementScore => _incrementScore(session, projection), + WatchCommandType.decrementScore => _decrementScore(session, projection), }; } @@ -3450,6 +3830,78 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress { return WatchCommandAck.accepted; } + Future _incrementScore( + ActiveWorkoutSession session, + WatchSessionProjection projection, + ) async { + return _updateScore(session, projection, increment: true); + } + + Future _decrementScore( + ActiveWorkoutSession session, + WatchSessionProjection projection, + ) async { + return _updateScore(session, projection, increment: false); + } + + Future _updateScore( + ActiveWorkoutSession session, + WatchSessionProjection projection, { + required bool increment, + }) async { + final changed = switch (projection.manualScoreScope) { + WatchManualScoreScope.series => await _updateSeriesScore( + session, + increment: increment, + ), + WatchManualScoreScope.step => await _updateIndependentStepScore( + session, + increment: increment, + ), + null => throw const DomainException('Manual score scope is missing.'), + }; + return changed ? WatchCommandAck.accepted : WatchCommandAck.acceptedNoOp; + } + + Future _updateSeriesScore( + ActiveWorkoutSession session, { + required bool increment, + }) async { + final result = increment + ? await _activeSessionUseCases.incrementManualScore( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ) + : await _activeSessionUseCases.decrementManualScore( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + return result.changed; + } + + Future _updateIndependentStepScore( + ActiveWorkoutSession session, { + required bool increment, + }) { + return increment + ? _stepUseCases.incrementCurrentIndependentStepScore( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ) + : _stepUseCases.decrementCurrentIndependentStepScore( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + } + Future _emitProjectionAfterCommand() async { try { await _projectionSource.emitCurrentProjection(); @@ -3553,9 +4005,22 @@ final class WatchSessionProjectionProjector { exerciseIndex: session.currentExerciseIndex, setIndex: session.currentSetIndex, ); + final manualScore = await sessionRepository.findManualScoreState( + sessionId: session.metadata.id, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); final stepView = await _readStepViewIfStarted(session, snapshot); final stepState = stepView?.state; final currentStep = stepView?.currentStep ?? _initialStep(snapshot); + final manualScoreProjection = _watchManualScoreProjection( + snapshot: snapshot, + currentStep: currentStep, + stepState: stepState, + stepResults: stepView?.results ?? const [], + manualScore: manualScore, + ); final expectedPassages = _expectedPassages(snapshot); final projectedAtEpochMs = _epochMs(now); @@ -3589,6 +4054,9 @@ final class WatchSessionProjectionProjector { seriesIndex: session.currentSetIndex + 1, seriesTotal: snapshot.setsCount, exerciseName: snapshot.exerciseNameSnapshot, + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, passageIndex: expectedPassages > 1 && stepState != null ? stepState.currentPassageIndex + 1 : null, @@ -3622,6 +4090,12 @@ final class WatchSessionProjectionProjector { ) : null, statusLabel: _statusLabel(phase, dominantTimer), + hasManualScore: manualScoreProjection != null, + currentManualScoreValue: manualScoreProjection?.value, + canDecrementScore: (manualScoreProjection?.value ?? 0) > 0, + manualScoreTargetValue: manualScoreProjection?.targetValue, + manualScoreTargetLabel: manualScoreProjection?.targetLabel, + manualScoreScope: manualScoreProjection?.scope, ); } @@ -3658,10 +4132,25 @@ final class WatchSessionProjectionProjector { state: state, steps: snapshot.steps, expectedPassages: _expectedPassages(snapshot), - results: const [], + results: await _stepResultsForCurrentSet(session), ); } + Future> _stepResultsForCurrentSet( + ActiveWorkoutSession session, + ) async { + return (await sessionRepository.listExerciseStepResults( + session.metadata.id, + )) + .where( + (result) => + result.programIndex == session.currentProgramIndex && + result.exerciseIndex == session.currentExerciseIndex && + result.setIndex == session.currentSetIndex, + ) + .toList(); + } + Future _findActiveRest(String sessionId) async { final active = (await sessionRepository.listRestStates(sessionId)) @@ -3672,6 +4161,76 @@ final class WatchSessionProjectionProjector { } } +final class _WatchManualScoreProjectionData { + const _WatchManualScoreProjectionData({ + required this.scope, + required this.value, + this.targetValue, + this.targetLabel, + }); + + final WatchManualScoreScope scope; + final double value; + final double? targetValue; + final String? targetLabel; +} + +_WatchManualScoreProjectionData? _watchManualScoreProjection({ + required _ResolvedExerciseSnapshot snapshot, + required ExerciseStep? currentStep, + required ActiveExerciseStepProgressState? stepState, + required List stepResults, + required ActiveManualScoreState? manualScore, +}) { + final step = currentStep; + if (step != null && + step.hasScore && + step.scoreInputMode == ScoreInputMode.manual && + !step.linkedToSeriesScore) { + final result = _currentIndependentStepScoreResult( + step: step, + stepState: stepState, + stepResults: stepResults, + ); + return _WatchManualScoreProjectionData( + scope: WatchManualScoreScope.step, + value: result?.actualScore ?? 0, + targetValue: step.defaultTargetScore, + targetLabel: step.defaultTargetScore == null ? null : 'Cible', + ); + } + if (snapshot.scoreEnabled && + snapshot.scoreInputModeSnapshot == ScoreInputMode.manual) { + return _WatchManualScoreProjectionData( + scope: WatchManualScoreScope.series, + value: manualScore?.value ?? 0, + targetValue: snapshot.targetScore, + targetLabel: snapshot.targetScore == null ? null : 'Cible', + ); + } + return null; +} + +ActiveExerciseStepResult? _currentIndependentStepScoreResult({ + required ExerciseStep step, + required ActiveExerciseStepProgressState? stepState, + required List stepResults, +}) { + final state = stepState; + if (state == null) { + return null; + } + final matches = stepResults.where( + (result) => + result.passageIndex == state.currentPassageIndex && + result.stepIndex == state.currentStepIndex && + result.stepSnapshotId == step.id && + result.hasScoreSnapshot && + result.scoreInputModeSnapshot == ScoreInputMode.manual, + ); + return matches.isEmpty ? null : matches.last; +} + WatchSessionPhase _phase({ required ActiveWorkoutSession session, required _ResolvedExerciseSnapshot snapshot, @@ -3791,10 +4350,10 @@ WatchTimerProjection _stepTimerProjection( _ => WatchTimerRunState.stopped, }, referenceEpochMs: _epochMs(now), - accumulatedMs: state?.accumulatedMs ?? 0, + accumulatedMs: state?.elapsedMillisecondsAt(now) ?? 0, startedAtEpochMs: state?.status == ActiveExerciseStepProgressStatus.runningTimer - ? _epochMs(state!.startedAt!) + ? _epochMs(now) : null, targetMs: step.defaultTargetValue * 1000, ); @@ -3953,12 +4512,14 @@ final class ActiveExerciseStepUseCases { required this.clock, required this.ids, required this.originDeviceId, + this.activeSessionUseCases, }); final ActiveSessionRepository sessionRepository; final Clock clock; final IdGenerator ids; final String originDeviceId; + final ActiveWorkoutSessionUseCases? activeSessionUseCases; Future startOrResumeProgress({ required String sessionId, @@ -4094,6 +4655,66 @@ final class ActiveExerciseStepUseCases { return remaining < 0 ? 0 : remaining; } + Future incrementCurrentStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateCurrentLinkedStepScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + increment: true, + ); + } + + Future decrementCurrentStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateCurrentLinkedStepScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + increment: false, + ); + } + + Future incrementCurrentIndependentStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateCurrentIndependentStepScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + increment: true, + ); + } + + Future decrementCurrentIndependentStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) { + return _updateCurrentIndependentStepScore( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + increment: false, + ); + } + Future completeCurrentStep({ required String sessionId, required int programIndex, @@ -4123,18 +4744,25 @@ final class ActiveExerciseStepUseCases { final targetMs = currentStep.defaultTargetValue * 1000; final elapsedMs = currentState.elapsedMillisecondsAt(now); if (elapsedMs >= targetMs) { + final existing = await _findStepResultForState( + context, + currentState, + currentStep, + ); final completed = _stepResult( context: context, state: currentState, step: currentStep, + existingResult: existing, status: SetResultStatus.completed, now: now, actualTimeMs: targetMs, - actualScore: actualScore, + actualScore: actualScore ?? existing?.actualScore, actualScoreTimeMs: actualScoreTimeMs, note: note, ); await sessionRepository.saveExerciseStepResult(completed); + await _syncLinkedSeriesScore(context, currentStep, actualScore); await sessionRepository.saveExerciseStepProgressState( _advanceState( context, @@ -4152,10 +4780,12 @@ final class ActiveExerciseStepUseCases { if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) { throw const DomainException('Exercise step sequence is complete.'); } + final existing = await _findStepResultForState(context, state, step); final completed = _stepResult( context: context, state: state, step: step, + existingResult: existing, status: SetResultStatus.completed, now: now, actualTimeMs: step.type == ExerciseStepType.time @@ -4164,11 +4794,12 @@ final class ActiveExerciseStepUseCases { actualReps: step.type == ExerciseStepType.reps ? actualReps ?? step.defaultTargetValue : null, - actualScore: actualScore, + actualScore: actualScore ?? existing?.actualScore, actualScoreTimeMs: actualScoreTimeMs, note: note, ); await sessionRepository.saveExerciseStepResult(completed); + await _syncLinkedSeriesScore(context, step, actualScore); await sessionRepository.saveExerciseStepProgressState( _advanceState(context, state, now, completedStep: step), ); @@ -4195,10 +4826,12 @@ final class ActiveExerciseStepUseCases { ); final now = clock.now(); final step = _currentStep(context, state); + final existing = await _findStepResultForState(context, state, step); final skipped = _stepResult( context: context, state: state, step: step, + existingResult: existing, status: SetResultStatus.skipped, now: now, ); @@ -4329,6 +4962,8 @@ final class ActiveExerciseStepUseCases { exerciseIndex: exerciseIndex, setIndex: setIndex, steps: snapshot.steps, + scoreEnabled: snapshot.scoreEnabled, + scoreInputMode: snapshot.scoreInputModeSnapshot, expectedPassages: snapshot.repsEnabled ? (snapshot.targetReps ?? 1).clamp(1, 1 << 31) : 1, @@ -4384,6 +5019,7 @@ final class ActiveExerciseStepUseCases { actualTimeMs: targetMs, ); await sessionRepository.saveExerciseStepResult(result); + await _syncLinkedSeriesScore(context, step, result.actualScore); final overflowMs = elapsed - targetMs; current = _advanceState( context, @@ -4469,10 +5105,25 @@ final class ActiveExerciseStepUseCases { .toList(); } + Future _findStepResultForState( + _StepSequenceContext context, + ActiveExerciseStepProgressState state, + ExerciseStep step, + ) async { + final matches = (await _stepResultsForPosition(context)).where( + (result) => + result.passageIndex == state.currentPassageIndex && + result.stepIndex == state.currentStepIndex && + result.stepSnapshotId == step.id, + ); + return matches.isEmpty ? null : matches.last; + } + ActiveExerciseStepResult _stepResult({ required _StepSequenceContext context, required ActiveExerciseStepProgressState state, required ExerciseStep step, + ActiveExerciseStepResult? existingResult, required SetResultStatus status, required DateTime now, int? actualTimeMs, @@ -4482,7 +5133,9 @@ final class ActiveExerciseStepUseCases { String? note, }) { return ActiveExerciseStepResult( - metadata: _newMetadata(ids, originDeviceId, now), + metadata: + existingResult?.metadata.touch(now) ?? + _newMetadata(ids, originDeviceId, now), activeWorkoutSessionId: context.sessionId, programSnapshotId: context.programSnapshotId, exerciseSnapshotId: context.exerciseSnapshotId, @@ -4513,6 +5166,150 @@ final class ActiveExerciseStepUseCases { note: note, ); } + + Future _syncLinkedSeriesScore( + _StepSequenceContext context, + ExerciseStep step, + double? actualScore, + ) async { + if (!step.linkedToSeriesScore || actualScore == null || actualScore == 0) { + return; + } + _ensureLinkedSeriesScore(context, step); + final useCases = _requiredActiveSessionUseCases(); + final current = await useCases.findManualScore( + sessionId: context.sessionId, + programIndex: context.programIndex, + exerciseIndex: context.exerciseIndex, + setIndex: context.setIndex, + ); + await useCases.setManualScore( + sessionId: context.sessionId, + programIndex: context.programIndex, + exerciseIndex: context.exerciseIndex, + setIndex: context.setIndex, + value: (current?.value ?? 0) + actualScore, + ); + } + + Future _updateCurrentIndependentStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required bool increment, + }) async { + final context = await _stepContext( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final view = await startOrResumeProgress( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final state = view.state; + if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) { + throw const DomainException('Exercise step sequence is complete.'); + } + final step = _currentStep(context, state); + if (!step.hasScore || + step.scoreInputMode != ScoreInputMode.manual || + step.linkedToSeriesScore) { + throw const DomainException( + 'Current exercise step does not have independent manual score.', + ); + } + final existing = await _findStepResultForState(context, state, step); + final current = existing?.actualScore ?? 0; + final next = (current + (increment ? 1 : -1)).clamp(0, double.infinity); + if (next == current) { + return false; + } + final now = clock.now(); + final updated = _stepResult( + context: context, + state: state, + step: step, + existingResult: existing, + status: SetResultStatus.completed, + now: now, + actualScore: next.toDouble(), + actualTimeMs: existing?.actualTimeMs, + actualReps: existing?.actualReps, + actualScoreTimeMs: existing?.actualScoreTimeMs, + note: existing?.note, + ); + await sessionRepository.saveExerciseStepResult(updated); + return true; + } + + Future _updateCurrentLinkedStepScore({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required bool increment, + }) async { + final context = await _stepContext( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final state = await _requiredStepProgressState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + final step = _currentStep(context, state); + _ensureLinkedSeriesScore(context, step); + final useCases = _requiredActiveSessionUseCases(); + return increment + ? useCases.incrementManualScore( + sessionId: context.sessionId, + programIndex: context.programIndex, + exerciseIndex: context.exerciseIndex, + setIndex: context.setIndex, + ) + : useCases.decrementManualScore( + sessionId: context.sessionId, + programIndex: context.programIndex, + exerciseIndex: context.exerciseIndex, + setIndex: context.setIndex, + ); + } + + void _ensureLinkedSeriesScore( + _StepSequenceContext context, + ExerciseStep step, + ) { + if (!step.linkedToSeriesScore) { + throw const DomainException( + 'Current exercise step score is not linked to series score.', + ); + } + if (!context.scoreEnabled || + context.scoreInputMode != ScoreInputMode.manual) { + throw const DomainException( + 'Linked series score requires manual series score.', + ); + } + } + + ActiveWorkoutSessionUseCases _requiredActiveSessionUseCases() { + final useCases = activeSessionUseCases; + if (useCases == null) { + throw const DomainException( + 'Linked series score cannot be updated without session score use cases.', + ); + } + return useCases; + } } final class CloseWorkoutSessionUseCase { @@ -4642,6 +5439,194 @@ final class CloseWorkoutSessionUseCase { } } +final class ActiveWorkoutSensorState { + const ActiveWorkoutSensorState({ + required this.sessionId, + required this.firstSampleAt, + required this.latestSampleAt, + this.latestHeartRateBpm, + required this.sampleCount, + this.minHeartRateBpm, + this.averageHeartRateBpm, + this.maxHeartRateBpm, + this.latestDistanceMeters, + this.latestCaloriesKcal, + required this.estimatedCaloriesKcal, + }); + + final String sessionId; + final DateTime firstSampleAt; + final DateTime latestSampleAt; + final int? latestHeartRateBpm; + final int sampleCount; + final int? minHeartRateBpm; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; + final double? latestDistanceMeters; + final double? latestCaloriesKcal; + final double estimatedCaloriesKcal; +} + +final class ActiveWorkoutSensorUseCases { + ActiveWorkoutSensorUseCases({required this.clock}); + + final Clock clock; + final _states = {}; + final _updates = StreamController.broadcast(); + + Stream get updates => _updates.stream; + + ActiveWorkoutSensorState? current(String sessionId) { + return _states[sessionId]?.snapshot; + } + + ActiveWorkoutSensorState? recordHeartRateSample(WatchSensorSample sample) { + return recordTelemetrySample(sample); + } + + ActiveWorkoutSensorState? recordTelemetrySample(WatchTelemetrySample sample) { + final sessionId = sample.sessionId.trim(); + final hasHeartRate = + sample.heartRateBpm != null && sample.heartRateBpm! > 0; + final hasDistance = + sample.distanceMeters != null && sample.distanceMeters! >= 0; + final hasCalories = + sample.caloriesKcal != null && sample.caloriesKcal! >= 0; + if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) { + return null; + } + final recordedAt = sample.capturedAtEpochMs > 0 + ? DateTime.fromMillisecondsSinceEpoch( + sample.capturedAtEpochMs, + isUtc: true, + ) + : clock.now(); + final accumulator = _states.putIfAbsent( + sessionId, + () => _ActiveWorkoutSensorAccumulator(sessionId, recordedAt), + ); + final snapshot = accumulator.add( + sampleId: sample.sampleId, + heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, + distanceMeters: hasDistance ? sample.distanceMeters : null, + caloriesKcal: hasCalories ? sample.caloriesKcal : null, + recordedAt: recordedAt, + ); + if (snapshot == null) { + return null; + } + _updates.add(snapshot); + return snapshot; + } + + void clear(String sessionId) { + _states.remove(sessionId); + } + + Future dispose() async { + await _updates.close(); + } +} + +final class _ActiveWorkoutSensorAccumulator { + _ActiveWorkoutSensorAccumulator(this.sessionId, this.firstSampleAt); + + final String sessionId; + final DateTime firstSampleAt; + var _latestSampleAt = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + final _seenSampleIds = {}; + int? _latestHeartRateBpm; + var _sampleCount = 0; + var _heartRateSampleCount = 0; + var _heartRateSum = 0.0; + int? _minHeartRateBpm; + int? _maxHeartRateBpm; + double? _latestDistanceMeters; + double? _latestCaloriesKcal; + + ActiveWorkoutSensorState get snapshot { + final averageHeartRateBpm = _heartRateSampleCount == 0 + ? null + : _heartRateSum / _heartRateSampleCount; + return ActiveWorkoutSensorState( + sessionId: sessionId, + firstSampleAt: firstSampleAt, + latestSampleAt: _latestSampleAt, + latestHeartRateBpm: _latestHeartRateBpm, + sampleCount: _sampleCount, + minHeartRateBpm: _minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: _maxHeartRateBpm, + latestDistanceMeters: _latestDistanceMeters, + latestCaloriesKcal: _latestCaloriesKcal, + estimatedCaloriesKcal: _estimatedCaloriesKcal( + averageHeartRateBpm: averageHeartRateBpm ?? 0, + activeDuration: _latestSampleAt.difference(firstSampleAt), + ), + ); + } + + ActiveWorkoutSensorState? add({ + required String? sampleId, + required int? heartRateBpm, + required double? distanceMeters, + required double? caloriesKcal, + required DateTime recordedAt, + }) { + final stableSampleId = sampleId?.trim(); + if (stableSampleId != null && stableSampleId.isNotEmpty) { + final added = _seenSampleIds.add(stableSampleId); + if (!added) { + return null; + } + } + _sampleCount += 1; + if (recordedAt.isAfter(_latestSampleAt)) { + _latestSampleAt = recordedAt; + } + if (heartRateBpm != null) { + _heartRateSampleCount += 1; + _heartRateSum += heartRateBpm; + _latestHeartRateBpm = heartRateBpm; + _minHeartRateBpm = _minHeartRateBpm == null + ? heartRateBpm + : (_minHeartRateBpm! < heartRateBpm + ? _minHeartRateBpm + : heartRateBpm); + _maxHeartRateBpm = _maxHeartRateBpm == null + ? heartRateBpm + : (_maxHeartRateBpm! > heartRateBpm + ? _maxHeartRateBpm + : heartRateBpm); + } + if (distanceMeters != null && + (_latestDistanceMeters == null || + distanceMeters >= _latestDistanceMeters!)) { + _latestDistanceMeters = distanceMeters; + } + if (caloriesKcal != null && + (_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) { + _latestCaloriesKcal = caloriesKcal; + } + return snapshot; + } +} + +double _estimatedCaloriesKcal({ + required double averageHeartRateBpm, + required Duration activeDuration, +}) { + final durationHours = activeDuration.inMilliseconds / 3600000; + if (durationHours <= 0) { + return 0; + } + final elevatedBpm = averageHeartRateBpm - 60; + if (elevatedBpm <= 0) { + return 0; + } + return elevatedBpm * durationHours * 5; +} + final class WorkoutHistoryUseCases { const WorkoutHistoryUseCases({required this.repository, required this.clock}); @@ -4653,6 +5638,39 @@ final class WorkoutHistoryUseCases { Future> listActive() => repository.listActive(); Future delete(String id) => repository.delete(id, clock.now()); + + Future updateHeartRateSummary(WatchSensorSummary summary) async { + if (summary.sampleCount < 3 || + summary.averageHeartRateBpm == null || + summary.maxHeartRateBpm == null) { + return; + } + if (summary.sessionId.trim().isEmpty || + summary.averageHeartRateBpm! <= 0 || + summary.maxHeartRateBpm! <= 0) { + return; + } + final histories = await repository.listActive(); + WorkoutHistory? history; + for (final candidate in histories) { + if (candidate.metadata.id == summary.sessionId || + candidate.sourceActiveWorkoutSessionId == summary.sessionId) { + history = candidate; + break; + } + } + if (history == null || + history.averageHeartRateBpm != null || + history.maxHeartRateBpm != null) { + return; + } + await repository.patchHeartRateSummary( + historyId: history.metadata.id, + averageHeartRateBpm: summary.averageHeartRateBpm!, + maxHeartRateBpm: summary.maxHeartRateBpm!, + patchedAt: clock.now(), + ); + } } final class ProgressionStatsUseCase { @@ -4955,7 +5973,8 @@ void _validateExerciseSteps(List steps) { if (step.scoreLabel != null || step.scoreUnit != null || step.defaultTargetScore != null || - step.defaultTargetScoreTimeMs != null) { + step.defaultTargetScoreTimeMs != null || + step.linkedToSeriesScore) { throw const DomainException( 'Disabled exercise step score must not define score values.', ); @@ -4991,6 +6010,11 @@ void _validateExerciseSteps(List steps) { ); } case ScoreInputMode.stopwatch: + if (step.linkedToSeriesScore) { + throw const DomainException( + 'Linked series score requires manual exercise step score.', + ); + } if (step.scoreLabel != null || step.scoreUnit != null || step.defaultTargetScore != null) { @@ -5360,6 +6384,55 @@ _SetPositionSnapshot? _nextPositionAfter( return snapshots[currentIndex + 1]; } +String _resolvedTemplateSnapshotJson(WorkoutTemplate template) { + return jsonEncode({ + 'templateId': template.metadata.id, + 'name': template.name, + 'programs': template.programs + .map( + (program) => { + 'id': program.metadata.id, + 'programNameSnapshot': program.programNameSnapshot, + 'programSnapshotJson': program.programSnapshotJson, + }, + ) + .toList(), + 'overrides': template.overrides + .map( + (override) => { + 'workoutTemplateProgramId': override.workoutTemplateProgramId, + 'snapshotProgramExerciseId': override.snapshotProgramExerciseId, + 'setsCountOverride': override.setsCountOverride, + 'targetTimeSecondsOverride': override.targetTimeSecondsOverride, + 'targetRepsOverride': override.targetRepsOverride, + 'targetScoreOverride': override.targetScoreOverride, + 'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride, + 'autoStartNextTimedStepOverride': + override.autoStartNextTimedStepOverride, + }, + ) + .toList(), + }); +} + +void _ensurePlayableResolvedTemplateSnapshot( + String resolvedTemplateSnapshotJson, +) { + if (!_isPlayableResolvedTemplateSnapshot(resolvedTemplateSnapshotJson)) { + throw const DomainException( + 'Workout template must contain at least one exercise.', + ); + } +} + +bool _isPlayableResolvedTemplateSnapshot(String resolvedTemplateSnapshotJson) { + try { + return _listSetSnapshots(resolvedTemplateSnapshotJson).isNotEmpty; + } on Object { + return false; + } +} + _ResolvedExerciseSnapshot? _findExerciseSnapshot({ required String resolvedTemplateSnapshotJson, required int programIndex, @@ -5524,6 +6597,8 @@ final class _StepSequenceContext { required this.exerciseIndex, required this.setIndex, required this.steps, + required this.scoreEnabled, + required this.scoreInputMode, required this.expectedPassages, required this.autoStartNextTimedStep, }); @@ -5535,6 +6610,8 @@ final class _StepSequenceContext { final int exerciseIndex; final int setIndex; final List steps; + final bool scoreEnabled; + final ScoreInputMode scoreInputMode; final int expectedPassages; final bool autoStartNextTimedStep; } @@ -5709,6 +6786,7 @@ List _exerciseStepsFromSnapshot(Object? value) { scoreUnit: json['scoreUnit'] as String?, defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(), defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?, + linkedToSeriesScore: json['linkedToSeriesScore'] == true, ); }) .toList(growable: false); diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index bc0fdb4..6c1919a 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -375,6 +375,11 @@ final class Exercise { defaultTargetScoreTimeMs, 'Default target score time ms', ); + _requireLinkedStepSeriesScoreShape( + steps: steps, + scoreEnabled: hasScoreMeasure, + scoreInputMode: scoreInputMode, + ); } final EntityMetadata metadata; @@ -508,6 +513,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 +525,7 @@ final class ExerciseStep { scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, ); } @@ -533,6 +540,7 @@ final class ExerciseStep { final String? scoreUnit; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; + final bool linkedToSeriesScore; Map toSnapshotJson() => { 'id': id, @@ -546,6 +554,7 @@ final class ExerciseStep { 'scoreUnit': scoreUnit, 'defaultTargetScore': defaultTargetScore, 'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs, + 'linkedToSeriesScore': linkedToSeriesScore, }; } @@ -658,6 +667,11 @@ final class ProgramExercise { targetScore: targetScore, targetScoreTimeMs: targetScoreTimeMs, ); + _requireLinkedStepSeriesScoreShape( + steps: exerciseStepsSnapshot, + scoreEnabled: scoreEnabled, + scoreInputMode: scoreInputModeSnapshot, + ); } final EntityMetadata metadata; @@ -1100,6 +1114,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 +1460,7 @@ final class ActiveExerciseStepResult { } final class WorkoutHistory { - const WorkoutHistory({ + WorkoutHistory({ required this.metadata, this.sourceWorkoutTemplateId, this.sourceActiveWorkoutSessionId, @@ -1415,9 +1470,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 +1491,36 @@ final class WorkoutHistory { final int totalActiveMs; final bool completed; final String historySnapshotJson; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; final List results; final List 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 +1812,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 +1850,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 +1883,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 +1924,19 @@ void _requireScoreResultShape({ } } +void _requireLinkedStepSeriesScoreShape({ + required List 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, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index dfe5d70..9de4fc0 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -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 _migrateToSchema20(Migrator migrator) async { + await migrator.createTable(activeManualScoreStates); + } + + Future _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 _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 _backfillWorkoutHistorySetSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_set_results AS result diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index f79f92e..fe707b6 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -6921,6 +6921,1030 @@ class ActiveRestStatesCompanion extends UpdateCompanion { } } +class $ActiveManualScoreStatesTable extends ActiveManualScoreStates + with TableInfo<$ActiveManualScoreStatesTable, ActiveManualScoreState> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ActiveManualScoreStatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _deletedAtMeta = const VerificationMeta( + 'deletedAt', + ); + @override + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _schemaVersionMeta = const VerificationMeta( + 'schemaVersion', + ); + @override + late final GeneratedColumn schemaVersion = GeneratedColumn( + 'schema_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _syncStateMeta = const VerificationMeta( + 'syncState', + ); + @override + late final GeneratedColumn syncState = GeneratedColumn( + 'sync_state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL CHECK (sync_state IN (\'localOnly\', \'dirty\', \'synced\', \'deleted\'))', + ); + static const VerificationMeta _localRevisionMeta = const VerificationMeta( + 'localRevision', + ); + @override + late final GeneratedColumn localRevision = GeneratedColumn( + 'local_revision', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (local_revision >= 0)', + ); + static const VerificationMeta _originDeviceIdMeta = const VerificationMeta( + 'originDeviceId', + ); + @override + late final GeneratedColumn originDeviceId = GeneratedColumn( + 'origin_device_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _futureOwnerProfileIdMeta = + const VerificationMeta('futureOwnerProfileId'); + @override + late final GeneratedColumn futureOwnerProfileId = + GeneratedColumn( + 'future_owner_profile_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSyncedAtMeta = const VerificationMeta( + 'lastSyncedAt', + ); + @override + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteRevisionMeta = const VerificationMeta( + 'remoteRevision', + ); + @override + late final GeneratedColumn remoteRevision = GeneratedColumn( + 'remote_revision', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _activeWorkoutSessionIdMeta = + const VerificationMeta('activeWorkoutSessionId'); + @override + late final GeneratedColumn activeWorkoutSessionId = + GeneratedColumn( + 'active_workout_session_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES active_workout_sessions (id)', + ), + ); + static const VerificationMeta _programIndexMeta = const VerificationMeta( + 'programIndex', + ); + @override + late final GeneratedColumn programIndex = GeneratedColumn( + 'program_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _exerciseIndexMeta = const VerificationMeta( + 'exerciseIndex', + ); + @override + late final GeneratedColumn exerciseIndex = GeneratedColumn( + 'exercise_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _setIndexMeta = const VerificationMeta( + 'setIndex', + ); + @override + late final GeneratedColumn setIndex = GeneratedColumn( + 'set_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _scoreUpdatedAtMeta = const VerificationMeta( + 'scoreUpdatedAt', + ); + @override + late final GeneratedColumn scoreUpdatedAt = + GeneratedColumn( + 'score_updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + activeWorkoutSessionId, + programIndex, + exerciseIndex, + setIndex, + value, + scoreUpdatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'active_manual_score_states'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('deleted_at')) { + context.handle( + _deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), + ); + } + if (data.containsKey('schema_version')) { + context.handle( + _schemaVersionMeta, + schemaVersion.isAcceptableOrUnknown( + data['schema_version']!, + _schemaVersionMeta, + ), + ); + } + if (data.containsKey('sync_state')) { + context.handle( + _syncStateMeta, + syncState.isAcceptableOrUnknown(data['sync_state']!, _syncStateMeta), + ); + } else if (isInserting) { + context.missing(_syncStateMeta); + } + if (data.containsKey('local_revision')) { + context.handle( + _localRevisionMeta, + localRevision.isAcceptableOrUnknown( + data['local_revision']!, + _localRevisionMeta, + ), + ); + } else if (isInserting) { + context.missing(_localRevisionMeta); + } + if (data.containsKey('origin_device_id')) { + context.handle( + _originDeviceIdMeta, + originDeviceId.isAcceptableOrUnknown( + data['origin_device_id']!, + _originDeviceIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_originDeviceIdMeta); + } + if (data.containsKey('future_owner_profile_id')) { + context.handle( + _futureOwnerProfileIdMeta, + futureOwnerProfileId.isAcceptableOrUnknown( + data['future_owner_profile_id']!, + _futureOwnerProfileIdMeta, + ), + ); + } + if (data.containsKey('last_synced_at')) { + context.handle( + _lastSyncedAtMeta, + lastSyncedAt.isAcceptableOrUnknown( + data['last_synced_at']!, + _lastSyncedAtMeta, + ), + ); + } + if (data.containsKey('remote_revision')) { + context.handle( + _remoteRevisionMeta, + remoteRevision.isAcceptableOrUnknown( + data['remote_revision']!, + _remoteRevisionMeta, + ), + ); + } + if (data.containsKey('active_workout_session_id')) { + context.handle( + _activeWorkoutSessionIdMeta, + activeWorkoutSessionId.isAcceptableOrUnknown( + data['active_workout_session_id']!, + _activeWorkoutSessionIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_activeWorkoutSessionIdMeta); + } + if (data.containsKey('program_index')) { + context.handle( + _programIndexMeta, + programIndex.isAcceptableOrUnknown( + data['program_index']!, + _programIndexMeta, + ), + ); + } else if (isInserting) { + context.missing(_programIndexMeta); + } + if (data.containsKey('exercise_index')) { + context.handle( + _exerciseIndexMeta, + exerciseIndex.isAcceptableOrUnknown( + data['exercise_index']!, + _exerciseIndexMeta, + ), + ); + } else if (isInserting) { + context.missing(_exerciseIndexMeta); + } + if (data.containsKey('set_index')) { + context.handle( + _setIndexMeta, + setIndex.isAcceptableOrUnknown(data['set_index']!, _setIndexMeta), + ); + } else if (isInserting) { + context.missing(_setIndexMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + if (data.containsKey('score_updated_at')) { + context.handle( + _scoreUpdatedAtMeta, + scoreUpdatedAt.isAcceptableOrUnknown( + data['score_updated_at']!, + _scoreUpdatedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_scoreUpdatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ActiveManualScoreState map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ActiveManualScoreState( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + deletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}deleted_at'], + ), + schemaVersion: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}schema_version'], + )!, + syncState: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}sync_state'], + )!, + localRevision: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}local_revision'], + )!, + originDeviceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin_device_id'], + )!, + futureOwnerProfileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}future_owner_profile_id'], + ), + lastSyncedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_synced_at'], + ), + remoteRevision: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}remote_revision'], + ), + activeWorkoutSessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}active_workout_session_id'], + )!, + programIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}program_index'], + )!, + exerciseIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}exercise_index'], + )!, + setIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}set_index'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}value'], + )!, + scoreUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}score_updated_at'], + )!, + ); + } + + @override + $ActiveManualScoreStatesTable createAlias(String alias) { + return $ActiveManualScoreStatesTable(attachedDatabase, alias); + } +} + +class ActiveManualScoreState extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final int schemaVersion; + final String syncState; + final int localRevision; + final String originDeviceId; + final String? futureOwnerProfileId; + final DateTime? lastSyncedAt; + final String? remoteRevision; + final String activeWorkoutSessionId; + final int programIndex; + final int exerciseIndex; + final int setIndex; + final double value; + final DateTime scoreUpdatedAt; + const ActiveManualScoreState({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.schemaVersion, + required this.syncState, + required this.localRevision, + required this.originDeviceId, + this.futureOwnerProfileId, + this.lastSyncedAt, + this.remoteRevision, + required this.activeWorkoutSessionId, + required this.programIndex, + required this.exerciseIndex, + required this.setIndex, + required this.value, + required this.scoreUpdatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['schema_version'] = Variable(schemaVersion); + map['sync_state'] = Variable(syncState); + map['local_revision'] = Variable(localRevision); + map['origin_device_id'] = Variable(originDeviceId); + if (!nullToAbsent || futureOwnerProfileId != null) { + map['future_owner_profile_id'] = Variable(futureOwnerProfileId); + } + if (!nullToAbsent || lastSyncedAt != null) { + map['last_synced_at'] = Variable(lastSyncedAt); + } + if (!nullToAbsent || remoteRevision != null) { + map['remote_revision'] = Variable(remoteRevision); + } + map['active_workout_session_id'] = Variable(activeWorkoutSessionId); + map['program_index'] = Variable(programIndex); + map['exercise_index'] = Variable(exerciseIndex); + map['set_index'] = Variable(setIndex); + map['value'] = Variable(value); + map['score_updated_at'] = Variable(scoreUpdatedAt); + return map; + } + + ActiveManualScoreStatesCompanion toCompanion(bool nullToAbsent) { + return ActiveManualScoreStatesCompanion( + id: Value(id), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + deletedAt: deletedAt == null && nullToAbsent + ? const Value.absent() + : Value(deletedAt), + schemaVersion: Value(schemaVersion), + syncState: Value(syncState), + localRevision: Value(localRevision), + originDeviceId: Value(originDeviceId), + futureOwnerProfileId: futureOwnerProfileId == null && nullToAbsent + ? const Value.absent() + : Value(futureOwnerProfileId), + lastSyncedAt: lastSyncedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastSyncedAt), + remoteRevision: remoteRevision == null && nullToAbsent + ? const Value.absent() + : Value(remoteRevision), + activeWorkoutSessionId: Value(activeWorkoutSessionId), + programIndex: Value(programIndex), + exerciseIndex: Value(exerciseIndex), + setIndex: Value(setIndex), + value: Value(value), + scoreUpdatedAt: Value(scoreUpdatedAt), + ); + } + + factory ActiveManualScoreState.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ActiveManualScoreState( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + schemaVersion: serializer.fromJson(json['schemaVersion']), + syncState: serializer.fromJson(json['syncState']), + localRevision: serializer.fromJson(json['localRevision']), + originDeviceId: serializer.fromJson(json['originDeviceId']), + futureOwnerProfileId: serializer.fromJson( + json['futureOwnerProfileId'], + ), + lastSyncedAt: serializer.fromJson(json['lastSyncedAt']), + remoteRevision: serializer.fromJson(json['remoteRevision']), + activeWorkoutSessionId: serializer.fromJson( + json['activeWorkoutSessionId'], + ), + programIndex: serializer.fromJson(json['programIndex']), + exerciseIndex: serializer.fromJson(json['exerciseIndex']), + setIndex: serializer.fromJson(json['setIndex']), + value: serializer.fromJson(json['value']), + scoreUpdatedAt: serializer.fromJson(json['scoreUpdatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'schemaVersion': serializer.toJson(schemaVersion), + 'syncState': serializer.toJson(syncState), + 'localRevision': serializer.toJson(localRevision), + 'originDeviceId': serializer.toJson(originDeviceId), + 'futureOwnerProfileId': serializer.toJson(futureOwnerProfileId), + 'lastSyncedAt': serializer.toJson(lastSyncedAt), + 'remoteRevision': serializer.toJson(remoteRevision), + 'activeWorkoutSessionId': serializer.toJson( + activeWorkoutSessionId, + ), + 'programIndex': serializer.toJson(programIndex), + 'exerciseIndex': serializer.toJson(exerciseIndex), + 'setIndex': serializer.toJson(setIndex), + 'value': serializer.toJson(value), + 'scoreUpdatedAt': serializer.toJson(scoreUpdatedAt), + }; + } + + ActiveManualScoreState copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + int? schemaVersion, + String? syncState, + int? localRevision, + String? originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + String? activeWorkoutSessionId, + int? programIndex, + int? exerciseIndex, + int? setIndex, + double? value, + DateTime? scoreUpdatedAt, + }) => ActiveManualScoreState( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId.present + ? futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt.present ? lastSyncedAt.value : this.lastSyncedAt, + remoteRevision: remoteRevision.present + ? remoteRevision.value + : this.remoteRevision, + activeWorkoutSessionId: + activeWorkoutSessionId ?? this.activeWorkoutSessionId, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + value: value ?? this.value, + scoreUpdatedAt: scoreUpdatedAt ?? this.scoreUpdatedAt, + ); + ActiveManualScoreState copyWithCompanion( + ActiveManualScoreStatesCompanion data, + ) { + return ActiveManualScoreState( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + schemaVersion: data.schemaVersion.present + ? data.schemaVersion.value + : this.schemaVersion, + syncState: data.syncState.present ? data.syncState.value : this.syncState, + localRevision: data.localRevision.present + ? data.localRevision.value + : this.localRevision, + originDeviceId: data.originDeviceId.present + ? data.originDeviceId.value + : this.originDeviceId, + futureOwnerProfileId: data.futureOwnerProfileId.present + ? data.futureOwnerProfileId.value + : this.futureOwnerProfileId, + lastSyncedAt: data.lastSyncedAt.present + ? data.lastSyncedAt.value + : this.lastSyncedAt, + remoteRevision: data.remoteRevision.present + ? data.remoteRevision.value + : this.remoteRevision, + activeWorkoutSessionId: data.activeWorkoutSessionId.present + ? data.activeWorkoutSessionId.value + : this.activeWorkoutSessionId, + programIndex: data.programIndex.present + ? data.programIndex.value + : this.programIndex, + exerciseIndex: data.exerciseIndex.present + ? data.exerciseIndex.value + : this.exerciseIndex, + setIndex: data.setIndex.present ? data.setIndex.value : this.setIndex, + value: data.value.present ? data.value.value : this.value, + scoreUpdatedAt: data.scoreUpdatedAt.present + ? data.scoreUpdatedAt.value + : this.scoreUpdatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ActiveManualScoreState(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('activeWorkoutSessionId: $activeWorkoutSessionId, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('value: $value, ') + ..write('scoreUpdatedAt: $scoreUpdatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + createdAt, + updatedAt, + deletedAt, + schemaVersion, + syncState, + localRevision, + originDeviceId, + futureOwnerProfileId, + lastSyncedAt, + remoteRevision, + activeWorkoutSessionId, + programIndex, + exerciseIndex, + setIndex, + value, + scoreUpdatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ActiveManualScoreState && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.schemaVersion == this.schemaVersion && + other.syncState == this.syncState && + other.localRevision == this.localRevision && + other.originDeviceId == this.originDeviceId && + other.futureOwnerProfileId == this.futureOwnerProfileId && + other.lastSyncedAt == this.lastSyncedAt && + other.remoteRevision == this.remoteRevision && + other.activeWorkoutSessionId == this.activeWorkoutSessionId && + other.programIndex == this.programIndex && + other.exerciseIndex == this.exerciseIndex && + other.setIndex == this.setIndex && + other.value == this.value && + other.scoreUpdatedAt == this.scoreUpdatedAt); +} + +class ActiveManualScoreStatesCompanion + extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value schemaVersion; + final Value syncState; + final Value localRevision; + final Value originDeviceId; + final Value futureOwnerProfileId; + final Value lastSyncedAt; + final Value remoteRevision; + final Value activeWorkoutSessionId; + final Value programIndex; + final Value exerciseIndex; + final Value setIndex; + final Value value; + final Value scoreUpdatedAt; + final Value rowid; + const ActiveManualScoreStatesCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + this.syncState = const Value.absent(), + this.localRevision = const Value.absent(), + this.originDeviceId = const Value.absent(), + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + this.activeWorkoutSessionId = const Value.absent(), + this.programIndex = const Value.absent(), + this.exerciseIndex = const Value.absent(), + this.setIndex = const Value.absent(), + this.value = const Value.absent(), + this.scoreUpdatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ActiveManualScoreStatesCompanion.insert({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + this.deletedAt = const Value.absent(), + this.schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + this.futureOwnerProfileId = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.remoteRevision = const Value.absent(), + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required double value, + required DateTime scoreUpdatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + syncState = Value(syncState), + localRevision = Value(localRevision), + originDeviceId = Value(originDeviceId), + activeWorkoutSessionId = Value(activeWorkoutSessionId), + programIndex = Value(programIndex), + exerciseIndex = Value(exerciseIndex), + setIndex = Value(setIndex), + value = Value(value), + scoreUpdatedAt = Value(scoreUpdatedAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? schemaVersion, + Expression? syncState, + Expression? localRevision, + Expression? originDeviceId, + Expression? futureOwnerProfileId, + Expression? lastSyncedAt, + Expression? remoteRevision, + Expression? activeWorkoutSessionId, + Expression? programIndex, + Expression? exerciseIndex, + Expression? setIndex, + Expression? value, + Expression? scoreUpdatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (schemaVersion != null) 'schema_version': schemaVersion, + if (syncState != null) 'sync_state': syncState, + if (localRevision != null) 'local_revision': localRevision, + if (originDeviceId != null) 'origin_device_id': originDeviceId, + if (futureOwnerProfileId != null) + 'future_owner_profile_id': futureOwnerProfileId, + if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt, + if (remoteRevision != null) 'remote_revision': remoteRevision, + if (activeWorkoutSessionId != null) + 'active_workout_session_id': activeWorkoutSessionId, + if (programIndex != null) 'program_index': programIndex, + if (exerciseIndex != null) 'exercise_index': exerciseIndex, + if (setIndex != null) 'set_index': setIndex, + if (value != null) 'value': value, + if (scoreUpdatedAt != null) 'score_updated_at': scoreUpdatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ActiveManualScoreStatesCompanion copyWith({ + Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? schemaVersion, + Value? syncState, + Value? localRevision, + Value? originDeviceId, + Value? futureOwnerProfileId, + Value? lastSyncedAt, + Value? remoteRevision, + Value? activeWorkoutSessionId, + Value? programIndex, + Value? exerciseIndex, + Value? setIndex, + Value? value, + Value? scoreUpdatedAt, + Value? rowid, + }) { + return ActiveManualScoreStatesCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + schemaVersion: schemaVersion ?? this.schemaVersion, + syncState: syncState ?? this.syncState, + localRevision: localRevision ?? this.localRevision, + originDeviceId: originDeviceId ?? this.originDeviceId, + futureOwnerProfileId: futureOwnerProfileId ?? this.futureOwnerProfileId, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + remoteRevision: remoteRevision ?? this.remoteRevision, + activeWorkoutSessionId: + activeWorkoutSessionId ?? this.activeWorkoutSessionId, + programIndex: programIndex ?? this.programIndex, + exerciseIndex: exerciseIndex ?? this.exerciseIndex, + setIndex: setIndex ?? this.setIndex, + value: value ?? this.value, + scoreUpdatedAt: scoreUpdatedAt ?? this.scoreUpdatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (schemaVersion.present) { + map['schema_version'] = Variable(schemaVersion.value); + } + if (syncState.present) { + map['sync_state'] = Variable(syncState.value); + } + if (localRevision.present) { + map['local_revision'] = Variable(localRevision.value); + } + if (originDeviceId.present) { + map['origin_device_id'] = Variable(originDeviceId.value); + } + if (futureOwnerProfileId.present) { + map['future_owner_profile_id'] = Variable( + futureOwnerProfileId.value, + ); + } + if (lastSyncedAt.present) { + map['last_synced_at'] = Variable(lastSyncedAt.value); + } + if (remoteRevision.present) { + map['remote_revision'] = Variable(remoteRevision.value); + } + if (activeWorkoutSessionId.present) { + map['active_workout_session_id'] = Variable( + activeWorkoutSessionId.value, + ); + } + if (programIndex.present) { + map['program_index'] = Variable(programIndex.value); + } + if (exerciseIndex.present) { + map['exercise_index'] = Variable(exerciseIndex.value); + } + if (setIndex.present) { + map['set_index'] = Variable(setIndex.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (scoreUpdatedAt.present) { + map['score_updated_at'] = Variable(scoreUpdatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ActiveManualScoreStatesCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('schemaVersion: $schemaVersion, ') + ..write('syncState: $syncState, ') + ..write('localRevision: $localRevision, ') + ..write('originDeviceId: $originDeviceId, ') + ..write('futureOwnerProfileId: $futureOwnerProfileId, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('remoteRevision: $remoteRevision, ') + ..write('activeWorkoutSessionId: $activeWorkoutSessionId, ') + ..write('programIndex: $programIndex, ') + ..write('exerciseIndex: $exerciseIndex, ') + ..write('setIndex: $setIndex, ') + ..write('value: $value, ') + ..write('scoreUpdatedAt: $scoreUpdatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ActiveScoreStopwatchStatesTable extends ActiveScoreStopwatchStates with TableInfo<$ActiveScoreStopwatchStatesTable, ActiveScoreStopwatchState> { @@ -15454,6 +16478,20 @@ class $ExerciseStepsTable extends ExerciseSteps type: DriftSqlType.int, requiredDuringInsert: false, ); + static const VerificationMeta _linkedToSeriesScoreMeta = + const VerificationMeta('linkedToSeriesScore'); + @override + late final GeneratedColumn linkedToSeriesScore = GeneratedColumn( + 'linked_to_series_score', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("linked_to_series_score" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -15478,6 +16516,7 @@ class $ExerciseStepsTable extends ExerciseSteps scoreUnit, defaultTargetScore, defaultTargetScoreTimeMs, + linkedToSeriesScore, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -15674,6 +16713,15 @@ class $ExerciseStepsTable extends ExerciseSteps ), ); } + if (data.containsKey('linked_to_series_score')) { + context.handle( + _linkedToSeriesScoreMeta, + linkedToSeriesScore.isAcceptableOrUnknown( + data['linked_to_series_score']!, + _linkedToSeriesScoreMeta, + ), + ); + } return context; } @@ -15771,6 +16819,10 @@ class $ExerciseStepsTable extends ExerciseSteps DriftSqlType.int, data['${effectivePrefix}default_target_score_time_ms'], ), + linkedToSeriesScore: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}linked_to_series_score'], + )!, ); } @@ -15803,6 +16855,7 @@ class ExerciseStep extends DataClass implements Insertable { final String? scoreUnit; final double? defaultTargetScore; final int? defaultTargetScoreTimeMs; + final bool linkedToSeriesScore; const ExerciseStep({ required this.id, required this.createdAt, @@ -15826,6 +16879,7 @@ class ExerciseStep extends DataClass implements Insertable { this.scoreUnit, this.defaultTargetScore, this.defaultTargetScoreTimeMs, + required this.linkedToSeriesScore, }); @override Map toColumns(bool nullToAbsent) { @@ -15872,6 +16926,7 @@ class ExerciseStep extends DataClass implements Insertable { defaultTargetScoreTimeMs, ); } + map['linked_to_series_score'] = Variable(linkedToSeriesScore); return map; } @@ -15917,6 +16972,7 @@ class ExerciseStep extends DataClass implements Insertable { defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == null && nullToAbsent ? const Value.absent() : Value(defaultTargetScoreTimeMs), + linkedToSeriesScore: Value(linkedToSeriesScore), ); } @@ -15954,6 +17010,9 @@ class ExerciseStep extends DataClass implements Insertable { defaultTargetScoreTimeMs: serializer.fromJson( json['defaultTargetScoreTimeMs'], ), + linkedToSeriesScore: serializer.fromJson( + json['linkedToSeriesScore'], + ), ); } @override @@ -15984,6 +17043,7 @@ class ExerciseStep extends DataClass implements Insertable { 'defaultTargetScoreTimeMs': serializer.toJson( defaultTargetScoreTimeMs, ), + 'linkedToSeriesScore': serializer.toJson(linkedToSeriesScore), }; } @@ -16010,6 +17070,7 @@ class ExerciseStep extends DataClass implements Insertable { Value scoreUnit = const Value.absent(), Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), + bool? linkedToSeriesScore, }) => ExerciseStep( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -16043,6 +17104,7 @@ class ExerciseStep extends DataClass implements Insertable { defaultTargetScoreTimeMs: defaultTargetScoreTimeMs.present ? defaultTargetScoreTimeMs.value : this.defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore ?? this.linkedToSeriesScore, ); ExerciseStep copyWithCompanion(ExerciseStepsCompanion data) { return ExerciseStep( @@ -16092,6 +17154,9 @@ class ExerciseStep extends DataClass implements Insertable { defaultTargetScoreTimeMs: data.defaultTargetScoreTimeMs.present ? data.defaultTargetScoreTimeMs.value : this.defaultTargetScoreTimeMs, + linkedToSeriesScore: data.linkedToSeriesScore.present + ? data.linkedToSeriesScore.value + : this.linkedToSeriesScore, ); } @@ -16119,7 +17184,8 @@ class ExerciseStep extends DataClass implements Insertable { ..write('scoreLabel: $scoreLabel, ') ..write('scoreUnit: $scoreUnit, ') ..write('defaultTargetScore: $defaultTargetScore, ') - ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs') + ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') + ..write('linkedToSeriesScore: $linkedToSeriesScore') ..write(')')) .toString(); } @@ -16148,6 +17214,7 @@ class ExerciseStep extends DataClass implements Insertable { scoreUnit, defaultTargetScore, defaultTargetScoreTimeMs, + linkedToSeriesScore, ]); @override bool operator ==(Object other) => @@ -16174,7 +17241,8 @@ class ExerciseStep extends DataClass implements Insertable { other.scoreLabel == this.scoreLabel && other.scoreUnit == this.scoreUnit && other.defaultTargetScore == this.defaultTargetScore && - other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs); + other.defaultTargetScoreTimeMs == this.defaultTargetScoreTimeMs && + other.linkedToSeriesScore == this.linkedToSeriesScore); } class ExerciseStepsCompanion extends UpdateCompanion { @@ -16200,6 +17268,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { final Value scoreUnit; final Value defaultTargetScore; final Value defaultTargetScoreTimeMs; + final Value linkedToSeriesScore; final Value rowid; const ExerciseStepsCompanion({ this.id = const Value.absent(), @@ -16224,6 +17293,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { this.scoreUnit = const Value.absent(), this.defaultTargetScore = const Value.absent(), this.defaultTargetScoreTimeMs = const Value.absent(), + this.linkedToSeriesScore = const Value.absent(), this.rowid = const Value.absent(), }); ExerciseStepsCompanion.insert({ @@ -16249,6 +17319,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { this.scoreUnit = const Value.absent(), this.defaultTargetScore = const Value.absent(), this.defaultTargetScoreTimeMs = const Value.absent(), + this.linkedToSeriesScore = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -16285,6 +17356,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { Expression? scoreUnit, Expression? defaultTargetScore, Expression? defaultTargetScoreTimeMs, + Expression? linkedToSeriesScore, Expression? rowid, }) { return RawValuesInsertable({ @@ -16314,6 +17386,8 @@ class ExerciseStepsCompanion extends UpdateCompanion { 'default_target_score': defaultTargetScore, if (defaultTargetScoreTimeMs != null) 'default_target_score_time_ms': defaultTargetScoreTimeMs, + if (linkedToSeriesScore != null) + 'linked_to_series_score': linkedToSeriesScore, if (rowid != null) 'rowid': rowid, }); } @@ -16341,6 +17415,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { Value? scoreUnit, Value? defaultTargetScore, Value? defaultTargetScoreTimeMs, + Value? linkedToSeriesScore, Value? rowid, }) { return ExerciseStepsCompanion( @@ -16367,6 +17442,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { defaultTargetScore: defaultTargetScore ?? this.defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs ?? this.defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore ?? this.linkedToSeriesScore, rowid: rowid ?? this.rowid, ); } @@ -16444,6 +17520,9 @@ class ExerciseStepsCompanion extends UpdateCompanion { defaultTargetScoreTimeMs.value, ); } + if (linkedToSeriesScore.present) { + map['linked_to_series_score'] = Variable(linkedToSeriesScore.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -16475,6 +17554,7 @@ class ExerciseStepsCompanion extends UpdateCompanion { ..write('scoreUnit: $scoreUnit, ') ..write('defaultTargetScore: $defaultTargetScore, ') ..write('defaultTargetScoreTimeMs: $defaultTargetScoreTimeMs, ') + ..write('linkedToSeriesScore: $linkedToSeriesScore, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -22842,6 +23922,28 @@ class $WorkoutHistoriesTable extends WorkoutHistories type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _averageHeartRateBpmMeta = + const VerificationMeta('averageHeartRateBpm'); + @override + late final GeneratedColumn averageHeartRateBpm = + GeneratedColumn( + 'average_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _maxHeartRateBpmMeta = const VerificationMeta( + 'maxHeartRateBpm', + ); + @override + late final GeneratedColumn maxHeartRateBpm = GeneratedColumn( + 'max_heart_rate_bpm', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -22863,6 +23965,8 @@ class $WorkoutHistoriesTable extends WorkoutHistories totalActiveMs, completed, historySnapshotJson, + averageHeartRateBpm, + maxHeartRateBpm, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -23044,6 +24148,24 @@ class $WorkoutHistoriesTable extends WorkoutHistories } else if (isInserting) { context.missing(_historySnapshotJsonMeta); } + if (data.containsKey('average_heart_rate_bpm')) { + context.handle( + _averageHeartRateBpmMeta, + averageHeartRateBpm.isAcceptableOrUnknown( + data['average_heart_rate_bpm']!, + _averageHeartRateBpmMeta, + ), + ); + } + if (data.containsKey('max_heart_rate_bpm')) { + context.handle( + _maxHeartRateBpmMeta, + maxHeartRateBpm.isAcceptableOrUnknown( + data['max_heart_rate_bpm']!, + _maxHeartRateBpmMeta, + ), + ); + } return context; } @@ -23129,6 +24251,14 @@ class $WorkoutHistoriesTable extends WorkoutHistories DriftSqlType.string, data['${effectivePrefix}history_snapshot_json'], )!, + averageHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}average_heart_rate_bpm'], + ), + maxHeartRateBpm: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}max_heart_rate_bpm'], + ), ); } @@ -23158,6 +24288,8 @@ class WorkoutHistory extends DataClass implements Insertable { final int totalActiveMs; final bool completed; final String historySnapshotJson; + final double? averageHeartRateBpm; + final int? maxHeartRateBpm; const WorkoutHistory({ required this.id, required this.createdAt, @@ -23178,6 +24310,8 @@ class WorkoutHistory extends DataClass implements Insertable { required this.totalActiveMs, required this.completed, required this.historySnapshotJson, + this.averageHeartRateBpm, + this.maxHeartRateBpm, }); @override Map toColumns(bool nullToAbsent) { @@ -23217,6 +24351,12 @@ class WorkoutHistory extends DataClass implements Insertable { map['total_active_ms'] = Variable(totalActiveMs); map['completed'] = Variable(completed); map['history_snapshot_json'] = Variable(historySnapshotJson); + if (!nullToAbsent || averageHeartRateBpm != null) { + map['average_heart_rate_bpm'] = Variable(averageHeartRateBpm); + } + if (!nullToAbsent || maxHeartRateBpm != null) { + map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm); + } return map; } @@ -23254,6 +24394,12 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs: Value(totalActiveMs), completed: Value(completed), historySnapshotJson: Value(historySnapshotJson), + averageHeartRateBpm: averageHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(averageHeartRateBpm), + maxHeartRateBpm: maxHeartRateBpm == null && nullToAbsent + ? const Value.absent() + : Value(maxHeartRateBpm), ); } @@ -23290,6 +24436,10 @@ class WorkoutHistory extends DataClass implements Insertable { historySnapshotJson: serializer.fromJson( json['historySnapshotJson'], ), + averageHeartRateBpm: serializer.fromJson( + json['averageHeartRateBpm'], + ), + maxHeartRateBpm: serializer.fromJson(json['maxHeartRateBpm']), ); } @override @@ -23319,6 +24469,8 @@ class WorkoutHistory extends DataClass implements Insertable { 'totalActiveMs': serializer.toJson(totalActiveMs), 'completed': serializer.toJson(completed), 'historySnapshotJson': serializer.toJson(historySnapshotJson), + 'averageHeartRateBpm': serializer.toJson(averageHeartRateBpm), + 'maxHeartRateBpm': serializer.toJson(maxHeartRateBpm), }; } @@ -23342,6 +24494,8 @@ class WorkoutHistory extends DataClass implements Insertable { int? totalActiveMs, bool? completed, String? historySnapshotJson, + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), }) => WorkoutHistory( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -23370,6 +24524,12 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs: totalActiveMs ?? this.totalActiveMs, completed: completed ?? this.completed, historySnapshotJson: historySnapshotJson ?? this.historySnapshotJson, + averageHeartRateBpm: averageHeartRateBpm.present + ? averageHeartRateBpm.value + : this.averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm.present + ? maxHeartRateBpm.value + : this.maxHeartRateBpm, ); WorkoutHistory copyWithCompanion(WorkoutHistoriesCompanion data) { return WorkoutHistory( @@ -23414,6 +24574,12 @@ class WorkoutHistory extends DataClass implements Insertable { historySnapshotJson: data.historySnapshotJson.present ? data.historySnapshotJson.value : this.historySnapshotJson, + averageHeartRateBpm: data.averageHeartRateBpm.present + ? data.averageHeartRateBpm.value + : this.averageHeartRateBpm, + maxHeartRateBpm: data.maxHeartRateBpm.present + ? data.maxHeartRateBpm.value + : this.maxHeartRateBpm, ); } @@ -23440,13 +24606,15 @@ class WorkoutHistory extends DataClass implements Insertable { ..write('endedAt: $endedAt, ') ..write('totalActiveMs: $totalActiveMs, ') ..write('completed: $completed, ') - ..write('historySnapshotJson: $historySnapshotJson') + ..write('historySnapshotJson: $historySnapshotJson, ') + ..write('averageHeartRateBpm: $averageHeartRateBpm, ') + ..write('maxHeartRateBpm: $maxHeartRateBpm') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, createdAt, updatedAt, @@ -23466,7 +24634,9 @@ class WorkoutHistory extends DataClass implements Insertable { totalActiveMs, completed, historySnapshotJson, - ); + averageHeartRateBpm, + maxHeartRateBpm, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -23490,7 +24660,9 @@ class WorkoutHistory extends DataClass implements Insertable { other.endedAt == this.endedAt && other.totalActiveMs == this.totalActiveMs && other.completed == this.completed && - other.historySnapshotJson == this.historySnapshotJson); + other.historySnapshotJson == this.historySnapshotJson && + other.averageHeartRateBpm == this.averageHeartRateBpm && + other.maxHeartRateBpm == this.maxHeartRateBpm); } class WorkoutHistoriesCompanion extends UpdateCompanion { @@ -23513,6 +24685,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { final Value totalActiveMs; final Value completed; final Value historySnapshotJson; + final Value averageHeartRateBpm; + final Value maxHeartRateBpm; final Value rowid; const WorkoutHistoriesCompanion({ this.id = const Value.absent(), @@ -23534,6 +24708,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { this.totalActiveMs = const Value.absent(), this.completed = const Value.absent(), this.historySnapshotJson = const Value.absent(), + this.averageHeartRateBpm = const Value.absent(), + this.maxHeartRateBpm = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutHistoriesCompanion.insert({ @@ -23556,6 +24732,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { required int totalActiveMs, required bool completed, required String historySnapshotJson, + this.averageHeartRateBpm = const Value.absent(), + this.maxHeartRateBpm = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -23589,6 +24767,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { Expression? totalActiveMs, Expression? completed, Expression? historySnapshotJson, + Expression? averageHeartRateBpm, + Expression? maxHeartRateBpm, Expression? rowid, }) { return RawValuesInsertable({ @@ -23615,6 +24795,9 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { if (completed != null) 'completed': completed, if (historySnapshotJson != null) 'history_snapshot_json': historySnapshotJson, + if (averageHeartRateBpm != null) + 'average_heart_rate_bpm': averageHeartRateBpm, + if (maxHeartRateBpm != null) 'max_heart_rate_bpm': maxHeartRateBpm, if (rowid != null) 'rowid': rowid, }); } @@ -23639,6 +24822,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { Value? totalActiveMs, Value? completed, Value? historySnapshotJson, + Value? averageHeartRateBpm, + Value? maxHeartRateBpm, Value? rowid, }) { return WorkoutHistoriesCompanion( @@ -23663,6 +24848,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { totalActiveMs: totalActiveMs ?? this.totalActiveMs, completed: completed ?? this.completed, historySnapshotJson: historySnapshotJson ?? this.historySnapshotJson, + averageHeartRateBpm: averageHeartRateBpm ?? this.averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm ?? this.maxHeartRateBpm, rowid: rowid ?? this.rowid, ); } @@ -23735,6 +24922,14 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { historySnapshotJson.value, ); } + if (averageHeartRateBpm.present) { + map['average_heart_rate_bpm'] = Variable( + averageHeartRateBpm.value, + ); + } + if (maxHeartRateBpm.present) { + map['max_heart_rate_bpm'] = Variable(maxHeartRateBpm.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -23765,6 +24960,8 @@ class WorkoutHistoriesCompanion extends UpdateCompanion { ..write('totalActiveMs: $totalActiveMs, ') ..write('completed: $completed, ') ..write('historySnapshotJson: $historySnapshotJson, ') + ..write('averageHeartRateBpm: $averageHeartRateBpm, ') + ..write('maxHeartRateBpm: $maxHeartRateBpm, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -30438,6 +31635,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ActiveRestStatesTable activeRestStates = $ActiveRestStatesTable( this, ); + late final $ActiveManualScoreStatesTable activeManualScoreStates = + $ActiveManualScoreStatesTable(this); late final $ActiveScoreStopwatchStatesTable activeScoreStopwatchStates = $ActiveScoreStopwatchStatesTable(this); late final $ActiveSetTimerStatesTable activeSetTimerStates = @@ -30492,6 +31691,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { activeExerciseStepProgressStates, activeExerciseStepResults, activeRestStates, + activeManualScoreStates, activeScoreStopwatchStates, activeSetTimerStates, activeSetResults, @@ -31436,6 +32636,38 @@ final class $$ActiveWorkoutSessionsTableReferences ); } + static MultiTypedResultKey< + $ActiveManualScoreStatesTable, + List + > + _activeManualScoreStatesRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.activeManualScoreStates, + aliasName: + 'active_workout_sessions__id__active_manual_score_states__active_workout_session_id', + ); + + $$ActiveManualScoreStatesTableProcessedTableManager + get activeManualScoreStatesRefs { + final manager = + $$ActiveManualScoreStatesTableTableManager( + $_db, + $_db.activeManualScoreStates, + ).filter( + (f) => f.activeWorkoutSessionId.id.sqlEquals( + $_itemColumn('id')!, + ), + ); + + final cache = $_typedResult.readTableOrNull( + _activeManualScoreStatesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } + static MultiTypedResultKey< $ActiveScoreStopwatchStatesTable, List @@ -31765,6 +32997,32 @@ class $$ActiveWorkoutSessionsTableFilterComposer return f(composer); } + Expression activeManualScoreStatesRefs( + Expression Function($$ActiveManualScoreStatesTableFilterComposer f) f, + ) { + final $$ActiveManualScoreStatesTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeManualScoreStates, + getReferencedColumn: (t) => t.activeWorkoutSessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveManualScoreStatesTableFilterComposer( + $db: $db, + $table: $db.activeManualScoreStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeScoreStopwatchStatesRefs( Expression Function($$ActiveScoreStopwatchStatesTableFilterComposer f) f, @@ -32208,6 +33466,33 @@ class $$ActiveWorkoutSessionsTableAnnotationComposer return f(composer); } + Expression activeManualScoreStatesRefs( + Expression Function($$ActiveManualScoreStatesTableAnnotationComposer a) + f, + ) { + final $$ActiveManualScoreStatesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.activeManualScoreStates, + getReferencedColumn: (t) => t.activeWorkoutSessionId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveManualScoreStatesTableAnnotationComposer( + $db: $db, + $table: $db.activeManualScoreStates, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + Expression activeScoreStopwatchStatesRefs( Expression Function( $$ActiveScoreStopwatchStatesTableAnnotationComposer a, @@ -32332,6 +33617,7 @@ class $$ActiveWorkoutSessionsTableTableManager bool activeExerciseStepProgressStatesRefs, bool activeExerciseStepResultsRefs, bool activeRestStatesRefs, + bool activeManualScoreStatesRefs, bool activeScoreStopwatchStatesRefs, bool activeSetTimerStatesRefs, bool activeSetResultsRefs, @@ -32475,6 +33761,7 @@ class $$ActiveWorkoutSessionsTableTableManager activeExerciseStepProgressStatesRefs = false, activeExerciseStepResultsRefs = false, activeRestStatesRefs = false, + activeManualScoreStatesRefs = false, activeScoreStopwatchStatesRefs = false, activeSetTimerStatesRefs = false, activeSetResultsRefs = false, @@ -32488,6 +33775,7 @@ class $$ActiveWorkoutSessionsTableTableManager if (activeExerciseStepResultsRefs) db.activeExerciseStepResults, if (activeRestStatesRefs) db.activeRestStates, + if (activeManualScoreStatesRefs) db.activeManualScoreStates, if (activeScoreStopwatchStatesRefs) db.activeScoreStopwatchStates, if (activeSetTimerStatesRefs) db.activeSetTimerStates, @@ -32599,6 +33887,28 @@ class $$ActiveWorkoutSessionsTableTableManager ), typedResults: items, ), + if (activeManualScoreStatesRefs) + await $_getPrefetchedData< + ActiveWorkoutSession, + $ActiveWorkoutSessionsTable, + ActiveManualScoreState + >( + currentTable: table, + referencedTable: + $$ActiveWorkoutSessionsTableReferences + ._activeManualScoreStatesRefsTable(db), + managerFromTypedResult: (p0) => + $$ActiveWorkoutSessionsTableReferences( + db, + table, + p0, + ).activeManualScoreStatesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.activeWorkoutSessionId == item.id, + ), + typedResults: items, + ), if (activeScoreStopwatchStatesRefs) await $_getPrefetchedData< ActiveWorkoutSession, @@ -32713,6 +34023,7 @@ typedef $$ActiveWorkoutSessionsTableProcessedTableManager = bool activeExerciseStepProgressStatesRefs, bool activeExerciseStepResultsRefs, bool activeRestStatesRefs, + bool activeManualScoreStatesRefs, bool activeScoreStopwatchStatesRefs, bool activeSetTimerStatesRefs, bool activeSetResultsRefs, @@ -35116,6 +36427,599 @@ typedef $$ActiveRestStatesTableProcessedTableManager = ActiveRestState, PrefetchHooks Function({bool activeWorkoutSessionId}) >; +typedef $$ActiveManualScoreStatesTableCreateCompanionBuilder = + ActiveManualScoreStatesCompanion Function({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt, + Value schemaVersion, + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required double value, + required DateTime scoreUpdatedAt, + Value rowid, + }); +typedef $$ActiveManualScoreStatesTableUpdateCompanionBuilder = + ActiveManualScoreStatesCompanion Function({ + Value id, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value schemaVersion, + Value syncState, + Value localRevision, + Value originDeviceId, + Value futureOwnerProfileId, + Value lastSyncedAt, + Value remoteRevision, + Value activeWorkoutSessionId, + Value programIndex, + Value exerciseIndex, + Value setIndex, + Value value, + Value scoreUpdatedAt, + Value rowid, + }); + +final class $$ActiveManualScoreStatesTableReferences + extends + BaseReferences< + _$AppDatabase, + $ActiveManualScoreStatesTable, + ActiveManualScoreState + > { + $$ActiveManualScoreStatesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $ActiveWorkoutSessionsTable _activeWorkoutSessionIdTable( + _$AppDatabase db, + ) => db.activeWorkoutSessions.createAlias( + 'active_manual_score_states__active_workout_session_id__active_workout_sessions__id', + ); + + $$ActiveWorkoutSessionsTableProcessedTableManager get activeWorkoutSessionId { + final $_column = $_itemColumn('active_workout_session_id')!; + + final manager = $$ActiveWorkoutSessionsTableTableManager( + $_db, + $_db.activeWorkoutSessions, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull( + _activeWorkoutSessionIdTable($_db), + ); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$ActiveManualScoreStatesTableFilterComposer + extends Composer<_$AppDatabase, $ActiveManualScoreStatesTable> { + $$ActiveManualScoreStatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get scoreUpdatedAt => $composableBuilder( + column: $table.scoreUpdatedAt, + builder: (column) => ColumnFilters(column), + ); + + $$ActiveWorkoutSessionsTableFilterComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableFilterComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveManualScoreStatesTableOrderingComposer + extends Composer<_$AppDatabase, $ActiveManualScoreStatesTable> { + $$ActiveManualScoreStatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedAt => $composableBuilder( + column: $table.deletedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get syncState => $composableBuilder( + column: $table.syncState, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setIndex => $composableBuilder( + column: $table.setIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scoreUpdatedAt => $composableBuilder( + column: $table.scoreUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$ActiveWorkoutSessionsTableOrderingComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableOrderingComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveManualScoreStatesTableAnnotationComposer + extends Composer<_$AppDatabase, $ActiveManualScoreStatesTable> { + $$ActiveManualScoreStatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => column); + + GeneratedColumn get schemaVersion => $composableBuilder( + column: $table.schemaVersion, + builder: (column) => column, + ); + + GeneratedColumn get syncState => + $composableBuilder(column: $table.syncState, builder: (column) => column); + + GeneratedColumn get localRevision => $composableBuilder( + column: $table.localRevision, + builder: (column) => column, + ); + + GeneratedColumn get originDeviceId => $composableBuilder( + column: $table.originDeviceId, + builder: (column) => column, + ); + + GeneratedColumn get futureOwnerProfileId => $composableBuilder( + column: $table.futureOwnerProfileId, + builder: (column) => column, + ); + + GeneratedColumn get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => column, + ); + + GeneratedColumn get remoteRevision => $composableBuilder( + column: $table.remoteRevision, + builder: (column) => column, + ); + + GeneratedColumn get programIndex => $composableBuilder( + column: $table.programIndex, + builder: (column) => column, + ); + + GeneratedColumn get exerciseIndex => $composableBuilder( + column: $table.exerciseIndex, + builder: (column) => column, + ); + + GeneratedColumn get setIndex => + $composableBuilder(column: $table.setIndex, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + GeneratedColumn get scoreUpdatedAt => $composableBuilder( + column: $table.scoreUpdatedAt, + builder: (column) => column, + ); + + $$ActiveWorkoutSessionsTableAnnotationComposer get activeWorkoutSessionId { + final $$ActiveWorkoutSessionsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.activeWorkoutSessionId, + referencedTable: $db.activeWorkoutSessions, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$ActiveWorkoutSessionsTableAnnotationComposer( + $db: $db, + $table: $db.activeWorkoutSessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$ActiveManualScoreStatesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $ActiveManualScoreStatesTable, + ActiveManualScoreState, + $$ActiveManualScoreStatesTableFilterComposer, + $$ActiveManualScoreStatesTableOrderingComposer, + $$ActiveManualScoreStatesTableAnnotationComposer, + $$ActiveManualScoreStatesTableCreateCompanionBuilder, + $$ActiveManualScoreStatesTableUpdateCompanionBuilder, + (ActiveManualScoreState, $$ActiveManualScoreStatesTableReferences), + ActiveManualScoreState, + PrefetchHooks Function({bool activeWorkoutSessionId}) + > { + $$ActiveManualScoreStatesTableTableManager( + _$AppDatabase db, + $ActiveManualScoreStatesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ActiveManualScoreStatesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$ActiveManualScoreStatesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$ActiveManualScoreStatesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + Value syncState = const Value.absent(), + Value localRevision = const Value.absent(), + Value originDeviceId = const Value.absent(), + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + Value activeWorkoutSessionId = const Value.absent(), + Value programIndex = const Value.absent(), + Value exerciseIndex = const Value.absent(), + Value setIndex = const Value.absent(), + Value value = const Value.absent(), + Value scoreUpdatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ActiveManualScoreStatesCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + activeWorkoutSessionId: activeWorkoutSessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + value: value, + scoreUpdatedAt: scoreUpdatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + Value deletedAt = const Value.absent(), + Value schemaVersion = const Value.absent(), + required String syncState, + required int localRevision, + required String originDeviceId, + Value futureOwnerProfileId = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value remoteRevision = const Value.absent(), + required String activeWorkoutSessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required double value, + required DateTime scoreUpdatedAt, + Value rowid = const Value.absent(), + }) => ActiveManualScoreStatesCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + schemaVersion: schemaVersion, + syncState: syncState, + localRevision: localRevision, + originDeviceId: originDeviceId, + futureOwnerProfileId: futureOwnerProfileId, + lastSyncedAt: lastSyncedAt, + remoteRevision: remoteRevision, + activeWorkoutSessionId: activeWorkoutSessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + value: value, + scoreUpdatedAt: scoreUpdatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$ActiveManualScoreStatesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({activeWorkoutSessionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (activeWorkoutSessionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.activeWorkoutSessionId, + referencedTable: + $$ActiveManualScoreStatesTableReferences + ._activeWorkoutSessionIdTable(db), + referencedColumn: + $$ActiveManualScoreStatesTableReferences + ._activeWorkoutSessionIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$ActiveManualScoreStatesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $ActiveManualScoreStatesTable, + ActiveManualScoreState, + $$ActiveManualScoreStatesTableFilterComposer, + $$ActiveManualScoreStatesTableOrderingComposer, + $$ActiveManualScoreStatesTableAnnotationComposer, + $$ActiveManualScoreStatesTableCreateCompanionBuilder, + $$ActiveManualScoreStatesTableUpdateCompanionBuilder, + (ActiveManualScoreState, $$ActiveManualScoreStatesTableReferences), + ActiveManualScoreState, + PrefetchHooks Function({bool activeWorkoutSessionId}) + >; typedef $$ActiveScoreStopwatchStatesTableCreateCompanionBuilder = ActiveScoreStopwatchStatesCompanion Function({ required String id, @@ -40350,6 +42254,7 @@ typedef $$ExerciseStepsTableCreateCompanionBuilder = Value scoreUnit, Value defaultTargetScore, Value defaultTargetScoreTimeMs, + Value linkedToSeriesScore, Value rowid, }); typedef $$ExerciseStepsTableUpdateCompanionBuilder = @@ -40376,6 +42281,7 @@ typedef $$ExerciseStepsTableUpdateCompanionBuilder = Value scoreUnit, Value defaultTargetScore, Value defaultTargetScoreTimeMs, + Value linkedToSeriesScore, Value rowid, }); @@ -40519,6 +42425,11 @@ class $$ExerciseStepsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get linkedToSeriesScore => $composableBuilder( + column: $table.linkedToSeriesScore, + builder: (column) => ColumnFilters(column), + ); + $$ExercisesTableFilterComposer get exerciseId { final $$ExercisesTableFilterComposer composer = $composerBuilder( composer: this, @@ -40657,6 +42568,11 @@ class $$ExerciseStepsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get linkedToSeriesScore => $composableBuilder( + column: $table.linkedToSeriesScore, + builder: (column) => ColumnOrderings(column), + ); + $$ExercisesTableOrderingComposer get exerciseId { final $$ExercisesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -40775,6 +42691,11 @@ class $$ExerciseStepsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get linkedToSeriesScore => $composableBuilder( + column: $table.linkedToSeriesScore, + builder: (column) => column, + ); + $$ExercisesTableAnnotationComposer get exerciseId { final $$ExercisesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -40849,6 +42770,7 @@ class $$ExerciseStepsTableTableManager Value scoreUnit = const Value.absent(), Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), + Value linkedToSeriesScore = const Value.absent(), Value rowid = const Value.absent(), }) => ExerciseStepsCompanion( id: id, @@ -40873,6 +42795,7 @@ class $$ExerciseStepsTableTableManager scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, rowid: rowid, ), createCompanionCallback: @@ -40899,6 +42822,7 @@ class $$ExerciseStepsTableTableManager Value scoreUnit = const Value.absent(), Value defaultTargetScore = const Value.absent(), Value defaultTargetScoreTimeMs = const Value.absent(), + Value linkedToSeriesScore = const Value.absent(), Value rowid = const Value.absent(), }) => ExerciseStepsCompanion.insert( id: id, @@ -40923,6 +42847,7 @@ class $$ExerciseStepsTableTableManager scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -44506,6 +46431,8 @@ typedef $$WorkoutHistoriesTableCreateCompanionBuilder = required int totalActiveMs, required bool completed, required String historySnapshotJson, + Value averageHeartRateBpm, + Value maxHeartRateBpm, Value rowid, }); typedef $$WorkoutHistoriesTableUpdateCompanionBuilder = @@ -44529,6 +46456,8 @@ typedef $$WorkoutHistoriesTableUpdateCompanionBuilder = Value totalActiveMs, Value completed, Value historySnapshotJson, + Value averageHeartRateBpm, + Value maxHeartRateBpm, Value rowid, }); @@ -44741,6 +46670,16 @@ class $$WorkoutHistoriesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => ColumnFilters(column), + ); + $$WorkoutTemplatesTableFilterComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableFilterComposer composer = $composerBuilder( composer: this, @@ -44937,6 +46876,16 @@ class $$WorkoutHistoriesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => ColumnOrderings(column), + ); + $$WorkoutTemplatesTableOrderingComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableOrderingComposer composer = $composerBuilder( composer: this, @@ -45064,6 +47013,16 @@ class $$WorkoutHistoriesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get averageHeartRateBpm => $composableBuilder( + column: $table.averageHeartRateBpm, + builder: (column) => column, + ); + + GeneratedColumn get maxHeartRateBpm => $composableBuilder( + column: $table.maxHeartRateBpm, + builder: (column) => column, + ); + $$WorkoutTemplatesTableAnnotationComposer get sourceWorkoutTemplateId { final $$WorkoutTemplatesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -45222,6 +47181,8 @@ class $$WorkoutHistoriesTableTableManager Value totalActiveMs = const Value.absent(), Value completed = const Value.absent(), Value historySnapshotJson = const Value.absent(), + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoriesCompanion( id: id, @@ -45243,6 +47204,8 @@ class $$WorkoutHistoriesTableTableManager totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, rowid: rowid, ), createCompanionCallback: @@ -45267,6 +47230,8 @@ class $$WorkoutHistoriesTableTableManager required int totalActiveMs, required bool completed, required String historySnapshotJson, + Value averageHeartRateBpm = const Value.absent(), + Value maxHeartRateBpm = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutHistoriesCompanion.insert( id: id, @@ -45288,6 +47253,8 @@ class $$WorkoutHistoriesTableTableManager totalActiveMs: totalActiveMs, completed: completed, historySnapshotJson: historySnapshotJson, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -48953,6 +50920,11 @@ class $AppDatabaseManager { ); $$ActiveRestStatesTableTableManager get activeRestStates => $$ActiveRestStatesTableTableManager(_db, _db.activeRestStates); + $$ActiveManualScoreStatesTableTableManager get activeManualScoreStates => + $$ActiveManualScoreStatesTableTableManager( + _db, + _db.activeManualScoreStates, + ); $$ActiveScoreStopwatchStatesTableTableManager get activeScoreStopwatchStates => $$ActiveScoreStopwatchStatesTableTableManager( diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index d0c2d82..763bb68 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { ); } + @override + Future 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 saveExerciseStepProgressState( domain.ActiveExerciseStepProgressState state, @@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { ); } + @override + Future 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 findScoreStopwatchState({ required String sessionId, @@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return row == null ? null : _activeScoreStopwatchStateFromRow(row); } + @override + Future 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 findSetTimerState({ required String sessionId, @@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { .get(); return rows.map(_activeScoreStopwatchStateFromRow).toList(); } + + @override + Future> 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 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 saveSetResult(domain.WorkoutHistorySetResult result) async { await _upsertWithChangeLog( @@ -2777,6 +2911,7 @@ Future _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, + createdAt: values[1] as Value, + updatedAt: values[2] as Value, + deletedAt: values[3] as Value, + schemaVersion: values[4] as Value, + syncState: values[5] as Value, + localRevision: values[6] as Value, + originDeviceId: values[7] as Value, + futureOwnerProfileId: values[8] as Value, + lastSyncedAt: values[9] as Value, + remoteRevision: values[10] as Value, + 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 _workoutHistoryPayload(domain.WorkoutHistory history) => { 'totalActiveMs': history.totalActiveMs, 'completed': history.completed, 'historySnapshotJson': history.historySnapshotJson, + 'averageHeartRateBpm': history.averageHeartRateBpm, + 'maxHeartRateBpm': history.maxHeartRateBpm, }; Map _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 _stepsFromPayload(Object? value) { json, 'defaultTargetScoreTimeMs', ), + linkedToSeriesScore: json['linkedToSeriesScore'] == true, ); }) .toList(growable: false); @@ -5487,6 +5671,7 @@ List _decodeExerciseStepsSnapshot(String? encoded) { json, 'defaultTargetScoreTimeMs', ), + linkedToSeriesScore: json['linkedToSeriesScore'] == true, ); }) .toList(growable: false); diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index dfde9e3..515304f 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -137,7 +137,7 @@ class PendingShareActions extends Table { @override List 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 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 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 get customConstraints => ['CHECK (total_active_ms >= 0)']; + List 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 { diff --git a/lib/infrastructure/remote/http_api_client.dart b/lib/infrastructure/remote/http_api_client.dart index cdfdfb1..343ee6f 100644 --- a/lib/infrastructure/remote/http_api_client.dart +++ b/lib/infrastructure/remote/http_api_client.dart @@ -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), }; } diff --git a/lib/infrastructure/session_notification/session_notification.dart b/lib/infrastructure/session_notification/session_notification.dart new file mode 100644 index 0000000..d0102ee --- /dev/null +++ b/lib/infrastructure/session_notification/session_notification.dart @@ -0,0 +1 @@ +export 'session_notification_gateway.dart'; diff --git a/lib/infrastructure/session_notification/session_notification_gateway.dart b/lib/infrastructure/session_notification/session_notification_gateway.dart new file mode 100644 index 0000000..52311ea --- /dev/null +++ b/lib/infrastructure/session_notification/session_notification_gateway.dart @@ -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 show(SessionNotificationContent content) { + return _invokeIgnoringMissingPlugin('show', content.toJson()); + } + + @override + Future clear() { + return _invokeIgnoringMissingPlugin('clear'); + } + + Future _invokeIgnoringMissingPlugin( + String method, [ + Object? arguments, + ]) { + return _methodChannel + .invokeMethod(method, arguments) + .onError((_, _) {}); + } +} diff --git a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart index 22738e1..39c181f 100644 --- a/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart +++ b/lib/infrastructure/watch_bridge/native_watch_bridge_channel.dart @@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent { abstract interface class WatchBridgeNativeChannel { Stream get commands; + Stream get sensorSummaries; + + Stream get sensorSamples; + Stream get connectionEvents; Future 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 get sensorSummaries { + return _sensorSummaryChannel + .receiveBroadcastStream() + .where((event) { + return event is Map; + }) + .map((event) { + return WatchSensorSummary.fromJson(_stringObjectMap(event)); + }); + } + + @override + Stream get sensorSamples { + return _sensorSampleChannel + .receiveBroadcastStream() + .where((event) { + return event is Map; + }) + .map((event) { + return WatchSensorSample.fromJson(_stringObjectMap(event)); + }); + } + @override Stream get connectionEvents { return _connectionChannel diff --git a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart index 9f06fbf..98d690b 100644 --- a/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart +++ b/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart @@ -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 = >[]; Future _commandTail = Future.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 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 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 _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, diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 1a93454..4c36dbf 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State { 'Mode de saisie', style: Theme.of(context).textTheme.titleSmall, ), - RadioListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Saisie libre'), - value: ScoreInputMode.manual, - groupValue: _scoreInputMode, - onChanged: (value) { - if (value == null) return; - setState(() => _scoreInputMode = value); - }, - ), - RadioListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Chrono intégré'), - subtitle: const Text('Temps réalisé'), - value: ScoreInputMode.stopwatch, + RadioGroup( groupValue: _scoreInputMode, onChanged: (value) { if (value == null) return; setState(() => _scoreInputMode = value); }, + child: const Column( + children: [ + RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text('Saisie libre'), + value: ScoreInputMode.manual, + ), + RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text('Chrono intégré'), + subtitle: Text('Temps réalisé'), + value: ScoreInputMode.stopwatch, + ), + ], + ), ), if (_scoreInputMode == ScoreInputMode.manual) ...[ const SizedBox(height: 12), @@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State { 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 { 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 { ), const SizedBox(height: 8), Text('Type d’étape', style: Theme.of(context).textTheme.titleSmall), - RadioListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Temps'), - value: ExerciseStepType.time, - groupValue: draft.type, - onChanged: (value) { - if (value == null) return; - setState(() => draft.type = value); - }, - ), - RadioListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Répétitions'), - value: ExerciseStepType.reps, + RadioGroup( groupValue: draft.type, onChanged: (value) { if (value == null) return; setState(() => draft.type = value); }, + child: const Column( + children: [ + RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text('Temps'), + value: ExerciseStepType.time, + ), + RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text('Répétitions'), + value: ExerciseStepType.reps, + ), + ], + ), ), TextFormField( controller: draft.targetController, @@ -1021,79 +1030,103 @@ final class _ExerciseFormScreenState extends State { 'Mode de score', style: Theme.of(context).textTheme.titleSmall, ), - RadioListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Saisie libre'), - value: ScoreInputMode.manual, + RadioGroup( groupValue: draft.scoreInputMode, onChanged: (value) { if (value == null) return; - setState(() => draft.scoreInputMode = value); - }, - ), - RadioListTile( - 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( + contentPadding: EdgeInsets.zero, + title: Text('Saisie libre'), + value: ScoreInputMode.manual, + ), + RadioListTile( + 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 - ? 'L’unité 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 + ? 'L’unité 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), @@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State { 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 { 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 { 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 { } 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 { navigator.pop(true); } } - } on Exception catch (error) { - _showSnackBar(error.toString()); + } on Exception { + _showSnackBar( + 'Impossible d’enregistrer l’exercice. 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, ); } diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index e326cb0..b085e0e 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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 { 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 _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 ' + 'd’en 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 _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 _confirmDelete(BuildContext context) async { final confirmed = await showDialog( 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}); diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 6a54c27..9e4e1ac 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -93,7 +93,10 @@ final class _HomeScreenState extends State 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 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 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 with RouteAware { } Future _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(); } diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index 5774707..fce0f61 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_screen.dart @@ -89,14 +89,13 @@ final class _ProfileScreenState extends State { ), ), ); - 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 _openRegister(BuildContext context) async { @@ -109,14 +108,13 @@ final class _ProfileScreenState extends State { ), ), ); - 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 _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 { }, 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 { 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.', }; } diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index 0304e1d..4e20205 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State { @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 { ), 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 { 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 { } Future _save() async { + if (_exercises.isEmpty) { + return; + } if (!_formKey.currentState!.validate()) { return; } @@ -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, diff --git a/lib/presentation/share_screen.dart b/lib/presentation/share_screen.dart index b3a3169..e2413a4 100644 --- a/lib/presentation/share_screen.dart +++ b/lib/presentation/share_screen.dart @@ -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 l’app reste utilisable sans compte.', + 'GameTime.', ), const SizedBox(height: 16), FilledButton( diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index bfecb06..b16cb85 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -28,6 +28,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { this.mediaAssetLoader, this.videoMediaBuilder, this.stepUseCases, + this.sensorUseCases, this.stepAudioCuePlayer, super.key, }); @@ -42,6 +43,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { final MediaUseCases? mediaUseCases; final Future Function(String id)? mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; + final ActiveWorkoutSensorUseCases? sensorUseCases; final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; @override @@ -50,21 +52,24 @@ final class WorkoutExecutionScreen extends StatefulWidget { final class _WorkoutExecutionScreenState extends State { late ActiveWorkoutSession _session; - late final WorkoutExecutionPlan _plan; + WorkoutExecutionPlan? _validatedPlan; late WorkoutExecutionMode _mode; Timer? _ticker; Timer? _restTicker; Timer? _scoreStopwatchTicker; Timer? _stepTicker; Timer? _stepScoreTicker; + StreamSubscription? _sensorSubscription; + var _externalSyncInFlight = false; WorkoutHistory? _completedHistory; + ActiveWorkoutSensorState? _sensorState; String? _activeRestStateId; ActiveSetTimerState? _setTimer; ActiveScoreStopwatchState? _scoreStopwatch; var _scoreStopwatchLoadGeneration = 0; ActiveExerciseStepProgressView? _stepProgress; late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer; - late final bool _ownsStepAudioCuePlayer; + var _ownsStepAudioCuePlayer = false; final _stepScoreController = TextEditingController(); int? _manualScoreTimeMs; DateTime? _stepScoreStartedAt; @@ -75,7 +80,10 @@ final class _WorkoutExecutionScreenState extends State { var _reps = 0; Future? _performanceReference; final _scoreController = TextEditingController(); + final _scoreFocusNode = FocusNode(); + Future? _manualScorePersistInFlight; var _remainingRestSeconds = 0; + String? _invalidSessionMessage; ExecutionPosition get _position => ExecutionPosition( programIndex: _session.currentProgramIndex, @@ -85,11 +93,20 @@ final class _WorkoutExecutionScreenState extends State { ExecutionExercise get _exercise => _plan.exerciseAt(_position); + WorkoutExecutionPlan get _plan => _validatedPlan!; + @override void initState() { super.initState(); _session = widget.initialSession; - _plan = WorkoutExecutionPlan.fromSession(_session); + try { + _validatedPlan = WorkoutExecutionPlan.fromSession(_session); + } on Object { + _invalidSessionMessage = + 'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.'; + unawaited(_abandonInvalidSession()); + return; + } _ownsStepAudioCuePlayer = widget.stepAudioCuePlayer == null && widget.stepUseCases != null; _stepAudioCuePlayer = @@ -103,10 +120,22 @@ final class _WorkoutExecutionScreenState extends State { _reps = _initialRepsFor(_exercise); _performanceReference = _loadPerformanceReference(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); + if (mounted) { + setState(() {}); + unawaited(_syncExternalSessionChanges()); + } + }); + _scoreFocusNode.addListener(_handleScoreFocusChange); + _sensorState = widget.sensorUseCases?.current(_session.metadata.id); + _sensorSubscription = widget.sensorUseCases?.updates.listen((state) { + if (!mounted || state.sessionId != _session.metadata.id) { + return; + } + setState(() => _sensorState = state); }); unawaited(_loadSetTimer()); unawaited(_loadScoreStopwatch()); + unawaited(_syncManualScoreInput(force: true)); unawaited(_loadStepProgress()); unawaited(_restoreActiveRest()); } @@ -118,19 +147,61 @@ final class _WorkoutExecutionScreenState extends State { _scoreStopwatchTicker?.cancel(); _stepTicker?.cancel(); _stepScoreTicker?.cancel(); + unawaited(_sensorSubscription?.cancel()); if (_ownsStepAudioCuePlayer) { unawaited(_stepAudioCuePlayer.dispose()); } _scoreController.dispose(); + _scoreFocusNode.dispose(); _stepScoreController.dispose(); super.dispose(); } + void _handleScoreFocusChange() { + if (!_scoreFocusNode.hasFocus) { + unawaited(_persistManualScoreInput()); + } + } + + Future _abandonInvalidSession() async { + try { + await widget.activeUseCases.abandon(_session.metadata.id); + } on Exception { + // The UI still needs to leave the user on an explicit recovery state. + } + if (mounted) { + setState(() {}); + } + } + + void _showInvalidSessionState() { + _ticker?.cancel(); + _restTicker?.cancel(); + _scoreStopwatchTicker?.cancel(); + _stepTicker?.cancel(); + _stepScoreTicker?.cancel(); + setState(() { + _invalidSessionMessage = + 'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.'; + }); + } + @override Widget build(BuildContext context) { + final invalidMessage = _invalidSessionMessage; + if (invalidMessage != null) { + return Scaffold( + appBar: AppBar(title: const Text('Séance indisponible')), + body: _InvalidSessionMessage( + message: invalidMessage, + onBackHome: () => + Navigator.of(context).popUntil((route) => route.isFirst), + ), + ); + } return PopScope( canPop: false, - onPopInvoked: _handleSystemBack, + onPopInvokedWithResult: _handleSystemBack, child: Scaffold( appBar: AppBar( automaticallyImplyLeading: false, @@ -195,6 +266,15 @@ final class _WorkoutExecutionScreenState extends State { : null, ), ), + if (_hasLiveSensorMetrics(_sensorState)) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _LiveSensorBar( + sensorState: _sensorState!, + muted: _isSensorStateStale(_sensorState), + ), + ), + ], if (_performanceReference != null) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), @@ -239,6 +319,9 @@ final class _WorkoutExecutionScreenState extends State { child: _StepSetResultSummary( exercise: _exercise, scoreController: _scoreController, + scoreFocusNode: _scoreFocusNode, + onScoreSubmitted: (_) => + unawaited(_persistManualScoreInput()), ), ), ], @@ -288,6 +371,9 @@ final class _WorkoutExecutionScreenState extends State { showTime: false, reps: _reps, scoreController: _scoreController, + scoreFocusNode: _scoreFocusNode, + onScoreSubmitted: (_) => + unawaited(_persistManualScoreInput()), onRepsChanged: (value) => setState(() => _reps = value), ), @@ -311,6 +397,13 @@ final class _WorkoutExecutionScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text('Repos avant la prochaine série'), + if (_hasLiveSensorMetrics(_sensorState)) ...[ + const SizedBox(height: 12), + _LiveSensorBar( + sensorState: _sensorState!, + muted: _isSensorStateStale(_sensorState), + ), + ], const SizedBox(height: 24), Center( child: Text( @@ -384,6 +477,13 @@ final class _WorkoutExecutionScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Pause', style: Theme.of(context).textTheme.headlineMedium), + if (_hasLiveSensorMetrics(_sensorState)) ...[ + const SizedBox(height: 12), + _LiveSensorBar( + sensorState: _sensorState!, + muted: _isSensorStateStale(_sensorState), + ), + ], const SizedBox(height: 24), FilledButton( onPressed: _resume, @@ -422,6 +522,19 @@ final class _WorkoutExecutionScreenState extends State { ), const SizedBox(height: 12), Text('Temps total : ${_formatDuration(elapsed)}'), + if (_caloriesLabel(_sensorState) case final caloriesLabel?) ...[ + const SizedBox(height: 8), + Text('Calories : $caloriesLabel'), + ], + if (_completedHistory case final history? + when history.averageHeartRateBpm != null && + history.maxHeartRateBpm != null) ...[ + const SizedBox(height: 16), + _HeartRateSummaryCard( + averageHeartRateBpm: history.averageHeartRateBpm!, + maxHeartRateBpm: history.maxHeartRateBpm!, + ), + ], const SizedBox(height: 24), OutlinedButton( onPressed: _completedHistory == null ? null : _openHistoryDetail, @@ -598,7 +711,7 @@ final class _WorkoutExecutionScreenState extends State { } on Exception catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Impossible de démarrer l’exercice : $error")), + SnackBar(content: Text('Impossible de démarrer l’exercice : $error')), ); } } @@ -636,6 +749,76 @@ final class _WorkoutExecutionScreenState extends State { _refreshScoreStopwatchTicker(); } + Future _syncManualScoreInput({bool force = false}) async { + if (!_exercise.manualScoreEnabled) { + if (force && _scoreController.text.isNotEmpty) { + _scoreController.clear(); + } + return; + } + if (_scoreFocusNode.hasFocus && !force) { + return; + } + final state = await widget.activeUseCases.findManualScore( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + ); + if (!mounted) { + return; + } + final nextText = state == null ? '' : _formatScore(state.value); + if (_scoreController.text != nextText) { + _scoreController.text = nextText; + } + } + + Future _persistManualScoreInput() async { + final inFlight = _manualScorePersistInFlight; + if (inFlight != null) { + await inFlight; + return; + } + final future = _persistManualScoreInputOnce(); + _manualScorePersistInFlight = future; + try { + await future; + } finally { + if (identical(_manualScorePersistInFlight, future)) { + _manualScorePersistInFlight = null; + } + } + } + + Future _persistManualScoreInputOnce() async { + if (!_exercise.manualScoreEnabled) { + return; + } + final rawValue = _scoreController.text.trim(); + if (rawValue.isEmpty) { + return; + } + final value = double.tryParse(rawValue); + if (value == null) { + return; + } + final result = await widget.activeUseCases.setManualScore( + sessionId: _session.metadata.id, + programIndex: _position.programIndex, + exerciseIndex: _position.exerciseIndex, + setIndex: _position.setIndex, + value: value, + ); + if (!mounted || _scoreFocusNode.hasFocus) { + return; + } + final nextText = _formatScore(result.state.value); + if (_scoreController.text != nextText) { + _scoreController.text = nextText; + } + } + Future _startScoreStopwatch() async { _scoreStopwatchLoadGeneration++; final state = await widget.activeUseCases.startScoreStopwatch( @@ -979,6 +1162,54 @@ final class _WorkoutExecutionScreenState extends State { } } + Future _syncExternalSessionChanges() async { + if (_externalSyncInFlight || !mounted) { + return; + } + _externalSyncInFlight = true; + try { + final session = await widget.activeUseCases.findOpen(); + if (!mounted || + session == null || + session.metadata.id != _session.metadata.id) { + return; + } + final sessionChanged = + session.status != _session.status || + session.currentProgramIndex != _session.currentProgramIndex || + session.currentExerciseIndex != _session.currentExerciseIndex || + session.currentSetIndex != _session.currentSetIndex; + _session = session; + if (sessionChanged) { + _reps = _initialRepsFor(_exercise); + } + await _syncManualScoreInput(force: sessionChanged); + await _loadSetTimer(); + await _loadScoreStopwatch(); + await _readStepProgress(); + await _restoreActiveRest(); + if (!mounted) { + return; + } + setState(() { + _mode = switch (session.status) { + ActiveWorkoutStatus.running => + _activeRestStateId == null + ? WorkoutExecutionMode.active + : WorkoutExecutionMode.rest, + ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused, + ActiveWorkoutStatus.completed || + ActiveWorkoutStatus.abandoned || + ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished, + }; + }); + _refreshScoreStopwatchTicker(); + _refreshStepTicker(); + } finally { + _externalSyncInFlight = false; + } + } + Future _skipCurrentPassage() async { final confirmed = await _confirmStepSkip( 'Passer ce passage ?', @@ -1111,7 +1342,7 @@ final class _WorkoutExecutionScreenState extends State { _mode == WorkoutExecutionMode.rest; } - void _handleSystemBack(bool didPop) { + void _handleSystemBack(bool didPop, Object? result) { if (didPop || !_canPauseFromNavigation) return; unawaited(_pause()); } @@ -1132,7 +1363,13 @@ final class _WorkoutExecutionScreenState extends State { } Future _resume() async { - _session = await widget.activeUseCases.resume(_session.metadata.id); + try { + _session = await widget.activeUseCases.resume(_session.metadata.id); + } on DomainException { + if (!mounted) return; + _showInvalidSessionState(); + return; + } if (!mounted) return; final restoredRest = await _restoreActiveRest(); if (!mounted || restoredRest) return; @@ -1216,11 +1453,12 @@ final class _WorkoutExecutionScreenState extends State { if (confirmed != true) return; } if (!skipped && _shouldPromptStartExerciseChrono) { + if (!mounted) return; final action = await showDialog<_MissingSetTimerAction>( context: context, builder: (context) => AlertDialog( title: const Text('Chrono non lancé'), - content: const Text("Tu n’as pas démarré le chrono de cette série."), + content: const Text('Tu n’as pas démarré le chrono de cette série.'), actions: [ TextButton( onPressed: () => @@ -1250,11 +1488,12 @@ final class _WorkoutExecutionScreenState extends State { _exercise.stopwatchScoreEnabled && _scoreStopwatch == null && _manualScoreTimeMs == null) { + if (!mounted) return; final action = await showDialog<_MissingStopwatchAction>( context: context, builder: (context) => AlertDialog( title: const Text('Chrono non lancé'), - content: const Text("Tu n’as pas démarré le chrono de cette série."), + content: const Text('Tu n’as pas démarré le chrono de cette série.'), actions: [ TextButton( onPressed: () => @@ -1283,6 +1522,9 @@ final class _WorkoutExecutionScreenState extends State { Future _recordAndAdvance({required bool skipped}) async { try { + if (!skipped) { + await _persistManualScoreInput(); + } final score = double.tryParse(_scoreController.text.trim()); final setTimer = skipped ? await widget.activeUseCases.skipSetExecutionTimers( @@ -1465,6 +1707,7 @@ final class _WorkoutExecutionScreenState extends State { setIndex: position.setIndex, ); if (!mounted) return; + await _syncManualScoreInput(force: true); await _loadSetTimer(); await _loadScoreStopwatch(); await _loadStepProgress(); @@ -1503,13 +1746,28 @@ final class _WorkoutExecutionScreenState extends State { Future _restartCompleted() async { final history = _completedHistory; if (history == null) return; + final hasBlockingOpenSession = await _hasBlockingOpenSessionForRestart(); + if (!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 ' + 'd’en lancer une nouvelle.', + ), + ), + ); + return; + } ActiveWorkoutSession session; final sourceId = history.sourceWorkoutTemplateId; - if (sourceId != null && - await widget.workoutTemplateUseCases.findById(sourceId) != null) { - session = await widget.activeUseCases.startFromTemplate(sourceId); - } else { - if (mounted) { + try { + if (sourceId != null && + await widget.workoutTemplateUseCases.findById(sourceId) != null) { + session = await widget.activeUseCases.startFromTemplate(sourceId); + } else { + session = await widget.activeUseCases.startFromHistory(history); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( @@ -1518,7 +1776,16 @@ final class _WorkoutExecutionScreenState extends State { ), ); } - session = await widget.activeUseCases.startFromHistory(history); + } on DomainException { + if (!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 (!mounted) return; await Navigator.of(context).pushReplacement( @@ -1534,11 +1801,29 @@ final class _WorkoutExecutionScreenState extends State { mediaUseCases: widget.mediaUseCases, mediaAssetLoader: widget.mediaAssetLoader, videoMediaBuilder: widget.videoMediaBuilder, + sensorUseCases: widget.sensorUseCases, ), ), ); } + Future _hasBlockingOpenSessionForRestart() async { + final openSession = await widget.activeUseCases.findOpen(); + if (openSession == null || + openSession.metadata.id == _session.metadata.id) { + 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 restart. + } + return false; + } + int _initialRepsFor(ExecutionExercise exercise) { if (!exercise.repsEnabled) return 0; return exercise.targetReps ?? 0; @@ -1565,6 +1850,79 @@ final class _WorkoutExecutionScreenState extends State { } } +final class _HeartRateSummaryCard extends StatelessWidget { + const _HeartRateSummaryCard({ + 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: _HeartRateMetric( + label: 'Moyenne', + value: '${averageHeartRateBpm.round()}', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _HeartRateMetric( + label: 'Max', + value: '$maxHeartRateBpm', + ), + ), + ], + ), + ], + ), + ); + } +} + +final class _HeartRateMetric extends StatelessWidget { + const _HeartRateMetric({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 PerformanceReferenceCard extends StatelessWidget { const PerformanceReferenceCard({ required this.reference, @@ -1726,6 +2084,8 @@ final class SetMeasureInput extends StatelessWidget { this.showTime = true, required this.reps, required this.scoreController, + required this.scoreFocusNode, + required this.onScoreSubmitted, required this.onRepsChanged, super.key, }); @@ -1734,6 +2094,8 @@ final class SetMeasureInput extends StatelessWidget { final bool showTime; final int reps; final TextEditingController scoreController; + final FocusNode scoreFocusNode; + final ValueChanged onScoreSubmitted; final ValueChanged onRepsChanged; @override @@ -1783,6 +2145,7 @@ final class SetMeasureInput extends StatelessWidget { const SizedBox(height: 12), TextField( controller: scoreController, + focusNode: scoreFocusNode, style: AppTextStyles.scoreNumber(context), decoration: InputDecoration( labelText: exercise.scoreUnit == null @@ -1790,6 +2153,7 @@ final class SetMeasureInput extends StatelessWidget { : 'Score (${exercise.scoreUnit})', ), keyboardType: TextInputType.number, + onSubmitted: onScoreSubmitted, ), ], ], @@ -1819,19 +2183,107 @@ final class _ExecutionAppBarTitle extends StatelessWidget { overflow: TextOverflow.ellipsis, style: Theme.of(context).appBarTheme.titleTextStyle, ), - Text( - programName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: tokens.mutedText), + Row( + children: [ + Flexible( + child: Text( + programName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: tokens.mutedText), + ), + ), + ], ), ], ); } } +final class _LiveSensorBar extends StatelessWidget { + const _LiveSensorBar({required this.sensorState, required this.muted}); + + final ActiveWorkoutSensorState sensorState; + final bool muted; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: muted ? 0.45 : 1, + child: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (sensorState.latestHeartRateBpm case final heartRate?) + _LiveSensorPill( + icon: Icons.favorite, + label: 'FC $heartRate bpm', + muted: false, + ), + if (_distanceLabel(sensorState) case final distanceLabel?) + _LiveSensorPill( + icon: Icons.directions_run, + label: distanceLabel, + muted: false, + ), + if (_caloriesLabel(sensorState) case final caloriesLabel?) + _LiveSensorPill( + icon: Icons.local_fire_department, + label: caloriesLabel, + muted: false, + ), + ], + ), + ); + } +} + +final class _LiveSensorPill extends StatelessWidget { + const _LiveSensorPill({ + required this.icon, + required this.label, + required this.muted, + }); + + final IconData icon; + final String label; + final bool muted; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return Opacity( + opacity: muted ? 0.45 : 1, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: color.withValues(alpha: 0.55)), + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 3), + Text( + label, + maxLines: 1, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: color, fontSize: 10), + ), + ], + ), + ), + ), + ); + } +} + final class _ExecutionContextHeader extends StatelessWidget { const _ExecutionContextHeader({ required this.exercise, @@ -1946,6 +2398,47 @@ final class _ExecutionContextHeader extends StatelessWidget { } } +final class _InvalidSessionMessage extends StatelessWidget { + const _InvalidSessionMessage({ + required this.message, + required this.onBackHome, + }); + + final String message; + final VoidCallback onBackHome; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.error_outline, + size: 40, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: onBackHome, + child: const Text('Revenir à l’accueil'), + ), + ], + ), + ), + ); + } +} + final class _SetActionBar extends StatelessWidget { const _SetActionBar({required this.onFinishSet, required this.onSkipSet}); @@ -1980,10 +2473,14 @@ final class _StepSetResultSummary extends StatelessWidget { const _StepSetResultSummary({ required this.exercise, required this.scoreController, + required this.scoreFocusNode, + required this.onScoreSubmitted, }); final ExecutionExercise exercise; final TextEditingController scoreController; + final FocusNode scoreFocusNode; + final ValueChanged onScoreSubmitted; @override Widget build(BuildContext context) { @@ -1998,6 +2495,7 @@ final class _StepSetResultSummary extends StatelessWidget { ), child: TextField( controller: scoreController, + focusNode: scoreFocusNode, decoration: InputDecoration( isDense: true, labelText: exercise.scoreUnit == null @@ -2005,6 +2503,7 @@ final class _StepSetResultSummary extends StatelessWidget { : 'Score (${exercise.scoreUnit})', ), keyboardType: TextInputType.number, + onSubmitted: onScoreSubmitted, ), ); } @@ -2096,35 +2595,32 @@ final class _StepSequencePanel extends StatelessWidget { ), ), const SizedBox(height: 8), - if (sequenceComplete || currentStep == null) - Expanded( - child: Align( - alignment: Alignment.topLeft, - child: _SequenceCompleteSummary( - completedPassages: completedPassages, - expectedPassages: view.expectedPassages, - ), - ), - ) - else - Expanded( - child: _CurrentStepPane( - view: view, - step: currentStep, - remainingLabel: remainingLabel, - stepScoreController: stepScoreController, - stepScoreElapsedLabel: stepScoreElapsedLabel, - stepScoreRunning: stepScoreRunning, - onStartTimer: onStartTimer, - onCompleteStep: onCompleteStep, - onSkipStep: onSkipStep, - onSkipPassage: onSkipPassage, - onSkipSequence: onSkipSequence, - onStartStepScore: onStartStepScore, - onStopStepScore: onStopStepScore, - onResetStepScore: onResetStepScore, - ), - ), + Expanded( + child: sequenceComplete || currentStep == null + ? Align( + alignment: Alignment.topLeft, + child: _SequenceCompleteSummary( + completedPassages: completedPassages, + expectedPassages: view.expectedPassages, + ), + ) + : _CurrentStepPane( + view: view, + step: currentStep, + remainingLabel: remainingLabel, + stepScoreController: stepScoreController, + stepScoreElapsedLabel: stepScoreElapsedLabel, + stepScoreRunning: stepScoreRunning, + onStartTimer: onStartTimer, + onCompleteStep: onCompleteStep, + onSkipStep: onSkipStep, + onSkipPassage: onSkipPassage, + onSkipSequence: onSkipSequence, + onStartStepScore: onStartStepScore, + onStopStepScore: onStopStepScore, + onResetStepScore: onResetStepScore, + ), + ), ], ), ); @@ -2198,97 +2694,100 @@ final class _CurrentStepPane extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 2), - Text( - step.name, - style: Theme.of(context).textTheme.headlineSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Flexible( - fit: FlexFit.tight, - child: step.type == ExerciseStepType.time - ? _TimedStepBody( - step: step, - remainingLabel: remainingLabel, - running: - view.state.status == - ActiveExerciseStepProgressStatus.runningTimer, - readyToStart: _isNextTimedStepReady(view), - onStartTimer: onStartTimer, - ) - : Align( - alignment: Alignment.topCenter, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topCenter, - child: _RepsStepBody( - step: step, - onCompleteStep: onCompleteStep, - ), + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 8), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + step.name, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ), - if (step.hasScore) ...[ - const SizedBox(height: 8), - Flexible( - fit: FlexFit.loose, - child: _StepScoreInput( - step: step, - controller: stepScoreController, - elapsedLabel: stepScoreElapsedLabel, - running: stepScoreRunning, - onStart: onStartStepScore, - onStop: onStopStepScore, - onReset: onResetStepScore, - ), - ), - ], - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: onSkipStep, - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), - ), - child: const Text('Passer l’étape'), + const SizedBox(height: 4), + Expanded( + child: step.type == ExerciseStepType.time + ? _TimedStepBody( + step: step, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + readyToStart: _isNextTimedStepReady(view), + onStartTimer: onStartTimer, + ) + : Align( + alignment: Alignment.topCenter, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: _RepsStepBody( + step: step, + onCompleteStep: onCompleteStep, + ), + ), + ), + ), + if (step.hasScore) ...[ + const SizedBox(height: 8), + _StepScoreInput( + step: step, + controller: stepScoreController, + elapsedLabel: stepScoreElapsedLabel, + running: stepScoreRunning, + onStart: onStartStepScore, + onStop: onStopStepScore, + onReset: onResetStepScore, + ), + ], + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onSkipStep, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + child: const Text('Passer l’étape'), + ), + ), + const SizedBox(width: 8), + PopupMenuButton<_StepSkipAction>( + tooltip: 'Plus d’actions', + icon: const Icon(Icons.more_horiz), + onSelected: (action) { + if (action == _StepSkipAction.passage) { + onSkipPassage(); + } else { + onSkipSequence(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _StepSkipAction.passage, + child: Text('Passer ce passage'), + ), + PopupMenuItem( + value: _StepSkipAction.sequence, + child: Text('Passer la séquence'), + ), + ], + ), + ], + ), + ], ), ), - const SizedBox(width: 8), - PopupMenuButton<_StepSkipAction>( - tooltip: 'Plus d’actions', - icon: const Icon(Icons.more_horiz), - onSelected: (action) { - if (action == _StepSkipAction.passage) { - onSkipPassage(); - } else { - onSkipSequence(); - } - }, - itemBuilder: (context) => const [ - PopupMenuItem( - value: _StepSkipAction.passage, - child: Text('Passer ce passage'), - ), - PopupMenuItem( - value: _StepSkipAction.sequence, - child: Text('Passer la séquence'), - ), - ], - ), - ], - ), - ], + ), + ); + }, ); } } @@ -3884,15 +4383,49 @@ final class WorkoutExecutionPlan { overrides: overrides, ); }).toList(); - return WorkoutExecutionPlan( + final plan = WorkoutExecutionPlan( name: snapshot['name'] as String? ?? 'Séance', programs: programs, ); + final position = ExecutionPosition( + programIndex: session.currentProgramIndex, + exerciseIndex: session.currentExerciseIndex, + setIndex: session.currentSetIndex, + ); + if (!plan.isPlayable || !plan.contains(position)) { + throw const InvalidWorkoutExecutionPlanException(); + } + return plan; + } + + static WorkoutExecutionPlan? tryFromSession(ActiveWorkoutSession session) { + try { + return WorkoutExecutionPlan.fromSession(session); + } on Object { + return null; + } } final String name; final List programs; + bool get isPlayable { + return programs.any((program) => program.exercises.isNotEmpty); + } + + bool contains(ExecutionPosition position) { + if (position.programIndex < 0 || position.programIndex >= programs.length) { + return false; + } + final program = programs[position.programIndex]; + if (position.exerciseIndex < 0 || + position.exerciseIndex >= program.exercises.length) { + return false; + } + final exercise = program.exercises[position.exerciseIndex]; + return position.setIndex >= 0 && position.setIndex < exercise.setsCount; + } + ExecutionProgram programAt(ExecutionPosition position) { return programs[position.programIndex]; } @@ -3972,6 +4505,13 @@ final class ExecutionProgram { final List exercises; } +final class InvalidWorkoutExecutionPlanException implements Exception { + const InvalidWorkoutExecutionPlanException(); + + @override + String toString() => 'Workout execution plan is not playable.'; +} + final class ExecutionExercise { const ExecutionExercise({ required this.id, @@ -4233,6 +4773,7 @@ List _exerciseStepsFromSnapshot(Map exercise) { scoreUnit: step['scoreUnit'] as String?, defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(), defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?, + linkedToSeriesScore: step['linkedToSeriesScore'] == true, ); }) .toList(growable: false); @@ -4275,6 +4816,40 @@ String _formatDuration(Duration duration) { return '$minutes:$seconds'; } +bool _isSensorStateStale(ActiveWorkoutSensorState? state) { + if (state == null) { + return false; + } + return DateTime.now().toUtc().difference(state.latestSampleAt) > + const Duration(seconds: 15); +} + +String? _distanceLabel(ActiveWorkoutSensorState? state) { + final meters = state?.latestDistanceMeters; + if (meters == null || meters < 0) { + return null; + } + if (meters >= 1000) { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + return '${meters.round()} m'; +} + +String? _caloriesLabel(ActiveWorkoutSensorState? state) { + final actualCalories = state?.latestCaloriesKcal; + if (actualCalories != null && actualCalories >= 0) { + return '${actualCalories.round()} kcal'; + } + return null; +} + +bool _hasLiveSensorMetrics(ActiveWorkoutSensorState? state) { + return state != null && + (state.latestHeartRateBpm != null || + state.latestDistanceMeters != null || + _caloriesLabel(state) != null); +} + String _formatStepCountdown(Duration duration) { final totalSeconds = (duration.inMilliseconds / 1000) .ceil() diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 33c627b..d514665 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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 _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 _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 _confirmDelete(WorkoutTemplate template) async { final confirmed = await showDialog( 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 _save() async { + if (_programs.isEmpty) { + return; + } if (!_formKey.currentState!.validate()) { return; } @@ -1050,6 +1093,7 @@ List _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( + final exerciseCount = _templateExerciseCount(template); + return '$programCount programme${programCount > 1 ? 's' : ''} · ' + '$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}'; +} + +int _templateExerciseCount(WorkoutTemplate template) { + return template.programs.fold( 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; - return (snapshot['exercises'] as List? ?? const []).length; + try { + final snapshot = jsonDecode(programSnapshotJson) as Map; + return (snapshot['exercises'] as List? ?? 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, diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index a378749..fa31db9 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -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 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 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 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 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 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 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 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(Object? value, List values, T fallback) { return fallback; } +T? _nullableEnumFromJson(Object? value, List values) { + if (value is String) { + for (final enumValue in values) { + if (enumValue.name == value) { + return enumValue; + } + } + } + return null; +} + List _enumListFromJson(Object? value, List 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; } diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index e58483f..0cf6976 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -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, + ); + + 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, + ); + + 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); }); }); diff --git a/server/test/auth_api_test.dart b/server/test/auth_api_test.dart index 0ae0ede..9bcda04 100644 --- a/server/test/auth_api_test.dart +++ b/server/test/auth_api_test.dart @@ -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({ diff --git a/server/test/share_api_test.dart b/server/test/share_api_test.dart index b400758..7f9b4df 100644 --- a/server/test/share_api_test.dart +++ b/server/test/share_api_test.dart @@ -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 users) + : _byId = {for (final user in users) user.id: user}, + _byEmail = {for (final user in users) user.email: user}; - final UserAccount user; + final Map _byId; + final Map _byEmail; @override Future findByEmail(String email) async { - return user.email == email.toLowerCase() ? user : null; + return _byEmail[email.trim().toLowerCase()]; } @override Future 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 = {}; + final recipientById = {}; + final recipientByKey = {}; + final inboxByRecipient = >{}; + + void seedShare({ + required Share share, + required List 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 items, + }) { + inboxByRecipient[recipientUserId] = items; + for (final item in items) { + seedShare(share: item.share, recipients: [item.recipient]); + } + } + @override Future insertShare({ required Share share, required List recipients, - }) async {} + }) async { + seedShare(share: share, recipients: recipients); + } @override Future> listInbox(String recipientUserId) async { - return const []; + return inboxByRecipient[recipientUserId] ?? const []; } @override Future 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 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 = []; + @override Future 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), + ); +} diff --git a/server/test/sync_api_test.dart b/server/test/sync_api_test.dart index 70aa0c0..6cb00df 100644 --- a/server/test/sync_api_test.dart +++ b/server/test/sync_api_test.dart @@ -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}) { diff --git a/test/application/session_notification_use_cases_test.dart b/test/application/session_notification_use_cases_test.dart new file mode 100644 index 0000000..4dfa185 --- /dev/null +++ b/test/application/session_notification_use_cases_test.dart @@ -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, + ); +} diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index e0cf883..59965d2 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:gametime/application/application.dart'; import 'package:gametime/domain/domain.dart'; +import 'package:watch_bridge_contract/watch_bridge_contract.dart'; void main() { test('Exercise requires at least one active measure', () { @@ -285,6 +286,40 @@ void main() { ); }); + test('linked step score requires manual series score', () async { + final linkedStep = _step( + hasScore: true, + scoreLabel: 'Paniers', + scoreUnit: 'pts', + linkedToSeriesScore: true, + ); + final useCase = _exerciseUseCase(_FakeExerciseRepository()); + + await expectLater( + useCase.create( + name: 'Tirs', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 1, + steps: [linkedStep], + ), + throwsA(isA()), + ); + await expectLater( + useCase.create( + name: 'Tirs chrono', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: true, + scoreInputMode: ScoreInputMode.stopwatch, + defaultTargetReps: 1, + steps: [linkedStep], + ), + throwsA(isA()), + ); + }); + test('Exercise image gallery rejects a sixth image', () async { final repository = _FakeExerciseRepository() ..exercise = Exercise( @@ -560,6 +595,26 @@ void main() { expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse); }); + test('program saveConfigured rejects empty exercise list', () async { + final useCase = ProgramUseCases( + programRepository: _FakeProgramRepository(), + exerciseRepository: _FakeExerciseRepository(), + templateRepository: _FakeWorkoutTemplateRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.saveConfigured( + name: 'Programme', + defaultRestSeconds: 60, + exercises: const [], + ), + throwsA(isA()), + ); + }); + test('workout template override preserves step chaining override', () async { final templateRepository = _FakeWorkoutTemplateRepository(); final useCase = WorkoutTemplateUseCases( @@ -603,6 +658,54 @@ void main() { expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse); }); + test('workout template saveConfigured rejects empty program list', () async { + final useCase = WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.saveConfigured( + name: 'Séance', + programs: const [], + overrides: const [], + ), + throwsA(isA()), + ); + }); + + test( + 'workout template saveConfigured rejects program snapshot without sets', + () async { + final useCase = WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.saveConfigured( + name: 'Séance', + programs: [ + WorkoutTemplateProgramConfig( + clientKey: 'program-1', + programNameSnapshot: 'Programme', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: jsonEncode({'exercises': const []}), + ), + ], + overrides: const [], + ), + throwsA(isA()), + ); + }, + ); + test('delete exercise keeps existing program snapshots unchanged', () async { final exerciseRepository = _FakeExerciseRepository() ..exercise = Exercise( @@ -854,6 +957,152 @@ void main() { expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000); }); + test( + 'startFromTemplate rejects template without playable exercise', + () async { + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add( + WorkoutTemplate( + metadata: _metadata('template-1'), + name: 'Séance vide', + ), + ); + final repository = _FakeActiveSessionRepository(); + final useCase = ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: templateRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + await expectLater( + useCase.startFromTemplate('template-1'), + throwsA(isA()), + ); + expect(repository.session, isNull); + }, + ); + + test('startFromTemplate stamps the source template start time', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.add(_playableTemplate(id: 'template-1', name: 'A')); + final repository = _FakeActiveSessionRepository(); + final useCase = ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final session = await useCase.startFromTemplate('template-1'); + + expect(session.sourceWorkoutTemplateId, 'template-1'); + expect(repository.session, session); + expect(templateRepository.templates.single.lastStartedAt, clock.now()); + }); + + test( + 'startFromLastTemplate starts most recently used playable template', + () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final templateRepository = _FakeWorkoutTemplateRepository() + ..templates.addAll([ + _playableTemplate( + id: 'older', + name: 'B', + lastStartedAt: DateTime.utc(2026, 7, 10, 12), + ), + _playableTemplate( + id: 'latest', + name: 'A', + lastStartedAt: DateTime.utc(2026, 7, 12, 12), + ), + WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'), + ]); + final repository = _FakeActiveSessionRepository(); + final useCase = ActiveWorkoutSessionUseCases( + sessionRepository: repository, + templateRepository: templateRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final session = await useCase.startFromLastTemplate(); + + expect(session.sourceWorkoutTemplateId, 'latest'); + expect(repository.session, session); + }, + ); + + test('resume abandons invalid session snapshot before throwing', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: ActiveWorkoutStatus.paused, + startedAt: clock.now(), + pausedAt: clock.now(), + lastPersistedAt: clock.now(), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}), + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + await expectLater( + useCase.resume(session.metadata.id), + throwsA(isA()), + ); + expect(repository.session?.status, ActiveWorkoutStatus.abandoned); + expect(repository.session?.endedAt, clock.now()); + }); + + test('findOpen silently abandons invalid legacy session', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = ActiveWorkoutSession( + metadata: _metadata('session-1'), + status: ActiveWorkoutStatus.running, + startedAt: clock.now(), + lastPersistedAt: clock.now(), + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}), + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + final open = await useCase.findOpen(); + + expect(open, isNull); + expect(repository.session?.status, ActiveWorkoutStatus.abandoned); + expect(repository.session?.endedAt, clock.now()); + }); + + test('findOpen returns playable open session unchanged', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + ); + final repository = _FakeActiveSessionRepository()..session = session; + final useCase = _activeUseCase(repository, clock); + + final open = await useCase.findOpen(); + + expect(open, session); + expect(repository.session?.status, ActiveWorkoutStatus.running); + expect(repository.session?.endedAt, isNull); + }); + test('adjustRestSeconds persists adjusted rest duration', () async { final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); final repository = _FakeActiveSessionRepository() @@ -1420,6 +1669,78 @@ void main() { }, ); + test('linked step score controls current series manual score', () async { + final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12)); + final session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + setsCount: 1, + targetReps: 1, + scoreEnabled: true, + exerciseSteps: [ + _step( + id: 'step-1', + position: 0, + name: 'Tirs main droite', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + hasScore: true, + scoreLabel: 'Paniers', + scoreUnit: 'pts', + linkedToSeriesScore: true, + ), + ], + ); + final repository = _FakeActiveSessionRepository()..session = session; + final activeUseCase = _activeUseCase(repository, clock); + final stepUseCase = ActiveExerciseStepUseCases( + sessionRepository: repository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + activeSessionUseCases: activeUseCase, + ); + + await stepUseCase.startOrResumeProgress( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await stepUseCase.incrementCurrentStepScore( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await stepUseCase.incrementCurrentStepScore( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await stepUseCase.decrementCurrentStepScore( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + expect(repository.manualScoreStates.values.single.value, 1); + + final result = await stepUseCase.completeCurrentStep( + sessionId: session.metadata.id, + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualScore: 3, + ); + + expect(result.actualScore, 3); + expect(repository.manualScoreStates.values.single.value, 4); + }); + test( 'step chaining resolution uses template override before program override', () async { @@ -2257,6 +2578,365 @@ void main() { ); }); + test( + 'recordCurrentSetResult uses live manual score unless overridden', + () async { + final repository = _FakeActiveSessionRepository() + ..session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + scoreEnabled: true, + ); + final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); + final useCase = _activeUseCase(repository, clock); + await useCase.incrementManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + await useCase.incrementManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + + final fromLiveState = await useCase.recordCurrentSetResult( + sessionId: 'session-1', + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + scoreInputModeSnapshot: ScoreInputMode.manual, + scoreLabelSnapshot: 'Points', + scoreUnitSnapshot: 'pts', + ); + + expect(fromLiveState.actualScore, 2); + expect(repository.manualScoreStates, isEmpty); + + await useCase.incrementManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + final overridden = await useCase.recordCurrentSetResult( + sessionId: 'session-1', + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualScore: 5, + scoreInputModeSnapshot: ScoreInputMode.manual, + scoreLabelSnapshot: 'Points', + scoreUnitSnapshot: 'pts', + ); + + expect(overridden.actualScore, 5); + }, + ); + + test('recordCurrentSetResult upserts by logical set key', () async { + final repository = _FakeActiveSessionRepository() + ..session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + scoreEnabled: true, + ); + final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); + final useCase = _activeUseCase(repository, clock); + + final first = await useCase.recordCurrentSetResult( + sessionId: 'session-1', + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualScore: 2, + scoreInputModeSnapshot: ScoreInputMode.manual, + scoreLabelSnapshot: 'Points', + scoreUnitSnapshot: 'pts', + ); + clock.value = DateTime.utc(2026, 7, 25, 12, 1); + final second = await useCase.recordCurrentSetResult( + sessionId: 'session-1', + programSnapshotId: 'program-snapshot-1', + exerciseSnapshotId: 'exercise-snapshot-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + actualScore: 5, + scoreInputModeSnapshot: ScoreInputMode.manual, + scoreLabelSnapshot: 'Points', + scoreUnitSnapshot: 'pts', + ); + + expect(second.metadata.id, first.metadata.id); + expect(second.metadata.localRevision, first.metadata.localRevision + 1); + expect(repository.results, hasLength(1)); + expect(repository.results.single.actualScore, 5); + }); + + test( + 'setManualScore pushes phone corrections into live manual score state', + () async { + final repository = _FakeActiveSessionRepository() + ..session = _sessionWithSnapshot( + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + scoreEnabled: true, + ); + final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12)); + final useCase = _activeUseCase(repository, clock); + + final corrected = await useCase.setManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: 7.5, + ); + expect(corrected.state.value, 7.5); + expect(corrected.changed, isTrue); + + final incremented = await useCase.incrementManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + expect(incremented.state.value, 8.5); + + final clamped = await useCase.setManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + value: -4, + ); + expect(clamped.state.value, 0); + + final decremented = await useCase.decrementManualScore( + sessionId: 'session-1', + programIndex: 0, + exerciseIndex: 0, + setIndex: 0, + ); + expect(decremented.state.value, 0); + expect(decremented.changed, isFalse); + }, + ); + + test('WorkoutHistoryUseCases applies heart rate summary once', () async { + final repository = _FakeWorkoutHistoryRepository() + ..histories.add( + WorkoutHistory( + metadata: _metadata('history-1'), + sourceActiveWorkoutSessionId: 'session-1', + nameSnapshot: 'Seance', + startedAt: DateTime.utc(2026, 7, 25, 11), + endedAt: DateTime.utc(2026, 7, 25, 12), + totalActiveMs: 3600000, + completed: true, + historySnapshotJson: '{"programs":[]}', + ), + ); + final useCase = WorkoutHistoryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)), + ); + + await useCase.updateHeartRateSummary( + const WatchSensorSummary( + sessionId: 'session-1', + sampleCount: 12, + averageHeartRateBpm: 126.5, + maxHeartRateBpm: 171, + ), + ); + await useCase.updateHeartRateSummary( + const WatchSensorSummary( + sessionId: 'session-1', + sampleCount: 14, + averageHeartRateBpm: 130, + maxHeartRateBpm: 180, + ), + ); + + expect(repository.histories.single.averageHeartRateBpm, 126.5); + expect(repository.histories.single.maxHeartRateBpm, 171); + }); + + test( + 'ActiveWorkoutSensorUseCases tracks live heart rate and calories', + () async { + final useCase = ActiveWorkoutSensorUseCases( + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)), + ); + final updates = []; + final subscription = useCase.updates.listen(updates.add); + + expect( + useCase.recordHeartRateSample( + const WatchSensorSample( + sessionId: '', + recordedAtEpochMs: 0, + heartRateBpm: 120, + ), + ), + isNull, + ); + useCase.recordHeartRateSample( + WatchSensorSample( + sessionId: 'session-1', + recordedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + ).millisecondsSinceEpoch, + heartRateBpm: 120, + ), + ); + final state = useCase.recordHeartRateSample( + WatchSensorSample( + sessionId: 'session-1', + recordedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + 30, + ).millisecondsSinceEpoch, + heartRateBpm: 150, + ), + ); + await Future.delayed(Duration.zero); + + expect(state?.latestHeartRateBpm, 150); + expect(state?.sampleCount, 2); + expect(state?.averageHeartRateBpm, 135); + expect(state?.maxHeartRateBpm, 150); + expect(state?.estimatedCaloriesKcal, 187.5); + expect(updates, hasLength(2)); + + useCase.clear('session-1'); + expect(useCase.current('session-1'), isNull); + await subscription.cancel(); + await useCase.dispose(); + }, + ); + + test( + 'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates', + () async { + final useCase = ActiveWorkoutSensorUseCases( + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)), + ); + + final first = useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-1', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + ).millisecondsSinceEpoch, + heartRateBpm: 120, + distanceMeters: 500, + caloriesKcal: 42, + ), + ); + final duplicate = useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-1', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + 1, + ).millisecondsSinceEpoch, + heartRateBpm: 160, + distanceMeters: 300, + caloriesKcal: 10, + ), + ); + final distanceOnly = useCase.recordTelemetrySample( + WatchTelemetrySample( + sampleId: 'sample-2', + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 25, + 12, + 2, + ).millisecondsSinceEpoch, + distanceMeters: 620, + caloriesKcal: 48, + ), + ); + + expect(first?.latestHeartRateBpm, 120); + expect(duplicate, isNull); + expect(distanceOnly?.sampleCount, 2); + expect(distanceOnly?.latestHeartRateBpm, 120); + expect(distanceOnly?.minHeartRateBpm, 120); + expect(distanceOnly?.averageHeartRateBpm, 120); + expect(distanceOnly?.maxHeartRateBpm, 120); + expect(distanceOnly?.latestDistanceMeters, 620); + expect(distanceOnly?.latestCaloriesKcal, 48); + await useCase.dispose(); + }, + ); + + test( + 'WorkoutHistoryUseCases ignores insufficient heart rate summary', + () async { + final repository = _FakeWorkoutHistoryRepository() + ..histories.add( + WorkoutHistory( + metadata: _metadata('history-1'), + sourceActiveWorkoutSessionId: 'session-1', + nameSnapshot: 'Seance', + startedAt: DateTime.utc(2026, 7, 25, 11), + endedAt: DateTime.utc(2026, 7, 25, 12), + totalActiveMs: 3600000, + completed: true, + historySnapshotJson: '{"programs":[]}', + ), + ); + final useCase = WorkoutHistoryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)), + ); + + await useCase.updateHeartRateSummary( + const WatchSensorSummary( + sessionId: 'session-1', + sampleCount: 2, + averageHeartRateBpm: 126.5, + maxHeartRateBpm: 171, + ), + ); + + expect(repository.histories.single.averageHeartRateBpm, isNull); + expect(repository.histories.single.maxHeartRateBpm, isNull); + }, + ); + test('Tags are normalized and validated on taggable entities', () { final exercise = Exercise( metadata: _metadata('exercise-tags'), @@ -3046,6 +3726,7 @@ ExerciseStep _step({ String? scoreUnit, double? defaultTargetScore, int? defaultTargetScoreTimeMs, + bool linkedToSeriesScore = false, }) { return ExerciseStep( id: id, @@ -3059,6 +3740,7 @@ ExerciseStep _step({ scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, ); } @@ -3720,6 +4402,43 @@ WorkoutTemplateProgram _templateProgram({ ); } +WorkoutTemplate _playableTemplate({ + required String id, + required String name, + DateTime? lastStartedAt, +}) { + return WorkoutTemplate( + metadata: _metadata(id), + name: name, + lastStartedAt: lastStartedAt, + programs: [ + WorkoutTemplateProgram( + metadata: _metadata('$id-program'), + workoutTemplateId: id, + sourceProgramId: null, + position: 0, + programNameSnapshot: 'Programme', + defaultRestSecondsSnapshot: 60, + programSnapshotJson: jsonEncode({ + 'exercises': [ + { + 'id': '$id-exercise', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': 2, + 'timeEnabled': false, + 'repsEnabled': true, + 'scoreEnabled': false, + 'targetReps': 10, + 'exerciseStepsSnapshot': const [], + 'autoStartNextTimedStepSnapshot': false, + }, + ], + }), + ), + ], + ); +} + final class _FakeExerciseRepository implements ExerciseRepository { Exercise? exercise; final saved = []; @@ -3970,6 +4689,72 @@ final class _FakeProgramRepository implements ProgramRepository { } } +final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + final histories = []; + + @override + Future findById(String id) async { + return histories.where((history) => history.metadata.id == id).firstOrNull; + } + + @override + Future> listActive() async { + return histories + .where((history) => history.metadata.deletedAt == null) + .toList(); + } + + @override + Future save(WorkoutHistory history) async { + histories.removeWhere( + (existing) => existing.metadata.id == history.metadata.id, + ); + histories.add(history); + } + + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async { + final index = histories.indexWhere( + (history) => + history.metadata.id == historyId && + history.metadata.deletedAt == null && + 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 saveSetResult(WorkoutHistorySetResult result) async {} + + @override + Future saveStepResult(WorkoutHistoryStepResult result) async {} + + @override + Future delete(String id, DateTime deletedAt) async { + final index = histories.indexWhere((history) => history.metadata.id == id); + if (index == -1) { + return; + } + histories[index] = histories[index].copyWith( + metadata: histories[index].metadata.markDeleted(deletedAt), + ); + } +} + ActiveWorkoutSessionUseCases _activeUseCase( _FakeActiveSessionRepository repository, _FakeClock clock, @@ -3989,6 +4774,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { final restStates = {}; final setTimerStates = {}; final scoreStopwatchStates = {}; + final manualScoreStates = {}; final stepProgressStates = {}; final stepResults = []; @@ -4022,6 +4808,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .firstOrNull; } + @override + Future findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return manualScoreStates.values + .where( + (state) => + state.activeWorkoutSessionId == sessionId && + state.programIndex == programIndex && + state.exerciseIndex == exerciseIndex && + state.setIndex == setIndex && + state.metadata.deletedAt == null, + ) + .firstOrNull; + } + @override Future findSetTimerState({ required String sessionId, @@ -4080,6 +4885,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .toList(); } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return manualScoreStates.values + .where( + (state) => + state.activeWorkoutSessionId == sessionId && + state.metadata.deletedAt == null, + ) + .toList(); + } + @override Future> listSetResults(String sessionId) async { return results @@ -4136,6 +4954,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates[state.metadata.id] = state; } + @override + Future saveManualScoreState(ActiveManualScoreState state) async { + manualScoreStates[state.metadata.id] = state; + } + @override Future saveExerciseStepProgressState( ActiveExerciseStepProgressState state, @@ -4177,6 +5000,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates.remove(state.metadata.id); } + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + final state = await findManualScoreState( + sessionId: sessionId, + programIndex: programIndex, + exerciseIndex: exerciseIndex, + setIndex: setIndex, + ); + if (state == null) { + return; + } + manualScoreStates.remove(state.metadata.id); + } + @override Future saveSetResult(ActiveSetResult result) async { results.removeWhere( diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 07b99a9..1debd94 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -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 dispatch(WatchCommandType type) { - return handler.dispatch(_command(type)); + Future 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 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 findById(String id) async => null; + final templates = []; @override - Future> listActive() async => const []; + Future findById(String id) async { + for (final template in templates) { + if (template.metadata.id == id && template.metadata.deletedAt == null) { + return template; + } + } + return null; + } + + @override + Future> listActive() async { + return templates + .where((template) => template.metadata.deletedAt == null) + .toList(); + } @override Future replaceComposition( @@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository ) async {} @override - Future save(WorkoutTemplate template) async {} + Future save(WorkoutTemplate template) async { + templates.removeWhere((saved) => saved.metadata.id == template.metadata.id); + templates.add(template); + } @override Future saveOverride(WorkoutTemplateExerciseOverride override) async {} @@ -527,6 +777,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { final restStates = {}; final setTimerStates = {}; final scoreStopwatchStates = {}; + final manualScoreStates = {}; final stepProgressStates = {}; final stepResults = []; @@ -541,6 +792,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates.clear(); } + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + manualScoreStates.clear(); + } + @override Future findById(String id) async { return session?.metadata.id == id ? session : null; @@ -584,6 +846,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { }).firstOrNull; } + @override + Future 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 findSetTimerState({ required String sessionId, @@ -633,6 +910,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .toList(); } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return manualScoreStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + @override Future> listSetResults(String sessionId) async { return results @@ -661,6 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future 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 saveManualScoreState(ActiveManualScoreState state) async { + manualScoreStates[state.metadata.id] = state; + } + @override Future saveSetResult(ActiveSetResult result) async { results.add(result); diff --git a/test/application/watch_companion_projection_test.dart b/test/application/watch_companion_projection_test.dart index 629fc6a..029496c 100644 --- a/test/application/watch_companion_projection_test.dart +++ b/test/application/watch_companion_projection_test.dart @@ -71,10 +71,10 @@ void main() { expect(projection.phase, WatchSessionPhase.running); expect(projection.dominantTimer?.kind, WatchTimerKind.step); - expect(projection.dominantTimer?.accumulatedMs, 0); + expect(projection.dominantTimer?.accumulatedMs, 5000); expect( projection.dominantTimer?.startedAtEpochMs, - now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch, + now.millisecondsSinceEpoch, ); expect(projection.secondaryTimers.map((timer) => timer.kind), [ WatchTimerKind.scoreStopwatch, @@ -88,6 +88,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 +379,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 +403,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.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 +479,7 @@ ActiveWorkoutSession _session({ bool repsEnabled = true, bool scoreEnabled = false, int? targetTimeSeconds, + double? targetScore, ScoreInputMode scoreInputMode = ScoreInputMode.manual, bool? autoStartNextTimedStepSnapshot = true, List steps = const [], @@ -355,6 +494,7 @@ ActiveWorkoutSession _session({ 'scoreEnabled': scoreEnabled, 'targetTimeSeconds': targetTimeSeconds, 'targetReps': repsEnabled ? setsCount : null, + 'targetScore': targetScore, 'scoreInputModeSnapshot': scoreInputMode.name, 'exerciseStepsSnapshot': steps .map((step) => step.toSnapshotJson()) @@ -403,6 +543,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 +554,10 @@ ExerciseStep _step({ name: 'Step ${position + 1}', type: ExerciseStepType.time, defaultTargetValue: defaultTargetValue, + hasScore: hasScore, + scoreLabel: scoreLabel, + scoreUnit: scoreUnit, + defaultTargetScore: defaultTargetScore, ); } @@ -514,6 +662,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { final restStates = {}; final setTimerStates = {}; final scoreStopwatchStates = {}; + final manualScoreStates = {}; final stepProgressStates = {}; final stepResults = []; @@ -528,6 +677,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates.clear(); } + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async { + manualScoreStates.clear(); + } + @override Future findById(String id) async { return session?.metadata.id == id ? session : null; @@ -571,6 +731,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { }).firstOrNull; } + @override + Future 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 findSetTimerState({ required String sessionId, @@ -620,6 +795,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .toList(); } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return manualScoreStates.values + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + @override Future> listSetResults(String sessionId) async { return results @@ -661,6 +845,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates[state.metadata.id] = state; } + @override + Future saveManualScoreState(ActiveManualScoreState state) async { + manualScoreStates[state.metadata.id] = state; + } + @override Future saveSetResult(ActiveSetResult result) async { results.add(result); diff --git a/test/infrastructure/remote/http_api_client_test.dart b/test/infrastructure/remote/http_api_client_test.dart new file mode 100644 index 0000000..4aaaff2 --- /dev/null +++ b/test/infrastructure/remote/http_api_client_test.dart @@ -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() + .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() + .having( + (error) => error.failure, + 'failure', + RemoteAuthFailure.network, + ) + .having((error) => error.message, 'message', 'refused'), + ), + ); + }); +} diff --git a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart index f3d4794..822b803 100644 --- a/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart +++ b/test/infrastructure/watch_bridge/wear_data_layer_adapter_test.dart @@ -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.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.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 = []; final acks = <_SentAck>[]; final _commands = StreamController.broadcast(); + final _sensorSummaries = StreamController.broadcast(); + final _sensorSamples = StreamController.broadcast(); final _connections = StreamController.broadcast(); var capabilityRefreshCount = 0; var foregroundStartCount = 0; @@ -268,6 +393,12 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel { @override Stream get commands => _commands.stream; + @override + Stream get sensorSummaries => _sensorSummaries.stream; + + @override + Stream get sensorSamples => _sensorSamples.stream; + @override Stream 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 = []; + + @override + Future findById(String id) async { + return histories.where((history) => history.metadata.id == id).firstOrNull; + } + + @override + Future> listActive() async => histories; + + @override + Future save(WorkoutHistory history) async {} + + @override + Future 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 saveSetResult(WorkoutHistorySetResult result) async {} + + @override + Future saveStepResult(WorkoutHistoryStepResult result) async {} + + @override + Future delete(String id, DateTime deletedAt) async {} +} + final class _SentAck { const _SentAck(this.command, this.ack, this.revisionAtAck); diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index f7163e2..50735a7 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -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(); @@ -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 d’enregistrer l’exercice. Vérifie les champs puis réessaie.', + ), + findsOneWidget, + ); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Enregistrer'), + ) + .onPressed, + isNotNull, + ); + }); + testWidgets( 'le réglage d’enchaînement automatique des étapes est sauvegardé', (tester) async { @@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository { final exercises = []; final saved = []; var referenced = false; + var throwOnSave = false; @override Future findById(String id) async { @@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository { @override Future 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); diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 1b8f533..79e8f7b 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -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 save(WorkoutHistory history) async {} + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async {} + @override Future saveSetResult(WorkoutHistorySetResult result) async {} @@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository { } final class _FakeActiveSessionRepository implements ActiveSessionRepository { - @override - Future findById(String id) async => null; + ActiveWorkoutSession? session; @override - Future findOpen() async => null; + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; @override Future findRestStateById(String id) async => null; @@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required int setIndex, }) async => null; + @override + Future findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + @override Future findSetTimerState({ required String sessionId, @@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return const []; } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return const []; + } + @override Future> listSetTimerStates(String sessionId) async { return const []; @@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } @override - Future save(ActiveWorkoutSession session) async {} + Future save(ActiveWorkoutSession session) async { + this.session = session; + } @override Future saveRestState(ActiveRestState restState) async {} @@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + @override + Future saveManualScoreState(ActiveManualScoreState state) async {} + @override Future saveSetTimerState(ActiveSetTimerState state) async {} @@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required DateTime deletedAt, }) async {} + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} + @override Future saveSetResult(ActiveSetResult result) async {} } diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 282a6f5..52b8382 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -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 findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return null; + } + @override Future findSetTimerState({ required String sessionId, @@ -585,6 +646,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return scoreStopwatchStates; } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return const []; + } + @override Future> listSetTimerStates(String sessionId) async { return setTimerStates; @@ -624,6 +692,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { scoreStopwatchStates.add(state); } + @override + Future saveManualScoreState(ActiveManualScoreState state) async {} + @override Future saveSetTimerState(ActiveSetTimerState state) async { setTimerStates.add(state); @@ -646,6 +717,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required DateTime deletedAt, }) async {} + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} + @override Future saveSetResult(ActiveSetResult result) async { results.add(result); @@ -808,6 +888,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(WorkoutHistory history) async {} + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async {} + @override Future saveSetResult(WorkoutHistorySetResult result) async {} diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index ed1ad9a..bdaad08 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -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 d’export 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(); diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index d597d9a..6e1c6a0 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -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( + 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( diff --git a/test/presentation/progression_screen_test.dart b/test/presentation/progression_screen_test.dart index 1fe3e28..11d5ea0 100644 --- a/test/presentation/progression_screen_test.dart +++ b/test/presentation/progression_screen_test.dart @@ -456,6 +456,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(WorkoutHistory history) async {} + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async {} + @override Future saveSetResult(WorkoutHistorySetResult result) async {} @@ -539,6 +547,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required int setIndex, }) async => null; + @override + Future findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + @override Future findSetTimerState({ required String sessionId, @@ -567,6 +583,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { String sessionId, ) async => const []; + @override + Future> listManualScoreStates( + String sessionId, + ) async => const []; + @override Future> listSetResults(String sessionId) async { return const []; @@ -594,9 +615,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + @override + Future saveManualScoreState(ActiveManualScoreState state) async {} + @override Future saveSetResult(ActiveSetResult result) async {} @override Future saveSetTimerState(ActiveSetTimerState state) async {} + + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} } diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 9c333d7..d349d2d 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -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', ( @@ -45,6 +46,48 @@ void main() { 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('n’affiche 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 { @@ -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,6 +423,8 @@ void main() { ), reps: 0, scoreController: scoreController, + scoreFocusNode: scoreFocusNode, + onScoreSubmitted: (_) {}, onRepsChanged: (_) {}, ), ), @@ -808,6 +1063,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 +1498,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 +1841,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 +1902,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 +2223,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 _step( String id, int position, @@ -1960,6 +2350,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { final results = []; final restStates = []; final scoreStopwatchStates = []; + final manualScoreStates = []; final setTimerStates = []; final stepProgressStates = []; final stepResults = []; @@ -2001,6 +2392,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return null; } + @override + Future 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 findSetTimerState({ required String sessionId, @@ -2055,6 +2465,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { .toList(); } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return manualScoreStates + .where((state) => state.activeWorkoutSessionId == sessionId) + .toList(); + } + @override Future> listSetResults(String sessionId) async { return results; @@ -2114,6 +2533,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { } } + @override + Future 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 saveSetTimerState(ActiveSetTimerState state) async { final index = setTimerStates.indexWhere( @@ -2183,6 +2614,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { ); } + @override + Future 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 saveSetResult(ActiveSetResult result) async { final index = results.indexWhere( @@ -2237,6 +2685,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(WorkoutHistory history) async {} + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async {} + @override Future saveSetResult(WorkoutHistorySetResult result) async {} diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 1941331..b1bb9ee 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -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( + 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 findManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async { + return null; + } + @override Future findSetTimerState({ required String sessionId, @@ -860,6 +1081,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { return const []; } + @override + Future> listManualScoreStates( + String sessionId, + ) async { + return const []; + } + @override Future> listSetTimerStates(String sessionId) async { return const []; @@ -896,6 +1124,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { @override Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + @override + Future saveManualScoreState(ActiveManualScoreState state) async {} + @override Future saveSetTimerState(ActiveSetTimerState state) async {} @@ -916,6 +1147,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository { required DateTime deletedAt, }) async {} + @override + Future deleteManualScoreState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} + @override Future saveSetResult(ActiveSetResult result) async {} } @@ -964,6 +1204,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { @override Future save(WorkoutHistory history) async {} + @override + Future patchHeartRateSummary({ + required String historyId, + required double averageHeartRateBpm, + required int maxHeartRateBpm, + required DateTime patchedAt, + }) async {} + @override Future saveSetResult(WorkoutHistorySetResult result) async {} diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts index 4fcfa4f..58bdfba 100644 --- a/watch_app/android/app/build.gradle.kts +++ b/watch_app/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { } defaultConfig { - applicationId = "com.gametime.watch" - minSdk = 26 + applicationId = "com.gametime.app" + minSdk = 30 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName @@ -29,10 +29,21 @@ android { } } +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") } diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 4cc8229..54e4599 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -4,16 +4,23 @@ android:required="true" /> + + + + diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt index 4ad6f1c..b96ee28 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/MainActivity.kt @@ -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, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + WatchBridgePlugin.handlePermissionResult(requestCode, grantResults) + } } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt index 9529700..7959923 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchBridgePlugin.kt @@ -1,7 +1,11 @@ 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.Handler +import android.os.Looper import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.DataEvent import com.google.android.gms.wearable.DataMapItem @@ -17,18 +21,32 @@ import java.nio.charset.StandardCharsets object WatchBridgePlugin { private const val METHOD_CHANNEL = "gametime.watch_bridge/methods" private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections" + private const val 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 BODY_SENSORS_PERMISSION_REQUEST = 4106 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 bodySensorPermissionRequested = false fun register(flutterEngine: FlutterEngine, context: Context) { appContext = context.applicationContext @@ -59,6 +77,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 { @@ -74,25 +104,63 @@ object WatchBridgePlugin { ) } + fun attachActivity(activity: Activity) { + this.activity = activity + } + + fun detachActivity(activity: Activity) { + if (this.activity === activity) { + this.activity = null + } + } + + fun handlePermissionResult(requestCode: Int, grantResults: IntArray) { + if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) { + return + } + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + appContext?.let { heartRateCollector.onBodySensorsGranted(it) } + } + } + fun emitProjection(payload: Map): 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): Boolean { val sink = ackSink ?: return false - sink.success(payload) + mainHandler.post { + sink.success(payload) + } + return true + } + + fun emitSensorSample(payload: Map): 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 +173,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 +263,7 @@ object WatchBridgePlugin { fun requestLatestProjection(context: Context) { val uri = Uri.Builder() .scheme("wear") + .authority("*") .path(STATE_PATH) .build() Wearable.getDataClient(context) @@ -212,6 +282,67 @@ object WatchBridgePlugin { } } } + + private fun updateHeartRateCollection(context: Context, projection: Map) { + 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 (!hasBodySensorsPermission(context)) { + heartRateCollector.noteActiveSession( + sessionId, + shouldAggregate = false, + executionContext = telemetryContext(projection), + ) + requestBodySensorsPermissionOnce() + return + } + heartRateCollector.noteActiveSession( + sessionId, + shouldAggregate, + executionContext = telemetryContext(projection), + ) + if (shouldAggregate) { + heartRateCollector.start(context) + } else { + heartRateCollector.pause(context) + } + } + + private fun hasBodySensorsPermission(context: Context): Boolean { + return context.checkSelfPermission(android.Manifest.permission.BODY_SENSORS) == + PackageManager.PERMISSION_GRANTED + } + + private fun requestBodySensorsPermissionOnce() { + val activity = activity ?: return + if (bodySensorPermissionRequested) { + return + } + bodySensorPermissionRequested = true + activity.requestPermissions( + arrayOf(android.Manifest.permission.BODY_SENSORS), + BODY_SENSORS_PERMISSION_REQUEST, + ) + } + + private fun telemetryContext(projection: Map): Map { + 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 { diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt new file mode 100644 index 0000000..c5e522d --- /dev/null +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -0,0 +1,186 @@ +package com.gametime.watch.bridge + +import android.content.Context +import androidx.health.services.client.HealthServices +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) -> Unit = {}, +) { + 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 sampleSequence = 0 + private var executionContext: Map = emptyMap() + private var registered = false + private var shouldAggregate = false + private var appContext: Context? = null + + private val callback = object : MeasureCallback { + override fun onAvailabilityChanged( + dataType: DeltaDataType<*, *>, + availability: Availability, + ) = Unit + + override fun onDataReceived(data: DataPointContainer) { + if (!shouldAggregate) { + return + } + for (point in data.getData(DataType.HEART_RATE_BPM)) { + record(point.value) + } + } + + override fun onRegistrationFailed(throwable: Throwable) { + registered = false + } + } + + fun noteActiveSession( + nextSessionId: String, + shouldAggregate: Boolean, + executionContext: Map = 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 (registered || sessionId.isNullOrBlank()) { + return + } + appContext = context.applicationContext + try { + HealthServices.getClient(context) + .measureClient + .registerMeasureCallback(DataType.HEART_RATE_BPM, callback) + registered = true + } catch (_: RuntimeException) { + registered = false + } + } + + 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) + } + reset(null) + } + + private fun record(bpm: Double) { + if (bpm <= 0) { + return + } + sampleCount += 1 + sampleSum += bpm + val rounded = bpm.roundToInt() + minBpm = minOf(minBpm ?: rounded, rounded) + maxBpm = maxOf(maxBpm ?: rounded, rounded) + sendSample(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, + ) + onLocalSample(sample) + val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) + Wearable.getCapabilityClient(context) + .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) + .addOnSuccessListener { capability -> + for (node in capability.nodes) { + Wearable.getMessageClient(context) + .sendMessage(node.id, sensorSamplePath, payload) + } + } + } + + 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 -> + for (node in capability.nodes) { + Wearable.getMessageClient(context) + .sendMessage(node.id, sensorSummaryPath, payload) + } + } + } + + private fun unregister(context: Context) { + if (!registered) { + return + } + HealthServices.getClient(context) + .measureClient + .unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback) + registered = false + appContext = null + } + + private fun reset(nextSessionId: String?) { + sessionId = nextSessionId + sampleCount = 0 + sampleSum = 0.0 + minBpm = null + maxBpm = null + sampleSequence = 0 + executionContext = emptyMap() + shouldAggregate = false + } +} diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt new file mode 100644 index 0000000..be15e33 --- /dev/null +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt @@ -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, 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) { + 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, + ) + } +} diff --git a/watch_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/watch_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7b9ffb2 Binary files /dev/null and b/watch_app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/watch_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/watch_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9b35174 Binary files /dev/null and b/watch_app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/watch_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/watch_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2029a58 Binary files /dev/null and b/watch_app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/watch_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/watch_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4cdabd6 Binary files /dev/null and b/watch_app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/watch_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/watch_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5a06959 Binary files /dev/null and b/watch_app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/watch_app/android/app/src/main/res/drawable/ic_ongoing_gt.xml b/watch_app/android/app/src/main/res/drawable/ic_ongoing_gt.xml new file mode 100644 index 0000000..ab8eb7c --- /dev/null +++ b/watch_app/android/app/src/main/res/drawable/ic_ongoing_gt.xml @@ -0,0 +1,9 @@ + + + diff --git a/watch_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/watch_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6af6c5e --- /dev/null +++ b/watch_app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/watch_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/watch_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5068f89 Binary files /dev/null and b/watch_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/watch_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/watch_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..eef996f Binary files /dev/null and b/watch_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/watch_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/watch_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6b31ac0 Binary files /dev/null and b/watch_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/watch_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/watch_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..cf54e80 Binary files /dev/null and b/watch_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/watch_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/watch_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..592fa33 Binary files /dev/null and b/watch_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/watch_app/android/app/src/main/res/values-round/styles.xml b/watch_app/android/app/src/main/res/values-round/styles.xml index 2ed69d5..6c30807 100644 --- a/watch_app/android/app/src/main/res/values-round/styles.xml +++ b/watch_app/android/app/src/main/res/values-round/styles.xml @@ -2,10 +2,12 @@ diff --git a/watch_app/android/app/src/main/res/values/colors.xml b/watch_app/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..08e7f3f --- /dev/null +++ b/watch_app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #080A12 + diff --git a/watch_app/android/app/src/main/res/values/styles.xml b/watch_app/android/app/src/main/res/values/styles.xml index 15c53e4..dd24189 100644 --- a/watch_app/android/app/src/main/res/values/styles.xml +++ b/watch_app/android/app/src/main/res/values/styles.xml @@ -1,9 +1,11 @@ diff --git a/watch_app/android/build.gradle.kts b/watch_app/android/build.gradle.kts index c410017..892928f 100644 --- a/watch_app/android/build.gradle.kts +++ b/watch_app/android/build.gradle.kts @@ -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 { diff --git a/watch_app/android/gradle.properties b/watch_app/android/gradle.properties new file mode 100644 index 0000000..b70d9fe --- /dev/null +++ b/watch_app/android/gradle.properties @@ -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 diff --git a/watch_app/assets/fonts/Anton-Regular.ttf b/watch_app/assets/fonts/Anton-Regular.ttf new file mode 100644 index 0000000..4d65707 Binary files /dev/null and b/watch_app/assets/fonts/Anton-Regular.ttf differ diff --git a/watch_app/assets/fonts/Archivo-Variable.ttf b/watch_app/assets/fonts/Archivo-Variable.ttf new file mode 100644 index 0000000..cc64253 Binary files /dev/null and b/watch_app/assets/fonts/Archivo-Variable.ttf differ diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index 011b4ea..b2ccb57 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -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 { @@ -59,6 +104,7 @@ final class WatchSessionViewModel extends ValueNotifier { _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 { Timer? _waitingTimer; Timer? _commandTimeoutTimer; + Timer? _scoreWaitingTimer; + Timer? _scoreCommandTimeoutTimer; Timer? _freshnessTimer; + Timer? _commandFailureClearTimer; WatchCommandEnvelope? _pendingCommand; + final _pendingScoreCommandIds = {}; + double? _optimisticManualScoreValue; DateTime? _lastProjectionReceivedAt; var _commandCounter = 0; + var _commandFailureSerial = 0; Future refresh() async { value = value.copyWith(connectionLost: false); @@ -119,18 +171,30 @@ final class WatchSessionViewModel extends ValueNotifier { 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 incrementScore() { + return _sendScoreCommand(WatchCommandType.incrementScore, 1); + } + + Future 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 { } Future _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 { value = value.copyWith( commandPending: true, waitingForPhone: false, + timerTogglePending: _isTimerToggleCommand(type), connectionLost: false, ); _waitingTimer?.cancel(); @@ -165,6 +232,7 @@ final class WatchSessionViewModel extends ValueNotifier { value = value.copyWith( commandPending: false, waitingForPhone: false, + timerTogglePending: false, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); @@ -177,25 +245,114 @@ final class WatchSessionViewModel extends ValueNotifier { value = value.copyWith( commandPending: false, waitingForPhone: false, + timerTogglePending: false, connectionLost: true, ); unawaited(HapticFeedback.heavyImpact()); } } + Future _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 { ); 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 { _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 { current.phase != WatchSessionPhase.restPaused; if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) { unawaited(HapticFeedback.mediumImpact()); - unawaited(Future.delayed(const Duration(milliseconds: 120), () { - return HapticFeedback.mediumImpact(); - })); + unawaited( + Future.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: '', diff --git a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart index a2b924b..5f71810 100644 --- a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart +++ b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart @@ -30,6 +30,8 @@ final class WatchCommandAckEvent { abstract interface class NativeWatchBridgeClient { Stream get projections; + Stream get sensorSamples; + Stream get acks; Stream 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 get sensorSamples { + return _sensorSampleChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) { + return WatchSensorSample.fromJson(_stringObjectMap(event)); + }); + } + @override Stream 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(Object? value, List values, T fallback) { diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index 8f865f2..44f5ae2 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -17,6 +17,11 @@ final class WatchSessionScreen extends StatefulWidget { final class _WatchSessionScreenState extends State { late final PageController _pageController; Timer? _ticker; + Timer? _failureNoticeTimer; + String? _failureNoticeMessage; + var _lastFailureNoticeSerial = 0; + var _returnToSessionAfterSecondarySuccess = false; + var _secondaryActionStartFailureSerial = 0; @override void initState() { @@ -32,6 +37,7 @@ final class _WatchSessionScreenState extends State { @override void dispose() { _ticker?.cancel(); + _failureNoticeTimer?.cancel(); _pageController.dispose(); super.dispose(); } @@ -41,40 +47,82 @@ final class _WatchSessionScreenState extends State { return ValueListenableBuilder( valueListenable: widget.viewModel, builder: (context, state, _) { + _syncFailureNotice(state); + _syncSecondaryNavigation(state); final projection = state.projection; if (projection.phase == WatchSessionPhase.noActiveSession) { + final phoneReachable = + projection.phoneReachable && !state.connectionLost; return _RoundScaffold( + notice: _failureNoticeMessage, child: _NoSessionView( - projection: projection, - pending: state.commandPending, - onRefresh: widget.viewModel.refresh, + state: state, + phoneReachable: phoneReachable, + onPrimaryAction: widget.viewModel.sendPrimaryAction, ), ); } - return PageView( - controller: _pageController, - children: [ - _RoundScaffold( - child: _SessionMainView( - state: state, - onPrimary: widget.viewModel.sendPrimaryAction, - onRetry: widget.viewModel.refresh, - onActions: _showActions, - ), + final pages = [ + _RoundScaffold( + key: const ValueKey('watch-session-page'), + notice: _failureNoticeMessage, + child: _SessionMainView( + state: state, + onActions: _showActions, + onStats: state.hasLiveSensors ? _showStats : null, + onTogglePause: widget.viewModel.sendPrimaryAction, + onIncrementScore: widget.viewModel.incrementScore, + onDecrementScore: widget.viewModel.decrementScore, ), - _RoundScaffold( - child: _ActionsView( - state: state, - onAction: _handleSecondaryAction, - onSession: _showSession, - ), + ), + _RoundScaffold( + key: const ValueKey('watch-actions-page'), + notice: _failureNoticeMessage, + child: _ActionsView( + state: state, + onAction: _handleSecondaryAction, + onSession: _showSession, ), - ], - ); + ), + ]; + if (state.hasLiveSensors) { + pages.add( + _RoundScaffold( + key: const ValueKey('watch-stats-page'), + notice: _failureNoticeMessage, + child: _StatsView(state: state, onSession: _showSession), + ), + ); + } + return PageView(controller: _pageController, children: pages); }, ); } + void _syncFailureNotice(WatchSessionUiState state) { + if (state.commandFailureSerial == _lastFailureNoticeSerial || + state.commandFailureMessage == null) { + return; + } + _lastFailureNoticeSerial = state.commandFailureSerial; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + _failureNoticeTimer?.cancel(); + setState(() { + _failureNoticeMessage = state.commandFailureMessage; + }); + _failureNoticeTimer = Timer(const Duration(milliseconds: 1800), () { + if (mounted) { + setState(() { + _failureNoticeMessage = null; + }); + } + }); + }); + } + void _showActions() { _pageController.animateToPage( 1, @@ -83,6 +131,14 @@ final class _WatchSessionScreenState extends State { ); } + void _showStats() { + _pageController.animateToPage( + 2, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + } + void _showSession() { _pageController.animateToPage( 0, @@ -105,10 +161,41 @@ final class _WatchSessionScreenState extends State { ), _ => true, }; - if (confirmed && mounted) { + if (!confirmed || !mounted) { + return; + } + if (action != WatchSecondaryAction.skipCurrentRest) { unawaited(widget.viewModel.sendSecondaryAction(action)); _showSession(); + return; } + if (mounted) { + _returnToSessionAfterSecondarySuccess = true; + _secondaryActionStartFailureSerial = + widget.viewModel.value.commandFailureSerial; + await widget.viewModel.sendSecondaryAction(action); + if (mounted) { + _syncSecondaryNavigation(widget.viewModel.value); + } + } + } + + void _syncSecondaryNavigation(WatchSessionUiState state) { + if (!_returnToSessionAfterSecondarySuccess || state.commandPending) { + return; + } + final failed = + state.connectionLost || + state.commandFailureSerial != _secondaryActionStartFailureSerial; + _returnToSessionAfterSecondarySuccess = false; + if (failed) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _showSession(); + } + }); } Future _confirm({ @@ -119,19 +206,10 @@ final class _WatchSessionScreenState extends State { final result = await showDialog( context: context, builder: (context) { - return AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(confirmLabel), - ), - ], + return _ConfirmSheet( + title: title, + message: message, + confirmLabel: confirmLabel, ); }, ); @@ -140,61 +218,150 @@ final class _WatchSessionScreenState extends State { } final class _RoundScaffold extends StatelessWidget { - const _RoundScaffold({required this.child}); + const _RoundScaffold({required this.child, this.notice, super.key}); final Widget child; + final String? notice; @override Widget build(BuildContext context) { return Scaffold( - body: SafeArea( - minimum: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 210, maxHeight: 210), - child: child, - ), + body: LayoutBuilder( + builder: (context, constraints) { + final diameter = constraints.biggest.shortestSide; + final contentSize = diameter < 210 ? diameter : 210.0; + return Center( + child: SizedBox.square( + dimension: contentSize, + child: Padding( + padding: EdgeInsets.all(contentSize * 0.09), + child: Stack( + children: [ + Positioned.fill(child: child), + if (notice case final message?) + Positioned( + left: 8, + right: 8, + bottom: 0, + child: _FailureNotice(message), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +final class _FailureNotice extends StatelessWidget { + const _FailureNotice(this.message); + + final String message; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF141824), + border: Border.all(color: const Color(0xFFD72638)), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + child: Text( + message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), ), ), ); } } +final class _GtMark extends StatelessWidget { + const _GtMark(); + + @override + Widget build(BuildContext context) { + const size = 52.0; + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF141824), + border: Border.all(color: const Color(0xFFC9A24A), width: 2), + borderRadius: BorderRadius.circular(6), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Transform.rotate( + angle: -0.48, + child: Container( + width: size * 0.82, + height: size * 0.18, + color: const Color(0xFFD72638), + ), + ), + Text( + 'GT', + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontSize: size * 0.46, + color: const Color(0xFFC9A24A), + height: 1, + ), + ), + ], + ), + ); + } +} + final class _NoSessionView extends StatelessWidget { const _NoSessionView({ - required this.projection, - required this.pending, - required this.onRefresh, + required this.state, + required this.phoneReachable, + required this.onPrimaryAction, }); - final WatchSessionProjection projection; - final bool pending; - final VoidCallback onRefresh; + final WatchSessionUiState state; + final bool phoneReachable; + final VoidCallback onPrimaryAction; @override Widget build(BuildContext context) { - final phoneReachable = projection.phoneReachable; + final canSend = phoneReachable && state.actionsEnabled; + final primaryAction = state.projection.primaryAction; + final showsAction = + primaryAction != WatchPrimaryAction.none || state.commandPending; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + const _GtMark(), + const SizedBox(height: 14), Text( phoneReachable ? 'Aucune séance en cours' : 'Téléphone indisponible', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 10), - Text( - phoneReachable - ? 'Lance une séance sur le téléphone.' - : 'Rouvre GameTime sur le téléphone.', + maxLines: 2, + overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), - const SizedBox(height: 16), - FilledButton( - onPressed: pending ? null : onRefresh, - child: Text(pending ? 'Envoi...' : 'Actualiser'), - ), + if (showsAction) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: canSend ? onPrimaryAction : null, + child: _ActionButtonLabel(_primaryLabel(state)), + ), + ), + ], ], ); } @@ -203,186 +370,761 @@ final class _NoSessionView extends StatelessWidget { final class _SessionMainView extends StatelessWidget { const _SessionMainView({ required this.state, - required this.onPrimary, - required this.onRetry, required this.onActions, + required this.onStats, + required this.onTogglePause, + required this.onIncrementScore, + required this.onDecrementScore, }); final WatchSessionUiState state; - final VoidCallback onPrimary; - final VoidCallback onRetry; final VoidCallback onActions; + final VoidCallback? onStats; + final VoidCallback onTogglePause; + final VoidCallback onIncrementScore; + final VoidCallback onDecrementScore; @override Widget build(BuildContext context) { final projection = state.projection; - final isRest = projection.phase == WatchSessionPhase.restRunning || + final isRest = + projection.phase == WatchSessionPhase.restRunning || projection.phase == WatchSessionPhase.restPaused; - if (state.connectionLost || !projection.phoneReachable) { - return _ConnectionLostView(onRetry: onRetry); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final connectionLost = state.connectionLost || !projection.phoneReachable; + final hasActions = projection.secondaryActions.isNotEmpty; + final timer = projection.dominantTimer; + final showsTimer = timer != null; + final canToggleTimer = + showsTimer && + !connectionLost && + !state.commandPending && + _timerButtonCommandMatches( + timer: timer, + primaryAction: projection.primaryAction, + ); + return Stack( children: [ - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: onActions, - style: TextButton.styleFrom( + Positioned( + top: 0, + left: 58, + right: 58, + child: Container( + height: 2, + decoration: BoxDecoration( + color: const Color(0xFFD72638), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Positioned.fill( + top: 18, + bottom: 18, + child: Opacity( + opacity: connectionLost ? 0.48 : 1, + child: isRest + ? _RestContent( + state: state, + onTogglePause: canToggleTimer ? onTogglePause : null, + ) + : _ActiveContent( + state: state, + onTogglePause: canToggleTimer ? onTogglePause : null, + onIncrementScore: onIncrementScore, + onDecrementScore: onDecrementScore, + ), + ), + ), + if (hasActions) + Positioned( + top: -7, + right: -8, + child: IconButton( + onPressed: onActions, + tooltip: 'Actions', visualDensity: VisualDensity.compact, - minimumSize: const Size(56, 26), - padding: const EdgeInsets.symmetric(horizontal: 8), - ), - child: const Text('Actions'), - ), - ), - Expanded( - child: isRest - ? _RestContent(projection: projection) - : _ActiveContent(projection: projection), - ), - if (state.staleProjection) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - 'Dernier état reçu', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + iconSize: 18, + color: const Color(0xFFC9A24A), + icon: const Icon(Icons.more_horiz), ), ), - FilledButton( - onPressed: state.actionsEnabled ? onPrimary : null, - child: Text(_primaryLabel(state)), + if (state.hasLiveSensors) + Positioned( + top: -7, + left: -8, + child: IconButton( + onPressed: onStats, + tooltip: 'Stats', + visualDensity: VisualDensity.compact, + iconSize: 17, + color: const Color(0xFFC9A24A), + icon: const Icon(Icons.monitor_heart_outlined), + ), + ), + if (connectionLost) + Positioned( + top: 2, + left: 0, + right: hasActions ? 30 : 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.link_off, size: 12, color: Color(0xFFFF4D5E)), + const SizedBox(width: 3), + Flexible( + child: Text( + 'Connexion au téléphone perdue', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 9, + ), + ), + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Text( + state.commandPending + ? _primaryLabel(state) + : state.staleProjection + ? 'Dernier état reçu' + : _exerciseProgress(projection), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontSize: 10), + ), ), ], ); } } -final class _ActiveContent extends StatelessWidget { - const _ActiveContent({required this.projection}); +final class _DominantValue extends StatelessWidget { + const _DominantValue(this.value); - final WatchSessionProjection projection; + final String value; @override Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, + maxLines: 1, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.displayLarge, + ), + ), + ); + } +} + +final class _SmallLabel extends StatelessWidget { + const _SmallLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall, + ); + } +} + +final class _ExerciseName extends StatelessWidget { + const _ExerciseName(this.name); + + final String name; + + @override + Widget build(BuildContext context) { + return Text( + name.isEmpty ? 'Séance en cours' : name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ); + } +} + +final class _StepNameBand extends StatelessWidget { + const _StepNameBand(this.name); + + final String? name; + + @override + Widget build(BuildContext context) { + final value = name?.trim(); + if (value == null || value.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 5, bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF141824), + border: Border.all(color: const Color(0xFF414754)), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 10), + ), + ); + } +} + +final class _StatusLine extends StatelessWidget { + const _StatusLine(this.text); + + final String? text; + + @override + Widget build(BuildContext context) { + final value = text; + if (value == null || value.isEmpty) { + return const SizedBox(height: 13); + } + return SizedBox( + height: 13, + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} + +final class _ScaledContent extends StatelessWidget { + const _ScaledContent({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: child), + ), + ); + }, + ); + } +} + +final class _ActiveContent extends StatelessWidget { + const _ActiveContent({ + required this.state, + required this.onTogglePause, + required this.onIncrementScore, + required this.onDecrementScore, + }); + + final WatchSessionUiState state; + final VoidCallback? onTogglePause; + final VoidCallback onIncrementScore; + final VoidCallback onDecrementScore; + + @override + Widget build(BuildContext context) { + final projection = state.projection; + if (projection.hasManualScore) { + return _ManualScoreContent( + state: state, + onTogglePause: onTogglePause, + onIncrement: onIncrementScore, + onDecrement: onDecrementScore, + ); + } final timer = projection.dominantTimer; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'SÉRIE ${projection.seriesIndex} / ${projection.seriesTotal}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(height: 3), - Text( - projection.exerciseName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, - ), - if (_contextLine(projection) case final contextLine?) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - contextLine, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + final dominantValue = timer == null + ? _seriesValue(projection) + : _timerText(timer); + final dominantLabel = timer == null ? 'SÉRIE' : timer.label; + return _ScaledContent( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ExerciseName(projection.exerciseName), + _StepNameBand(projection.stepName), + const SizedBox(height: 2), + _SmallLabel(dominantLabel), + const SizedBox(height: 2), + _DominantValue(dominantValue), + if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[ + const SizedBox(height: 1), + _LiveHeartRateLine(heartRate), + ], + if (timer != null) ...[ + const SizedBox(height: 3), + _TimerToggleButton( + runState: timer.runState, + pending: state.timerTogglePending, + onPressed: onTogglePause, + ), + ], + const SizedBox(height: 5), + _StatusLine(projection.statusLabel ?? timer?.label), + if (projection.secondaryTimers.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + projection.secondaryTimers.map(_compactTimerText).join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), ), - ), - const SizedBox(height: 8), - if (timer == null) - Text( - projection.statusLabel ?? '', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ) - else ...[ - Text( - _timerText(timer), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.displayLarge, - ), - Text( - projection.statusLabel ?? timer.label, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), ], - if (projection.secondaryTimers.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 7), + ), + ); + } +} + +final class _ManualScoreContent extends StatelessWidget { + const _ManualScoreContent({ + required this.state, + required this.onTogglePause, + required this.onIncrement, + required this.onDecrement, + }); + + final WatchSessionUiState state; + final VoidCallback? onTogglePause; + final VoidCallback onIncrement; + final VoidCallback onDecrement; + + @override + Widget build(BuildContext context) { + final projection = state.projection; + final score = + state.optimisticManualScoreValue ?? + projection.currentManualScoreValue ?? + 0; + final controlsEnabled = !state.connectionLost && projection.phoneReachable; + final canDecrement = + controlsEnabled && + score > 0 && + (state.scoreCommandPending || projection.canDecrementScore); + final timer = projection.dominantTimer; + final canToggleTimer = + timer != null && + controlsEnabled && + !state.commandPending && + _timerButtonCommandMatches( + timer: timer, + primaryAction: projection.primaryAction, + ); + final target = projection.manualScoreTargetValue; + final targetLabel = projection.manualScoreTargetLabel; + return _ScaledContent( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ExerciseName(projection.exerciseName), + _StepNameBand(projection.stepName), + if (target != null && + targetLabel != null && + targetLabel.isNotEmpty) ...[ + Text( + '$targetLabel : ${_scoreText(target)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 11, + ), + ), + const SizedBox(height: 1), + ], + const _SmallLabel('SCORE'), + const SizedBox(height: 2), + SizedBox( + height: 54, + child: Row( + children: [ + _ScoreButton( + label: '−', + onPressed: canDecrement ? onDecrement : null, + ), + Expanded( + child: Opacity( + opacity: state.scoreCommandPending ? 0.58 : 1, + child: _DominantValue(_scoreText(score)), + ), + ), + _ScoreButton( + label: '+', + onPressed: controlsEnabled ? onIncrement : null, + ), + ], + ), + ), + SizedBox( + height: 6, + child: state.scoreWaitingForPhone + ? const _PendingDot() + : const SizedBox.shrink(), + ), + if (_heartRateLabel(state.sensorSample) case final heartRate?) ...[ + const SizedBox(height: 1), + _LiveHeartRateLine(heartRate), + ], + const SizedBox(height: 5), + if (timer != null) + _CompactTimerLine( + timer: timer, + pending: state.timerTogglePending, + onTogglePause: canToggleTimer ? onTogglePause : null, + ) + else + _StatusLine(projection.statusLabel), + ], + ), + ); + } +} + +final class _CompactTimerLine extends StatelessWidget { + const _CompactTimerLine({ + required this.timer, + required this.pending, + required this.onTogglePause, + }); + + final WatchTimerProjection timer; + final bool pending; + final VoidCallback? onTogglePause; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 28, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( child: Text( - projection.secondaryTimers.map(_compactTimerText).join(' · '), + _compactTimerText(timer), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), + if (onTogglePause != null || pending) ...[ + const SizedBox(width: 4), + _TimerToggleButton( + runState: timer.runState, + pending: pending, + onPressed: onTogglePause, + compact: true, + ), + ], + ], + ), + ); + } +} + +final class _ScoreButton extends StatelessWidget { + const _ScoreButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 48, + child: IconButton( + onPressed: onPressed, + tooltip: label == '+' ? 'Ajouter' : 'Retirer', + visualDensity: VisualDensity.compact, + iconSize: 24, + color: const Color(0xFFC9A24A), + disabledColor: const Color(0xFF414754), + icon: Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: onPressed == null + ? const Color(0xFF414754) + : const Color(0xFFC9A24A), + fontSize: 24, + ), + ), + ), + ); + } +} + +final class _TimerToggleButton extends StatelessWidget { + const _TimerToggleButton({ + required this.runState, + required this.pending, + required this.onPressed, + this.compact = false, + }); + + final WatchTimerRunState runState; + final bool pending; + final VoidCallback? onPressed; + final bool compact; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: compact ? 28 : 48, + child: IconButton( + onPressed: onPressed, + tooltip: _timerButtonTooltip(runState), + visualDensity: VisualDensity.compact, + iconSize: compact ? 16 : 26, + color: const Color(0xFFC9A24A), + disabledColor: const Color(0xFF414754), + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF141824), + side: const BorderSide(color: Color(0xFFD72638)), + ), + icon: pending + ? const _PendingDot(key: ValueKey('timer-toggle-pending-dot')) + : Icon(_timerButtonIcon(runState)), + ), + ); + } +} + +final class _PendingDot extends StatelessWidget { + const _PendingDot({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 5, + height: 5, + decoration: const BoxDecoration( + color: Color(0xFFC9A24A), + shape: BoxShape.circle, + ), + ), + ); + } +} + +final class _LiveHeartRateLine extends StatelessWidget { + const _LiveHeartRateLine(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.favorite, size: 11, color: Color(0xFFD72638)), + const SizedBox(width: 3), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 10, + ), + ), ], ); } } final class _RestContent extends StatelessWidget { - const _RestContent({required this.projection}); + const _RestContent({required this.state, required this.onTogglePause}); - final WatchSessionProjection projection; + final WatchSessionUiState state; + final VoidCallback? onTogglePause; @override Widget build(BuildContext context) { + final projection = state.projection; final timer = projection.dominantTimer; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'REPOS', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall, + const _SmallLabel('REPOS'), + const SizedBox(height: 2), + _DominantValue(timer == null ? '--:--' : _timerText(timer)), + if (timer != null) ...[ + const SizedBox(height: 3), + _TimerToggleButton( + runState: timer.runState, + pending: state.timerTogglePending, + onPressed: onTogglePause, + ), + ], + const SizedBox(height: 6), + if (projection.nextExerciseName case final next?) ...[ + Text( + 'Ensuite : $next', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 5), + ] else ...[ + _ExerciseName(projection.exerciseName), + const SizedBox(height: 5), + ], + _StatusLine( + projection.statusLabel ?? + 'Série ${projection.seriesIndex}/${projection.seriesTotal}', ), - const SizedBox(height: 3), - Text( - 'Après série ${projection.seriesIndex} / ${projection.seriesTotal}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + ], + ); + } +} + +final class _StatsView extends StatelessWidget { + const _StatsView({required this.state, required this.onSession}); + + final WatchSessionUiState state; + final VoidCallback onSession; + + @override + Widget build(BuildContext context) { + final sample = state.sensorSample; + final metrics = [ + if (_heartRateLabel(sample) case final value?) + _StatsMetric(icon: Icons.favorite, label: 'FC', value: value), + if (_distanceLabel(sample) case final value?) + _StatsMetric( + icon: Icons.directions_run, + label: 'Distance', + value: value, ), - const SizedBox(height: 10), - Text( - timer == null ? '--:--' : _timerText(timer), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.displayLarge, + if (_caloriesLabel(sample) case final value?) + _StatsMetric( + icon: Icons.local_fire_department, + label: 'Calories', + value: value, ), - Text( - projection.statusLabel ?? timer?.label ?? '', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + ]; + return Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 36, + child: Text( + 'Stats', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), ), - if (projection.nextExerciseName case final next?) - Padding( - padding: const EdgeInsets.only(top: 9), - child: Column( - children: [ - Text( - 'Exercice suivant', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - Text( - next, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), + Positioned( + top: -8, + right: -8, + child: IconButton( + onPressed: onSession, + tooltip: 'Séance', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.chevron_left), + ), + ), + Positioned.fill( + top: 30, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < metrics.length; index += 1) ...[ + metrics[index], + if (index < metrics.length - 1) const SizedBox(height: 6), ], + ], + ), + ), + ], + ); + } +} + +final class _StatsMetric extends StatelessWidget { + const _StatsMetric({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFF141824), + border: Border.all(color: const Color(0xFF414754)), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Icon(icon, size: 14, color: const Color(0xFFC9A24A)), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, ), ), - ], + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 14, + color: const Color(0xFFC9A24A), + ), + ), + ], + ), ); } } @@ -401,82 +1143,166 @@ final class _ActionsView extends StatelessWidget { @override Widget build(BuildContext context) { final actions = state.projection.secondaryActions; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final connectionLost = + state.connectionLost || !state.projection.phoneReachable; + if (actions.isEmpty || connectionLost) { + return _EmptyActions(connectionLost: connectionLost); + } + final visibleActions = actions.take(5).toList(); + final buttonHeight = visibleActions.length > 3 ? 24.0 : 30.0; + final buttonGap = visibleActions.length > 3 ? 4.0 : 6.0; + return Stack( children: [ - Row( - children: [ - Expanded( - child: Text( - 'Actions', - style: Theme.of(context).textTheme.titleSmall, - ), - ), - IconButton( - onPressed: onSession, - tooltip: 'Séance', - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.chevron_left), - ), - ], + Positioned( + top: 0, + left: 0, + right: 36, + child: Text( + 'Actions', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), ), - Expanded( - child: actions.isEmpty || !state.projection.phoneReachable || - state.connectionLost - ? Center( + Positioned( + top: -8, + right: -8, + child: IconButton( + onPressed: onSession, + tooltip: 'Séance', + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.chevron_left), + ), + ), + Positioned.fill( + top: 34, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for ( + var index = 0; + index < visibleActions.length; + index += 1 + ) ...[ + SizedBox( + height: buttonHeight, + width: double.infinity, + child: OutlinedButton( + onPressed: state.actionsEnabled + ? () => onAction(visibleActions[index]) + : null, + style: OutlinedButton.styleFrom( + minimumSize: Size.fromHeight(buttonHeight), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: _ActionButtonLabel( + _secondaryLabel(visibleActions[index]), + ), + ), + ), + if (index < visibleActions.length - 1) + SizedBox(height: buttonGap), + ], + if (actions.length > visibleActions.length) + Padding( + padding: const EdgeInsets.only(top: 5), child: Text( - state.projection.phoneReachable && !state.connectionLost - ? 'Aucune action' - : 'Connexion perdue', + '+${actions.length - visibleActions.length}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), - ) - : ListView.separated( - padding: const EdgeInsets.only(top: 4, bottom: 12), - itemBuilder: (context, index) { - final action = actions[index]; - return OutlinedButton( - onPressed: state.actionsEnabled - ? () => onAction(action) - : null, - child: Text(_secondaryLabel(action)), - ); - }, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemCount: actions.length, ), + ], + ), ), ], ); } } -final class _ConnectionLostView extends StatelessWidget { - const _ConnectionLostView({required this.onRetry}); +final class _ActionButtonLabel extends StatelessWidget { + const _ActionButtonLabel(this.text); - final VoidCallback onRetry; + final String text; @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Connexion perdue', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, + return FittedBox( + fit: BoxFit.scaleDown, + child: Text(text, maxLines: 1, textAlign: TextAlign.center), + ); + } +} + +final class _EmptyActions extends StatelessWidget { + const _EmptyActions({required this.connectionLost}); + + final bool connectionLost; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + connectionLost ? 'Connexion perdue' : 'Aucune action', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} + +final class _ConfirmSheet extends StatelessWidget { + const _ConfirmSheet({ + required this.title, + required this.message, + required this.confirmLabel, + }); + + final String title; + final String message; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + return Dialog.fullscreen( + backgroundColor: const Color(0xFF080A12), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: _ActionButtonLabel(confirmLabel), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const _ActionButtonLabel('Annuler'), + ), + ], ), - const SizedBox(height: 8), - Text( - 'Dernier état reçu il y a quelques secondes', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 16), - FilledButton(onPressed: onRetry, child: const Text('Réessayer')), - ], + ), ); } } @@ -505,6 +1331,37 @@ String _secondaryLabel(WatchSecondaryAction action) { }; } +bool _timerButtonCommandMatches({ + required WatchTimerProjection timer, + required WatchPrimaryAction primaryAction, +}) { + return switch (timer.runState) { + WatchTimerRunState.stopped => + primaryAction == WatchPrimaryAction.startCurrentExercise || + primaryAction == WatchPrimaryAction.startPreparedTimedStep, + WatchTimerRunState.running => + primaryAction == WatchPrimaryAction.pauseSession, + WatchTimerRunState.paused => + primaryAction == WatchPrimaryAction.resumeSession, + }; +} + +String _timerButtonTooltip(WatchTimerRunState runState) { + return switch (runState) { + WatchTimerRunState.stopped => 'Démarrer', + WatchTimerRunState.running => 'Pause', + WatchTimerRunState.paused => 'Reprendre', + }; +} + +IconData _timerButtonIcon(WatchTimerRunState runState) { + return switch (runState) { + WatchTimerRunState.stopped => Icons.play_arrow, + WatchTimerRunState.running => Icons.pause, + WatchTimerRunState.paused => Icons.play_arrow, + }; +} + String? _contextLine(WatchSessionProjection projection) { final parts = [ if (projection.passageIndex != null && projection.passageTotal != null) @@ -518,6 +1375,34 @@ String? _contextLine(WatchSessionProjection projection) { return parts.join(' · '); } +String _seriesValue(WatchSessionProjection projection) { + if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) { + return '--'; + } + return '${projection.seriesIndex}/${projection.seriesTotal}'; +} + +String _scoreText(double value) { + if (value == value.roundToDouble()) { + return value.toInt().toString(); + } + return value.toStringAsFixed(1); +} + +String _exerciseProgress(WatchSessionProjection projection) { + if (projection.passageIndex != null && projection.passageTotal != null) { + return '${projection.passageIndex}/${projection.passageTotal}'; + } + final contextLine = _contextLine(projection); + if (contextLine != null) { + return contextLine; + } + if (projection.seriesIndex > 0 && projection.seriesTotal > 0) { + return 'Série ${projection.seriesIndex}/${projection.seriesTotal}'; + } + return projection.statusLabel ?? ''; +} + String _timerText(WatchTimerProjection timer) { final duration = _displayDuration(timer); final totalSeconds = duration.inSeconds; @@ -530,6 +1415,33 @@ String _compactTimerText(WatchTimerProjection timer) { return '${timer.label} ${_timerText(timer)}'; } +String? _heartRateLabel(WatchSensorSample? sample) { + final bpm = sample?.heartRateBpm; + if (bpm == null || bpm <= 0) { + return null; + } + return '$bpm bpm'; +} + +String? _distanceLabel(WatchSensorSample? sample) { + final meters = sample?.distanceMeters; + if (meters == null || meters < 0) { + return null; + } + if (meters >= 1000) { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + return '${meters.round()} m'; +} + +String? _caloriesLabel(WatchSensorSample? sample) { + final calories = sample?.caloriesKcal; + if (calories == null || calories < 0) { + return null; + } + return '${calories.round()} kcal'; +} + Duration _displayDuration(WatchTimerProjection timer) { final elapsedMs = _interpolatedElapsedMs(timer); final displayMs = switch (timer.displayMode) { @@ -544,6 +1456,10 @@ int _interpolatedElapsedMs(WatchTimerProjection timer) { timer.startedAtEpochMs == null) { return timer.accumulatedMs; } - final nowMs = DateTime.now().millisecondsSinceEpoch; - return timer.accumulatedMs + nowMs - timer.startedAtEpochMs!; + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + final elapsedSinceReference = (nowMs - timer.referenceEpochMs).clamp( + 0, + 1 << 31, + ); + return timer.accumulatedMs + elapsedSinceReference.toInt(); } diff --git a/watch_app/lib/presentation/watch_theme.dart b/watch_app/lib/presentation/watch_theme.dart index e1154d6..6627f02 100644 --- a/watch_app/lib/presentation/watch_theme.dart +++ b/watch_app/lib/presentation/watch_theme.dart @@ -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, ), ), ), diff --git a/watch_app/pubspec.lock b/watch_app/pubspec.lock index 33d9400..a9dccbc 100644 --- a/watch_app/pubspec.lock +++ b/watch_app/pubspec.lock @@ -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" diff --git a/watch_app/pubspec.yaml b/watch_app/pubspec.yaml index 38b435c..b4031b3 100644 --- a/watch_app/pubspec.yaml +++ b/watch_app/pubspec.yaml @@ -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 diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart new file mode 100644 index 0000000..24b7a04 --- /dev/null +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -0,0 +1,689 @@ +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 session and telemetry on stats page', ( + 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('142 bpm'), findsOneWidget); + 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('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.broadcast(); + final _sensorSampleController = + StreamController.broadcast(); + final _ackController = StreamController.broadcast(); + final _connectionController = + StreamController.broadcast(); + + var resyncRequests = 0; + var capabilityRefreshRequests = 0; + final sentCommands = []; + + @override + Stream get projections => + _projectionController.stream; + + @override + Stream get sensorSamples => _sensorSampleController.stream; + + @override + Stream get acks => _ackController.stream; + + @override + Stream 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 requestCapabilityRefresh() async { + capabilityRefreshRequests += 1; + } + + @override + Future requestResync() async { + resyncRequests += 1; + } + + @override + Future 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, + ); +}