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