feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ -3,6 +3,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
@ -10,6 +12,9 @@
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.capabilities"
|
||||
android:resource="@array/android_wear_capabilities" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -41,6 +46,14 @@
|
||||
android:name=".watch.WatchCompanionForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||
<service
|
||||
android:name=".session.SessionStatusForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="workout-session-status" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".watch.PhoneWatchBridgeListenerService"
|
||||
android:exported="true">
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
package com.gametime.app.session
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
object SessionNotificationPlugin {
|
||||
private const val METHOD_CHANNEL = "gametime.session_notification/methods"
|
||||
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 9210
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
private var notificationPermissionRequested = false
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, activity: Activity) {
|
||||
appContext = activity.applicationContext
|
||||
activityRef = WeakReference(activity)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||
.setMethodCallHandler(::handleMethodCall)
|
||||
}
|
||||
|
||||
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val context = appContext
|
||||
if (context == null) {
|
||||
result.error("session_notification_unavailable", "Application context unavailable.", null)
|
||||
return
|
||||
}
|
||||
when (call.method) {
|
||||
"show" -> show(context, call.arguments, result)
|
||||
"clear" -> {
|
||||
context.stopService(Intent(context, SessionStatusForegroundService::class.java))
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun show(context: Context, arguments: Any?, result: MethodChannel.Result) {
|
||||
val map = arguments as? Map<*, *>
|
||||
if (map == null) {
|
||||
result.error("invalid_notification", "Notification payload must be a map.", null)
|
||||
return
|
||||
}
|
||||
requestPostNotificationsPermissionIfNeeded()
|
||||
val intent = Intent(context, SessionStatusForegroundService::class.java).apply {
|
||||
action = SessionStatusForegroundService.ACTION_SHOW
|
||||
putExtra(SessionStatusForegroundService.EXTRA_TITLE, map["title"] as? String ?: "GameTime")
|
||||
putExtra(
|
||||
SessionStatusForegroundService.EXTRA_PRIMARY_LINE,
|
||||
map["primaryLine"] as? String ?: "Séance en cours",
|
||||
)
|
||||
putExtra(
|
||||
SessionStatusForegroundService.EXTRA_SECONDARY_LINE,
|
||||
map["secondaryLine"] as? String,
|
||||
)
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun requestPostNotificationsPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
notificationPermissionRequested
|
||||
) {
|
||||
return
|
||||
}
|
||||
val activity = activityRef?.get() ?: return
|
||||
if (activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
notificationPermissionRequested = true
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -9,16 +9,23 @@ 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))
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
|
||||
if (capabilityInfo.name != WatchBridgePlugin.WATCH_CAPABILITY) {
|
||||
|
||||
@ -2,6 +2,8 @@ package com.gametime.app.watch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.gms.wearable.CapabilityClient
|
||||
import com.google.android.gms.wearable.PutDataMapRequest
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
@ -16,9 +18,13 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
object WatchBridgePlugin {
|
||||
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
|
||||
private const val COMMAND_CHANNEL = "gametime.watch_bridge/commands"
|
||||
private const val SENSOR_SUMMARY_CHANNEL = "gametime.watch_bridge/sensor_summaries"
|
||||
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
|
||||
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
|
||||
|
||||
const val COMMAND_PATH = "/gametime/watch/command"
|
||||
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
|
||||
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
|
||||
const val ACK_PATH = "/gametime/phone/ack"
|
||||
const val STATE_PATH = "/gametime/phone/projection"
|
||||
const val WATCH_CAPABILITY = "gametime_watch_companion"
|
||||
@ -26,7 +32,10 @@ object WatchBridgePlugin {
|
||||
private val pendingCommandNodes = ConcurrentHashMap<String, String>()
|
||||
private var appContext: Context? = null
|
||||
private var commandSink: EventChannel.EventSink? = null
|
||||
private var sensorSummarySink: EventChannel.EventSink? = null
|
||||
private var sensorSampleSink: EventChannel.EventSink? = null
|
||||
private var connectionSink: EventChannel.EventSink? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, context: Context) {
|
||||
appContext = context.applicationContext
|
||||
@ -44,6 +53,30 @@ object WatchBridgePlugin {
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SUMMARY_CHANNEL)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
sensorSummarySink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sensorSummarySink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
sensorSampleSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sensorSampleSink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
@ -65,18 +98,39 @@ object WatchBridgePlugin {
|
||||
if (commandId != null) {
|
||||
pendingCommandNodes[commandId] = sourceNodeId
|
||||
}
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitSensorSummary(payload: Map<String, Any?>): Boolean {
|
||||
val sink = sensorSummarySink ?: return false
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
|
||||
val sink = sensorSampleSink ?: return false
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
|
||||
connectionSink?.success(
|
||||
val sink = connectionSink ?: return
|
||||
mainHandler.post {
|
||||
sink.success(
|
||||
mapOf(
|
||||
"isReachable" to isReachable,
|
||||
"requestsResync" to requestsResync,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val context = appContext
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import '../infrastructure/local/local.dart';
|
||||
import '../infrastructure/remote/remote.dart';
|
||||
import '../infrastructure/security/security.dart';
|
||||
import '../infrastructure/session_notification/session_notification.dart';
|
||||
import '../infrastructure/watch_bridge/watch_bridge.dart';
|
||||
import 'application.dart';
|
||||
|
||||
@ -12,6 +13,7 @@ abstract interface class AppDependencies {
|
||||
WorkoutTemplateUseCases get workoutTemplateUseCases;
|
||||
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
|
||||
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
|
||||
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
|
||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
ProgressionStatsUseCase get progressionStatsUseCase;
|
||||
@ -32,9 +34,11 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.workoutTemplateUseCases,
|
||||
required this.activeWorkoutSessionUseCases,
|
||||
required this.activeExerciseStepUseCases,
|
||||
required this.activeWorkoutSensorUseCases,
|
||||
required this.watchCompanionProjectionUseCases,
|
||||
required this.watchCompanionCommandHandler,
|
||||
required this.watchWearDataLayerAdapter,
|
||||
required this.sessionNotificationCoordinator,
|
||||
required this.closeWorkoutSessionUseCase,
|
||||
required this.workoutHistoryUseCases,
|
||||
required this.progressionStatsUseCase,
|
||||
@ -61,9 +65,12 @@ final class AppBootstrap implements AppDependencies {
|
||||
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
|
||||
@override
|
||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||
@override
|
||||
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
|
||||
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
|
||||
final WatchCompanionCommandHandler watchCompanionCommandHandler;
|
||||
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
|
||||
final SessionNotificationCoordinator sessionNotificationCoordinator;
|
||||
@override
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
@override
|
||||
@ -122,6 +129,10 @@ final class AppBootstrap implements AppDependencies {
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
activeSessionUseCases: activeWorkoutSessionUseCases,
|
||||
);
|
||||
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
|
||||
clock: clock,
|
||||
);
|
||||
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
|
||||
sessionRepository: activeSessionRepository,
|
||||
@ -135,12 +146,23 @@ final class AppBootstrap implements AppDependencies {
|
||||
stepUseCases: activeExerciseStepUseCases,
|
||||
projectionSource: watchCompanionProjectionUseCases,
|
||||
);
|
||||
final workoutHistoryUseCases = WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: clock,
|
||||
);
|
||||
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
|
||||
nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
|
||||
commandIngress: watchCompanionCommandHandler,
|
||||
projectionSource: watchCompanionProjectionUseCases,
|
||||
workoutHistoryUseCases: workoutHistoryUseCases,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
);
|
||||
final sessionNotificationCoordinator = SessionNotificationCoordinator(
|
||||
projections: watchCompanionProjectionUseCases.projections,
|
||||
gateway: const MethodChannelSessionNotificationGateway(),
|
||||
);
|
||||
await watchWearDataLayerAdapter.start();
|
||||
sessionNotificationCoordinator.start();
|
||||
await SeedStarterContentUseCase(
|
||||
seedStateRepository: starterSeedRepository,
|
||||
contentRepository: starterSeedRepository,
|
||||
@ -198,9 +220,11 @@ final class AppBootstrap implements AppDependencies {
|
||||
),
|
||||
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
|
||||
activeExerciseStepUseCases: activeExerciseStepUseCases,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
|
||||
watchCompanionCommandHandler: watchCompanionCommandHandler,
|
||||
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
|
||||
sessionNotificationCoordinator: sessionNotificationCoordinator,
|
||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeSessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
@ -208,10 +232,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
),
|
||||
workoutHistoryUseCases: WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: clock,
|
||||
),
|
||||
workoutHistoryUseCases: workoutHistoryUseCases,
|
||||
progressionStatsUseCase: ProgressionStatsUseCase(
|
||||
repository: progressionStatsRepository,
|
||||
clock: clock,
|
||||
@ -255,8 +276,10 @@ final class AppBootstrap implements AppDependencies {
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await sessionNotificationCoordinator.dispose();
|
||||
await watchWearDataLayerAdapter.stop();
|
||||
await watchCompanionProjectionUseCases.dispose();
|
||||
await activeWorkoutSensorUseCases.dispose();
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -485,6 +485,7 @@ enum RemoteAuthFailure {
|
||||
invalidCredentials,
|
||||
emailAlreadyUsed,
|
||||
network,
|
||||
server,
|
||||
unknown,
|
||||
}
|
||||
|
||||
@ -857,6 +858,7 @@ abstract interface class ActiveSessionRepository {
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state);
|
||||
Future<void> saveRestState(ActiveRestState restState);
|
||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state);
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state);
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
ActiveExerciseStepProgressState state,
|
||||
);
|
||||
@ -868,6 +870,13 @@ abstract interface class ActiveSessionRepository {
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
});
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
});
|
||||
Future<ActiveRestState?> findRestStateById(String id);
|
||||
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
|
||||
required String sessionId,
|
||||
@ -875,6 +884,12 @@ abstract interface class ActiveSessionRepository {
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
});
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
});
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
@ -899,12 +914,19 @@ abstract interface class ActiveSessionRepository {
|
||||
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
|
||||
String sessionId,
|
||||
);
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(String sessionId);
|
||||
}
|
||||
|
||||
abstract interface class WorkoutHistoryRepository {
|
||||
Future<WorkoutHistory?> findById(String id);
|
||||
Future<List<WorkoutHistory>> listActive();
|
||||
Future<void> save(WorkoutHistory history);
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
});
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result);
|
||||
Future<void> delete(String id, DateTime deletedAt);
|
||||
|
||||
198
lib/application/session_notification_use_cases.dart
Normal file
@ -0,0 +1,198 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
abstract interface class SessionNotificationGateway {
|
||||
Future<void> show(SessionNotificationContent content);
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
final class SessionNotificationContent {
|
||||
const SessionNotificationContent({
|
||||
required this.title,
|
||||
required this.primaryLine,
|
||||
this.secondaryLine,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String primaryLine;
|
||||
final String? secondaryLine;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'primaryLine': primaryLine,
|
||||
'secondaryLine': secondaryLine,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
final class SessionNotificationCoordinator {
|
||||
SessionNotificationCoordinator({
|
||||
required Stream<WatchSessionProjection> projections,
|
||||
required SessionNotificationGateway gateway,
|
||||
Duration tickInterval = const Duration(seconds: 1),
|
||||
}) : _projections = projections,
|
||||
_gateway = gateway,
|
||||
_tickInterval = tickInterval;
|
||||
|
||||
final Stream<WatchSessionProjection> _projections;
|
||||
final SessionNotificationGateway _gateway;
|
||||
final Duration _tickInterval;
|
||||
StreamSubscription<WatchSessionProjection>? _subscription;
|
||||
Timer? _timer;
|
||||
WatchSessionProjection? _latestProjection;
|
||||
|
||||
void start() {
|
||||
if (_subscription != null) {
|
||||
return;
|
||||
}
|
||||
_subscription = _projections.listen(_handleProjection);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
void _handleProjection(WatchSessionProjection projection) {
|
||||
_latestProjection = projection;
|
||||
if (projection.phase == WatchSessionPhase.noActiveSession ||
|
||||
projection.deviceSessionId.isEmpty) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
unawaited(_gateway.clear().catchError((_) {}));
|
||||
return;
|
||||
}
|
||||
_show(projection);
|
||||
if (_timerShouldRun(projection)) {
|
||||
_timer ??= Timer.periodic(_tickInterval, (_) {
|
||||
final latest = _latestProjection;
|
||||
if (latest != null) {
|
||||
_show(latest);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _show(WatchSessionProjection projection) {
|
||||
unawaited(
|
||||
_gateway
|
||||
.show(buildSessionNotificationContent(projection))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SessionNotificationContent buildSessionNotificationContent(
|
||||
WatchSessionProjection projection, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final phase = projection.phase;
|
||||
final paused =
|
||||
phase == WatchSessionPhase.paused ||
|
||||
phase == WatchSessionPhase.restPaused;
|
||||
final timer = projection.dominantTimer;
|
||||
final title =
|
||||
phase == WatchSessionPhase.restRunning ||
|
||||
phase == WatchSessionPhase.restPaused
|
||||
? 'Repos'
|
||||
: projection.exerciseName.isEmpty
|
||||
? 'Séance en cours'
|
||||
: projection.exerciseName;
|
||||
final primary = switch (phase) {
|
||||
WatchSessionPhase.restRunning ||
|
||||
WatchSessionPhase.restPaused => _restLine(projection, now: now),
|
||||
_ when timer != null => _timerText(timer, now: now),
|
||||
_ => _measureLine(projection),
|
||||
};
|
||||
return SessionNotificationContent(
|
||||
title: title,
|
||||
primaryLine: paused ? 'En pause · $primary' : primary,
|
||||
secondaryLine: _secondaryLine(projection),
|
||||
);
|
||||
}
|
||||
|
||||
bool _timerShouldRun(WatchSessionProjection projection) {
|
||||
final timer = projection.dominantTimer;
|
||||
return timer != null && timer.runState == WatchTimerRunState.running;
|
||||
}
|
||||
|
||||
String _restLine(WatchSessionProjection projection, {DateTime? now}) {
|
||||
final timer = projection.dominantTimer;
|
||||
final value = timer == null ? '--:--' : _timerText(timer, now: now);
|
||||
final next = projection.nextExerciseName;
|
||||
if (next == null || next.isEmpty) {
|
||||
return '$value restant';
|
||||
}
|
||||
return '$value restant · Ensuite : $next';
|
||||
}
|
||||
|
||||
String _measureLine(WatchSessionProjection projection) {
|
||||
final score = projection.currentManualScoreValue;
|
||||
if (projection.hasManualScore && score != null) {
|
||||
return 'Série ${projection.seriesIndex}/${projection.seriesTotal} · ${_scoreText(score)}';
|
||||
}
|
||||
if (projection.stepName case final stepName? when stepName.isNotEmpty) {
|
||||
return stepName;
|
||||
}
|
||||
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
|
||||
return 'Série ${projection.seriesIndex}/${projection.seriesTotal}';
|
||||
}
|
||||
return projection.statusLabel ?? 'Séance en cours';
|
||||
}
|
||||
|
||||
String? _secondaryLine(WatchSessionProjection projection) {
|
||||
final parts = <String>[];
|
||||
if (projection.seriesIndex > 0 && projection.seriesTotal > 0) {
|
||||
parts.add('Série ${projection.seriesIndex}/${projection.seriesTotal}');
|
||||
}
|
||||
if (projection.stepIndex != null && projection.stepTotal != null) {
|
||||
parts.add('Étape ${projection.stepIndex}/${projection.stepTotal}');
|
||||
}
|
||||
return parts.isEmpty ? null : parts.join(' · ');
|
||||
}
|
||||
|
||||
String _timerText(WatchTimerProjection timer, {DateTime? now}) {
|
||||
final duration = _displayDuration(timer, now: now);
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
|
||||
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
Duration _displayDuration(WatchTimerProjection timer, {DateTime? now}) {
|
||||
final elapsed = _elapsedMs(timer, now: now);
|
||||
if (timer.displayMode == WatchTimerDisplayMode.countdown &&
|
||||
timer.targetMs != null) {
|
||||
return Duration(
|
||||
milliseconds: (timer.targetMs! - elapsed).clamp(0, 1 << 31).toInt(),
|
||||
);
|
||||
}
|
||||
return Duration(milliseconds: elapsed);
|
||||
}
|
||||
|
||||
int _elapsedMs(WatchTimerProjection timer, {DateTime? now}) {
|
||||
if (timer.runState != WatchTimerRunState.running ||
|
||||
timer.startedAtEpochMs == null) {
|
||||
return timer.accumulatedMs;
|
||||
}
|
||||
final reference =
|
||||
now?.toUtc().millisecondsSinceEpoch ??
|
||||
DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
return timer.accumulatedMs +
|
||||
(reference - timer.startedAtEpochMs!).clamp(0, 1 << 31).toInt();
|
||||
}
|
||||
|
||||
String _scoreText(double value) {
|
||||
if (value == value.roundToDouble()) {
|
||||
return value.toInt().toString();
|
||||
}
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@ -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<String, Object?> 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<WorkoutHistorySetResult> results;
|
||||
final List<WorkoutHistoryStepResult> stepResults;
|
||||
|
||||
WorkoutHistory copyWith({
|
||||
EntityMetadata? metadata,
|
||||
Object? averageHeartRateBpm = _unchanged,
|
||||
Object? maxHeartRateBpm = _unchanged,
|
||||
}) {
|
||||
return WorkoutHistory(
|
||||
metadata: metadata ?? this.metadata,
|
||||
sourceWorkoutTemplateId: sourceWorkoutTemplateId,
|
||||
sourceActiveWorkoutSessionId: sourceActiveWorkoutSessionId,
|
||||
nameSnapshot: nameSnapshot,
|
||||
startedAt: startedAt,
|
||||
endedAt: endedAt,
|
||||
totalActiveMs: totalActiveMs,
|
||||
completed: completed,
|
||||
historySnapshotJson: historySnapshotJson,
|
||||
averageHeartRateBpm: averageHeartRateBpm == _unchanged
|
||||
? this.averageHeartRateBpm
|
||||
: averageHeartRateBpm as double?,
|
||||
maxHeartRateBpm: maxHeartRateBpm == _unchanged
|
||||
? this.maxHeartRateBpm
|
||||
: maxHeartRateBpm as int?,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutHistorySetResult {
|
||||
@ -1721,6 +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<ExerciseStep> steps,
|
||||
required bool scoreEnabled,
|
||||
required ScoreInputMode scoreInputMode,
|
||||
}) {
|
||||
if (steps.any((step) => step.linkedToSeriesScore) &&
|
||||
(!scoreEnabled || scoreInputMode != ScoreInputMode.manual)) {
|
||||
throw const DomainException(
|
||||
'Linked step scores require manual series score.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _validateExerciseStepResult({
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
|
||||
@ -10,6 +10,7 @@ part 'app_database.g.dart';
|
||||
ActiveExerciseStepProgressStates,
|
||||
ActiveExerciseStepResults,
|
||||
ActiveRestStates,
|
||||
ActiveManualScoreStates,
|
||||
ActiveScoreStopwatchStates,
|
||||
ActiveSetTimerStates,
|
||||
ActiveSetResults,
|
||||
@ -48,7 +49,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
int get schemaVersion => 22;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -119,6 +120,15 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 19) {
|
||||
await _migrateToSchema19();
|
||||
}
|
||||
if (from < 20) {
|
||||
await _migrateToSchema20(migrator);
|
||||
}
|
||||
if (from < 21) {
|
||||
await _migrateToSchema21();
|
||||
}
|
||||
if (from < 22) {
|
||||
await _migrateToSchema22();
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -199,6 +209,10 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
|
||||
'ON active_set_results (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_manual_score_states_session_id '
|
||||
'ON active_manual_score_states (active_workout_session_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
|
||||
'ON active_score_stopwatch_states (active_workout_session_id)',
|
||||
@ -288,6 +302,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
const _syncableTableNames = [
|
||||
'active_exercise_step_progress_states',
|
||||
'active_exercise_step_results',
|
||||
'active_manual_score_states',
|
||||
'active_rest_states',
|
||||
'active_score_stopwatch_states',
|
||||
'active_set_timer_states',
|
||||
@ -770,6 +785,37 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema20(Migrator migrator) async {
|
||||
await migrator.createTable(activeManualScoreStates);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema21() async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'average_heart_rate_bpm',
|
||||
definition:
|
||||
'average_heart_rate_bpm REAL CHECK '
|
||||
'(average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
);
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'max_heart_rate_bpm',
|
||||
definition:
|
||||
'max_heart_rate_bpm INTEGER CHECK '
|
||||
'(max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema22() async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'exercise_steps',
|
||||
columnName: 'linked_to_series_score',
|
||||
definition:
|
||||
'linked_to_series_score INTEGER NOT NULL DEFAULT 0 '
|
||||
'CHECK (linked_to_series_score IN (0, 1))',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
|
||||
await customStatement(r'''
|
||||
UPDATE workout_history_set_results AS result
|
||||
|
||||
@ -1192,6 +1192,19 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(domain.ActiveManualScoreState state) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'active_manual_score_states',
|
||||
entityType: 'ActiveManualScoreState',
|
||||
metadata: state.metadata,
|
||||
write: () => database
|
||||
.into(database.activeManualScoreStates)
|
||||
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
@ -1260,6 +1273,42 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.activeManualScoreStates)..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.delete(
|
||||
database.activeManualScoreStates,
|
||||
)..where((table) => table.id.equals(row.id))).go();
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'ActiveManualScoreState',
|
||||
entityId: row.id,
|
||||
operation: 'delete',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveScoreStopwatchState?> findScoreStopwatchState({
|
||||
required String sessionId,
|
||||
@ -1280,6 +1329,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
return row == null ? null : _activeScoreStopwatchStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.activeManualScoreStates)..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex) &
|
||||
table.deletedAt.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _activeManualScoreStateFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -1432,6 +1501,26 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
.get();
|
||||
return rows.map(_activeScoreStopwatchStateFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
final rows =
|
||||
await (database.select(database.activeManualScoreStates)
|
||||
..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => OrderingTerm.asc(table.programIndex),
|
||||
(table) => OrderingTerm.asc(table.exerciseIndex),
|
||||
(table) => OrderingTerm.asc(table.setIndex),
|
||||
]))
|
||||
.get();
|
||||
return rows.map(_activeManualScoreStateFromRow).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@ -1522,6 +1611,51 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
|
||||
return;
|
||||
}
|
||||
final row =
|
||||
await (database.select(database.workoutHistories)..where(
|
||||
(table) =>
|
||||
table.id.equals(historyId) &
|
||||
table.deletedAt.isNull() &
|
||||
table.averageHeartRateBpm.isNull() &
|
||||
table.maxHeartRateBpm.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.update(
|
||||
database.workoutHistories,
|
||||
)..where((table) => table.id.equals(historyId))).write(
|
||||
db.WorkoutHistoriesCompanion(
|
||||
updatedAt: Value(patchedAt.toUtc()),
|
||||
syncState: const Value('dirty'),
|
||||
localRevision: Value(revision),
|
||||
averageHeartRateBpm: Value(averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(maxHeartRateBpm),
|
||||
),
|
||||
);
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'WorkoutHistory',
|
||||
entityId: historyId,
|
||||
operation: 'update',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: patchedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(domain.WorkoutHistorySetResult result) async {
|
||||
await _upsertWithChangeLog(
|
||||
@ -2777,6 +2911,7 @@ Future<void> _replaceExerciseSteps(
|
||||
scoreUnit: Value(step.scoreUnit),
|
||||
defaultTargetScore: Value(step.defaultTargetScore),
|
||||
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
|
||||
linkedToSeriesScore: Value(step.linkedToSeriesScore),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -3252,6 +3387,7 @@ domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
|
||||
scoreUnit: row.scoreUnit,
|
||||
defaultTargetScore: row.defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||
linkedToSeriesScore: row.linkedToSeriesScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4013,6 +4149,45 @@ db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion(
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
|
||||
domain.ActiveManualScoreState state,
|
||||
) {
|
||||
final values = _metadataValues(state.metadata);
|
||||
return db.ActiveManualScoreStatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
||||
programIndex: Value(state.programIndex),
|
||||
exerciseIndex: Value(state.exerciseIndex),
|
||||
setIndex: Value(state.setIndex),
|
||||
value: Value(state.value),
|
||||
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
|
||||
db.ActiveManualScoreState row,
|
||||
) {
|
||||
return domain.ActiveManualScoreState(
|
||||
metadata: _metadataFromRow(row),
|
||||
activeWorkoutSessionId: row.activeWorkoutSessionId,
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
value: row.value,
|
||||
updatedAt: _utc(row.scoreUpdatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
|
||||
db.ActiveScoreStopwatchState row,
|
||||
) {
|
||||
@ -4188,6 +4363,8 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||
totalActiveMs: Value(history.totalActiveMs),
|
||||
completed: Value(history.completed),
|
||||
historySnapshotJson: Value(history.historySnapshotJson),
|
||||
averageHeartRateBpm: Value(history.averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(history.maxHeartRateBpm),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4303,6 +4480,8 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
||||
totalActiveMs: row.totalActiveMs,
|
||||
completed: row.completed,
|
||||
historySnapshotJson: row.historySnapshotJson,
|
||||
averageHeartRateBpm: row.averageHeartRateBpm,
|
||||
maxHeartRateBpm: row.maxHeartRateBpm,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
@ -4560,6 +4739,8 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
|
||||
'totalActiveMs': history.totalActiveMs,
|
||||
'completed': history.completed,
|
||||
'historySnapshotJson': history.historySnapshotJson,
|
||||
'averageHeartRateBpm': history.averageHeartRateBpm,
|
||||
'maxHeartRateBpm': history.maxHeartRateBpm,
|
||||
};
|
||||
|
||||
Map<String, Object?> _localWorkoutHistoryPayload(
|
||||
@ -4714,6 +4895,8 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
|
||||
completed: payload['completed'] as bool? ?? false,
|
||||
historySnapshotJson:
|
||||
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
|
||||
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
|
||||
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
|
||||
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
|
||||
stepResults: _workoutHistoryStepResultsFromPayload(
|
||||
payload['stepResults'],
|
||||
@ -5142,6 +5325,7 @@ List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@ -5487,6 +5671,7 @@ List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
@ -137,7 +137,7 @@ class PendingShareActions extends Table {
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
|
||||
"CHECK (resource_type IS NULL OR resource_type IN "
|
||||
'CHECK (resource_type IS NULL OR resource_type IN '
|
||||
"('program', 'workoutTemplate'))",
|
||||
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
|
||||
];
|
||||
@ -250,6 +250,8 @@ class ExerciseSteps extends SyncableTable {
|
||||
TextColumn get scoreUnit => text().nullable()();
|
||||
RealColumn get defaultTargetScore => real().nullable()();
|
||||
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
|
||||
BoolColumn get linkedToSeriesScore =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
@ -276,6 +278,8 @@ class ExerciseSteps extends SyncableTable {
|
||||
'AND default_target_score IS NULL))',
|
||||
'CHECK (default_target_score IS NULL OR '
|
||||
'default_target_score_time_ms IS NULL)',
|
||||
'CHECK (NOT linked_to_series_score OR '
|
||||
"(has_score AND score_input_mode = 'manual'))",
|
||||
];
|
||||
}
|
||||
|
||||
@ -548,6 +552,29 @@ class ActiveScoreStopwatchStates extends SyncableTable {
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveManualScoreStates extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_manual_score_states';
|
||||
|
||||
TextColumn get activeWorkoutSessionId =>
|
||||
text().references(ActiveWorkoutSessions, #id)();
|
||||
IntColumn get programIndex => integer()();
|
||||
IntColumn get exerciseIndex => integer()();
|
||||
IntColumn get setIndex => integer()();
|
||||
RealColumn get value => real()();
|
||||
DateTimeColumn get scoreUpdatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE (active_workout_session_id, program_index, exercise_index, '
|
||||
'set_index)',
|
||||
'CHECK (program_index >= 0)',
|
||||
'CHECK (exercise_index >= 0)',
|
||||
'CHECK (set_index >= 0)',
|
||||
'CHECK (value >= 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class ActiveSetTimerStates extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'active_set_timer_states';
|
||||
@ -723,9 +750,15 @@ class WorkoutHistories extends SyncableTable {
|
||||
IntColumn get totalActiveMs => integer()();
|
||||
BoolColumn get completed => boolean()();
|
||||
TextColumn get historySnapshotJson => text().withLength(min: 1)();
|
||||
RealColumn get averageHeartRateBpm => real().nullable()();
|
||||
IntColumn get maxHeartRateBpm => integer().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => ['CHECK (total_active_ms >= 0)'];
|
||||
List<String> get customConstraints => [
|
||||
'CHECK (total_active_ms >= 0)',
|
||||
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutHistorySetResults extends SyncableTable {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@ -12,11 +13,28 @@ final class HttpApiClient {
|
||||
this.timeout = const Duration(seconds: 10),
|
||||
}) : client = client ?? http.Client();
|
||||
|
||||
static const defaultBaseUrl = String.fromEnvironment(
|
||||
static const _configuredBaseUrl = String.fromEnvironment(
|
||||
'GAMETIME_API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static String get defaultBaseUrl =>
|
||||
defaultBaseUrlFor(isAndroid: Platform.isAndroid);
|
||||
|
||||
static String defaultBaseUrlFor({
|
||||
required bool isAndroid,
|
||||
String configuredBaseUrl = _configuredBaseUrl,
|
||||
}) {
|
||||
final configured = configuredBaseUrl.trim();
|
||||
if (configured.isNotEmpty) {
|
||||
return configured;
|
||||
}
|
||||
if (isAndroid) {
|
||||
return 'http://10.0.2.2:8080';
|
||||
}
|
||||
return 'http://localhost:8080';
|
||||
}
|
||||
|
||||
final Uri baseUrl;
|
||||
final http.Client client;
|
||||
final Duration timeout;
|
||||
@ -142,7 +160,7 @@ final class HttpApiClient {
|
||||
return switch (statusCode) {
|
||||
401 => RemoteAuthException(RemoteAuthFailure.invalidCredentials, message),
|
||||
409 => RemoteAuthException(RemoteAuthFailure.emailAlreadyUsed, message),
|
||||
>= 500 => RemoteAuthException(RemoteAuthFailure.network, message),
|
||||
>= 500 => RemoteAuthException(RemoteAuthFailure.server, message),
|
||||
_ => RemoteAuthException(RemoteAuthFailure.unknown, message),
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export 'session_notification_gateway.dart';
|
||||
@ -0,0 +1,33 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../application/application.dart';
|
||||
|
||||
final class MethodChannelSessionNotificationGateway
|
||||
implements SessionNotificationGateway {
|
||||
const MethodChannelSessionNotificationGateway({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
}) : _methodChannel = methodChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.session_notification/methods';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
|
||||
@override
|
||||
Future<void> show(SessionNotificationContent content) {
|
||||
return _invokeIgnoringMissingPlugin('show', content.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() {
|
||||
return _invokeIgnoringMissingPlugin('clear');
|
||||
}
|
||||
|
||||
Future<void> _invokeIgnoringMissingPlugin(
|
||||
String method, [
|
||||
Object? arguments,
|
||||
]) {
|
||||
return _methodChannel
|
||||
.invokeMethod<void>(method, arguments)
|
||||
.onError<MissingPluginException>((_, _) {});
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,10 @@ final class WatchBridgeConnectionEvent {
|
||||
abstract interface class WatchBridgeNativeChannel {
|
||||
Stream<WatchCommandEnvelope> get commands;
|
||||
|
||||
Stream<WatchSensorSummary> get sensorSummaries;
|
||||
|
||||
Stream<WatchSensorSample> get sensorSamples;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
|
||||
Future<void> publishProjection(WatchSessionProjection projection);
|
||||
@ -38,17 +42,31 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
const MethodChannelWatchBridgeNativeChannel({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
EventChannel commandChannel = const EventChannel(_commandChannelName),
|
||||
EventChannel sensorSummaryChannel = const EventChannel(
|
||||
_sensorSummaryChannelName,
|
||||
),
|
||||
EventChannel sensorSampleChannel = const EventChannel(
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_commandChannel = commandChannel,
|
||||
_sensorSummaryChannel = sensorSummaryChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
static const _commandChannelName = 'gametime.watch_bridge/commands';
|
||||
static const _sensorSummaryChannelName =
|
||||
'gametime.watch_bridge/sensor_summaries';
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _commandChannel;
|
||||
final EventChannel _sensorSummaryChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@override
|
||||
@ -63,6 +81,30 @@ final class MethodChannelWatchBridgeNativeChannel
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSummary> get sensorSummaries {
|
||||
return _sensorSummaryChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSummary.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples {
|
||||
return _sensorSampleChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) {
|
||||
return event is Map;
|
||||
})
|
||||
.map((event) {
|
||||
return WatchSensorSample.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents {
|
||||
return _connectionChannel
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
import '../../application/use_cases.dart';
|
||||
import '../../application/watch_companion_use_cases.dart';
|
||||
import 'native_watch_bridge_channel.dart';
|
||||
|
||||
@ -10,20 +11,26 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
required WatchBridgeNativeChannel nativeChannel,
|
||||
required WatchCommandIngress commandIngress,
|
||||
required WatchProjectionSource projectionSource,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||
Duration projectionRefreshInterval = const Duration(seconds: 2),
|
||||
}) : _nativeChannel = nativeChannel,
|
||||
_commandIngress = commandIngress,
|
||||
_projectionSource = projectionSource,
|
||||
_heartbeatInterval = heartbeatInterval;
|
||||
_workoutHistoryUseCases = workoutHistoryUseCases,
|
||||
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
|
||||
_projectionRefreshInterval = projectionRefreshInterval;
|
||||
|
||||
final WatchBridgeNativeChannel _nativeChannel;
|
||||
final WatchCommandIngress _commandIngress;
|
||||
final WatchProjectionSource _projectionSource;
|
||||
final Duration _heartbeatInterval;
|
||||
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
|
||||
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
|
||||
final Duration _projectionRefreshInterval;
|
||||
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
Future<void> _commandTail = Future<void>.value();
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _projectionRefreshTimer;
|
||||
WatchSessionProjection? _latestProjection;
|
||||
bool _started = false;
|
||||
bool _foregroundActive = false;
|
||||
@ -33,6 +40,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
return;
|
||||
}
|
||||
_started = true;
|
||||
_ensureProjectionRefreshLoop();
|
||||
_subscriptions.add(
|
||||
_projectionSource.projections.listen((projection) {
|
||||
unawaited(publish(projection));
|
||||
@ -43,6 +51,22 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
unawaited(_enqueueCommand(command));
|
||||
}),
|
||||
);
|
||||
final workoutHistoryUseCases = _workoutHistoryUseCases;
|
||||
if (workoutHistoryUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSummaries.listen((summary) {
|
||||
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
|
||||
}),
|
||||
);
|
||||
}
|
||||
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
|
||||
if (activeWorkoutSensorUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSamples.listen((sample) {
|
||||
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
|
||||
}),
|
||||
);
|
||||
}
|
||||
_subscriptions.add(
|
||||
_nativeChannel.connectionEvents.listen((event) {
|
||||
if (event.isReachable || event.requestsResync) {
|
||||
@ -55,8 +79,8 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_projectionRefreshTimer?.cancel();
|
||||
_projectionRefreshTimer = null;
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
@ -66,10 +90,16 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
|
||||
@override
|
||||
Future<void> publish(WatchSessionProjection projection) async {
|
||||
final previousProjection = _latestProjection;
|
||||
_latestProjection = projection;
|
||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||
final previousSessionId = previousProjection?.deviceSessionId;
|
||||
if (previousSessionId != null && previousSessionId.isNotEmpty) {
|
||||
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
||||
}
|
||||
}
|
||||
await _nativeChannel.publishProjection(projection);
|
||||
await _syncForegroundService(projection);
|
||||
_syncHeartbeat(projection);
|
||||
}
|
||||
|
||||
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
|
||||
@ -136,26 +166,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
void _syncHeartbeat(WatchSessionProjection projection) {
|
||||
if (!_hasRunningTimer(projection)) {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
return;
|
||||
}
|
||||
_heartbeatTimer ??= Timer.periodic(_heartbeatInterval, (_) {
|
||||
void _ensureProjectionRefreshLoop() {
|
||||
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
||||
unawaited(_projectionSource.emitCurrentProjection());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasRunningTimer(WatchSessionProjection projection) {
|
||||
final timers = [
|
||||
if (projection.dominantTimer != null) projection.dominantTimer!,
|
||||
...projection.secondaryTimers,
|
||||
];
|
||||
return timers.any((timer) => timer.runState == WatchTimerRunState.running);
|
||||
}
|
||||
|
||||
final class _WatchAdapterCommandKey {
|
||||
_WatchAdapterCommandKey(WatchCommandEnvelope command)
|
||||
: sessionId = command.sessionId,
|
||||
|
||||
@ -681,26 +681,27 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
'Mode de saisie',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
RadioGroup<ScoreInputMode>(
|
||||
groupValue: _scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _scoreInputMode = value);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Chrono intégré'),
|
||||
subtitle: const Text('Temps réalisé'),
|
||||
title: Text('Chrono intégré'),
|
||||
subtitle: Text('Temps réalisé'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
groupValue: _scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _scoreInputMode = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_scoreInputMode == ScoreInputMode.manual) ...[
|
||||
const SizedBox(height: 12),
|
||||
@ -865,7 +866,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _stepDrafts.length,
|
||||
onReorder: _reorderStep,
|
||||
onReorderItem: _reorderStep,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildStepCard(context, index, _stepDrafts[index]);
|
||||
},
|
||||
@ -901,6 +902,13 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
int index,
|
||||
_ExerciseStepDraft draft,
|
||||
) {
|
||||
final canLinkToSeriesScore =
|
||||
_hasScore && _scoreInputMode == ScoreInputMode.manual;
|
||||
if ((!canLinkToSeriesScore ||
|
||||
draft.scoreInputMode != ScoreInputMode.manual) &&
|
||||
draft.linkedToSeriesScore) {
|
||||
draft.linkedToSeriesScore = false;
|
||||
}
|
||||
final targetLabel = draft.type == ExerciseStepType.time
|
||||
? 'Durée par défaut (s)'
|
||||
: 'Répétitions par défaut';
|
||||
@ -979,25 +987,26 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Type d’étape', style: Theme.of(context).textTheme.titleSmall),
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Temps'),
|
||||
value: ExerciseStepType.time,
|
||||
RadioGroup<ExerciseStepType>(
|
||||
groupValue: draft.type,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.type = value);
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Temps'),
|
||||
value: ExerciseStepType.time,
|
||||
),
|
||||
RadioListTile<ExerciseStepType>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Répétitions'),
|
||||
title: Text('Répétitions'),
|
||||
value: ExerciseStepType.reps,
|
||||
groupValue: draft.type,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.type = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: draft.targetController,
|
||||
@ -1021,27 +1030,47 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
'Mode de score',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
RadioGroup<ScoreInputMode>(
|
||||
groupValue: draft.scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.scoreInputMode = value);
|
||||
setState(() {
|
||||
draft.scoreInputMode = value;
|
||||
draft.linkedToSeriesScore = false;
|
||||
});
|
||||
},
|
||||
child: const Column(
|
||||
children: [
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Saisie libre'),
|
||||
value: ScoreInputMode.manual,
|
||||
),
|
||||
RadioListTile<ScoreInputMode>(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Chrono intégré'),
|
||||
title: Text('Chrono intégré'),
|
||||
value: ScoreInputMode.stopwatch,
|
||||
groupValue: draft.scoreInputMode,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => draft.scoreInputMode = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (draft.scoreInputMode == ScoreInputMode.manual) ...[
|
||||
if (canLinkToSeriesScore) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Utiliser le score de la série'),
|
||||
subtitle: const Text(
|
||||
'Le score de cette étape alimente directement le score '
|
||||
'de la série.',
|
||||
),
|
||||
value: draft.linkedToSeriesScore,
|
||||
onChanged: (value) {
|
||||
setState(() => draft.linkedToSeriesScore = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!draft.linkedToSeriesScore) ...[
|
||||
TextFormField(
|
||||
controller: draft.scoreLabelController,
|
||||
decoration: const InputDecoration(
|
||||
@ -1049,7 +1078,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual) {
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
@ -1063,7 +1093,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
decoration: const InputDecoration(labelText: 'Unité'),
|
||||
validator: (value) {
|
||||
if (!_stepsEnabled || !draft.hasScore) return null;
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual) {
|
||||
if (draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore) {
|
||||
return null;
|
||||
}
|
||||
return value == null || value.trim().isEmpty
|
||||
@ -1084,6 +1115,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
if (!_stepsEnabled ||
|
||||
!draft.hasScore ||
|
||||
draft.scoreInputMode != ScoreInputMode.manual ||
|
||||
draft.linkedToSeriesScore ||
|
||||
value == null ||
|
||||
value.trim().isEmpty) {
|
||||
return null;
|
||||
@ -1094,6 +1126,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
if (draft.type == ExerciseStepType.time) ...[
|
||||
const SizedBox(height: 8),
|
||||
@ -1163,9 +1196,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
|
||||
void _reorderStep(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final draft = _stepDrafts.removeAt(oldIndex);
|
||||
_stepDrafts.insert(newIndex, draft);
|
||||
});
|
||||
@ -1254,6 +1284,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
scoreInputMode: step.scoreInputMode,
|
||||
scoreLabel: step.scoreLabel,
|
||||
scoreUnit: step.scoreUnit,
|
||||
linkedToSeriesScore: step.linkedToSeriesScore,
|
||||
scoreTarget: step.scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalDoubleText(
|
||||
_millisecondsToSeconds(step.defaultTargetScoreTimeMs),
|
||||
@ -1268,6 +1299,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
for (var index = 0; index < _stepDrafts.length; index++)
|
||||
_stepDrafts[index].toExerciseStep(
|
||||
position: index,
|
||||
seriesScoreLabel: _scoreLabelController.text.trim(),
|
||||
seriesScoreUnit: _scoreUnitController.text.trim(),
|
||||
canLinkToSeriesScore:
|
||||
_hasScore && _scoreInputMode == ScoreInputMode.manual,
|
||||
defaultTargetScoreTimeMs:
|
||||
_stepDrafts[index].scoreInputMode == ScoreInputMode.stopwatch
|
||||
? _optionalSecondsToMilliseconds(
|
||||
@ -1386,7 +1421,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
|
||||
try {
|
||||
final scoreInputMode = _hasScore
|
||||
? _scoreInputMode
|
||||
: ScoreInputMode.manual;
|
||||
final scoreLabel = _hasScore
|
||||
? switch (scoreInputMode) {
|
||||
ScoreInputMode.manual => _scoreLabelController.text.trim(),
|
||||
@ -1411,7 +1449,6 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
|
||||
: null;
|
||||
final steps = _buildSteps();
|
||||
try {
|
||||
if (exercise == null) {
|
||||
await widget.exerciseUseCases.create(
|
||||
name: _nameController.text.trim(),
|
||||
@ -1462,8 +1499,10 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
navigator.pop(true);
|
||||
}
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
_showSnackBar(error.toString());
|
||||
} on Exception {
|
||||
_showSnackBar(
|
||||
'Impossible d’enregistrer l’exercice. Vérifie les champs puis réessaie.',
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _saving = false);
|
||||
@ -1556,6 +1595,7 @@ final class _ExerciseStepDraft {
|
||||
required String defaultTargetValue,
|
||||
this.hasScore = false,
|
||||
this.scoreInputMode = ScoreInputMode.manual,
|
||||
this.linkedToSeriesScore = false,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
String? scoreTarget,
|
||||
@ -1569,6 +1609,7 @@ final class _ExerciseStepDraft {
|
||||
ExerciseStepType type;
|
||||
bool hasScore;
|
||||
ScoreInputMode scoreInputMode;
|
||||
bool linkedToSeriesScore;
|
||||
final TextEditingController nameController;
|
||||
final TextEditingController targetController;
|
||||
final TextEditingController scoreLabelController;
|
||||
@ -1584,6 +1625,7 @@ final class _ExerciseStepDraft {
|
||||
return nameController.text.trim().isNotEmpty ||
|
||||
targetController.text.trim().isNotEmpty ||
|
||||
hasScore ||
|
||||
linkedToSeriesScore ||
|
||||
scoreLabelController.text.trim().isNotEmpty ||
|
||||
scoreUnitController.text.trim().isNotEmpty ||
|
||||
scoreTargetController.text.trim().isNotEmpty;
|
||||
@ -1597,6 +1639,7 @@ final class _ExerciseStepDraft {
|
||||
defaultTargetValue: targetController.text,
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
linkedToSeriesScore: linkedToSeriesScore,
|
||||
scoreLabel: scoreLabelController.text,
|
||||
scoreUnit: scoreUnitController.text,
|
||||
scoreTarget: scoreTargetController.text,
|
||||
@ -1605,9 +1648,17 @@ final class _ExerciseStepDraft {
|
||||
|
||||
ExerciseStep toExerciseStep({
|
||||
required int position,
|
||||
required String seriesScoreLabel,
|
||||
required String seriesScoreUnit,
|
||||
required bool canLinkToSeriesScore,
|
||||
required int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
final scoreTargetText = scoreTargetController.text.trim();
|
||||
final linksToSeries =
|
||||
hasScore &&
|
||||
scoreInputMode == ScoreInputMode.manual &&
|
||||
canLinkToSeriesScore &&
|
||||
linkedToSeriesScore;
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
@ -1617,14 +1668,19 @@ final class _ExerciseStepDraft {
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: hasScore ? scoreInputMode : ScoreInputMode.manual,
|
||||
scoreLabel: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreLabelController.text.trim()
|
||||
? linksToSeries
|
||||
? seriesScoreLabel
|
||||
: scoreLabelController.text.trim()
|
||||
: null,
|
||||
scoreUnit: hasScore && scoreInputMode == ScoreInputMode.manual
|
||||
? scoreUnitController.text.trim()
|
||||
? linksToSeries
|
||||
? seriesScoreUnit
|
||||
: scoreUnitController.text.trim()
|
||||
: null,
|
||||
defaultTargetScore:
|
||||
hasScore &&
|
||||
scoreInputMode == ScoreInputMode.manual &&
|
||||
!linksToSeries &&
|
||||
scoreTargetText.isNotEmpty
|
||||
? double.parse(scoreTargetText)
|
||||
: null,
|
||||
@ -1632,6 +1688,7 @@ final class _ExerciseStepDraft {
|
||||
hasScore && scoreInputMode == ScoreInputMode.stopwatch
|
||||
? defaultTargetScoreTimeMs
|
||||
: null,
|
||||
linkedToSeriesScore: linksToSeries,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ final class HistoryListScreen extends StatefulWidget {
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
this.onOpenProgression,
|
||||
super.key,
|
||||
@ -24,6 +25,7 @@ final class HistoryListScreen extends StatefulWidget {
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
@ -110,6 +112,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
stepUseCases: widget.stepUseCases,
|
||||
sensorUseCases: widget.sensorUseCases,
|
||||
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
@ -133,6 +136,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
required this.closeUseCase,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
super.key,
|
||||
});
|
||||
@ -142,6 +146,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final MediaUseCases? mediaUseCases;
|
||||
@ -157,6 +162,14 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
Text(_formatDateTime(history.startedAt)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
|
||||
if (history.averageHeartRateBpm != null &&
|
||||
history.maxHeartRateBpm != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_HistoryHeartRateSummary(
|
||||
averageHeartRateBpm: history.averageHeartRateBpm!,
|
||||
maxHeartRateBpm: history.maxHeartRateBpm!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
for (final program in detail.programs) ...[
|
||||
Text(program.name, style: Theme.of(context).textTheme.titleMedium),
|
||||
@ -202,13 +215,28 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _restart(BuildContext context) async {
|
||||
final hasBlockingOpenSession = await _hasBlockingOpenSession();
|
||||
if (!context.mounted) return;
|
||||
if (hasBlockingOpenSession) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
|
||||
'd’en lancer une nouvelle.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ActiveWorkoutSession session;
|
||||
final sourceId = history.sourceWorkoutTemplateId;
|
||||
try {
|
||||
if (sourceId != null &&
|
||||
await workoutTemplateUseCases.findById(sourceId) != null) {
|
||||
session = await activeUseCases.startFromTemplate(sourceId);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
session = await activeUseCases.startFromHistory(history);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@ -217,7 +245,16 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
session = await activeUseCases.startFromHistory(history);
|
||||
} on DomainException {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
@ -226,6 +263,7 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
initialSession: session,
|
||||
activeUseCases: activeUseCases,
|
||||
stepUseCases: stepUseCases,
|
||||
sensorUseCases: sensorUseCases,
|
||||
closeUseCase: closeUseCase,
|
||||
historyUseCases: historyUseCases,
|
||||
workoutTemplateUseCases: workoutTemplateUseCases,
|
||||
@ -236,6 +274,22 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _hasBlockingOpenSession() async {
|
||||
final openSession = await activeUseCases.findOpen();
|
||||
if (openSession == null) {
|
||||
return false;
|
||||
}
|
||||
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await activeUseCases.abandon(openSession.metadata.id);
|
||||
} on DomainException {
|
||||
// Legacy invalid sessions must not block a restart.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -261,6 +315,79 @@ final class HistoryDetailScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _HistoryHeartRateSummary extends StatelessWidget {
|
||||
const _HistoryHeartRateSummary({
|
||||
required this.averageHeartRateBpm,
|
||||
required this.maxHeartRateBpm,
|
||||
});
|
||||
|
||||
final double averageHeartRateBpm;
|
||||
final int maxHeartRateBpm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CourtBlazerAccentPanel(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Fréquence cardiaque',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _HistoryHeartRateMetric(
|
||||
label: 'Moyenne',
|
||||
value: '${averageHeartRateBpm.round()}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _HistoryHeartRateMetric(
|
||||
label: 'Max',
|
||||
value: '$maxHeartRateBpm',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _HistoryHeartRateMetric extends StatelessWidget {
|
||||
const _HistoryHeartRateMetric({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
children: [
|
||||
TextSpan(text: value),
|
||||
TextSpan(
|
||||
text: ' bpm',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class HistoryDetailData {
|
||||
const HistoryDetailData({required this.programs});
|
||||
|
||||
|
||||
@ -93,7 +93,10 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
if (session == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final plan = WorkoutExecutionPlan.fromSession(session);
|
||||
final plan = WorkoutExecutionPlan.tryFromSession(session);
|
||||
if (plan == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final position = ExecutionPosition(
|
||||
programIndex: session.currentProgramIndex,
|
||||
exerciseIndex: session.currentExerciseIndex,
|
||||
@ -158,6 +161,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
programUseCases: widget.bootstrap.programUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
@ -182,6 +186,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
widget.bootstrap.workoutTemplateUseCases,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
performanceReferenceUseCase:
|
||||
widget.bootstrap.exercisePerformanceReferenceUseCase,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
@ -257,12 +262,14 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
}
|
||||
|
||||
Future<void> _resume(ActiveWorkoutSession session) async {
|
||||
try {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
|
||||
stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
|
||||
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
|
||||
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
|
||||
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
|
||||
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
|
||||
@ -272,6 +279,16 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
|
||||
),
|
||||
),
|
||||
);
|
||||
} on DomainException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
_reloadOpenSession();
|
||||
}
|
||||
|
||||
@ -89,7 +89,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (connected == true && mounted) {
|
||||
if (connected != true || !mounted || !context.mounted) return;
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -97,7 +97,6 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openRegister(BuildContext context) async {
|
||||
final connected = await Navigator.of(context).push<bool>(
|
||||
@ -109,7 +108,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (connected == true && mounted) {
|
||||
if (connected != true || !mounted || !context.mounted) return;
|
||||
_reloadSession();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -117,7 +116,6 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@ -189,13 +187,12 @@ final class _SignedOutProfile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Compte optionnel',
|
||||
'Compte GameTime',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'GameTime fonctionne entièrement sans compte. Connecte-toi '
|
||||
'seulement si tu veux sauvegarder tes données en ligne ou '
|
||||
'Connecte-toi pour sauvegarder tes données en ligne et '
|
||||
'partager des programmes et séances.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -481,7 +478,8 @@ abstract interface class LocalBackupFileExporter {
|
||||
});
|
||||
}
|
||||
|
||||
final class SharePlusLocalBackupFileExporter implements LocalBackupFileExporter {
|
||||
final class SharePlusLocalBackupFileExporter
|
||||
implements LocalBackupFileExporter {
|
||||
const SharePlusLocalBackupFileExporter();
|
||||
|
||||
@override
|
||||
@ -950,11 +948,6 @@ final class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
child: const Text('Créer un compte'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tu peux continuer à utiliser GameTime sans compte.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -1103,7 +1096,7 @@ final class _RegisterScreenState extends State<RegisterScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Le compte sert à synchroniser tes données et partager tes '
|
||||
"contenus. L'app reste utilisable sans compte.",
|
||||
'contenus.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@ -1211,6 +1204,8 @@ String _loginErrorMessage(RemoteAuthFailure failure) {
|
||||
RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.',
|
||||
RemoteAuthFailure.network =>
|
||||
'Connexion impossible pour le moment. Réessaie plus tard.',
|
||||
RemoteAuthFailure.server =>
|
||||
'Serveur indisponible pour le moment. Réessaie plus tard.',
|
||||
_ => 'Connexion impossible pour le moment. Réessaie plus tard.',
|
||||
};
|
||||
}
|
||||
@ -1221,6 +1216,8 @@ String _registerErrorMessage(RemoteAuthFailure failure) {
|
||||
'Un compte existe déjà avec cet email.',
|
||||
RemoteAuthFailure.network =>
|
||||
'Création impossible pour le moment. Réessaie plus tard.',
|
||||
RemoteAuthFailure.server =>
|
||||
'Serveur indisponible pour le moment. Réessaie plus tard.',
|
||||
_ => 'Création impossible pour le moment. Réessaie plus tard.',
|
||||
};
|
||||
}
|
||||
|
||||
@ -420,6 +420,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canSave = !_saving && _exercises.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
@ -436,8 +437,12 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: canSave ? _save : null,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
@ -446,6 +451,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@ -495,7 +502,8 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
child: _exercises.isEmpty
|
||||
? const _CenteredMessage(
|
||||
title: 'Aucun exercice ajouté',
|
||||
message: 'Ajoute un exercice depuis la bibliothèque.',
|
||||
message:
|
||||
'Ajoute au moins un exercice pour enregistrer ce programme.',
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
@ -595,6 +603,9 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_exercises.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
@ -1402,7 +1413,7 @@ final class _CenteredMessage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@ -172,7 +172,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connecte-toi pour envoyer $targetLabel à un autre compte '
|
||||
'GameTime. Le reste de l’app reste utilisable sans compte.',
|
||||
'GameTime.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
|
||||
@ -28,6 +28,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
|
||||
this.mediaAssetLoader,
|
||||
this.videoMediaBuilder,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.stepAudioCuePlayer,
|
||||
super.key,
|
||||
});
|
||||
@ -42,6 +43,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
|
||||
final MediaUseCases? mediaUseCases;
|
||||
final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
|
||||
final VideoMediaBuilder? videoMediaBuilder;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
|
||||
|
||||
@override
|
||||
@ -50,21 +52,24 @@ final class WorkoutExecutionScreen extends StatefulWidget {
|
||||
|
||||
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
late ActiveWorkoutSession _session;
|
||||
late final WorkoutExecutionPlan _plan;
|
||||
WorkoutExecutionPlan? _validatedPlan;
|
||||
late WorkoutExecutionMode _mode;
|
||||
Timer? _ticker;
|
||||
Timer? _restTicker;
|
||||
Timer? _scoreStopwatchTicker;
|
||||
Timer? _stepTicker;
|
||||
Timer? _stepScoreTicker;
|
||||
StreamSubscription<ActiveWorkoutSensorState>? _sensorSubscription;
|
||||
var _externalSyncInFlight = false;
|
||||
WorkoutHistory? _completedHistory;
|
||||
ActiveWorkoutSensorState? _sensorState;
|
||||
String? _activeRestStateId;
|
||||
ActiveSetTimerState? _setTimer;
|
||||
ActiveScoreStopwatchState? _scoreStopwatch;
|
||||
var _scoreStopwatchLoadGeneration = 0;
|
||||
ActiveExerciseStepProgressView? _stepProgress;
|
||||
late final ExerciseStepAudioCuePlayer _stepAudioCuePlayer;
|
||||
late final bool _ownsStepAudioCuePlayer;
|
||||
var _ownsStepAudioCuePlayer = false;
|
||||
final _stepScoreController = TextEditingController();
|
||||
int? _manualScoreTimeMs;
|
||||
DateTime? _stepScoreStartedAt;
|
||||
@ -75,7 +80,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
var _reps = 0;
|
||||
Future<ExercisePerformanceReference>? _performanceReference;
|
||||
final _scoreController = TextEditingController();
|
||||
final _scoreFocusNode = FocusNode();
|
||||
Future<void>? _manualScorePersistInFlight;
|
||||
var _remainingRestSeconds = 0;
|
||||
String? _invalidSessionMessage;
|
||||
|
||||
ExecutionPosition get _position => ExecutionPosition(
|
||||
programIndex: _session.currentProgramIndex,
|
||||
@ -85,11 +93,20 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
|
||||
ExecutionExercise get _exercise => _plan.exerciseAt(_position);
|
||||
|
||||
WorkoutExecutionPlan get _plan => _validatedPlan!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_session = widget.initialSession;
|
||||
_plan = WorkoutExecutionPlan.fromSession(_session);
|
||||
try {
|
||||
_validatedPlan = WorkoutExecutionPlan.fromSession(_session);
|
||||
} on Object {
|
||||
_invalidSessionMessage =
|
||||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.';
|
||||
unawaited(_abandonInvalidSession());
|
||||
return;
|
||||
}
|
||||
_ownsStepAudioCuePlayer =
|
||||
widget.stepAudioCuePlayer == null && widget.stepUseCases != null;
|
||||
_stepAudioCuePlayer =
|
||||
@ -103,10 +120,22 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_reps = _initialRepsFor(_exercise);
|
||||
_performanceReference = _loadPerformanceReference();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
unawaited(_syncExternalSessionChanges());
|
||||
}
|
||||
});
|
||||
_scoreFocusNode.addListener(_handleScoreFocusChange);
|
||||
_sensorState = widget.sensorUseCases?.current(_session.metadata.id);
|
||||
_sensorSubscription = widget.sensorUseCases?.updates.listen((state) {
|
||||
if (!mounted || state.sessionId != _session.metadata.id) {
|
||||
return;
|
||||
}
|
||||
setState(() => _sensorState = state);
|
||||
});
|
||||
unawaited(_loadSetTimer());
|
||||
unawaited(_loadScoreStopwatch());
|
||||
unawaited(_syncManualScoreInput(force: true));
|
||||
unawaited(_loadStepProgress());
|
||||
unawaited(_restoreActiveRest());
|
||||
}
|
||||
@ -118,19 +147,61 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_scoreStopwatchTicker?.cancel();
|
||||
_stepTicker?.cancel();
|
||||
_stepScoreTicker?.cancel();
|
||||
unawaited(_sensorSubscription?.cancel());
|
||||
if (_ownsStepAudioCuePlayer) {
|
||||
unawaited(_stepAudioCuePlayer.dispose());
|
||||
}
|
||||
_scoreController.dispose();
|
||||
_scoreFocusNode.dispose();
|
||||
_stepScoreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScoreFocusChange() {
|
||||
if (!_scoreFocusNode.hasFocus) {
|
||||
unawaited(_persistManualScoreInput());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _abandonInvalidSession() async {
|
||||
try {
|
||||
await widget.activeUseCases.abandon(_session.metadata.id);
|
||||
} on Exception {
|
||||
// The UI still needs to leave the user on an explicit recovery state.
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidSessionState() {
|
||||
_ticker?.cancel();
|
||||
_restTicker?.cancel();
|
||||
_scoreStopwatchTicker?.cancel();
|
||||
_stepTicker?.cancel();
|
||||
_stepScoreTicker?.cancel();
|
||||
setState(() {
|
||||
_invalidSessionMessage =
|
||||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final invalidMessage = _invalidSessionMessage;
|
||||
if (invalidMessage != null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Séance indisponible')),
|
||||
body: _InvalidSessionMessage(
|
||||
message: invalidMessage,
|
||||
onBackHome: () =>
|
||||
Navigator.of(context).popUntil((route) => route.isFirst),
|
||||
),
|
||||
);
|
||||
}
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvoked: _handleSystemBack,
|
||||
onPopInvokedWithResult: _handleSystemBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
@ -195,6 +266,15 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: _LiveSensorBar(
|
||||
sensorState: _sensorState!,
|
||||
muted: _isSensorStateStale(_sensorState),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_performanceReference != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
@ -239,6 +319,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
child: _StepSetResultSummary(
|
||||
exercise: _exercise,
|
||||
scoreController: _scoreController,
|
||||
scoreFocusNode: _scoreFocusNode,
|
||||
onScoreSubmitted: (_) =>
|
||||
unawaited(_persistManualScoreInput()),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -288,6 +371,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
showTime: false,
|
||||
reps: _reps,
|
||||
scoreController: _scoreController,
|
||||
scoreFocusNode: _scoreFocusNode,
|
||||
onScoreSubmitted: (_) =>
|
||||
unawaited(_persistManualScoreInput()),
|
||||
onRepsChanged: (value) =>
|
||||
setState(() => _reps = value),
|
||||
),
|
||||
@ -311,6 +397,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Repos avant la prochaine série'),
|
||||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||||
const SizedBox(height: 12),
|
||||
_LiveSensorBar(
|
||||
sensorState: _sensorState!,
|
||||
muted: _isSensorStateStale(_sensorState),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Text(
|
||||
@ -384,6 +477,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Pause', style: Theme.of(context).textTheme.headlineMedium),
|
||||
if (_hasLiveSensorMetrics(_sensorState)) ...[
|
||||
const SizedBox(height: 12),
|
||||
_LiveSensorBar(
|
||||
sensorState: _sensorState!,
|
||||
muted: _isSensorStateStale(_sensorState),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _resume,
|
||||
@ -422,6 +522,19 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Temps total : ${_formatDuration(elapsed)}'),
|
||||
if (_caloriesLabel(_sensorState) case final caloriesLabel?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('Calories : $caloriesLabel'),
|
||||
],
|
||||
if (_completedHistory case final history?
|
||||
when history.averageHeartRateBpm != null &&
|
||||
history.maxHeartRateBpm != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_HeartRateSummaryCard(
|
||||
averageHeartRateBpm: history.averageHeartRateBpm!,
|
||||
maxHeartRateBpm: history.maxHeartRateBpm!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton(
|
||||
onPressed: _completedHistory == null ? null : _openHistoryDetail,
|
||||
@ -598,7 +711,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
} on Exception catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Impossible de démarrer l’exercice : $error")),
|
||||
SnackBar(content: Text('Impossible de démarrer l’exercice : $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -636,6 +749,76 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_refreshScoreStopwatchTicker();
|
||||
}
|
||||
|
||||
Future<void> _syncManualScoreInput({bool force = false}) async {
|
||||
if (!_exercise.manualScoreEnabled) {
|
||||
if (force && _scoreController.text.isNotEmpty) {
|
||||
_scoreController.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_scoreFocusNode.hasFocus && !force) {
|
||||
return;
|
||||
}
|
||||
final state = await widget.activeUseCases.findManualScore(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final nextText = state == null ? '' : _formatScore(state.value);
|
||||
if (_scoreController.text != nextText) {
|
||||
_scoreController.text = nextText;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistManualScoreInput() async {
|
||||
final inFlight = _manualScorePersistInFlight;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
final future = _persistManualScoreInputOnce();
|
||||
_manualScorePersistInFlight = future;
|
||||
try {
|
||||
await future;
|
||||
} finally {
|
||||
if (identical(_manualScorePersistInFlight, future)) {
|
||||
_manualScorePersistInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistManualScoreInputOnce() async {
|
||||
if (!_exercise.manualScoreEnabled) {
|
||||
return;
|
||||
}
|
||||
final rawValue = _scoreController.text.trim();
|
||||
if (rawValue.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final value = double.tryParse(rawValue);
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
final result = await widget.activeUseCases.setManualScore(
|
||||
sessionId: _session.metadata.id,
|
||||
programIndex: _position.programIndex,
|
||||
exerciseIndex: _position.exerciseIndex,
|
||||
setIndex: _position.setIndex,
|
||||
value: value,
|
||||
);
|
||||
if (!mounted || _scoreFocusNode.hasFocus) {
|
||||
return;
|
||||
}
|
||||
final nextText = _formatScore(result.state.value);
|
||||
if (_scoreController.text != nextText) {
|
||||
_scoreController.text = nextText;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScoreStopwatch() async {
|
||||
_scoreStopwatchLoadGeneration++;
|
||||
final state = await widget.activeUseCases.startScoreStopwatch(
|
||||
@ -979,6 +1162,54 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncExternalSessionChanges() async {
|
||||
if (_externalSyncInFlight || !mounted) {
|
||||
return;
|
||||
}
|
||||
_externalSyncInFlight = true;
|
||||
try {
|
||||
final session = await widget.activeUseCases.findOpen();
|
||||
if (!mounted ||
|
||||
session == null ||
|
||||
session.metadata.id != _session.metadata.id) {
|
||||
return;
|
||||
}
|
||||
final sessionChanged =
|
||||
session.status != _session.status ||
|
||||
session.currentProgramIndex != _session.currentProgramIndex ||
|
||||
session.currentExerciseIndex != _session.currentExerciseIndex ||
|
||||
session.currentSetIndex != _session.currentSetIndex;
|
||||
_session = session;
|
||||
if (sessionChanged) {
|
||||
_reps = _initialRepsFor(_exercise);
|
||||
}
|
||||
await _syncManualScoreInput(force: sessionChanged);
|
||||
await _loadSetTimer();
|
||||
await _loadScoreStopwatch();
|
||||
await _readStepProgress();
|
||||
await _restoreActiveRest();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_mode = switch (session.status) {
|
||||
ActiveWorkoutStatus.running =>
|
||||
_activeRestStateId == null
|
||||
? WorkoutExecutionMode.active
|
||||
: WorkoutExecutionMode.rest,
|
||||
ActiveWorkoutStatus.paused => WorkoutExecutionMode.paused,
|
||||
ActiveWorkoutStatus.completed ||
|
||||
ActiveWorkoutStatus.abandoned ||
|
||||
ActiveWorkoutStatus.savedExit => WorkoutExecutionMode.finished,
|
||||
};
|
||||
});
|
||||
_refreshScoreStopwatchTicker();
|
||||
_refreshStepTicker();
|
||||
} finally {
|
||||
_externalSyncInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _skipCurrentPassage() async {
|
||||
final confirmed = await _confirmStepSkip(
|
||||
'Passer ce passage ?',
|
||||
@ -1111,7 +1342,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_mode == WorkoutExecutionMode.rest;
|
||||
}
|
||||
|
||||
void _handleSystemBack(bool didPop) {
|
||||
void _handleSystemBack(bool didPop, Object? result) {
|
||||
if (didPop || !_canPauseFromNavigation) return;
|
||||
unawaited(_pause());
|
||||
}
|
||||
@ -1132,7 +1363,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
|
||||
Future<void> _resume() async {
|
||||
try {
|
||||
_session = await widget.activeUseCases.resume(_session.metadata.id);
|
||||
} on DomainException {
|
||||
if (!mounted) return;
|
||||
_showInvalidSessionState();
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
final restoredRest = await _restoreActiveRest();
|
||||
if (!mounted || restoredRest) return;
|
||||
@ -1216,11 +1453,12 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
if (!skipped && _shouldPromptStartExerciseChrono) {
|
||||
if (!mounted) return;
|
||||
final action = await showDialog<_MissingSetTimerAction>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Chrono non lancé'),
|
||||
content: const Text("Tu n’as pas démarré le chrono de cette série."),
|
||||
content: const Text('Tu n’as pas démarré le chrono de cette série.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
@ -1250,11 +1488,12 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
_exercise.stopwatchScoreEnabled &&
|
||||
_scoreStopwatch == null &&
|
||||
_manualScoreTimeMs == null) {
|
||||
if (!mounted) return;
|
||||
final action = await showDialog<_MissingStopwatchAction>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Chrono non lancé'),
|
||||
content: const Text("Tu n’as pas démarré le chrono de cette série."),
|
||||
content: const Text('Tu n’as pas démarré le chrono de cette série.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
@ -1283,6 +1522,9 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
|
||||
Future<void> _recordAndAdvance({required bool skipped}) async {
|
||||
try {
|
||||
if (!skipped) {
|
||||
await _persistManualScoreInput();
|
||||
}
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
final setTimer = skipped
|
||||
? await widget.activeUseCases.skipSetExecutionTimers(
|
||||
@ -1465,6 +1707,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
setIndex: position.setIndex,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _syncManualScoreInput(force: true);
|
||||
await _loadSetTimer();
|
||||
await _loadScoreStopwatch();
|
||||
await _loadStepProgress();
|
||||
@ -1503,13 +1746,28 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
Future<void> _restartCompleted() async {
|
||||
final history = _completedHistory;
|
||||
if (history == null) return;
|
||||
final hasBlockingOpenSession = await _hasBlockingOpenSessionForRestart();
|
||||
if (!mounted) return;
|
||||
if (hasBlockingOpenSession) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Une séance est déjà en cours. Termine-la ou reprends-la avant '
|
||||
'd’en lancer une nouvelle.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ActiveWorkoutSession session;
|
||||
final sourceId = history.sourceWorkoutTemplateId;
|
||||
try {
|
||||
if (sourceId != null &&
|
||||
await widget.workoutTemplateUseCases.findById(sourceId) != null) {
|
||||
session = await widget.activeUseCases.startFromTemplate(sourceId);
|
||||
} else {
|
||||
if (mounted) {
|
||||
session = await widget.activeUseCases.startFromHistory(history);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@ -1518,7 +1776,16 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
session = await widget.activeUseCases.startFromHistory(history);
|
||||
} on DomainException {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).pushReplacement(
|
||||
@ -1534,11 +1801,29 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
mediaAssetLoader: widget.mediaAssetLoader,
|
||||
videoMediaBuilder: widget.videoMediaBuilder,
|
||||
sensorUseCases: widget.sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _hasBlockingOpenSessionForRestart() async {
|
||||
final openSession = await widget.activeUseCases.findOpen();
|
||||
if (openSession == null ||
|
||||
openSession.metadata.id == _session.metadata.id) {
|
||||
return false;
|
||||
}
|
||||
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await widget.activeUseCases.abandon(openSession.metadata.id);
|
||||
} on DomainException {
|
||||
// Legacy invalid sessions must not block a restart.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int _initialRepsFor(ExecutionExercise exercise) {
|
||||
if (!exercise.repsEnabled) return 0;
|
||||
return exercise.targetReps ?? 0;
|
||||
@ -1565,6 +1850,79 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final class _HeartRateSummaryCard extends StatelessWidget {
|
||||
const _HeartRateSummaryCard({
|
||||
required this.averageHeartRateBpm,
|
||||
required this.maxHeartRateBpm,
|
||||
});
|
||||
|
||||
final double averageHeartRateBpm;
|
||||
final int maxHeartRateBpm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CourtBlazerAccentPanel(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Fréquence cardiaque',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _HeartRateMetric(
|
||||
label: 'Moyenne',
|
||||
value: '${averageHeartRateBpm.round()}',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _HeartRateMetric(
|
||||
label: 'Max',
|
||||
value: '$maxHeartRateBpm',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _HeartRateMetric extends StatelessWidget {
|
||||
const _HeartRateMetric({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
children: [
|
||||
TextSpan(text: value),
|
||||
TextSpan(
|
||||
text: ' bpm',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class PerformanceReferenceCard extends StatelessWidget {
|
||||
const PerformanceReferenceCard({
|
||||
required this.reference,
|
||||
@ -1726,6 +2084,8 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
this.showTime = true,
|
||||
required this.reps,
|
||||
required this.scoreController,
|
||||
required this.scoreFocusNode,
|
||||
required this.onScoreSubmitted,
|
||||
required this.onRepsChanged,
|
||||
super.key,
|
||||
});
|
||||
@ -1734,6 +2094,8 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
final bool showTime;
|
||||
final int reps;
|
||||
final TextEditingController scoreController;
|
||||
final FocusNode scoreFocusNode;
|
||||
final ValueChanged<String> onScoreSubmitted;
|
||||
final ValueChanged<int> onRepsChanged;
|
||||
|
||||
@override
|
||||
@ -1783,6 +2145,7 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: scoreController,
|
||||
focusNode: scoreFocusNode,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
decoration: InputDecoration(
|
||||
labelText: exercise.scoreUnit == null
|
||||
@ -1790,6 +2153,7 @@ final class SetMeasureInput extends StatelessWidget {
|
||||
: 'Score (${exercise.scoreUnit})',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: onScoreSubmitted,
|
||||
),
|
||||
],
|
||||
],
|
||||
@ -1819,7 +2183,10 @@ final class _ExecutionAppBarTitle extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).appBarTheme.titleTextStyle,
|
||||
),
|
||||
Text(
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
programName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@ -1827,7 +2194,92 @@ final class _ExecutionAppBarTitle extends StatelessWidget {
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: tokens.mutedText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _LiveSensorBar extends StatelessWidget {
|
||||
const _LiveSensorBar({required this.sensorState, required this.muted});
|
||||
|
||||
final ActiveWorkoutSensorState sensorState;
|
||||
final bool muted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: muted ? 0.45 : 1,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (sensorState.latestHeartRateBpm case final heartRate?)
|
||||
_LiveSensorPill(
|
||||
icon: Icons.favorite,
|
||||
label: 'FC $heartRate bpm',
|
||||
muted: false,
|
||||
),
|
||||
if (_distanceLabel(sensorState) case final distanceLabel?)
|
||||
_LiveSensorPill(
|
||||
icon: Icons.directions_run,
|
||||
label: distanceLabel,
|
||||
muted: false,
|
||||
),
|
||||
if (_caloriesLabel(sensorState) case final caloriesLabel?)
|
||||
_LiveSensorPill(
|
||||
icon: Icons.local_fire_department,
|
||||
label: caloriesLabel,
|
||||
muted: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _LiveSensorPill extends StatelessWidget {
|
||||
const _LiveSensorPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.muted,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool muted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
return Opacity(
|
||||
opacity: muted ? 0.45 : 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color.withValues(alpha: 0.55)),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 11, color: color),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: color, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1946,6 +2398,47 @@ final class _ExecutionContextHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _InvalidSessionMessage extends StatelessWidget {
|
||||
const _InvalidSessionMessage({
|
||||
required this.message,
|
||||
required this.onBackHome,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onBackHome;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: onBackHome,
|
||||
child: const Text('Revenir à l’accueil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _SetActionBar extends StatelessWidget {
|
||||
const _SetActionBar({required this.onFinishSet, required this.onSkipSet});
|
||||
|
||||
@ -1980,10 +2473,14 @@ final class _StepSetResultSummary extends StatelessWidget {
|
||||
const _StepSetResultSummary({
|
||||
required this.exercise,
|
||||
required this.scoreController,
|
||||
required this.scoreFocusNode,
|
||||
required this.onScoreSubmitted,
|
||||
});
|
||||
|
||||
final ExecutionExercise exercise;
|
||||
final TextEditingController scoreController;
|
||||
final FocusNode scoreFocusNode;
|
||||
final ValueChanged<String> onScoreSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -1998,6 +2495,7 @@ final class _StepSetResultSummary extends StatelessWidget {
|
||||
),
|
||||
child: TextField(
|
||||
controller: scoreController,
|
||||
focusNode: scoreFocusNode,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: exercise.scoreUnit == null
|
||||
@ -2005,6 +2503,7 @@ final class _StepSetResultSummary extends StatelessWidget {
|
||||
: 'Score (${exercise.scoreUnit})',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: onScoreSubmitted,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -2096,19 +2595,16 @@ final class _StepSequencePanel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (sequenceComplete || currentStep == null)
|
||||
Expanded(
|
||||
child: Align(
|
||||
child: sequenceComplete || currentStep == null
|
||||
? Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _SequenceCompleteSummary(
|
||||
completedPassages: completedPassages,
|
||||
expectedPassages: view.expectedPassages,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: _CurrentStepPane(
|
||||
: _CurrentStepPane(
|
||||
view: view,
|
||||
step: currentStep,
|
||||
remainingLabel: remainingLabel,
|
||||
@ -2198,14 +2694,16 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'ÉTAPE ${view.state.currentStepIndex + 1} / ${view.steps.length}',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
step.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
@ -2213,8 +2711,7 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Flexible(
|
||||
fit: FlexFit.tight,
|
||||
Expanded(
|
||||
child: step.type == ExerciseStepType.time
|
||||
? _TimedStepBody(
|
||||
step: step,
|
||||
@ -2239,9 +2736,7 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
),
|
||||
if (step.hasScore) ...[
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: _StepScoreInput(
|
||||
_StepScoreInput(
|
||||
step: step,
|
||||
controller: stepScoreController,
|
||||
elapsedLabel: stepScoreElapsedLabel,
|
||||
@ -2250,7 +2745,6 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
onStop: onStopStepScore,
|
||||
onReset: onResetStepScore,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
@ -2289,6 +2783,11 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3884,15 +4383,49 @@ final class WorkoutExecutionPlan {
|
||||
overrides: overrides,
|
||||
);
|
||||
}).toList();
|
||||
return WorkoutExecutionPlan(
|
||||
final plan = WorkoutExecutionPlan(
|
||||
name: snapshot['name'] as String? ?? 'Séance',
|
||||
programs: programs,
|
||||
);
|
||||
final position = ExecutionPosition(
|
||||
programIndex: session.currentProgramIndex,
|
||||
exerciseIndex: session.currentExerciseIndex,
|
||||
setIndex: session.currentSetIndex,
|
||||
);
|
||||
if (!plan.isPlayable || !plan.contains(position)) {
|
||||
throw const InvalidWorkoutExecutionPlanException();
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
static WorkoutExecutionPlan? tryFromSession(ActiveWorkoutSession session) {
|
||||
try {
|
||||
return WorkoutExecutionPlan.fromSession(session);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final String name;
|
||||
final List<ExecutionProgram> programs;
|
||||
|
||||
bool get isPlayable {
|
||||
return programs.any((program) => program.exercises.isNotEmpty);
|
||||
}
|
||||
|
||||
bool contains(ExecutionPosition position) {
|
||||
if (position.programIndex < 0 || position.programIndex >= programs.length) {
|
||||
return false;
|
||||
}
|
||||
final program = programs[position.programIndex];
|
||||
if (position.exerciseIndex < 0 ||
|
||||
position.exerciseIndex >= program.exercises.length) {
|
||||
return false;
|
||||
}
|
||||
final exercise = program.exercises[position.exerciseIndex];
|
||||
return position.setIndex >= 0 && position.setIndex < exercise.setsCount;
|
||||
}
|
||||
|
||||
ExecutionProgram programAt(ExecutionPosition position) {
|
||||
return programs[position.programIndex];
|
||||
}
|
||||
@ -3972,6 +4505,13 @@ final class ExecutionProgram {
|
||||
final List<ExecutionExercise> exercises;
|
||||
}
|
||||
|
||||
final class InvalidWorkoutExecutionPlanException implements Exception {
|
||||
const InvalidWorkoutExecutionPlanException();
|
||||
|
||||
@override
|
||||
String toString() => 'Workout execution plan is not playable.';
|
||||
}
|
||||
|
||||
final class ExecutionExercise {
|
||||
const ExecutionExercise({
|
||||
required this.id,
|
||||
@ -4233,6 +4773,7 @@ List<ExerciseStep> _exerciseStepsFromSnapshot(Map<String, dynamic> exercise) {
|
||||
scoreUnit: step['scoreUnit'] as String?,
|
||||
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
|
||||
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
|
||||
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
@ -4275,6 +4816,40 @@ String _formatDuration(Duration duration) {
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
bool _isSensorStateStale(ActiveWorkoutSensorState? state) {
|
||||
if (state == null) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().toUtc().difference(state.latestSampleAt) >
|
||||
const Duration(seconds: 15);
|
||||
}
|
||||
|
||||
String? _distanceLabel(ActiveWorkoutSensorState? state) {
|
||||
final meters = state?.latestDistanceMeters;
|
||||
if (meters == null || meters < 0) {
|
||||
return null;
|
||||
}
|
||||
if (meters >= 1000) {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
return '${meters.round()} m';
|
||||
}
|
||||
|
||||
String? _caloriesLabel(ActiveWorkoutSensorState? state) {
|
||||
final actualCalories = state?.latestCaloriesKcal;
|
||||
if (actualCalories != null && actualCalories >= 0) {
|
||||
return '${actualCalories.round()} kcal';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _hasLiveSensorMetrics(ActiveWorkoutSensorState? state) {
|
||||
return state != null &&
|
||||
(state.latestHeartRateBpm != null ||
|
||||
state.latestDistanceMeters != null ||
|
||||
_caloriesLabel(state) != null);
|
||||
}
|
||||
|
||||
String _formatStepCountdown(Duration duration) {
|
||||
final totalSeconds = (duration.inMilliseconds / 1000)
|
||||
.ceil()
|
||||
|
||||
@ -19,6 +19,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
required this.historyUseCases,
|
||||
this.mediaUseCases,
|
||||
this.stepUseCases,
|
||||
this.sensorUseCases,
|
||||
this.performanceReferenceUseCase,
|
||||
this.shareUseCases,
|
||||
this.authUseCases,
|
||||
@ -30,6 +31,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
|
||||
final ProgramUseCases programUseCases;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final ActiveExerciseStepUseCases? stepUseCases;
|
||||
final ActiveWorkoutSensorUseCases? sensorUseCases;
|
||||
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
final WorkoutHistoryUseCases historyUseCases;
|
||||
@ -264,9 +266,16 @@ final class _WorkoutTemplateListScreenState
|
||||
}
|
||||
|
||||
Future<void> _start(WorkoutTemplate template) async {
|
||||
final openSession = await widget.activeUseCases.findOpen();
|
||||
if (_templateExerciseCount(template) == 0) {
|
||||
_showSnackBar(
|
||||
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
|
||||
'au moins un exercice avant de la lancer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final hasBlockingOpenSession = await _hasBlockingOpenSession();
|
||||
if (!mounted) return;
|
||||
if (openSession != null) {
|
||||
if (hasBlockingOpenSession) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
@ -277,9 +286,18 @@ final class _WorkoutTemplateListScreenState
|
||||
);
|
||||
return;
|
||||
}
|
||||
final session = await widget.activeUseCases.startFromTemplate(
|
||||
final ActiveWorkoutSession session;
|
||||
try {
|
||||
session = await widget.activeUseCases.startFromTemplate(
|
||||
template.metadata.id,
|
||||
);
|
||||
} on DomainException {
|
||||
_showSnackBar(
|
||||
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
|
||||
'au moins un exercice avant de la lancer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
@ -292,11 +310,28 @@ final class _WorkoutTemplateListScreenState
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
performanceReferenceUseCase: widget.performanceReferenceUseCase,
|
||||
mediaUseCases: widget.mediaUseCases,
|
||||
sensorUseCases: widget.sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _hasBlockingOpenSession() async {
|
||||
final openSession = await widget.activeUseCases.findOpen();
|
||||
if (openSession == null) {
|
||||
return false;
|
||||
}
|
||||
if (WorkoutExecutionPlan.tryFromSession(openSession) != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await widget.activeUseCases.abandon(openSession.metadata.id);
|
||||
} on DomainException {
|
||||
// Legacy invalid sessions must not block a new start.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WorkoutTemplate template) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -458,6 +493,7 @@ final class _WorkoutTemplateFormScreenState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canSave = !_saving && _programs.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isEditing ? 'Modifier la séance' : 'Créer une séance'),
|
||||
@ -472,8 +508,12 @@ final class _WorkoutTemplateFormScreenState
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.all(16),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: canSave ? _save : null,
|
||||
icon: _saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
@ -482,6 +522,8 @@ final class _WorkoutTemplateFormScreenState
|
||||
: const Icon(Icons.check),
|
||||
label: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@ -521,13 +563,14 @@ final class _WorkoutTemplateFormScreenState
|
||||
child: _programs.isEmpty
|
||||
? const _CenteredMessage(
|
||||
title: 'Aucun programme ajouté',
|
||||
message: 'Ajoute un programme pour composer la séance.',
|
||||
message:
|
||||
'Ajoute au moins un programme pour enregistrer cette séance.',
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _programs.length,
|
||||
onReorder: _reorderProgram,
|
||||
onReorderItem: _reorderProgram,
|
||||
itemBuilder: (context, index) {
|
||||
final program = _programs[index];
|
||||
return Card(
|
||||
@ -579,9 +622,6 @@ final class _WorkoutTemplateFormScreenState
|
||||
|
||||
void _reorderProgram(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = _programs.removeAt(oldIndex);
|
||||
_programs.insert(newIndex, item);
|
||||
});
|
||||
@ -599,6 +639,9 @@ final class _WorkoutTemplateFormScreenState
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_programs.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
@ -1050,6 +1093,7 @@ List<ExerciseStep> _parseExerciseSteps(Object? value) {
|
||||
scoreUnit: step['scoreUnit'] as String?,
|
||||
defaultTargetScore: (step['defaultTargetScore'] as num?)?.toDouble(),
|
||||
defaultTargetScoreTimeMs: step['defaultTargetScoreTimeMs'] as int?,
|
||||
linkedToSeriesScore: step['linkedToSeriesScore'] == true,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
@ -1062,12 +1106,16 @@ String _autoStartHelpText(bool active) {
|
||||
|
||||
String _templateSummary(WorkoutTemplate template) {
|
||||
final programCount = template.programs.length;
|
||||
final exerciseCount = template.programs.fold<int>(
|
||||
final exerciseCount = _templateExerciseCount(template);
|
||||
return '$programCount programme${programCount > 1 ? 's' : ''} · '
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
int _templateExerciseCount(WorkoutTemplate template) {
|
||||
return template.programs.fold<int>(
|
||||
0,
|
||||
(total, program) => total + _exerciseCount(program.programSnapshotJson),
|
||||
);
|
||||
return '$programCount programme${programCount > 1 ? 's' : ''} · '
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
String _programDraftSummary(WorkoutTemplateProgramDraft program) {
|
||||
@ -1099,8 +1147,12 @@ String _exerciseDraftSummary(WorkoutTemplateExerciseDraft exercise) {
|
||||
}
|
||||
|
||||
int _exerciseCount(String programSnapshotJson) {
|
||||
try {
|
||||
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
||||
return (snapshot['exercises'] as List<dynamic>? ?? const []).length;
|
||||
} on Exception {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
final class _CenteredMessage extends StatelessWidget {
|
||||
@ -1119,7 +1171,7 @@ final class _CenteredMessage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const int watchBridgeSchemaVersion = 1;
|
||||
const int watchBridgeSchemaVersion = 4;
|
||||
|
||||
enum WatchCommandType {
|
||||
startCurrentExercise,
|
||||
@ -10,6 +10,8 @@ enum WatchCommandType {
|
||||
finishCurrentSet,
|
||||
skipCurrentSet,
|
||||
skipCurrentRest,
|
||||
incrementScore,
|
||||
decrementScore,
|
||||
}
|
||||
|
||||
enum WatchCommandAck {
|
||||
@ -56,6 +58,8 @@ enum WatchTimerDisplayMode { countdown, elapsed }
|
||||
|
||||
enum WatchTimerRunState { stopped, running, paused }
|
||||
|
||||
enum WatchManualScoreScope { series, step }
|
||||
|
||||
final class WatchCommandEnvelope {
|
||||
const WatchCommandEnvelope({
|
||||
this.schemaVersion = watchBridgeSchemaVersion,
|
||||
@ -138,6 +142,9 @@ final class WatchSessionProjection {
|
||||
required this.seriesIndex,
|
||||
required this.seriesTotal,
|
||||
required this.exerciseName,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.passageTotal,
|
||||
this.stepIndex,
|
||||
@ -149,6 +156,12 @@ final class WatchSessionProjection {
|
||||
this.secondaryActions = const [],
|
||||
this.nextExerciseName,
|
||||
this.statusLabel,
|
||||
this.hasManualScore = false,
|
||||
this.currentManualScoreValue,
|
||||
this.canDecrementScore = false,
|
||||
this.manualScoreTargetValue,
|
||||
this.manualScoreTargetLabel,
|
||||
this.manualScoreScope,
|
||||
});
|
||||
|
||||
factory WatchSessionProjection.fromJson(Map<String, Object?> json) {
|
||||
@ -169,6 +182,9 @@ final class WatchSessionProjection {
|
||||
seriesIndex: _intFromJson(json['seriesIndex'], 0),
|
||||
seriesTotal: _intFromJson(json['seriesTotal'], 0),
|
||||
exerciseName: _stringFromJson(json['exerciseName']),
|
||||
programIndex: _nullableIntFromJson(json['programIndex']),
|
||||
exerciseIndex: _nullableIntFromJson(json['exerciseIndex']),
|
||||
setIndex: _nullableIntFromJson(json['setIndex']),
|
||||
passageIndex: _nullableIntFromJson(json['passageIndex']),
|
||||
passageTotal: _nullableIntFromJson(json['passageTotal']),
|
||||
stepIndex: _nullableIntFromJson(json['stepIndex']),
|
||||
@ -187,6 +203,21 @@ final class WatchSessionProjection {
|
||||
),
|
||||
nextExerciseName: _nullableStringFromJson(json['nextExerciseName']),
|
||||
statusLabel: _nullableStringFromJson(json['statusLabel']),
|
||||
hasManualScore: _boolFromJson(json['hasManualScore'], false),
|
||||
currentManualScoreValue: _nullableDoubleFromJson(
|
||||
json['currentManualScoreValue'],
|
||||
),
|
||||
canDecrementScore: _boolFromJson(json['canDecrementScore'], false),
|
||||
manualScoreTargetValue: _nullableDoubleFromJson(
|
||||
json['manualScoreTargetValue'],
|
||||
),
|
||||
manualScoreTargetLabel: _nullableStringFromJson(
|
||||
json['manualScoreTargetLabel'],
|
||||
),
|
||||
manualScoreScope: _nullableEnumFromJson(
|
||||
json['manualScoreScope'],
|
||||
WatchManualScoreScope.values,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -199,6 +230,9 @@ final class WatchSessionProjection {
|
||||
final int seriesIndex;
|
||||
final int seriesTotal;
|
||||
final String exerciseName;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? passageTotal;
|
||||
final int? stepIndex;
|
||||
@ -210,6 +244,12 @@ final class WatchSessionProjection {
|
||||
final List<WatchSecondaryAction> secondaryActions;
|
||||
final String? nextExerciseName;
|
||||
final String? statusLabel;
|
||||
final bool hasManualScore;
|
||||
final double? currentManualScoreValue;
|
||||
final bool canDecrementScore;
|
||||
final double? manualScoreTargetValue;
|
||||
final String? manualScoreTargetLabel;
|
||||
final WatchManualScoreScope? manualScoreScope;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
@ -222,6 +262,9 @@ final class WatchSessionProjection {
|
||||
'seriesIndex': seriesIndex,
|
||||
'seriesTotal': seriesTotal,
|
||||
'exerciseName': exerciseName,
|
||||
'programIndex': programIndex,
|
||||
'exerciseIndex': exerciseIndex,
|
||||
'setIndex': setIndex,
|
||||
'passageIndex': passageIndex,
|
||||
'passageTotal': passageTotal,
|
||||
'stepIndex': stepIndex,
|
||||
@ -237,6 +280,12 @@ final class WatchSessionProjection {
|
||||
.toList(),
|
||||
'nextExerciseName': nextExerciseName,
|
||||
'statusLabel': statusLabel,
|
||||
'hasManualScore': hasManualScore,
|
||||
'currentManualScoreValue': currentManualScoreValue,
|
||||
'canDecrementScore': canDecrementScore,
|
||||
'manualScoreTargetValue': manualScoreTargetValue,
|
||||
'manualScoreTargetLabel': manualScoreTargetLabel,
|
||||
'manualScoreScope': manualScoreScope?.name,
|
||||
};
|
||||
}
|
||||
|
||||
@ -253,6 +302,9 @@ final class WatchSessionProjection {
|
||||
seriesIndex == other.seriesIndex &&
|
||||
seriesTotal == other.seriesTotal &&
|
||||
exerciseName == other.exerciseName &&
|
||||
programIndex == other.programIndex &&
|
||||
exerciseIndex == other.exerciseIndex &&
|
||||
setIndex == other.setIndex &&
|
||||
passageIndex == other.passageIndex &&
|
||||
passageTotal == other.passageTotal &&
|
||||
stepIndex == other.stepIndex &&
|
||||
@ -263,12 +315,18 @@ final class WatchSessionProjection {
|
||||
primaryAction == other.primaryAction &&
|
||||
_listEquals(secondaryActions, other.secondaryActions) &&
|
||||
nextExerciseName == other.nextExerciseName &&
|
||||
statusLabel == other.statusLabel;
|
||||
statusLabel == other.statusLabel &&
|
||||
hasManualScore == other.hasManualScore &&
|
||||
currentManualScoreValue == other.currentManualScoreValue &&
|
||||
canDecrementScore == other.canDecrementScore &&
|
||||
manualScoreTargetValue == other.manualScoreTargetValue &&
|
||||
manualScoreTargetLabel == other.manualScoreTargetLabel &&
|
||||
manualScoreScope == other.manualScoreScope;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
return Object.hashAll([
|
||||
schemaVersion,
|
||||
deviceSessionId,
|
||||
revision,
|
||||
@ -278,6 +336,9 @@ final class WatchSessionProjection {
|
||||
seriesIndex,
|
||||
seriesTotal,
|
||||
exerciseName,
|
||||
programIndex,
|
||||
exerciseIndex,
|
||||
setIndex,
|
||||
passageIndex,
|
||||
passageTotal,
|
||||
stepIndex,
|
||||
@ -289,10 +350,226 @@ final class WatchSessionProjection {
|
||||
Object.hashAll(secondaryActions),
|
||||
nextExerciseName,
|
||||
statusLabel,
|
||||
hasManualScore,
|
||||
currentManualScoreValue,
|
||||
canDecrementScore,
|
||||
manualScoreTargetValue,
|
||||
manualScoreTargetLabel,
|
||||
manualScoreScope,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchSensorSummary {
|
||||
const WatchSensorSummary({
|
||||
this.schemaVersion = watchBridgeSchemaVersion,
|
||||
required this.sessionId,
|
||||
required this.sampleCount,
|
||||
this.minHeartRateBpm,
|
||||
this.averageHeartRateBpm,
|
||||
this.maxHeartRateBpm,
|
||||
this.totalDistanceMeters,
|
||||
this.totalCaloriesKcal,
|
||||
});
|
||||
|
||||
factory WatchSensorSummary.fromJson(Map<String, Object?> json) {
|
||||
return WatchSensorSummary(
|
||||
schemaVersion: _intFromJson(
|
||||
json['schemaVersion'],
|
||||
watchBridgeSchemaVersion,
|
||||
),
|
||||
sessionId: _stringFromJson(json['sessionId']),
|
||||
sampleCount: _intFromJson(json['sampleCount'], 0),
|
||||
minHeartRateBpm: _nullableIntFromJson(json['minHeartRateBpm']),
|
||||
averageHeartRateBpm: _nullableDoubleFromJson(json['averageHeartRateBpm']),
|
||||
maxHeartRateBpm: _nullableIntFromJson(json['maxHeartRateBpm']),
|
||||
totalDistanceMeters: _nullableDoubleFromJson(json['totalDistanceMeters']),
|
||||
totalCaloriesKcal: _nullableDoubleFromJson(json['totalCaloriesKcal']),
|
||||
);
|
||||
}
|
||||
|
||||
final int schemaVersion;
|
||||
final String sessionId;
|
||||
final int sampleCount;
|
||||
final int? minHeartRateBpm;
|
||||
final double? averageHeartRateBpm;
|
||||
final int? maxHeartRateBpm;
|
||||
final double? totalDistanceMeters;
|
||||
final double? totalCaloriesKcal;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'schemaVersion': schemaVersion,
|
||||
'sessionId': sessionId,
|
||||
'sampleCount': sampleCount,
|
||||
'minHeartRateBpm': minHeartRateBpm,
|
||||
'averageHeartRateBpm': averageHeartRateBpm,
|
||||
'maxHeartRateBpm': maxHeartRateBpm,
|
||||
'totalDistanceMeters': totalDistanceMeters,
|
||||
'totalCaloriesKcal': totalCaloriesKcal,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is WatchSensorSummary &&
|
||||
schemaVersion == other.schemaVersion &&
|
||||
sessionId == other.sessionId &&
|
||||
sampleCount == other.sampleCount &&
|
||||
minHeartRateBpm == other.minHeartRateBpm &&
|
||||
averageHeartRateBpm == other.averageHeartRateBpm &&
|
||||
maxHeartRateBpm == other.maxHeartRateBpm &&
|
||||
totalDistanceMeters == other.totalDistanceMeters &&
|
||||
totalCaloriesKcal == other.totalCaloriesKcal;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
schemaVersion,
|
||||
sessionId,
|
||||
sampleCount,
|
||||
minHeartRateBpm,
|
||||
averageHeartRateBpm,
|
||||
maxHeartRateBpm,
|
||||
totalDistanceMeters,
|
||||
totalCaloriesKcal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchSensorSample {
|
||||
const WatchSensorSample({
|
||||
this.schemaVersion = watchBridgeSchemaVersion,
|
||||
this.sampleId,
|
||||
required this.sessionId,
|
||||
int? capturedAtEpochMs,
|
||||
int? recordedAtEpochMs,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.stepIndex,
|
||||
this.programSnapshotId,
|
||||
this.exerciseSnapshotId,
|
||||
this.stepSnapshotId,
|
||||
this.heartRateBpm,
|
||||
this.distanceMeters,
|
||||
this.caloriesKcal,
|
||||
}) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0;
|
||||
|
||||
factory WatchSensorSample.fromJson(Map<String, Object?> json) {
|
||||
return WatchSensorSample(
|
||||
schemaVersion: _intFromJson(
|
||||
json['schemaVersion'],
|
||||
watchBridgeSchemaVersion,
|
||||
),
|
||||
sampleId: _nullableStringFromJson(json['sampleId']),
|
||||
sessionId: _stringFromJson(json['sessionId']),
|
||||
capturedAtEpochMs: _intFromJson(
|
||||
json['capturedAtEpochMs'],
|
||||
_intFromJson(json['recordedAtEpochMs'], 0),
|
||||
),
|
||||
programIndex: _nullableIntFromJson(json['programIndex']),
|
||||
exerciseIndex: _nullableIntFromJson(json['exerciseIndex']),
|
||||
setIndex: _nullableIntFromJson(json['setIndex']),
|
||||
passageIndex: _nullableIntFromJson(json['passageIndex']),
|
||||
stepIndex: _nullableIntFromJson(json['stepIndex']),
|
||||
programSnapshotId: _nullableStringFromJson(json['programSnapshotId']),
|
||||
exerciseSnapshotId: _nullableStringFromJson(json['exerciseSnapshotId']),
|
||||
stepSnapshotId: _nullableStringFromJson(json['stepSnapshotId']),
|
||||
heartRateBpm: _nullableIntFromJson(json['heartRateBpm']),
|
||||
distanceMeters: _nullableDoubleFromJson(json['distanceMeters']),
|
||||
caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']),
|
||||
);
|
||||
}
|
||||
|
||||
final int schemaVersion;
|
||||
final String? sampleId;
|
||||
final String sessionId;
|
||||
final int capturedAtEpochMs;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? stepIndex;
|
||||
final String? programSnapshotId;
|
||||
final String? exerciseSnapshotId;
|
||||
final String? stepSnapshotId;
|
||||
final int? heartRateBpm;
|
||||
final double? distanceMeters;
|
||||
final double? caloriesKcal;
|
||||
|
||||
int get recordedAtEpochMs => capturedAtEpochMs;
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'schemaVersion': schemaVersion,
|
||||
'sampleId': sampleId,
|
||||
'sessionId': sessionId,
|
||||
'capturedAtEpochMs': capturedAtEpochMs,
|
||||
'recordedAtEpochMs': capturedAtEpochMs,
|
||||
'programIndex': programIndex,
|
||||
'exerciseIndex': exerciseIndex,
|
||||
'setIndex': setIndex,
|
||||
'passageIndex': passageIndex,
|
||||
'stepIndex': stepIndex,
|
||||
'programSnapshotId': programSnapshotId,
|
||||
'exerciseSnapshotId': exerciseSnapshotId,
|
||||
'stepSnapshotId': stepSnapshotId,
|
||||
'heartRateBpm': heartRateBpm,
|
||||
'distanceMeters': distanceMeters,
|
||||
'caloriesKcal': caloriesKcal,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is WatchSensorSample &&
|
||||
schemaVersion == other.schemaVersion &&
|
||||
sampleId == other.sampleId &&
|
||||
sessionId == other.sessionId &&
|
||||
capturedAtEpochMs == other.capturedAtEpochMs &&
|
||||
programIndex == other.programIndex &&
|
||||
exerciseIndex == other.exerciseIndex &&
|
||||
setIndex == other.setIndex &&
|
||||
passageIndex == other.passageIndex &&
|
||||
stepIndex == other.stepIndex &&
|
||||
programSnapshotId == other.programSnapshotId &&
|
||||
exerciseSnapshotId == other.exerciseSnapshotId &&
|
||||
stepSnapshotId == other.stepSnapshotId &&
|
||||
heartRateBpm == other.heartRateBpm &&
|
||||
distanceMeters == other.distanceMeters &&
|
||||
caloriesKcal == other.caloriesKcal;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
schemaVersion,
|
||||
sampleId,
|
||||
sessionId,
|
||||
capturedAtEpochMs,
|
||||
programIndex,
|
||||
exerciseIndex,
|
||||
setIndex,
|
||||
passageIndex,
|
||||
stepIndex,
|
||||
programSnapshotId,
|
||||
exerciseSnapshotId,
|
||||
stepSnapshotId,
|
||||
heartRateBpm,
|
||||
distanceMeters,
|
||||
caloriesKcal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef WatchTelemetrySummary = WatchSensorSummary;
|
||||
typedef WatchTelemetrySample = WatchSensorSample;
|
||||
|
||||
final class WatchTimerProjection {
|
||||
const WatchTimerProjection({
|
||||
required this.kind,
|
||||
@ -392,6 +669,17 @@ T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
T? _nullableEnumFromJson<T extends Enum>(Object? value, List<T> values) {
|
||||
if (value is String) {
|
||||
for (final enumValue in values) {
|
||||
if (enumValue.name == value) {
|
||||
return enumValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<T> _enumListFromJson<T extends Enum>(Object? value, List<T> values) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
@ -438,6 +726,14 @@ int? _nullableIntFromJson(Object? value) {
|
||||
return value is int ? value : null;
|
||||
}
|
||||
|
||||
double? _nullableDoubleFromJson(Object? value) {
|
||||
return switch (value) {
|
||||
double() => value,
|
||||
int() => value.toDouble(),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
bool _boolFromJson(Object? value, bool fallback) {
|
||||
return value is bool ? value : fallback;
|
||||
}
|
||||
|
||||
@ -52,6 +52,94 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchSensorSummary', () {
|
||||
test('round-trips heart rate summary through JSON', () {
|
||||
const summary = WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 12,
|
||||
minHeartRateBpm: 91,
|
||||
averageHeartRateBpm: 128.5,
|
||||
maxHeartRateBpm: 174,
|
||||
totalDistanceMeters: 842.4,
|
||||
totalCaloriesKcal: 186.2,
|
||||
);
|
||||
|
||||
final decoded = WatchSensorSummary.fromJson(
|
||||
jsonDecode(jsonEncode(summary.toJson())) as Map<String, Object?>,
|
||||
);
|
||||
|
||||
expect(decoded, summary);
|
||||
});
|
||||
|
||||
test('falls back safely for absent fields', () {
|
||||
final summary = WatchSensorSummary.fromJson({});
|
||||
|
||||
expect(summary.schemaVersion, watchBridgeSchemaVersion);
|
||||
expect(summary.sessionId, '');
|
||||
expect(summary.sampleCount, 0);
|
||||
expect(summary.minHeartRateBpm, isNull);
|
||||
expect(summary.averageHeartRateBpm, isNull);
|
||||
expect(summary.maxHeartRateBpm, isNull);
|
||||
expect(summary.totalDistanceMeters, isNull);
|
||||
expect(summary.totalCaloriesKcal, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchSensorSample', () {
|
||||
test('round-trips live telemetry sample through JSON', () {
|
||||
const sample = WatchSensorSample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: 1710000000300,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 1,
|
||||
setIndex: 2,
|
||||
passageIndex: 3,
|
||||
stepIndex: 4,
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
stepSnapshotId: 'step-snapshot-1',
|
||||
heartRateBpm: 142,
|
||||
distanceMeters: 840.5,
|
||||
caloriesKcal: 184.2,
|
||||
);
|
||||
|
||||
final decoded = WatchSensorSample.fromJson(
|
||||
jsonDecode(jsonEncode(sample.toJson())) as Map<String, Object?>,
|
||||
);
|
||||
|
||||
expect(decoded, sample);
|
||||
});
|
||||
|
||||
test('falls back safely for absent fields', () {
|
||||
final sample = WatchSensorSample.fromJson({});
|
||||
|
||||
expect(sample.schemaVersion, watchBridgeSchemaVersion);
|
||||
expect(sample.sampleId, isNull);
|
||||
expect(sample.sessionId, '');
|
||||
expect(sample.recordedAtEpochMs, 0);
|
||||
expect(sample.capturedAtEpochMs, 0);
|
||||
expect(sample.programIndex, isNull);
|
||||
expect(sample.exerciseIndex, isNull);
|
||||
expect(sample.setIndex, isNull);
|
||||
expect(sample.passageIndex, isNull);
|
||||
expect(sample.stepIndex, isNull);
|
||||
expect(sample.heartRateBpm, isNull);
|
||||
expect(sample.distanceMeters, isNull);
|
||||
expect(sample.caloriesKcal, isNull);
|
||||
});
|
||||
|
||||
test('accepts legacy recordedAtEpochMs as captured timestamp', () {
|
||||
final sample = WatchSensorSample.fromJson({
|
||||
'sessionId': 'session-1',
|
||||
'recordedAtEpochMs': 1710000000300,
|
||||
});
|
||||
|
||||
expect(sample.capturedAtEpochMs, 1710000000300);
|
||||
expect(sample.recordedAtEpochMs, 1710000000300);
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchSessionProjection', () {
|
||||
test('round-trips every phase, primary action, and secondary action', () {
|
||||
for (final phase in WatchSessionPhase.values) {
|
||||
@ -65,6 +153,9 @@ void main() {
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 5,
|
||||
exerciseName: 'Pompes tempo',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 1,
|
||||
setIndex: 1,
|
||||
passageIndex: 1,
|
||||
passageTotal: 3,
|
||||
stepIndex: 2,
|
||||
@ -76,6 +167,12 @@ void main() {
|
||||
secondaryActions: WatchSecondaryAction.values,
|
||||
nextExerciseName: 'Fentes sautees',
|
||||
statusLabel: 'Chrono etape',
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 7.5,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 10,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreScope: WatchManualScoreScope.step,
|
||||
);
|
||||
|
||||
final decoded = WatchSessionProjection.fromJson(
|
||||
@ -114,6 +211,12 @@ void main() {
|
||||
]);
|
||||
expect(projection.nextExerciseName, isNull);
|
||||
expect(projection.statusLabel, isNull);
|
||||
expect(projection.hasManualScore, isFalse);
|
||||
expect(projection.currentManualScoreValue, isNull);
|
||||
expect(projection.canDecrementScore, isFalse);
|
||||
expect(projection.manualScoreTargetValue, isNull);
|
||||
expect(projection.manualScoreTargetLabel, isNull);
|
||||
expect(projection.manualScoreScope, isNull);
|
||||
});
|
||||
|
||||
test('falls back to neutral values for absent required fields', () {
|
||||
@ -130,6 +233,12 @@ void main() {
|
||||
expect(projection.exerciseName, '');
|
||||
expect(projection.primaryAction, WatchPrimaryAction.none);
|
||||
expect(projection.secondaryActions, isEmpty);
|
||||
expect(projection.hasManualScore, isFalse);
|
||||
expect(projection.currentManualScoreValue, isNull);
|
||||
expect(projection.canDecrementScore, isFalse);
|
||||
expect(projection.manualScoreTargetValue, isNull);
|
||||
expect(projection.manualScoreTargetLabel, isNull);
|
||||
expect(projection.manualScoreScope, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -62,6 +62,24 @@ void main() {
|
||||
expect(response.statusCode, 409);
|
||||
});
|
||||
|
||||
test('register endpoint returns 400 for malformed json payload', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/auth/register'),
|
||||
body: jsonEncode(['not-an-object']),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Request body must be a JSON object.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('login endpoint returns a token and expiration', () async {
|
||||
final users = _FakeUserRepository();
|
||||
await users.insert(
|
||||
@ -121,6 +139,24 @@ void main() {
|
||||
expect(response.statusCode, 401);
|
||||
});
|
||||
|
||||
test('login endpoint returns 400 when email is blank', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/auth/login'),
|
||||
body: jsonEncode({'email': ' ', 'password': 'password123'}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'email must be a non-empty string.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('logout endpoint revokes the bearer session', () async {
|
||||
final users = _FakeUserRepository();
|
||||
await users.insert(
|
||||
@ -157,6 +193,20 @@ void main() {
|
||||
expect(response.statusCode, 204);
|
||||
expect(sessions.revokedSessionIds, ['session-1']);
|
||||
});
|
||||
|
||||
test('logout endpoint returns 401 without bearer token', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request('POST', Uri.parse('http://localhost/auth/logout')),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 401);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Missing bearer token.'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
AuthApi _authApi({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:gametime_server/api/router.dart';
|
||||
import 'package:gametime_server/api/share_api.dart';
|
||||
import 'package:gametime_server/application/application.dart';
|
||||
@ -22,20 +24,342 @@ void main() {
|
||||
expect(response.statusCode, 401, reason: request.url.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ShareApi _shareApi() {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||
final ids = _FakeIds();
|
||||
final users = _FakeUserRepository(
|
||||
UserAccount(
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
passwordHash: 'hash',
|
||||
createdAt: clock.now(),
|
||||
updatedAt: clock.now(),
|
||||
test('create share returns share id, resolved recipients and unresolved emails',
|
||||
() async {
|
||||
final shares = _FakeShareRepository();
|
||||
final users = _FakeUserRepository([
|
||||
_user('user-1', 'user@example.com'),
|
||||
_user('user-2', 'friend@example.com'),
|
||||
]);
|
||||
final handler = buildApiHandler(
|
||||
shareApi: _shareApi(users: users, shares: shares),
|
||||
);
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({
|
||||
'resourceType': 'program',
|
||||
'payload': {'schemaVersion': 2, 'name': 'Programme été'},
|
||||
'recipientEmails': [
|
||||
' friend@example.com ',
|
||||
'missing@example.com',
|
||||
'user@example.com',
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
|
||||
expect(response.statusCode, 201);
|
||||
expect(body['shareId'], 'id-1');
|
||||
expect(body['recipientUserIds'], ['user-2']);
|
||||
expect(body['unresolvedEmails'], ['missing@example.com']);
|
||||
expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program);
|
||||
});
|
||||
|
||||
test('create share returns 400 for invalid resource type', () async {
|
||||
final handler = buildApiHandler(shareApi: _shareApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({
|
||||
'resourceType': 'exercise',
|
||||
'payload': {'name': 'Squat'},
|
||||
'recipientEmails': ['friend@example.com'],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'resourceType must be program or workoutTemplate.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('inbox returns serialized shares for the authenticated recipient',
|
||||
() async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedInbox(
|
||||
recipientUserId: 'user-1',
|
||||
items: [
|
||||
ShareInboxItem(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipient: ShareRecipient(
|
||||
id: 'recipient-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'user-1',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'GET',
|
||||
Uri.parse('http://localhost/shares/inbox'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final items = body['items'] as List;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(items, hasLength(1));
|
||||
expect((items.single as Map)['shareId'], 'share-1');
|
||||
expect((items.single as Map)['resourceType'], 'program');
|
||||
expect((items.single as Map)['status'], 'pending');
|
||||
});
|
||||
|
||||
test('accept share returns the copied synced resource', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: SyncedResourceType.workoutTemplate,
|
||||
payloadJson: {'schemaVersion': 3, 'name': 'Template A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: [
|
||||
ShareRecipient(
|
||||
id: 'recipient-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'user-1',
|
||||
),
|
||||
],
|
||||
);
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
final handler = buildApiHandler(
|
||||
shareApi: _shareApi(shares: shares, resources: resources),
|
||||
);
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/accept'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final created = body['createdResource'] as Map;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(created['resourceType'], 'workoutTemplate');
|
||||
expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'});
|
||||
expect(shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.accepted);
|
||||
expect(resources.items.single.ownerUserId, 'user-1');
|
||||
});
|
||||
|
||||
test('accept share returns 404 when share is missing', () async {
|
||||
final handler = buildApiHandler(shareApi: _shareApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/missing/accept'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 404);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share not found.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('accept share returns 409 when share was already answered', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: [
|
||||
ShareRecipient(
|
||||
id: 'recipient-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'user-1',
|
||||
status: ShareRecipientStatus.accepted,
|
||||
respondedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/accept'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 409);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share has already been answered.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('decline share returns 204 and updates recipient status', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: [
|
||||
ShareRecipient(
|
||||
id: 'recipient-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'user-1',
|
||||
),
|
||||
],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/decline'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 204);
|
||||
expect(shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.declined);
|
||||
});
|
||||
|
||||
test('decline share returns 409 when share was already answered', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: [
|
||||
ShareRecipient(
|
||||
id: 'recipient-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'user-1',
|
||||
status: ShareRecipientStatus.declined,
|
||||
respondedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/decline'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 409);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share has already been answered.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('revoke share returns 204 and marks share as revoked', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: const [],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/revoke'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 204);
|
||||
expect(shares.shareById['share-1']?.isRevoked, isTrue);
|
||||
});
|
||||
|
||||
test('revoke share returns 404 when requester is not the sender', () async {
|
||||
final shares = _FakeShareRepository()
|
||||
..seedShare(
|
||||
share: Share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'other-user',
|
||||
resourceType: SyncedResourceType.program,
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
createdAt: DateTime.utc(2026, 7, 19, 10),
|
||||
),
|
||||
recipients: const [],
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/shares/share-1/revoke'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 404);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share not found.'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ShareApi _shareApi({
|
||||
_FakeUserRepository? users,
|
||||
_FakeShareRepository? shares,
|
||||
_FakeSyncedResourceRepository? resources,
|
||||
}) {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||
final ids = _FakeIds();
|
||||
final userRepository =
|
||||
users ??
|
||||
_FakeUserRepository([_user('user-1', 'user@example.com')]);
|
||||
final sessions = _FakeAuthSessionRepository(
|
||||
AuthSession(
|
||||
id: 'session-1',
|
||||
@ -47,46 +371,49 @@ ShareApi _shareApi() {
|
||||
);
|
||||
final tokens = _FakeTokenService();
|
||||
final authenticate = AuthenticateRequestUseCase(
|
||||
users: users,
|
||||
users: userRepository,
|
||||
sessions: sessions,
|
||||
tokens: tokens,
|
||||
clock: clock,
|
||||
);
|
||||
final shares = _FakeShareRepository();
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
final shareRepository = shares ?? _FakeShareRepository();
|
||||
final resourceRepository = resources ?? _FakeSyncedResourceRepository();
|
||||
return ShareApi(
|
||||
createShare: CreateShareUseCase(
|
||||
users: users,
|
||||
shares: shares,
|
||||
users: userRepository,
|
||||
shares: shareRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
listInbox: ListInboxUseCase(shares: shares),
|
||||
listInbox: ListInboxUseCase(shares: shareRepository),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
shares: shareRepository,
|
||||
resources: resourceRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
|
||||
declineShare: DeclineShareUseCase(shares: shareRepository, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shareRepository, clock: clock),
|
||||
authenticateRequest: authenticate,
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeUserRepository implements UserRepository {
|
||||
const _FakeUserRepository(this.user);
|
||||
_FakeUserRepository(List<UserAccount> users)
|
||||
: _byId = {for (final user in users) user.id: user},
|
||||
_byEmail = {for (final user in users) user.email: user};
|
||||
|
||||
final UserAccount user;
|
||||
final Map<String, UserAccount> _byId;
|
||||
final Map<String, UserAccount> _byEmail;
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findByEmail(String email) async {
|
||||
return user.email == email.toLowerCase() ? user : null;
|
||||
return _byEmail[email.trim().toLowerCase()];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findById(String id) async {
|
||||
return id == user.id ? user : null;
|
||||
return _byId[id];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -121,20 +448,49 @@ final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
||||
}
|
||||
|
||||
final class _FakeShareRepository implements ShareRepository {
|
||||
final shareById = <String, Share>{};
|
||||
final recipientById = <String, ShareRecipient>{};
|
||||
final recipientByKey = <String, ShareRecipient>{};
|
||||
final inboxByRecipient = <String, List<ShareInboxItem>>{};
|
||||
|
||||
void seedShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) {
|
||||
shareById[share.id] = share;
|
||||
for (final recipient in recipients) {
|
||||
recipientById[recipient.id] = recipient;
|
||||
recipientByKey['${recipient.shareId}:${recipient.recipientUserId}'] =
|
||||
recipient;
|
||||
}
|
||||
}
|
||||
|
||||
void seedInbox({
|
||||
required String recipientUserId,
|
||||
required List<ShareInboxItem> items,
|
||||
}) {
|
||||
inboxByRecipient[recipientUserId] = items;
|
||||
for (final item in items) {
|
||||
seedShare(share: item.share, recipients: [item.recipient]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) async {}
|
||||
}) async {
|
||||
seedShare(share: share, recipients: recipients);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
return const [];
|
||||
return inboxByRecipient[recipientUserId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
return null;
|
||||
return shareById[shareId];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -142,7 +498,7 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
return null;
|
||||
return recipientByKey['$shareId:$recipientUserId'];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -150,18 +506,60 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {}
|
||||
}) async {
|
||||
final existing = recipientById[recipientId];
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
final updated = ShareRecipient(
|
||||
id: existing.id,
|
||||
shareId: existing.shareId,
|
||||
recipientUserId: existing.recipientUserId,
|
||||
status: status,
|
||||
respondedAt: respondedAt,
|
||||
);
|
||||
recipientById[recipientId] = updated;
|
||||
recipientByKey['${existing.shareId}:${existing.recipientUserId}'] = updated;
|
||||
|
||||
final inbox = inboxByRecipient[existing.recipientUserId];
|
||||
if (inbox != null) {
|
||||
inboxByRecipient[existing.recipientUserId] = [
|
||||
for (final item in inbox)
|
||||
if (item.recipient.id == recipientId)
|
||||
ShareInboxItem(share: item.share, recipient: updated)
|
||||
else
|
||||
item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}) async {
|
||||
final existing = shareById[shareId];
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
final updated = Share(
|
||||
id: existing.id,
|
||||
senderUserId: existing.senderUserId,
|
||||
resourceType: existing.resourceType,
|
||||
payloadJson: existing.payloadJson,
|
||||
createdAt: existing.createdAt,
|
||||
revokedAt: revokedAt,
|
||||
);
|
||||
shareById[shareId] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
final items = <SyncedResource>[];
|
||||
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
items.add(resource);
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.accepted,
|
||||
resource: resource,
|
||||
@ -173,7 +571,10 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return const [];
|
||||
return items
|
||||
.where((item) => item.ownerUserId == ownerUserId)
|
||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,3 +604,13 @@ final class _FakeIds implements IdGenerator {
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
|
||||
UserAccount _user(String id, String email) {
|
||||
return UserAccount(
|
||||
id: id,
|
||||
email: email,
|
||||
passwordHash: 'hash',
|
||||
createdAt: DateTime.utc(2026, 7, 19, 12),
|
||||
updatedAt: DateTime.utc(2026, 7, 19, 12),
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,6 +62,140 @@ void main() {
|
||||
]);
|
||||
expect(repository.items.single.clientId, 'exercise-1');
|
||||
});
|
||||
|
||||
test('pull returns synced items and filters them with since query', () async {
|
||||
final repository = _FakeSyncedResourceRepository()
|
||||
..items.addAll([
|
||||
SyncedResource(
|
||||
serverId: 'resource-1',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.exercise,
|
||||
clientId: 'exercise-1',
|
||||
payloadJson: {'name': 'Squat'},
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
),
|
||||
SyncedResource(
|
||||
serverId: 'resource-2',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
clientId: 'program-1',
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
schemaVersion: 2,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
|
||||
),
|
||||
]);
|
||||
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'GET',
|
||||
Uri.parse(
|
||||
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
|
||||
),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final items = body['items'] as List;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
|
||||
expect(items, hasLength(1));
|
||||
expect((items.single as Map)['clientId'], 'program-1');
|
||||
expect((items.single as Map)['resourceType'], 'program');
|
||||
});
|
||||
|
||||
test('pull returns 400 for invalid since query parameter', () async {
|
||||
final handler = buildApiHandler(syncApi: _syncApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'GET',
|
||||
Uri.parse('http://localhost/sync/pull?since=not-a-date'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Invalid date format'},
|
||||
);
|
||||
});
|
||||
|
||||
test('exchange returns push results followed by pulled items', () async {
|
||||
final repository = _FakeSyncedResourceRepository()
|
||||
..items.add(
|
||||
SyncedResource(
|
||||
serverId: 'existing-1',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.workoutHistory,
|
||||
clientId: 'history-1',
|
||||
payloadJson: {'score': 12},
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
|
||||
),
|
||||
);
|
||||
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/sync/exchange'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({
|
||||
'deviceId': 'device-1',
|
||||
'since': '2026-07-19T08:30:00Z',
|
||||
'items': [
|
||||
{
|
||||
'resourceType': 'exercise',
|
||||
'clientId': 'exercise-2',
|
||||
'schemaVersion': 1,
|
||||
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
||||
'payload': {'name': 'Lunge'},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final pushResults = body['pushResults'] as List;
|
||||
final items = body['items'] as List;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(pushResults, hasLength(1));
|
||||
expect((pushResults.single as Map)['status'], 'accepted');
|
||||
expect(items, hasLength(2));
|
||||
expect(
|
||||
items.map((item) => (item as Map)['clientId']),
|
||||
containsAll(['history-1', 'exercise-2']),
|
||||
);
|
||||
});
|
||||
|
||||
test('exchange returns 400 when items is not a json array', () async {
|
||||
final handler = buildApiHandler(syncApi: _syncApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/sync/exchange'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'items must be a JSON array.'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {
|
||||
|
||||
126
test/application/session_notification_use_cases_test.dart
Normal file
@ -0,0 +1,126 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
test('builds active timer notification content', () {
|
||||
final content = buildSessionNotificationContent(
|
||||
_projection(
|
||||
exerciseName: 'Squats bulgares',
|
||||
dominantTimer: _timer(label: 'Chrono étape', accumulatedMs: 134000),
|
||||
),
|
||||
);
|
||||
|
||||
expect(content.title, 'Squats bulgares');
|
||||
expect(content.primaryLine, '02:14');
|
||||
expect(content.secondaryLine, 'Série 2/4');
|
||||
});
|
||||
|
||||
test('builds manual score notification content', () {
|
||||
final content = buildSessionNotificationContent(
|
||||
_projection(
|
||||
exerciseName: 'Lancers francs',
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 8,
|
||||
),
|
||||
);
|
||||
|
||||
expect(content.title, 'Lancers francs');
|
||||
expect(content.primaryLine, 'Série 2/4 · 8');
|
||||
});
|
||||
|
||||
test('builds rest notification content with next exercise', () {
|
||||
final now = DateTime.utc(2026, 7, 26, 10);
|
||||
final content = buildSessionNotificationContent(
|
||||
_projection(
|
||||
phase: WatchSessionPhase.restRunning,
|
||||
exerciseName: 'Squats',
|
||||
nextExerciseName: 'Fentes',
|
||||
dominantTimer: _timer(
|
||||
kind: WatchTimerKind.rest,
|
||||
displayMode: WatchTimerDisplayMode.countdown,
|
||||
runState: WatchTimerRunState.running,
|
||||
accumulatedMs: 10000,
|
||||
targetMs: 60000,
|
||||
startedAtEpochMs: now.millisecondsSinceEpoch,
|
||||
),
|
||||
),
|
||||
now: now.add(const Duration(seconds: 5)),
|
||||
);
|
||||
|
||||
expect(content.title, 'Repos');
|
||||
expect(content.primaryLine, '00:45 restant · Ensuite : Fentes');
|
||||
});
|
||||
|
||||
test('prefixes paused content without ticking', () {
|
||||
final content = buildSessionNotificationContent(
|
||||
_projection(
|
||||
phase: WatchSessionPhase.paused,
|
||||
dominantTimer: _timer(
|
||||
runState: WatchTimerRunState.paused,
|
||||
accumulatedMs: 42000,
|
||||
),
|
||||
),
|
||||
now: DateTime.utc(2026, 7, 26, 10),
|
||||
);
|
||||
|
||||
expect(content.primaryLine, 'En pause · 00:42');
|
||||
});
|
||||
|
||||
test('falls back to current step name without timer or score', () {
|
||||
final content = buildSessionNotificationContent(
|
||||
_projection(stepName: 'Gainage'),
|
||||
);
|
||||
|
||||
expect(content.primaryLine, 'Gainage');
|
||||
});
|
||||
}
|
||||
|
||||
WatchSessionProjection _projection({
|
||||
WatchSessionPhase phase = WatchSessionPhase.running,
|
||||
String exerciseName = 'Pompes',
|
||||
String? stepName,
|
||||
WatchTimerProjection? dominantTimer,
|
||||
bool hasManualScore = false,
|
||||
double? currentManualScoreValue,
|
||||
String? nextExerciseName,
|
||||
}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
|
||||
phase: phase,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: exerciseName,
|
||||
stepName: stepName,
|
||||
dominantTimer: dominantTimer,
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
nextExerciseName: nextExerciseName,
|
||||
hasManualScore: hasManualScore,
|
||||
currentManualScoreValue: currentManualScoreValue,
|
||||
canDecrementScore: (currentManualScoreValue ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
WatchTimerProjection _timer({
|
||||
WatchTimerKind kind = WatchTimerKind.step,
|
||||
String label = 'Chrono',
|
||||
WatchTimerDisplayMode displayMode = WatchTimerDisplayMode.elapsed,
|
||||
WatchTimerRunState runState = WatchTimerRunState.stopped,
|
||||
int accumulatedMs = 0,
|
||||
int? startedAtEpochMs,
|
||||
int? targetMs,
|
||||
}) {
|
||||
return WatchTimerProjection(
|
||||
kind: kind,
|
||||
label: label,
|
||||
displayMode: displayMode,
|
||||
runState: runState,
|
||||
referenceEpochMs: DateTime.utc(2026, 7, 26, 10).millisecondsSinceEpoch,
|
||||
accumulatedMs: accumulatedMs,
|
||||
startedAtEpochMs: startedAtEpochMs,
|
||||
targetMs: targetMs,
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
test('Exercise requires at least one active measure', () {
|
||||
@ -285,6 +286,40 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('linked step score requires manual series score', () async {
|
||||
final linkedStep = _step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Paniers',
|
||||
scoreUnit: 'pts',
|
||||
linkedToSeriesScore: true,
|
||||
);
|
||||
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
||||
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Tirs',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [linkedStep],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Tirs chrono',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetReps: 1,
|
||||
steps: [linkedStep],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise image gallery rejects a sixth image', () async {
|
||||
final repository = _FakeExerciseRepository()
|
||||
..exercise = Exercise(
|
||||
@ -560,6 +595,26 @@ void main() {
|
||||
expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
|
||||
});
|
||||
|
||||
test('program saveConfigured rejects empty exercise list', () async {
|
||||
final useCase = ProgramUseCases(
|
||||
programRepository: _FakeProgramRepository(),
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.saveConfigured(
|
||||
name: 'Programme',
|
||||
defaultRestSeconds: 60,
|
||||
exercises: const [],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('workout template override preserves step chaining override', () async {
|
||||
final templateRepository = _FakeWorkoutTemplateRepository();
|
||||
final useCase = WorkoutTemplateUseCases(
|
||||
@ -603,6 +658,54 @@ void main() {
|
||||
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
|
||||
});
|
||||
|
||||
test('workout template saveConfigured rejects empty program list', () async {
|
||||
final useCase = WorkoutTemplateUseCases(
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
programRepository: _FakeProgramRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.saveConfigured(
|
||||
name: 'Séance',
|
||||
programs: const [],
|
||||
overrides: const [],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'workout template saveConfigured rejects program snapshot without sets',
|
||||
() async {
|
||||
final useCase = WorkoutTemplateUseCases(
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
programRepository: _FakeProgramRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.saveConfigured(
|
||||
name: 'Séance',
|
||||
programs: [
|
||||
WorkoutTemplateProgramConfig(
|
||||
clientKey: 'program-1',
|
||||
programNameSnapshot: 'Programme',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||||
),
|
||||
],
|
||||
overrides: const [],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('delete exercise keeps existing program snapshots unchanged', () async {
|
||||
final exerciseRepository = _FakeExerciseRepository()
|
||||
..exercise = Exercise(
|
||||
@ -854,6 +957,152 @@ void main() {
|
||||
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
|
||||
});
|
||||
|
||||
test(
|
||||
'startFromTemplate rejects template without playable exercise',
|
||||
() async {
|
||||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||||
..templates.add(
|
||||
WorkoutTemplate(
|
||||
metadata: _metadata('template-1'),
|
||||
name: 'Séance vide',
|
||||
),
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final useCase = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
useCase.startFromTemplate('template-1'),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(repository.session, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('startFromTemplate stamps the source template start time', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||||
..templates.add(_playableTemplate(id: 'template-1', name: 'A'));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final useCase = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final session = await useCase.startFromTemplate('template-1');
|
||||
|
||||
expect(session.sourceWorkoutTemplateId, 'template-1');
|
||||
expect(repository.session, session);
|
||||
expect(templateRepository.templates.single.lastStartedAt, clock.now());
|
||||
});
|
||||
|
||||
test(
|
||||
'startFromLastTemplate starts most recently used playable template',
|
||||
() async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository()
|
||||
..templates.addAll([
|
||||
_playableTemplate(
|
||||
id: 'older',
|
||||
name: 'B',
|
||||
lastStartedAt: DateTime.utc(2026, 7, 10, 12),
|
||||
),
|
||||
_playableTemplate(
|
||||
id: 'latest',
|
||||
name: 'A',
|
||||
lastStartedAt: DateTime.utc(2026, 7, 12, 12),
|
||||
),
|
||||
WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'),
|
||||
]);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final useCase = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final session = await useCase.startFromLastTemplate();
|
||||
|
||||
expect(session.sourceWorkoutTemplateId, 'latest');
|
||||
expect(repository.session, session);
|
||||
},
|
||||
);
|
||||
|
||||
test('resume abandons invalid session snapshot before throwing', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
status: ActiveWorkoutStatus.paused,
|
||||
startedAt: clock.now(),
|
||||
pausedAt: clock.now(),
|
||||
lastPersistedAt: clock.now(),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
|
||||
await expectLater(
|
||||
useCase.resume(session.metadata.id),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
||||
expect(repository.session?.endedAt, clock.now());
|
||||
});
|
||||
|
||||
test('findOpen silently abandons invalid legacy session', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: clock.now(),
|
||||
lastPersistedAt: clock.now(),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
|
||||
final open = await useCase.findOpen();
|
||||
|
||||
expect(open, isNull);
|
||||
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
||||
expect(repository.session?.endedAt, clock.now());
|
||||
});
|
||||
|
||||
test('findOpen returns playable open session unchanged', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
|
||||
final open = await useCase.findOpen();
|
||||
|
||||
expect(open, session);
|
||||
expect(repository.session?.status, ActiveWorkoutStatus.running);
|
||||
expect(repository.session?.endedAt, isNull);
|
||||
});
|
||||
|
||||
test('adjustRestSeconds persists adjusted rest duration', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
@ -1420,6 +1669,78 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('linked step score controls current series manual score', () async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
setsCount: 1,
|
||||
targetReps: 1,
|
||||
scoreEnabled: true,
|
||||
exerciseSteps: [
|
||||
_step(
|
||||
id: 'step-1',
|
||||
position: 0,
|
||||
name: 'Tirs main droite',
|
||||
type: ExerciseStepType.reps,
|
||||
defaultTargetValue: 10,
|
||||
hasScore: true,
|
||||
scoreLabel: 'Paniers',
|
||||
scoreUnit: 'pts',
|
||||
linkedToSeriesScore: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
final activeUseCase = _activeUseCase(repository, clock);
|
||||
final stepUseCase = ActiveExerciseStepUseCases(
|
||||
sessionRepository: repository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
activeSessionUseCases: activeUseCase,
|
||||
);
|
||||
|
||||
await stepUseCase.startOrResumeProgress(
|
||||
sessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
await stepUseCase.incrementCurrentStepScore(
|
||||
sessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
await stepUseCase.incrementCurrentStepScore(
|
||||
sessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
await stepUseCase.decrementCurrentStepScore(
|
||||
sessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
|
||||
expect(repository.manualScoreStates.values.single.value, 1);
|
||||
|
||||
final result = await stepUseCase.completeCurrentStep(
|
||||
sessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
actualScore: 3,
|
||||
);
|
||||
|
||||
expect(result.actualScore, 3);
|
||||
expect(repository.manualScoreStates.values.single.value, 4);
|
||||
});
|
||||
|
||||
test(
|
||||
'step chaining resolution uses template override before program override',
|
||||
() async {
|
||||
@ -2257,6 +2578,365 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'recordCurrentSetResult uses live manual score unless overridden',
|
||||
() async {
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
scoreEnabled: true,
|
||||
);
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
await useCase.incrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
await useCase.incrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
|
||||
final fromLiveState = await useCase.recordCurrentSetResult(
|
||||
sessionId: 'session-1',
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
scoreLabelSnapshot: 'Points',
|
||||
scoreUnitSnapshot: 'pts',
|
||||
);
|
||||
|
||||
expect(fromLiveState.actualScore, 2);
|
||||
expect(repository.manualScoreStates, isEmpty);
|
||||
|
||||
await useCase.incrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
final overridden = await useCase.recordCurrentSetResult(
|
||||
sessionId: 'session-1',
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
actualScore: 5,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
scoreLabelSnapshot: 'Points',
|
||||
scoreUnitSnapshot: 'pts',
|
||||
);
|
||||
|
||||
expect(overridden.actualScore, 5);
|
||||
},
|
||||
);
|
||||
|
||||
test('recordCurrentSetResult upserts by logical set key', () async {
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
scoreEnabled: true,
|
||||
);
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
|
||||
final first = await useCase.recordCurrentSetResult(
|
||||
sessionId: 'session-1',
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
actualScore: 2,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
scoreLabelSnapshot: 'Points',
|
||||
scoreUnitSnapshot: 'pts',
|
||||
);
|
||||
clock.value = DateTime.utc(2026, 7, 25, 12, 1);
|
||||
final second = await useCase.recordCurrentSetResult(
|
||||
sessionId: 'session-1',
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
actualScore: 5,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
scoreLabelSnapshot: 'Points',
|
||||
scoreUnitSnapshot: 'pts',
|
||||
);
|
||||
|
||||
expect(second.metadata.id, first.metadata.id);
|
||||
expect(second.metadata.localRevision, first.metadata.localRevision + 1);
|
||||
expect(repository.results, hasLength(1));
|
||||
expect(repository.results.single.actualScore, 5);
|
||||
});
|
||||
|
||||
test(
|
||||
'setManualScore pushes phone corrections into live manual score state',
|
||||
() async {
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _sessionWithSnapshot(
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
scoreEnabled: true,
|
||||
);
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
|
||||
final useCase = _activeUseCase(repository, clock);
|
||||
|
||||
final corrected = await useCase.setManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
value: 7.5,
|
||||
);
|
||||
expect(corrected.state.value, 7.5);
|
||||
expect(corrected.changed, isTrue);
|
||||
|
||||
final incremented = await useCase.incrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
expect(incremented.state.value, 8.5);
|
||||
|
||||
final clamped = await useCase.setManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
value: -4,
|
||||
);
|
||||
expect(clamped.state.value, 0);
|
||||
|
||||
final decremented = await useCase.decrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
expect(decremented.state.value, 0);
|
||||
expect(decremented.changed, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('WorkoutHistoryUseCases applies heart rate summary once', () async {
|
||||
final repository = _FakeWorkoutHistoryRepository()
|
||||
..histories.add(
|
||||
WorkoutHistory(
|
||||
metadata: _metadata('history-1'),
|
||||
sourceActiveWorkoutSessionId: 'session-1',
|
||||
nameSnapshot: 'Seance',
|
||||
startedAt: DateTime.utc(2026, 7, 25, 11),
|
||||
endedAt: DateTime.utc(2026, 7, 25, 12),
|
||||
totalActiveMs: 3600000,
|
||||
completed: true,
|
||||
historySnapshotJson: '{"programs":[]}',
|
||||
),
|
||||
);
|
||||
final useCase = WorkoutHistoryUseCases(
|
||||
repository: repository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
|
||||
);
|
||||
|
||||
await useCase.updateHeartRateSummary(
|
||||
const WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 12,
|
||||
averageHeartRateBpm: 126.5,
|
||||
maxHeartRateBpm: 171,
|
||||
),
|
||||
);
|
||||
await useCase.updateHeartRateSummary(
|
||||
const WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 14,
|
||||
averageHeartRateBpm: 130,
|
||||
maxHeartRateBpm: 180,
|
||||
),
|
||||
);
|
||||
|
||||
expect(repository.histories.single.averageHeartRateBpm, 126.5);
|
||||
expect(repository.histories.single.maxHeartRateBpm, 171);
|
||||
});
|
||||
|
||||
test(
|
||||
'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
|
||||
() async {
|
||||
final useCase = ActiveWorkoutSensorUseCases(
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
|
||||
);
|
||||
final updates = <ActiveWorkoutSensorState>[];
|
||||
final subscription = useCase.updates.listen(updates.add);
|
||||
|
||||
expect(
|
||||
useCase.recordHeartRateSample(
|
||||
const WatchSensorSample(
|
||||
sessionId: '',
|
||||
recordedAtEpochMs: 0,
|
||||
heartRateBpm: 120,
|
||||
),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
useCase.recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 120,
|
||||
),
|
||||
);
|
||||
final state = useCase.recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
30,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 150,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(state?.latestHeartRateBpm, 150);
|
||||
expect(state?.sampleCount, 2);
|
||||
expect(state?.averageHeartRateBpm, 135);
|
||||
expect(state?.maxHeartRateBpm, 150);
|
||||
expect(state?.estimatedCaloriesKcal, 187.5);
|
||||
expect(updates, hasLength(2));
|
||||
|
||||
useCase.clear('session-1');
|
||||
expect(useCase.current('session-1'), isNull);
|
||||
await subscription.cancel();
|
||||
await useCase.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates',
|
||||
() async {
|
||||
final useCase = ActiveWorkoutSensorUseCases(
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
|
||||
);
|
||||
|
||||
final first = useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
caloriesKcal: 42,
|
||||
),
|
||||
);
|
||||
final duplicate = useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
1,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 160,
|
||||
distanceMeters: 300,
|
||||
caloriesKcal: 10,
|
||||
),
|
||||
);
|
||||
final distanceOnly = useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
25,
|
||||
12,
|
||||
2,
|
||||
).millisecondsSinceEpoch,
|
||||
distanceMeters: 620,
|
||||
caloriesKcal: 48,
|
||||
),
|
||||
);
|
||||
|
||||
expect(first?.latestHeartRateBpm, 120);
|
||||
expect(duplicate, isNull);
|
||||
expect(distanceOnly?.sampleCount, 2);
|
||||
expect(distanceOnly?.latestHeartRateBpm, 120);
|
||||
expect(distanceOnly?.minHeartRateBpm, 120);
|
||||
expect(distanceOnly?.averageHeartRateBpm, 120);
|
||||
expect(distanceOnly?.maxHeartRateBpm, 120);
|
||||
expect(distanceOnly?.latestDistanceMeters, 620);
|
||||
expect(distanceOnly?.latestCaloriesKcal, 48);
|
||||
await useCase.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
||||
() async {
|
||||
final repository = _FakeWorkoutHistoryRepository()
|
||||
..histories.add(
|
||||
WorkoutHistory(
|
||||
metadata: _metadata('history-1'),
|
||||
sourceActiveWorkoutSessionId: 'session-1',
|
||||
nameSnapshot: 'Seance',
|
||||
startedAt: DateTime.utc(2026, 7, 25, 11),
|
||||
endedAt: DateTime.utc(2026, 7, 25, 12),
|
||||
totalActiveMs: 3600000,
|
||||
completed: true,
|
||||
historySnapshotJson: '{"programs":[]}',
|
||||
),
|
||||
);
|
||||
final useCase = WorkoutHistoryUseCases(
|
||||
repository: repository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
|
||||
);
|
||||
|
||||
await useCase.updateHeartRateSummary(
|
||||
const WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 2,
|
||||
averageHeartRateBpm: 126.5,
|
||||
maxHeartRateBpm: 171,
|
||||
),
|
||||
);
|
||||
|
||||
expect(repository.histories.single.averageHeartRateBpm, isNull);
|
||||
expect(repository.histories.single.maxHeartRateBpm, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('Tags are normalized and validated on taggable entities', () {
|
||||
final exercise = Exercise(
|
||||
metadata: _metadata('exercise-tags'),
|
||||
@ -3046,6 +3726,7 @@ ExerciseStep _step({
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
bool linkedToSeriesScore = false,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
@ -3059,6 +3740,7 @@ ExerciseStep _step({
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
linkedToSeriesScore: linkedToSeriesScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -3720,6 +4402,43 @@ WorkoutTemplateProgram _templateProgram({
|
||||
);
|
||||
}
|
||||
|
||||
WorkoutTemplate _playableTemplate({
|
||||
required String id,
|
||||
required String name,
|
||||
DateTime? lastStartedAt,
|
||||
}) {
|
||||
return WorkoutTemplate(
|
||||
metadata: _metadata(id),
|
||||
name: name,
|
||||
lastStartedAt: lastStartedAt,
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('$id-program'),
|
||||
workoutTemplateId: id,
|
||||
sourceProgramId: null,
|
||||
position: 0,
|
||||
programNameSnapshot: 'Programme',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({
|
||||
'exercises': [
|
||||
{
|
||||
'id': '$id-exercise',
|
||||
'exerciseNameSnapshot': 'Squat',
|
||||
'setsCount': 2,
|
||||
'timeEnabled': false,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': false,
|
||||
'targetReps': 10,
|
||||
'exerciseStepsSnapshot': const [],
|
||||
'autoStartNextTimedStepSnapshot': false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeExerciseRepository implements ExerciseRepository {
|
||||
Exercise? exercise;
|
||||
final saved = <Exercise>[];
|
||||
@ -3970,6 +4689,72 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
final histories = <WorkoutHistory>[];
|
||||
|
||||
@override
|
||||
Future<WorkoutHistory?> findById(String id) async {
|
||||
return histories.where((history) => history.metadata.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutHistory>> listActive() async {
|
||||
return histories
|
||||
.where((history) => history.metadata.deletedAt == null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {
|
||||
histories.removeWhere(
|
||||
(existing) => existing.metadata.id == history.metadata.id,
|
||||
);
|
||||
histories.add(history);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
final index = histories.indexWhere(
|
||||
(history) =>
|
||||
history.metadata.id == historyId &&
|
||||
history.metadata.deletedAt == null &&
|
||||
history.averageHeartRateBpm == null &&
|
||||
history.maxHeartRateBpm == null,
|
||||
);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final history = histories[index];
|
||||
histories[index] = history.copyWith(
|
||||
metadata: history.metadata.touch(patchedAt),
|
||||
averageHeartRateBpm: averageHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id, DateTime deletedAt) async {
|
||||
final index = histories.indexWhere((history) => history.metadata.id == id);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
histories[index] = histories[index].copyWith(
|
||||
metadata: histories[index].metadata.markDeleted(deletedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ActiveWorkoutSessionUseCases _activeUseCase(
|
||||
_FakeActiveSessionRepository repository,
|
||||
_FakeClock clock,
|
||||
@ -3989,6 +4774,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
final restStates = <String, ActiveRestState>{};
|
||||
final setTimerStates = <String, ActiveSetTimerState>{};
|
||||
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
||||
final manualScoreStates = <String, ActiveManualScoreState>{};
|
||||
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
||||
final stepResults = <ActiveExerciseStepResult>[];
|
||||
|
||||
@ -4022,6 +4808,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
return manualScoreStates.values
|
||||
.where(
|
||||
(state) =>
|
||||
state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex &&
|
||||
state.metadata.deletedAt == null,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -4080,6 +4885,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return manualScoreStates.values
|
||||
.where(
|
||||
(state) =>
|
||||
state.activeWorkoutSessionId == sessionId &&
|
||||
state.metadata.deletedAt == null,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return results
|
||||
@ -4136,6 +4954,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
|
||||
manualScoreStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
ActiveExerciseStepProgressState state,
|
||||
@ -4177,6 +5000,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates.remove(state.metadata.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
final state = await findManualScoreState(
|
||||
sessionId: sessionId,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
manualScoreStates.remove(state.metadata.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
results.removeWhere(
|
||||
|
||||
@ -213,6 +213,189 @@ void main() {
|
||||
expect(env.repository.session?.currentSetIndex, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'accepts skipCurrentRest after a refresh that keeps the same revision',
|
||||
() async {
|
||||
final session = _session(setsCount: 2);
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
repository.restStates['rest'] = ActiveRestState(
|
||||
metadata: _metadata('rest'),
|
||||
activeWorkoutSessionId: session.metadata.id,
|
||||
afterProgramIndex: 0,
|
||||
afterExerciseIndex: 0,
|
||||
afterSetIndex: 0,
|
||||
plannedRestSeconds: 60,
|
||||
adjustedRestSeconds: 60,
|
||||
startedAt: _now,
|
||||
);
|
||||
final clock = _FakeClock(_now);
|
||||
final ids = _FakeIds();
|
||||
final activeUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
final projectionUseCases = WatchCompanionProjectionUseCases(
|
||||
sessionRepository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
final handler = WatchCompanionCommandHandler(
|
||||
sessionRepository: repository,
|
||||
activeSessionUseCases: activeUseCases,
|
||||
stepUseCases: ActiveExerciseStepUseCases(
|
||||
sessionRepository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'device-1',
|
||||
activeSessionUseCases: activeUseCases,
|
||||
),
|
||||
projectionSource: projectionUseCases,
|
||||
);
|
||||
|
||||
final firstProjection = await projectionUseCases.emitCurrentProjection();
|
||||
final refreshedProjection = await projectionUseCases
|
||||
.emitCurrentProjection();
|
||||
|
||||
final ack = await handler.dispatch(
|
||||
_command(
|
||||
WatchCommandType.skipCurrentRest,
|
||||
expectedRevision: firstProjection.revision,
|
||||
),
|
||||
);
|
||||
|
||||
expect(refreshedProjection.revision, firstProjection.revision);
|
||||
expect(ack, WatchCommandAck.accepted);
|
||||
expect(repository.restStates['rest']?.skippedAt, isNotNull);
|
||||
expect(repository.session?.currentSetIndex, 1);
|
||||
|
||||
await projectionUseCases.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'routes incrementScore and decrementScore to manual score use cases',
|
||||
() async {
|
||||
final env = _env(
|
||||
session: _session(scoreEnabled: true),
|
||||
projection: _projection(hasManualScore: true),
|
||||
);
|
||||
|
||||
expect(
|
||||
await env.dispatch(WatchCommandType.incrementScore),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(env.repository.manualScoreStates.values.single.value, 1);
|
||||
|
||||
expect(
|
||||
await env.dispatch(
|
||||
WatchCommandType.decrementScore,
|
||||
commandId: 'command-2',
|
||||
),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(env.repository.manualScoreStates.values.single.value, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('routes score commands to independent current step score', () async {
|
||||
final session = _session(
|
||||
scoreEnabled: true,
|
||||
steps: [
|
||||
_step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Réussites',
|
||||
scoreUnit: 'pts',
|
||||
defaultTargetScore: 5,
|
||||
),
|
||||
],
|
||||
);
|
||||
final env = _env(
|
||||
session: session,
|
||||
projection: _projection(
|
||||
hasManualScore: true,
|
||||
manualScoreScope: WatchManualScoreScope.step,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await env.dispatch(WatchCommandType.incrementScore),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(env.repository.manualScoreStates, isEmpty);
|
||||
expect(env.repository.stepResults.single.actualScore, 1);
|
||||
|
||||
expect(
|
||||
await env.dispatch(
|
||||
WatchCommandType.decrementScore,
|
||||
commandId: 'command-2',
|
||||
),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(env.repository.manualScoreStates, isEmpty);
|
||||
expect(env.repository.stepResults.last.actualScore, 0);
|
||||
});
|
||||
|
||||
test('decrementScore at zero is accepted no-op', () async {
|
||||
final env = _env(
|
||||
session: _session(scoreEnabled: true),
|
||||
projection: _projection(hasManualScore: true),
|
||||
);
|
||||
|
||||
final ack = await env.dispatch(WatchCommandType.decrementScore);
|
||||
|
||||
expect(ack, WatchCommandAck.acceptedNoOp);
|
||||
expect(env.repository.manualScoreStates.values.single.value, 0);
|
||||
expect(env.projections.emitCount, 0);
|
||||
});
|
||||
|
||||
test('rejects score commands outside manual score mode', () async {
|
||||
final env = _env(
|
||||
session: _session(
|
||||
scoreEnabled: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
),
|
||||
projection: _projection(hasManualScore: false),
|
||||
);
|
||||
|
||||
expect(
|
||||
await env.dispatch(WatchCommandType.incrementScore),
|
||||
WatchCommandAck.rejectedNotApplicable,
|
||||
);
|
||||
expect(env.repository.manualScoreStates, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'accepts repeated score commands with stale expected revisions',
|
||||
() async {
|
||||
final env = _env(
|
||||
session: _session(scoreEnabled: true),
|
||||
projection: _projection(revision: 3, hasManualScore: true),
|
||||
);
|
||||
|
||||
expect(
|
||||
await env.dispatch(
|
||||
WatchCommandType.incrementScore,
|
||||
commandId: 'command-1',
|
||||
expectedRevision: 1,
|
||||
),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(
|
||||
await env.dispatch(
|
||||
WatchCommandType.incrementScore,
|
||||
commandId: 'command-2',
|
||||
expectedRevision: 1,
|
||||
),
|
||||
WatchCommandAck.accepted,
|
||||
);
|
||||
expect(env.repository.manualScoreStates.values.single.value, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects stale revision, non applicable, missing and mismatch',
|
||||
() async {
|
||||
@ -256,6 +439,22 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('rejects commands when no session is active', () async {
|
||||
final env = _env(
|
||||
projection: _projection(
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
deviceSessionId: '',
|
||||
primaryAction: WatchPrimaryAction.none,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await env.dispatch(WatchCommandType.startCurrentExercise),
|
||||
WatchCommandAck.rejectedNoActiveSession,
|
||||
);
|
||||
expect(env.repository.session, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
final _now = DateTime.utc(2026, 7, 25, 12);
|
||||
@ -263,13 +462,14 @@ final _now = DateTime.utc(2026, 7, 25, 12);
|
||||
_Harness _env({
|
||||
ActiveWorkoutSession? session,
|
||||
required WatchSessionProjection projection,
|
||||
_FakeWorkoutTemplateRepository? templateRepository,
|
||||
}) {
|
||||
final repository = _FakeActiveSessionRepository()..session = session;
|
||||
final clock = _FakeClock(_now);
|
||||
final ids = _FakeIds();
|
||||
final activeUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: repository,
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: 'device-1',
|
||||
@ -304,20 +504,27 @@ final class _Harness {
|
||||
final _FakeProjectionSource projections;
|
||||
final WatchCompanionCommandHandler handler;
|
||||
|
||||
Future<WatchCommandAck> dispatch(WatchCommandType type) {
|
||||
return handler.dispatch(_command(type));
|
||||
Future<WatchCommandAck> dispatch(
|
||||
WatchCommandType type, {
|
||||
String commandId = 'command-1',
|
||||
int expectedRevision = 1,
|
||||
}) {
|
||||
return handler.dispatch(
|
||||
_command(type, commandId: commandId, expectedRevision: expectedRevision),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
WatchCommandEnvelope _command(
|
||||
WatchCommandType type, {
|
||||
String commandId = 'command-1',
|
||||
int expectedRevision = 1,
|
||||
}) {
|
||||
return WatchCommandEnvelope(
|
||||
commandId: commandId,
|
||||
type: type,
|
||||
sessionId: 'session-1',
|
||||
expectedRevision: 1,
|
||||
expectedRevision: expectedRevision,
|
||||
sentAtEpochMs: _now.millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
@ -331,6 +538,10 @@ WatchSessionProjection _projection({
|
||||
WatchSecondaryAction.finishCurrentSet,
|
||||
WatchSecondaryAction.skipCurrentSet,
|
||||
],
|
||||
bool hasManualScore = false,
|
||||
double? currentManualScoreValue,
|
||||
bool canDecrementScore = false,
|
||||
WatchManualScoreScope? manualScoreScope,
|
||||
}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: deviceSessionId,
|
||||
@ -343,6 +554,12 @@ WatchSessionProjection _projection({
|
||||
exerciseName: 'Squat',
|
||||
primaryAction: primaryAction,
|
||||
secondaryActions: secondaryActions,
|
||||
hasManualScore: hasManualScore,
|
||||
currentManualScoreValue: currentManualScoreValue,
|
||||
canDecrementScore: canDecrementScore,
|
||||
manualScoreScope:
|
||||
manualScoreScope ??
|
||||
(hasManualScore ? WatchManualScoreScope.series : null),
|
||||
);
|
||||
}
|
||||
|
||||
@ -354,6 +571,8 @@ ActiveWorkoutSession _session({
|
||||
bool timeEnabled = false,
|
||||
int? targetReps = 10,
|
||||
int restSeconds = 0,
|
||||
bool scoreEnabled = false,
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
List<ExerciseStep> steps = const [],
|
||||
}) {
|
||||
final exerciseSnapshot = {
|
||||
@ -362,9 +581,9 @@ ActiveWorkoutSession _session({
|
||||
'setsCount': setsCount,
|
||||
'timeEnabled': timeEnabled,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': false,
|
||||
'scoreEnabled': scoreEnabled,
|
||||
'targetReps': targetReps,
|
||||
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
|
||||
'scoreInputModeSnapshot': scoreInputMode.name,
|
||||
'restSecondsOverride': restSeconds,
|
||||
'exerciseStepsSnapshot': steps
|
||||
.map((step) => step.toSnapshotJson())
|
||||
@ -395,13 +614,24 @@ ActiveWorkoutSession _session({
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseStep _step({String id = 'step-1', int position = 0}) {
|
||||
ExerciseStep _step({
|
||||
String id = 'step-1',
|
||||
int position = 0,
|
||||
bool hasScore = false,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
name: 'Step ${position + 1}',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 1,
|
||||
hasScore: hasScore,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -473,6 +703,10 @@ final class _FakeProjectionSource implements WatchProjectionSource {
|
||||
exerciseName: projection.exerciseName,
|
||||
primaryAction: projection.primaryAction,
|
||||
secondaryActions: projection.secondaryActions,
|
||||
hasManualScore: projection.hasManualScore,
|
||||
currentManualScoreValue: projection.currentManualScoreValue,
|
||||
canDecrementScore: projection.canDecrementScore,
|
||||
manualScoreScope: projection.manualScoreScope,
|
||||
);
|
||||
return projection;
|
||||
}
|
||||
@ -499,11 +733,24 @@ final class _FakeIds implements IdGenerator {
|
||||
|
||||
final class _FakeWorkoutTemplateRepository
|
||||
implements WorkoutTemplateRepository {
|
||||
@override
|
||||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||||
final templates = <WorkoutTemplate>[];
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||||
Future<WorkoutTemplate?> findById(String id) async {
|
||||
for (final template in templates) {
|
||||
if (template.metadata.id == id && template.metadata.deletedAt == null) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTemplate>> listActive() async {
|
||||
return templates
|
||||
.where((template) => template.metadata.deletedAt == null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceComposition(
|
||||
@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutTemplate template) async {}
|
||||
Future<void> save(WorkoutTemplate template) async {
|
||||
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
|
||||
templates.add(template);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||||
@ -527,6 +777,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
final restStates = <String, ActiveRestState>{};
|
||||
final setTimerStates = <String, ActiveSetTimerState>{};
|
||||
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
||||
final manualScoreStates = <String, ActiveManualScoreState>{};
|
||||
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
||||
final stepResults = <ActiveExerciseStepResult>[];
|
||||
|
||||
@ -541,6 +792,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
manualScoreStates.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||
return session?.metadata.id == id ? session : null;
|
||||
@ -584,6 +846,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
return manualScoreStates.values.where((state) {
|
||||
return state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -633,6 +910,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return manualScoreStates.values
|
||||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return results
|
||||
@ -661,6 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
|
||||
@override
|
||||
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
|
||||
stepResults.removeWhere((item) => item.metadata.id == result.metadata.id);
|
||||
stepResults.add(result);
|
||||
}
|
||||
|
||||
@ -674,6 +961,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
|
||||
manualScoreStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
results.add(result);
|
||||
|
||||
@ -71,10 +71,10 @@ void main() {
|
||||
|
||||
expect(projection.phase, WatchSessionPhase.running);
|
||||
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
|
||||
expect(projection.dominantTimer?.accumulatedMs, 0);
|
||||
expect(projection.dominantTimer?.accumulatedMs, 5000);
|
||||
expect(
|
||||
projection.dominantTimer?.startedAtEpochMs,
|
||||
now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
|
||||
now.millisecondsSinceEpoch,
|
||||
);
|
||||
expect(projection.secondaryTimers.map((timer) => timer.kind), [
|
||||
WatchTimerKind.scoreStopwatch,
|
||||
@ -88,6 +88,103 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('projects manual score state for the current set', () async {
|
||||
final session = _session(scoreEnabled: true, targetScore: 8);
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = session
|
||||
..manualScoreStates['manual-score'] = ActiveManualScoreState(
|
||||
metadata: _metadata('manual-score'),
|
||||
activeWorkoutSessionId: session.metadata.id,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
value: 3,
|
||||
updatedAt: DateTime.utc(2026, 7, 25, 12),
|
||||
);
|
||||
final projector = _projector(repository, _clock());
|
||||
|
||||
final projection = await projector.project(revision: 1);
|
||||
|
||||
expect(projection.hasManualScore, isTrue);
|
||||
expect(projection.currentManualScoreValue, 3);
|
||||
expect(projection.canDecrementScore, isTrue);
|
||||
expect(projection.manualScoreTargetValue, 8);
|
||||
expect(projection.manualScoreTargetLabel, 'Cible');
|
||||
expect(projection.manualScoreScope, WatchManualScoreScope.series);
|
||||
});
|
||||
|
||||
test(
|
||||
'projects independent current step manual score before series score',
|
||||
() async {
|
||||
final session = _session(
|
||||
scoreEnabled: true,
|
||||
targetScore: 8,
|
||||
steps: [
|
||||
_step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Réussites',
|
||||
scoreUnit: 'pts',
|
||||
defaultTargetScore: 5,
|
||||
),
|
||||
],
|
||||
);
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = session
|
||||
..stepProgressStates['step-state'] = _stepState(
|
||||
sessionId: session.metadata.id,
|
||||
status: ActiveExerciseStepProgressStatus.waitingManual,
|
||||
)
|
||||
..stepResults.add(
|
||||
ActiveExerciseStepResult(
|
||||
metadata: _metadata('step-score'),
|
||||
activeWorkoutSessionId: session.metadata.id,
|
||||
programSnapshotId: 'program-snapshot-1',
|
||||
exerciseSnapshotId: 'exercise-snapshot-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
passageIndex: 0,
|
||||
stepIndex: 0,
|
||||
stepSnapshotId: 'step-1',
|
||||
stepNameSnapshot: 'Step 1',
|
||||
stepTypeSnapshot: ExerciseStepType.time,
|
||||
targetValueSnapshot: 1,
|
||||
hasScoreSnapshot: true,
|
||||
scoreInputModeSnapshot: ScoreInputMode.manual,
|
||||
scoreLabelSnapshot: 'Réussites',
|
||||
scoreUnitSnapshot: 'pts',
|
||||
targetScoreSnapshot: 5,
|
||||
status: SetResultStatus.completed,
|
||||
actualScore: 2,
|
||||
),
|
||||
);
|
||||
final projector = _projector(repository, _clock());
|
||||
|
||||
final projection = await projector.project(revision: 1);
|
||||
|
||||
expect(projection.hasManualScore, isTrue);
|
||||
expect(projection.currentManualScoreValue, 2);
|
||||
expect(projection.manualScoreTargetValue, 5);
|
||||
expect(projection.manualScoreScope, WatchManualScoreScope.step);
|
||||
},
|
||||
);
|
||||
|
||||
test('does not project manual score controls for stopwatch score', () async {
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _session(
|
||||
scoreEnabled: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
);
|
||||
final projector = _projector(repository, _clock());
|
||||
|
||||
final projection = await projector.project(revision: 1);
|
||||
|
||||
expect(projection.hasManualScore, isFalse);
|
||||
expect(projection.currentManualScoreValue, isNull);
|
||||
expect(projection.canDecrementScore, isFalse);
|
||||
expect(projection.manualScoreTargetValue, isNull);
|
||||
});
|
||||
|
||||
test('projects paused after a running session is paused', () async {
|
||||
final now = DateTime.utc(2026, 7, 25, 12);
|
||||
final session = _session(
|
||||
@ -282,14 +379,22 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'emits projections through stream and publisher with incremented revision',
|
||||
'keeps revision stable when refresh only changes volatile timing fields',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 25, 12);
|
||||
final clock = _clock(now);
|
||||
final session = _session(steps: [_step(defaultTargetValue: 30)]);
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _session(steps: [_step()]);
|
||||
..session = session
|
||||
..stepProgressStates['step-state'] = _stepState(
|
||||
sessionId: session.metadata.id,
|
||||
status: ActiveExerciseStepProgressStatus.runningTimer,
|
||||
startedAt: now,
|
||||
);
|
||||
final publisher = _FakeWatchProjectionPublisher();
|
||||
final useCases = WatchCompanionProjectionUseCases(
|
||||
sessionRepository: repository,
|
||||
clock: _clock(),
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
publisher: publisher,
|
||||
@ -298,21 +403,54 @@ void main() {
|
||||
final subscription = useCases.projections.listen(emitted.add);
|
||||
|
||||
final first = await useCases.emitCurrentProjection();
|
||||
clock.value = now.add(const Duration(seconds: 2));
|
||||
final second = await useCases.emitCurrentProjection();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(first.revision, 1);
|
||||
expect(second.revision, 2);
|
||||
expect(emitted.map((projection) => projection.revision), [1, 2]);
|
||||
expect(second.revision, 1);
|
||||
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
|
||||
expect(
|
||||
second.dominantTimer?.accumulatedMs,
|
||||
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
|
||||
);
|
||||
expect(emitted.map((projection) => projection.revision), [1, 1]);
|
||||
expect(publisher.published.map((projection) => projection.revision), [
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
]);
|
||||
|
||||
await subscription.cancel();
|
||||
await useCases.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('increments revision when session command state changes', () async {
|
||||
final repository = _FakeActiveSessionRepository()
|
||||
..session = _session(setsCount: 2);
|
||||
final publisher = _FakeWatchProjectionPublisher();
|
||||
final useCases = WatchCompanionProjectionUseCases(
|
||||
sessionRepository: repository,
|
||||
clock: _clock(),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
publisher: publisher,
|
||||
);
|
||||
|
||||
final first = await useCases.emitCurrentProjection();
|
||||
repository.session = repository.session!.copyWith(currentSetIndex: 1);
|
||||
final second = await useCases.emitCurrentProjection();
|
||||
|
||||
expect(first.revision, 1);
|
||||
expect(second.revision, 2);
|
||||
expect(second.seriesIndex, 2);
|
||||
expect(publisher.published.map((projection) => projection.revision), [
|
||||
1,
|
||||
2,
|
||||
]);
|
||||
|
||||
await useCases.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
WatchSessionProjectionProjector _projector(
|
||||
@ -341,6 +479,7 @@ ActiveWorkoutSession _session({
|
||||
bool repsEnabled = true,
|
||||
bool scoreEnabled = false,
|
||||
int? targetTimeSeconds,
|
||||
double? targetScore,
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
bool? autoStartNextTimedStepSnapshot = true,
|
||||
List<ExerciseStep> steps = const [],
|
||||
@ -355,6 +494,7 @@ ActiveWorkoutSession _session({
|
||||
'scoreEnabled': scoreEnabled,
|
||||
'targetTimeSeconds': targetTimeSeconds,
|
||||
'targetReps': repsEnabled ? setsCount : null,
|
||||
'targetScore': targetScore,
|
||||
'scoreInputModeSnapshot': scoreInputMode.name,
|
||||
'exerciseStepsSnapshot': steps
|
||||
.map((step) => step.toSnapshotJson())
|
||||
@ -403,6 +543,10 @@ ExerciseStep _step({
|
||||
String id = 'step-1',
|
||||
int position = 0,
|
||||
int defaultTargetValue = 1,
|
||||
bool hasScore = false,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
@ -410,6 +554,10 @@ ExerciseStep _step({
|
||||
name: 'Step ${position + 1}',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: defaultTargetValue,
|
||||
hasScore: hasScore,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
);
|
||||
}
|
||||
|
||||
@ -514,6 +662,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
final restStates = <String, ActiveRestState>{};
|
||||
final setTimerStates = <String, ActiveSetTimerState>{};
|
||||
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
|
||||
final manualScoreStates = <String, ActiveManualScoreState>{};
|
||||
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
|
||||
final stepResults = <ActiveExerciseStepResult>[];
|
||||
|
||||
@ -528,6 +677,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
manualScoreStates.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||
return session?.metadata.id == id ? session : null;
|
||||
@ -571,6 +731,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
return manualScoreStates.values.where((state) {
|
||||
return state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -620,6 +795,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return manualScoreStates.values
|
||||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return results
|
||||
@ -661,6 +845,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
|
||||
manualScoreStates[state.metadata.id] = state;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
results.add(result);
|
||||
|
||||
76
test/infrastructure/remote/http_api_client_test.dart
Normal file
@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/infrastructure/remote/remote.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'defaultBaseUrl keeps localhost outside Android without env override',
|
||||
() {
|
||||
expect(
|
||||
HttpApiClient.defaultBaseUrlFor(isAndroid: false),
|
||||
'http://localhost:8080',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('defaultBaseUrl uses Android emulator host without env override', () {
|
||||
expect(
|
||||
HttpApiClient.defaultBaseUrlFor(isAndroid: true),
|
||||
'http://10.0.2.2:8080',
|
||||
);
|
||||
});
|
||||
|
||||
test('defaultBaseUrl keeps env override on Android', () {
|
||||
expect(
|
||||
HttpApiClient.defaultBaseUrlFor(
|
||||
isAndroid: true,
|
||||
configuredBaseUrl: ' http://192.168.1.42:8080 ',
|
||||
),
|
||||
'http://192.168.1.42:8080',
|
||||
);
|
||||
});
|
||||
|
||||
test('postJson maps server errors to server failure', () async {
|
||||
final client = HttpApiClient(
|
||||
baseUrl: Uri.parse('http://api.example.test'),
|
||||
client: MockClient(
|
||||
(_) async => http.Response('{"error":"internal"}', 500),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.postJson('/auth/login'),
|
||||
throwsA(
|
||||
isA<RemoteAuthException>()
|
||||
.having(
|
||||
(error) => error.failure,
|
||||
'failure',
|
||||
RemoteAuthFailure.server,
|
||||
)
|
||||
.having((error) => error.message, 'message', 'internal'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('postJson maps transport failures to network failure', () async {
|
||||
final client = HttpApiClient(
|
||||
baseUrl: Uri.parse('http://api.example.test'),
|
||||
client: MockClient((_) async => throw http.ClientException('refused')),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.postJson('/auth/login'),
|
||||
throwsA(
|
||||
isA<RemoteAuthException>()
|
||||
.having(
|
||||
(error) => error.failure,
|
||||
'failure',
|
||||
RemoteAuthFailure.network,
|
||||
)
|
||||
.having((error) => error.message, 'message', 'refused'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -2,7 +2,9 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/infrastructure/infrastructure.dart'
|
||||
hide WorkoutHistory, WorkoutHistorySetResult, WorkoutHistoryStepResult;
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
@ -120,19 +122,122 @@ void main() {
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test('patches workout history when a sensor summary arrives', () async {
|
||||
final native = _FakeWatchBridgeNativeChannel();
|
||||
final source = _FakeProjectionSource(_projection(revision: 0));
|
||||
final historyRepository = _FakeWorkoutHistoryRepository()
|
||||
..histories.add(
|
||||
WorkoutHistory(
|
||||
metadata: _metadata('history-1'),
|
||||
sourceActiveWorkoutSessionId: 'session-1',
|
||||
nameSnapshot: 'Seance',
|
||||
startedAt: _now.subtract(const Duration(hours: 1)),
|
||||
endedAt: _now,
|
||||
totalActiveMs: 3600000,
|
||||
completed: true,
|
||||
historySnapshotJson: '{"programs":[]}',
|
||||
),
|
||||
);
|
||||
final adapter = _adapter(
|
||||
native: native,
|
||||
source: source,
|
||||
historyUseCases: WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: _FakeClock(_now),
|
||||
),
|
||||
);
|
||||
await adapter.start();
|
||||
|
||||
native.emitSensorSummary(
|
||||
const WatchSensorSummary(
|
||||
sessionId: 'session-1',
|
||||
sampleCount: 8,
|
||||
averageHeartRateBpm: 121.5,
|
||||
maxHeartRateBpm: 168,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(historyRepository.histories.single.averageHeartRateBpm, 121.5);
|
||||
expect(historyRepository.histories.single.maxHeartRateBpm, 168);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test('records live telemetry samples in active sensor state', () async {
|
||||
final native = _FakeWatchBridgeNativeChannel();
|
||||
final source = _FakeProjectionSource(_runningProjection(revision: 1));
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: _FakeClock(_now));
|
||||
final adapter = _adapter(
|
||||
native: native,
|
||||
source: source,
|
||||
sensorUseCases: sensorUseCases,
|
||||
);
|
||||
await adapter.start();
|
||||
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now.millisecondsSinceEpoch,
|
||||
heartRateBpm: 120,
|
||||
distanceMeters: 500,
|
||||
),
|
||||
);
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now
|
||||
.add(const Duration(minutes: 30))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 150,
|
||||
distanceMeters: 900,
|
||||
caloriesKcal: 120,
|
||||
),
|
||||
);
|
||||
native.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: _now
|
||||
.add(const Duration(minutes: 31))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 170,
|
||||
distanceMeters: 100,
|
||||
caloriesKcal: 10,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final state = sensorUseCases.current('session-1');
|
||||
expect(state?.latestHeartRateBpm, 150);
|
||||
expect(state?.sampleCount, 2);
|
||||
expect(state?.averageHeartRateBpm, 135);
|
||||
expect(state?.maxHeartRateBpm, 150);
|
||||
expect(state?.latestDistanceMeters, 900);
|
||||
expect(state?.latestCaloriesKcal, 120);
|
||||
expect(state?.estimatedCaloriesKcal, 187.5);
|
||||
await adapter.stop();
|
||||
await sensorUseCases.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
WatchWearDataLayerAdapter _adapter({
|
||||
required _FakeWatchBridgeNativeChannel native,
|
||||
WatchCommandIngress? ingress,
|
||||
required _FakeProjectionSource source,
|
||||
WorkoutHistoryUseCases? historyUseCases,
|
||||
ActiveWorkoutSensorUseCases? sensorUseCases,
|
||||
Duration heartbeatInterval = const Duration(seconds: 5),
|
||||
}) {
|
||||
return WatchWearDataLayerAdapter(
|
||||
nativeChannel: native,
|
||||
commandIngress: ingress ?? _FakeCommandIngress(),
|
||||
projectionSource: source,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
workoutHistoryUseCases: historyUseCases,
|
||||
activeWorkoutSensorUseCases: sensorUseCases,
|
||||
projectionRefreshInterval: heartbeatInterval,
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,6 +294,24 @@ WatchSessionProjection _runningProjection({required int revision}) {
|
||||
|
||||
final _now = DateTime.utc(2026, 7, 25, 12);
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
createdAt: _now,
|
||||
updatedAt: _now,
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeProjectionSource implements WatchProjectionSource {
|
||||
_FakeProjectionSource(this.current);
|
||||
|
||||
@ -260,6 +383,8 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
final published = <WatchSessionProjection>[];
|
||||
final acks = <_SentAck>[];
|
||||
final _commands = StreamController<WatchCommandEnvelope>.broadcast();
|
||||
final _sensorSummaries = StreamController<WatchSensorSummary>.broadcast();
|
||||
final _sensorSamples = StreamController<WatchSensorSample>.broadcast();
|
||||
final _connections = StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
var capabilityRefreshCount = 0;
|
||||
var foregroundStartCount = 0;
|
||||
@ -268,6 +393,12 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
@override
|
||||
Stream<WatchCommandEnvelope> get commands => _commands.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSummary> get sensorSummaries => _sensorSummaries.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples => _sensorSamples.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connections.stream;
|
||||
@ -276,6 +407,14 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
_commands.add(command);
|
||||
}
|
||||
|
||||
void emitSensorSummary(WatchSensorSummary summary) {
|
||||
_sensorSummaries.add(summary);
|
||||
}
|
||||
|
||||
void emitSensorSample(WatchSensorSample sample) {
|
||||
_sensorSamples.add(sample);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connections.add(event);
|
||||
}
|
||||
@ -310,6 +449,54 @@ final class _FakeWatchBridgeNativeChannel implements WatchBridgeNativeChannel {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
final histories = <WorkoutHistory>[];
|
||||
|
||||
@override
|
||||
Future<WorkoutHistory?> findById(String id) async {
|
||||
return histories.where((history) => history.metadata.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutHistory>> listActive() async => histories;
|
||||
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
final index = histories.indexWhere(
|
||||
(history) =>
|
||||
history.metadata.id == historyId &&
|
||||
history.averageHeartRateBpm == null &&
|
||||
history.maxHeartRateBpm == null,
|
||||
);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final history = histories[index];
|
||||
histories[index] = history.copyWith(
|
||||
metadata: history.metadata.touch(patchedAt),
|
||||
averageHeartRateBpm: averageHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id, DateTime deletedAt) async {}
|
||||
}
|
||||
|
||||
final class _SentAck {
|
||||
const _SentAck(this.command, this.ack, this.revisionAtAck);
|
||||
|
||||
|
||||
@ -405,6 +405,66 @@ void main() {
|
||||
expect(find.text('Mode de score'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('une étape peut utiliser le score de la série', (tester) async {
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(400, 4200));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await _pumpExerciseForm(tester, exerciseRepository);
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
|
||||
'30',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Score à saisir').first,
|
||||
'Paniers',
|
||||
);
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Unité'), 'pts');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Score par défaut'),
|
||||
'0',
|
||||
);
|
||||
await tester.tap(
|
||||
find.widgetWithText(
|
||||
SwitchListTile,
|
||||
'Rythmer cet exercice avec des étapes',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Ajouter une étape'));
|
||||
await tester.pump();
|
||||
await tester.tap(_stepTypeRadio('Répétitions').last);
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Nom de l’étape').last,
|
||||
'Tirs main droite',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
|
||||
'10',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(SwitchListTile, 'Score d’étape'));
|
||||
await tester.pump();
|
||||
await tester.tap(
|
||||
find.widgetWithText(SwitchListTile, 'Utiliser le score de la série'),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.ensureVisible(find.text('Enregistrer'));
|
||||
await tester.tap(find.text('Enregistrer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final step = exerciseRepository.saved.single.steps.single;
|
||||
expect(step.linkedToSeriesScore, isTrue);
|
||||
expect(step.scoreLabel, 'Paniers');
|
||||
expect(step.scoreUnit, 'pts');
|
||||
expect(step.defaultTargetScore, isNull);
|
||||
});
|
||||
|
||||
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
@ -543,6 +603,82 @@ void main() {
|
||||
expect(saved.steps.single.defaultTargetValue, 12);
|
||||
});
|
||||
|
||||
testWidgets('une étape invalide affiche une erreur inline sans sauvegarder', (
|
||||
tester,
|
||||
) async {
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(400, 3600));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await _pumpExerciseForm(tester, exerciseRepository);
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
|
||||
'30',
|
||||
);
|
||||
await tester.tap(
|
||||
find.widgetWithText(
|
||||
SwitchListTile,
|
||||
'Rythmer cet exercice avec des étapes',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Ajouter une étape'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Nom de l’étape').last,
|
||||
'Dribble main droite',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
|
||||
'abc',
|
||||
);
|
||||
|
||||
await tester.ensureVisible(find.text('Enregistrer'));
|
||||
await tester.tap(find.text('Enregistrer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Saisis une durée supérieure à 0.'), findsOneWidget);
|
||||
expect(exerciseRepository.saved, isEmpty);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('un échec inattendu de sauvegarde libère le bouton', (
|
||||
tester,
|
||||
) async {
|
||||
final exerciseRepository = _FakeExerciseRepository()..throwOnSave = true;
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1600));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await _pumpExerciseForm(tester, exerciseRepository);
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
|
||||
'30',
|
||||
);
|
||||
|
||||
await tester.ensureVisible(find.text('Enregistrer'));
|
||||
await tester.tap(find.text('Enregistrer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Impossible d’enregistrer l’exercice. Vérifie les champs puis réessaie.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Enregistrer'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'le réglage d’enchaînement automatique des étapes est sauvegardé',
|
||||
(tester) async {
|
||||
@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
||||
final exercises = <Exercise>[];
|
||||
final saved = <Exercise>[];
|
||||
var referenced = false;
|
||||
var throwOnSave = false;
|
||||
|
||||
@override
|
||||
Future<Exercise?> findById(String id) async {
|
||||
@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
|
||||
|
||||
@override
|
||||
Future<void> save(Exercise exercise) async {
|
||||
if (throwOnSave) {
|
||||
throw Exception('save failed');
|
||||
}
|
||||
saved.add(exercise);
|
||||
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
|
||||
exercises.add(exercise);
|
||||
|
||||
@ -71,6 +71,32 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HistoryDetailScreen(
|
||||
history: _history(
|
||||
id: 'history-1',
|
||||
averageHeartRateBpm: 126.4,
|
||||
maxHeartRateBpm: 171,
|
||||
),
|
||||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
activeUseCases: _activeUseCases(),
|
||||
closeUseCase: _closeUseCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Fréquence cardiaque'), findsOneWidget);
|
||||
expect(find.text('Moyenne'), findsOneWidget);
|
||||
expect(find.text('Max'), findsOneWidget);
|
||||
expect(find.text('126 bpm', findRichText: true), findsOneWidget);
|
||||
expect(find.text('171 bpm', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('le détail affiche le score chrono comme temps réalisé', (
|
||||
tester,
|
||||
) async {
|
||||
@ -156,6 +182,72 @@ void main() {
|
||||
|
||||
expect(repository.deletedIds, ['history-1']);
|
||||
});
|
||||
|
||||
testWidgets('relancer un historique vide affiche une erreur sans navigation', (
|
||||
tester,
|
||||
) async {
|
||||
final activeRepository = _FakeActiveSessionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HistoryDetailScreen(
|
||||
history: _history(id: 'history-empty', emptySnapshot: true),
|
||||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
activeUseCases: _activeUseCases(activeRepository),
|
||||
closeUseCase: _closeUseCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Relancer cette séance'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(WorkoutExecutionScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('une session legacy invalide ne bloque pas une relance valide', (
|
||||
tester,
|
||||
) async {
|
||||
final activeRepository = _FakeActiveSessionRepository()
|
||||
..session = ActiveWorkoutSession(
|
||||
metadata: _metadata('legacy-session'),
|
||||
sourceWorkoutTemplateId: 'template-legacy',
|
||||
status: ActiveWorkoutStatus.paused,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 9),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: HistoryDetailScreen(
|
||||
history: _history(id: 'history-1'),
|
||||
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
activeUseCases: _activeUseCases(activeRepository),
|
||||
closeUseCase: _closeUseCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Relancer cette séance'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
|
||||
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
|
||||
expect(activeRepository.session?.sourceWorkoutTemplateId, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
WorkoutHistory _history({
|
||||
@ -163,6 +255,9 @@ WorkoutHistory _history({
|
||||
DateTime? startedAt,
|
||||
bool stopwatchScore = false,
|
||||
bool withStepResults = false,
|
||||
bool emptySnapshot = false,
|
||||
double? averageHeartRateBpm,
|
||||
int? maxHeartRateBpm,
|
||||
}) {
|
||||
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
|
||||
return WorkoutHistory(
|
||||
@ -173,10 +268,14 @@ WorkoutHistory _history({
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 30)),
|
||||
totalActiveMs: 1800000,
|
||||
averageHeartRateBpm: averageHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
completed: true,
|
||||
historySnapshotJson: jsonEncode({
|
||||
'sessionId': 'session-1',
|
||||
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
|
||||
'resolvedTemplateSnapshotJson': emptySnapshot
|
||||
? _emptyResolvedSnapshot()
|
||||
: _resolvedSnapshot(
|
||||
stopwatchScore: stopwatchScore,
|
||||
withSteps: withStepResults,
|
||||
),
|
||||
@ -308,6 +407,20 @@ String _resolvedSnapshot({
|
||||
});
|
||||
}
|
||||
|
||||
String _emptyResolvedSnapshot() {
|
||||
return jsonEncode({
|
||||
'name': 'Séance vide',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'program-snapshot-empty',
|
||||
'programNameSnapshot': 'Programme vide',
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
});
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
@ -336,9 +449,11 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
|
||||
);
|
||||
}
|
||||
|
||||
ActiveWorkoutSessionUseCases _activeUseCases() {
|
||||
ActiveWorkoutSessionUseCases _activeUseCases([
|
||||
_FakeActiveSessionRepository? repository,
|
||||
]) {
|
||||
return ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: _FakeActiveSessionRepository(),
|
||||
sessionRepository: repository ?? _FakeActiveSessionRepository(),
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
|
||||
ids: _FakeIds(),
|
||||
@ -392,6 +507,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
}
|
||||
|
||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findById(String id) async => null;
|
||||
ActiveWorkoutSession? session;
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findOpen() async => null;
|
||||
Future<ActiveWorkoutSession?> findById(String id) async {
|
||||
return session?.metadata.id == id ? session : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveWorkoutSession?> findOpen() async => session;
|
||||
|
||||
@override
|
||||
Future<ActiveRestState?> findRestStateById(String id) async => null;
|
||||
@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required int setIndex,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
|
||||
return const [];
|
||||
@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(ActiveWorkoutSession session) async {}
|
||||
Future<void> save(ActiveWorkoutSession session) async {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveRestState(ActiveRestState restState) async {}
|
||||
@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
@override
|
||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
|
||||
|
||||
@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {}
|
||||
}
|
||||
|
||||
@ -102,6 +102,37 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('le bandeau de reprise masque une séance invalide', (
|
||||
tester,
|
||||
) async {
|
||||
final activeRepository = _FakeActiveSessionRepository()
|
||||
..session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.paused,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
|
||||
elapsedActiveMs: 300000,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorObservers: [homeRouteObserver],
|
||||
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Séance en cours'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
|
||||
tester,
|
||||
) async {
|
||||
@ -196,6 +227,9 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
),
|
||||
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeRepository,
|
||||
historyRepository: _FakeWorkoutHistoryRepository(),
|
||||
@ -282,6 +316,9 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
@override
|
||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||
|
||||
@override
|
||||
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
|
||||
|
||||
@override
|
||||
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
|
||||
|
||||
@ -327,6 +364,20 @@ String _sessionSnapshot() {
|
||||
});
|
||||
}
|
||||
|
||||
String _emptySessionSnapshot() {
|
||||
return jsonEncode({
|
||||
'name': 'Séance vide',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'template-program-1',
|
||||
'programNameSnapshot': 'Programme vide',
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
});
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
@ -553,6 +604,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -585,6 +646,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return scoreStopwatchStates;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
|
||||
return setTimerStates;
|
||||
@ -624,6 +692,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
scoreStopwatchStates.add(state);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
|
||||
setTimerStates.add(state);
|
||||
@ -646,6 +717,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
results.add(result);
|
||||
@ -808,6 +888,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Compte optionnel'), findsOneWidget);
|
||||
expect(find.text('Compte GameTime'), findsOneWidget);
|
||||
expect(find.text('Créer un compte'), findsOneWidget);
|
||||
expect(find.text('Se connecter'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
@ -52,6 +52,43 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Se connecter'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tu peux revenir à GameTime.'), findsNothing);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Email'),
|
||||
'a@b.fr',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Mot de passe'),
|
||||
'password1',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('une erreur serveur affiche le message dédié', (tester) async {
|
||||
final harness = _AuthHarness()
|
||||
..remote.loginFailure = RemoteAuthFailure.server;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ProfileScreen(
|
||||
authUseCases: harness.useCases,
|
||||
syncUseCases: harness.syncUseCases,
|
||||
shareUseCases: harness.shareUseCases,
|
||||
dataExportUseCase: harness.dataExportUseCase,
|
||||
dataImportUseCase: harness.dataImportUseCase,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Se connecter'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
@ -65,7 +102,10 @@ void main() {
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Serveur indisponible pour le moment. Réessaie plus tard.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
|
||||
@ -142,7 +182,7 @@ void main() {
|
||||
|
||||
expect(harness.tokenStore.token, isNull);
|
||||
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
|
||||
expect(find.text('Compte optionnel'), findsOneWidget);
|
||||
expect(find.text('Compte GameTime'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@ -295,13 +335,11 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'export réussi affiche le message de sauvegarde exportée',
|
||||
(tester) async {
|
||||
testWidgets('export réussi affiche le message de sauvegarde exportée', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _AuthHarness();
|
||||
final exporter = _FakeBackupFileExporter(
|
||||
status: ShareResultStatus.success,
|
||||
);
|
||||
final exporter = _FakeBackupFileExporter(status: ShareResultStatus.success);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -323,8 +361,7 @@ void main() {
|
||||
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
|
||||
expect(exporter.exportedFileName, isNotNull);
|
||||
expect(exporter.exportedFileName, endsWith('.gametime'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('un échec d’export affiche un message neutre', (tester) async {
|
||||
final harness = _AuthHarness();
|
||||
@ -404,9 +441,9 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'remplacer tout exige une seconde confirmation destructive',
|
||||
(tester) async {
|
||||
testWidgets('remplacer tout exige une seconde confirmation destructive', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _AuthHarness()..backupRepository.hasData = true;
|
||||
final bytes = const LocalBackupCodec().encode(
|
||||
LocalDataExportSnapshot(
|
||||
@ -448,10 +485,7 @@ void main() {
|
||||
await tester.tap(find.text('Remplacer tout'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Remplacer toutes les données locales ?'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Remplacer toutes les données locales ?'), findsOneWidget);
|
||||
expect(harness.backupRepository.appliedModes, isEmpty);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
|
||||
@ -470,8 +504,7 @@ void main() {
|
||||
expect(harness.backupRepository.appliedModes, [
|
||||
LocalBackupImportMode.replaceAll,
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
|
||||
final harness = _AuthHarness();
|
||||
|
||||
@ -15,6 +15,22 @@ void main() {
|
||||
name: 'Fondations basket - 45 min',
|
||||
defaultRestSeconds: 45,
|
||||
isExample: true,
|
||||
exercises: [
|
||||
ProgramExercise(
|
||||
metadata: _metadata('program-exercise-1'),
|
||||
programId: 'program-1',
|
||||
position: 0,
|
||||
exerciseNameSnapshot: 'Tirs',
|
||||
availableTimeSnapshot: false,
|
||||
availableRepsSnapshot: true,
|
||||
availableScoreSnapshot: false,
|
||||
setsCount: 3,
|
||||
timeEnabled: false,
|
||||
repsEnabled: true,
|
||||
scoreEnabled: false,
|
||||
targetReps: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
@ -65,6 +81,44 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('un programme sans exercice ne peut pas être enregistré', (
|
||||
tester,
|
||||
) async {
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ProgramFormScreen(
|
||||
programUseCases: _programUseCases(
|
||||
programRepository,
|
||||
exerciseRepository,
|
||||
),
|
||||
exerciseUseCases: _exerciseUseCases(exerciseRepository),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Ajoute au moins un exercice pour enregistrer ce programme.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Enregistrer'),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
|
||||
await tester.tap(find.text('Enregistrer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(programRepository.saved, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('filtre les programmes par tag et peut effacer les filtres', (
|
||||
tester,
|
||||
) async {
|
||||
@ -125,9 +179,36 @@ void main() {
|
||||
name: 'Jambes',
|
||||
defaultRestSeconds: 60,
|
||||
tags: const ['match'],
|
||||
exercises: [
|
||||
ProgramExercise(
|
||||
metadata: _metadata('program-exercise-1'),
|
||||
programId: 'program-1',
|
||||
sourceExerciseId: 'exercise-1',
|
||||
position: 0,
|
||||
exerciseNameSnapshot: 'Tirs',
|
||||
availableTimeSnapshot: false,
|
||||
availableRepsSnapshot: true,
|
||||
availableScoreSnapshot: false,
|
||||
setsCount: 3,
|
||||
timeEnabled: false,
|
||||
repsEnabled: true,
|
||||
scoreEnabled: false,
|
||||
targetReps: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final exerciseRepository = _FakeExerciseRepository()
|
||||
..exercises.add(
|
||||
Exercise(
|
||||
metadata: _metadata('exercise-1'),
|
||||
name: 'Tirs',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 10,
|
||||
),
|
||||
);
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -167,9 +248,36 @@ void main() {
|
||||
name: 'Fondations basket - 45 min',
|
||||
defaultRestSeconds: 45,
|
||||
isExample: true,
|
||||
exercises: [
|
||||
ProgramExercise(
|
||||
metadata: _metadata('program-exercise-1'),
|
||||
programId: 'program-1',
|
||||
sourceExerciseId: 'exercise-1',
|
||||
position: 0,
|
||||
exerciseNameSnapshot: 'Tirs',
|
||||
availableTimeSnapshot: false,
|
||||
availableRepsSnapshot: true,
|
||||
availableScoreSnapshot: false,
|
||||
setsCount: 3,
|
||||
timeEnabled: false,
|
||||
repsEnabled: true,
|
||||
scoreEnabled: false,
|
||||
targetReps: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final exerciseRepository = _FakeExerciseRepository()
|
||||
..exercises.add(
|
||||
Exercise(
|
||||
metadata: _metadata('exercise-1'),
|
||||
name: 'Tirs',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 10,
|
||||
),
|
||||
);
|
||||
final exerciseRepository = _FakeExerciseRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
@ -456,6 +456,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
@ -539,6 +547,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required int setIndex,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -567,6 +583,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
String sessionId,
|
||||
) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return const [];
|
||||
@ -594,9 +615,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
@override
|
||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime/application/application.dart';
|
||||
import 'package:gametime/domain/domain.dart';
|
||||
import 'package:gametime/presentation/presentation.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('le temps affiché est recalculé depuis les horodatages', (
|
||||
@ -45,6 +46,48 @@ void main() {
|
||||
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('une séance invalide est abandonnée avec un message clair', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 30000,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Séance indisponible'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
|
||||
});
|
||||
|
||||
testWidgets('les répétitions sont initialisées avec la cible', (
|
||||
tester,
|
||||
) async {
|
||||
@ -81,6 +124,214 @@ void main() {
|
||||
expect(find.text('8'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('affiche les statistiques montre live disponibles', (
|
||||
tester,
|
||||
) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final clock = _FakeClock(now);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
|
||||
..recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: now
|
||||
.subtract(const Duration(hours: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 124,
|
||||
),
|
||||
)
|
||||
..recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: now.millisecondsSinceEpoch,
|
||||
heartRateBpm: 124,
|
||||
distanceMeters: 840,
|
||||
caloriesKcal: 186,
|
||||
),
|
||||
);
|
||||
addTearDown(sensorUseCases.dispose);
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
sensorUseCases: sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('FC 124 bpm'), findsOneWidget);
|
||||
expect(find.text('840 m'), findsOneWidget);
|
||||
expect(find.text('186 kcal'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('n’affiche aucun indicateur capteur sans donnée FC', (
|
||||
tester,
|
||||
) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final clock = _FakeClock(now);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
|
||||
addTearDown(sensorUseCases.dispose);
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
sensorUseCases: sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('bpm'), findsNothing);
|
||||
expect(find.textContaining('kcal'), findsNothing);
|
||||
expect(find.byIcon(Icons.favorite), findsNothing);
|
||||
expect(find.byIcon(Icons.local_fire_department), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ne fabrique pas de calories sans mesure montre native', (
|
||||
tester,
|
||||
) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final clock = _FakeClock(now);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
|
||||
..recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: now
|
||||
.subtract(const Duration(hours: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 124,
|
||||
),
|
||||
)
|
||||
..recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: now.millisecondsSinceEpoch,
|
||||
heartRateBpm: 124,
|
||||
),
|
||||
);
|
||||
addTearDown(sensorUseCases.dispose);
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
sensorUseCases: sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('FC 124 bpm'), findsOneWidget);
|
||||
expect(find.textContaining('kcal'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final clock = _FakeClock(now);
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
|
||||
..recordHeartRateSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
recordedAtEpochMs: now
|
||||
.subtract(const Duration(seconds: 30))
|
||||
.millisecondsSinceEpoch,
|
||||
heartRateBpm: 118,
|
||||
),
|
||||
);
|
||||
addTearDown(sensorUseCases.dispose);
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
sensorUseCases: sensorUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('FC 118 bpm'), findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.text('FC 118 bpm'),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is Opacity && widget.opacity == 0.45,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('les références de temps sont formatées sans valeurs brutes', (
|
||||
tester,
|
||||
) async {
|
||||
@ -152,7 +403,9 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
final scoreController = TextEditingController();
|
||||
final scoreFocusNode = FocusNode();
|
||||
addTearDown(scoreController.dispose);
|
||||
addTearDown(scoreFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -170,6 +423,8 @@ void main() {
|
||||
),
|
||||
reps: 0,
|
||||
scoreController: scoreController,
|
||||
scoreFocusNode: scoreFocusNode,
|
||||
onScoreSubmitted: (_) {},
|
||||
onRepsChanged: (_) {},
|
||||
),
|
||||
),
|
||||
@ -808,6 +1063,63 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'la saisie du score téléphone alimente le score vivant de la montre',
|
||||
(tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1400));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final activeUseCases = _activeUseCases(repository, clock);
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
restSeconds: 0,
|
||||
scoreUnit: 'points',
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: activeUseCases,
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Score (points)'),
|
||||
'7.5',
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(repository.manualScoreStates.single.value, 7.5);
|
||||
|
||||
final incremented = await activeUseCases.incrementManualScore(
|
||||
sessionId: 'session-1',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
);
|
||||
expect(incremented.state.value, 8.5);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('la flèche de retour met la séance active en pause', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1186,11 +1498,75 @@ void main() {
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.text('SÉQUENCE'), findsOneWidget);
|
||||
expect(find.text('Passage 1 / 10'), findsOneWidget);
|
||||
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
|
||||
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
|
||||
expect(find.text('Dribble main droite'), findsOneWidget);
|
||||
expect(find.text('00:10'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final repository = _FakeActiveSessionRepository();
|
||||
final session = ActiveWorkoutSession(
|
||||
metadata: _metadata('session-1'),
|
||||
sourceWorkoutTemplateId: 'template-1',
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _sessionSnapshot(
|
||||
setsCount: 3,
|
||||
targetReps: 20,
|
||||
scoreEnabled: true,
|
||||
scoreUnit: 'paniers',
|
||||
exerciseSteps: [
|
||||
_step(
|
||||
'step-1',
|
||||
0,
|
||||
'Tirs après dribble',
|
||||
ExerciseStepType.time,
|
||||
10,
|
||||
hasScore: true,
|
||||
scoreLabel: 'Paniers',
|
||||
scoreUnit: 'paniers',
|
||||
defaultTargetScore: 8,
|
||||
),
|
||||
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
|
||||
],
|
||||
),
|
||||
);
|
||||
repository.session = session;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutExecutionScreen(
|
||||
initialSession: session,
|
||||
activeUseCases: _activeUseCases(repository, clock),
|
||||
stepUseCases: _stepUseCases(repository, clock),
|
||||
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
|
||||
closeUseCase: _closeUseCase(repository, clock),
|
||||
historyUseCases: _historyUseCases(clock),
|
||||
workoutTemplateUseCases: _workoutTemplateUseCases(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
|
||||
expect(find.text('Tirs après dribble'), findsOneWidget);
|
||||
expect(find.text('00:10'), findsOneWidget);
|
||||
expect(find.text('Passer l’étape'), findsOneWidget);
|
||||
expect(find.byType(SingleChildScrollView), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('l’écran actif avec séquence longue reste sans scroll', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1465,7 +1841,7 @@ void main() {
|
||||
|
||||
expect(repository.stepResults.single.stepIndex, 0);
|
||||
expect(repository.stepResults.single.status, SetResultStatus.completed);
|
||||
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
|
||||
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
|
||||
expect(find.text('Droite'), findsOneWidget);
|
||||
expect(find.text('00:02'), findsOneWidget);
|
||||
expect(
|
||||
@ -1526,7 +1902,7 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(repository.stepResults.single.stepIndex, 0);
|
||||
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
|
||||
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
|
||||
expect(find.text('Droite'), findsOneWidget);
|
||||
expect(find.text('00:02'), findsOneWidget);
|
||||
expect(find.text('Chrono prêt'), findsOneWidget);
|
||||
@ -1847,6 +2223,20 @@ String _sessionSnapshot({
|
||||
});
|
||||
}
|
||||
|
||||
String _emptySessionSnapshot() {
|
||||
return jsonEncode({
|
||||
'name': 'Séance vide',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'template-program-1',
|
||||
'programNameSnapshot': 'Programme vide',
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object?> _step(
|
||||
String id,
|
||||
int position,
|
||||
@ -1960,6 +2350,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
final results = <ActiveSetResult>[];
|
||||
final restStates = <ActiveRestState>[];
|
||||
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
|
||||
final manualScoreStates = <ActiveManualScoreState>[];
|
||||
final setTimerStates = <ActiveSetTimerState>[];
|
||||
final stepProgressStates = <ActiveExerciseStepProgressState>[];
|
||||
final stepResults = <ActiveExerciseStepResult>[];
|
||||
@ -2001,6 +2392,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
for (final state in manualScoreStates) {
|
||||
if (state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex &&
|
||||
state.metadata.deletedAt == null) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -2055,6 +2465,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return manualScoreStates
|
||||
.where((state) => state.activeWorkoutSessionId == sessionId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
|
||||
return results;
|
||||
@ -2114,6 +2533,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
|
||||
final index = manualScoreStates.indexWhere(
|
||||
(saved) => saved.metadata.id == state.metadata.id,
|
||||
);
|
||||
if (index == -1) {
|
||||
manualScoreStates.add(state);
|
||||
} else {
|
||||
manualScoreStates[index] = state;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
|
||||
final index = setTimerStates.indexWhere(
|
||||
@ -2183,6 +2614,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {
|
||||
manualScoreStates.removeWhere(
|
||||
(state) =>
|
||||
state.activeWorkoutSessionId == sessionId &&
|
||||
state.programIndex == programIndex &&
|
||||
state.exerciseIndex == exerciseIndex &&
|
||||
state.setIndex == setIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {
|
||||
final index = results.indexWhere(
|
||||
@ -2237,6 +2685,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
|
||||
@ -126,6 +126,134 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('une séance sans programme ne peut pas être enregistrée', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository(const []);
|
||||
final programRepository = _FakeProgramRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutTemplateFormScreen(
|
||||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
programUseCases: ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.text('Ajoute au moins un programme pour enregistrer cette séance.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Enregistrer'),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
|
||||
await tester.tap(find.text('Enregistrer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(templateRepository.saved, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('le lancement est bloqué si aucun exercice ne peut être joué', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||||
WorkoutTemplate(
|
||||
metadata: _metadata('template-empty'),
|
||||
name: 'Séance vide',
|
||||
programs: [
|
||||
WorkoutTemplateProgram(
|
||||
metadata: _metadata('template-program-empty'),
|
||||
workoutTemplateId: 'template-empty',
|
||||
position: 0,
|
||||
programNameSnapshot: 'Programme vide',
|
||||
defaultRestSecondsSnapshot: 60,
|
||||
programSnapshotJson: jsonEncode({'exercises': const []}),
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
final activeRepository = _FakeActiveSessionRepository();
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final historyRepository = _FakeWorkoutHistoryRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutTemplateListScreen(
|
||||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
programUseCases: ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
activeUseCases: ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
closeUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeRepository,
|
||||
historyRepository: historyRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
historyUseCases: WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: clock,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Lancer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
|
||||
'au moins un exercice avant de la lancer.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(activeRepository.session, isNull);
|
||||
});
|
||||
|
||||
testWidgets('filtre les séances par tag et peut effacer les filtres', (
|
||||
tester,
|
||||
) async {
|
||||
@ -517,6 +645,89 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('une session legacy invalide ne bloque pas le lancement', (
|
||||
tester,
|
||||
) async {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
|
||||
final templateRepository = _FakeWorkoutTemplateRepository([
|
||||
_workoutTemplate(),
|
||||
]);
|
||||
final activeRepository = _FakeActiveSessionRepository()
|
||||
..session = ActiveWorkoutSession(
|
||||
metadata: _metadata('legacy-session'),
|
||||
sourceWorkoutTemplateId: 'template-legacy',
|
||||
status: ActiveWorkoutStatus.paused,
|
||||
startedAt: DateTime.utc(2026, 7, 17, 9),
|
||||
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: jsonEncode({
|
||||
'name': 'Séance vide',
|
||||
'programs': [
|
||||
{
|
||||
'id': 'template-program-empty',
|
||||
'programNameSnapshot': 'Programme vide',
|
||||
'programSnapshotJson': jsonEncode({'exercises': const []}),
|
||||
},
|
||||
],
|
||||
'overrides': const [],
|
||||
}),
|
||||
);
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final historyRepository = _FakeWorkoutHistoryRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: WorkoutTemplateListScreen(
|
||||
workoutTemplateUseCases: WorkoutTemplateUseCases(
|
||||
templateRepository: templateRepository,
|
||||
programRepository: programRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
programUseCases: ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: _FakeExerciseRepository(),
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
activeUseCases: ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
closeUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeRepository,
|
||||
historyRepository: historyRepository,
|
||||
clock: clock,
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
historyUseCases: WorkoutHistoryUseCases(
|
||||
repository: historyRepository,
|
||||
clock: clock,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Lancer'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
|
||||
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
|
||||
expect(activeRepository.session?.sourceWorkoutTemplateId, 'template-1');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'supprimer une séance demande confirmation puis la retire de la liste',
|
||||
(tester) async {
|
||||
@ -828,6 +1039,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveManualScoreState?> findManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActiveSetTimerState?> findSetTimerState({
|
||||
required String sessionId,
|
||||
@ -860,6 +1081,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveManualScoreState>> listManualScoreStates(
|
||||
String sessionId,
|
||||
) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
|
||||
return const [];
|
||||
@ -896,6 +1124,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
@override
|
||||
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
|
||||
|
||||
@ -916,6 +1147,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> deleteManualScoreState({
|
||||
required String sessionId,
|
||||
required int programIndex,
|
||||
required int exerciseIndex,
|
||||
required int setIndex,
|
||||
required DateTime deletedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(ActiveSetResult result) async {}
|
||||
}
|
||||
@ -964,6 +1204,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> save(WorkoutHistory history) async {}
|
||||
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
required DateTime patchedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
|
||||
|
||||
|
||||
@ -15,8 +15,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.gametime.watch"
|
||||
minSdk = 26
|
||||
applicationId = "com.gametime.app"
|
||||
minSdk = 30
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@ -29,10 +29,21 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.health:health-services-client:1.1.0-rc02")
|
||||
implementation("androidx.wear:wear:1.4.0")
|
||||
implementation("androidx.wear:wear-ongoing:1.0.0")
|
||||
implementation("com.google.guava:guava:33.6.0-android")
|
||||
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
||||
}
|
||||
|
||||
@ -4,16 +4,23 @@
|
||||
android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.BODY_SENSORS" />
|
||||
<uses-permission android:name="android.permission.BODY_SENSORS_BACKGROUND" />
|
||||
|
||||
<application
|
||||
android:label="GameTime"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:usesCleartextTraffic="false">
|
||||
<uses-library
|
||||
android:name="com.google.android.wearable"
|
||||
android:required="true" />
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.capabilities"
|
||||
android:resource="@array/android_wear_capabilities" />
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.standalone"
|
||||
android:value="false" />
|
||||
|
||||
@ -1,12 +1,40 @@
|
||||
package com.gametime.watch
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.wear.ambient.AmbientModeSupport
|
||||
import com.gametime.watch.bridge.WatchBridgePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
class MainActivity :
|
||||
FlutterFragmentActivity(),
|
||||
AmbientModeSupport.AmbientCallbackProvider {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AmbientModeSupport.attach(this)
|
||||
WatchBridgePlugin.attachActivity(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
WatchBridgePlugin.detachActivity(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
WatchBridgePlugin.register(flutterEngine, applicationContext)
|
||||
}
|
||||
|
||||
override fun getAmbientCallback(): AmbientModeSupport.AmbientCallback {
|
||||
return object : AmbientModeSupport.AmbientCallback() {}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
WatchBridgePlugin.handlePermissionResult(requestCode, grantResults)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package com.gametime.watch.bridge
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.gms.wearable.CapabilityClient
|
||||
import com.google.android.gms.wearable.DataEvent
|
||||
import com.google.android.gms.wearable.DataMapItem
|
||||
@ -17,18 +21,32 @@ import java.nio.charset.StandardCharsets
|
||||
object WatchBridgePlugin {
|
||||
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
|
||||
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
|
||||
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
|
||||
private const val ACK_CHANNEL = "gametime.watch_bridge/acks"
|
||||
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
|
||||
|
||||
const val COMMAND_PATH = "/gametime/watch/command"
|
||||
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
|
||||
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
|
||||
const val ACK_PATH = "/gametime/phone/ack"
|
||||
const val STATE_PATH = "/gametime/phone/projection"
|
||||
const val PHONE_CAPABILITY = "gametime_phone_companion"
|
||||
private const val BODY_SENSORS_PERMISSION_REQUEST = 4106
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var activity: Activity? = null
|
||||
private var projectionSink: EventChannel.EventSink? = null
|
||||
private var sensorSampleSink: EventChannel.EventSink? = null
|
||||
private var ackSink: EventChannel.EventSink? = null
|
||||
private var connectionSink: EventChannel.EventSink? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val heartRateCollector = WatchHeartRateCollector(
|
||||
phoneCapability = PHONE_CAPABILITY,
|
||||
sensorSummaryPath = SENSOR_SUMMARY_PATH,
|
||||
sensorSamplePath = SENSOR_SAMPLE_PATH,
|
||||
onLocalSample = ::emitSensorSample,
|
||||
)
|
||||
private var bodySensorPermissionRequested = false
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, context: Context) {
|
||||
appContext = context.applicationContext
|
||||
@ -59,6 +77,18 @@ object WatchBridgePlugin {
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
sensorSampleSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sensorSampleSink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
@ -74,26 +104,64 @@ object WatchBridgePlugin {
|
||||
)
|
||||
}
|
||||
|
||||
fun attachActivity(activity: Activity) {
|
||||
this.activity = activity
|
||||
}
|
||||
|
||||
fun detachActivity(activity: Activity) {
|
||||
if (this.activity === activity) {
|
||||
this.activity = null
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
|
||||
if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) {
|
||||
return
|
||||
}
|
||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
||||
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun emitProjection(payload: Map<String, Any?>): Boolean {
|
||||
appContext?.let {
|
||||
WatchOngoingActivityController.update(it, payload, activity)
|
||||
updateHeartRateCollection(it, payload)
|
||||
}
|
||||
val sink = projectionSink ?: return false
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitAck(payload: Map<String, Any?>): Boolean {
|
||||
val sink = ackSink ?: return false
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
|
||||
val sink = sensorSampleSink ?: return false
|
||||
mainHandler.post {
|
||||
sink.success(payload)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
|
||||
connectionSink?.success(
|
||||
val sink = connectionSink ?: return
|
||||
mainHandler.post {
|
||||
sink.success(
|
||||
mapOf(
|
||||
"isReachable" to isReachable,
|
||||
"requestsResync" to requestsResync,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleDataEvent(event: DataEvent) {
|
||||
if (event.type != DataEvent.TYPE_CHANGED ||
|
||||
@ -105,7 +173,8 @@ object WatchBridgePlugin {
|
||||
.dataMap
|
||||
.getString("projectionJson")
|
||||
?: return
|
||||
emitProjection(JSONObject(projectionJson).toMap())
|
||||
val projection = JSONObject(projectionJson).toMap()
|
||||
emitProjection(projection)
|
||||
}
|
||||
|
||||
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
@ -194,6 +263,7 @@ object WatchBridgePlugin {
|
||||
fun requestLatestProjection(context: Context) {
|
||||
val uri = Uri.Builder()
|
||||
.scheme("wear")
|
||||
.authority("*")
|
||||
.path(STATE_PATH)
|
||||
.build()
|
||||
Wearable.getDataClient(context)
|
||||
@ -212,6 +282,67 @@ object WatchBridgePlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
|
||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||
heartRateCollector.finishCurrentSession(context)
|
||||
return
|
||||
}
|
||||
val shouldAggregate = phase == "running"
|
||||
if (!hasBodySensorsPermission(context)) {
|
||||
heartRateCollector.noteActiveSession(
|
||||
sessionId,
|
||||
shouldAggregate = false,
|
||||
executionContext = telemetryContext(projection),
|
||||
)
|
||||
requestBodySensorsPermissionOnce()
|
||||
return
|
||||
}
|
||||
heartRateCollector.noteActiveSession(
|
||||
sessionId,
|
||||
shouldAggregate,
|
||||
executionContext = telemetryContext(projection),
|
||||
)
|
||||
if (shouldAggregate) {
|
||||
heartRateCollector.start(context)
|
||||
} else {
|
||||
heartRateCollector.pause(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBodySensorsPermission(context: Context): Boolean {
|
||||
return context.checkSelfPermission(android.Manifest.permission.BODY_SENSORS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun requestBodySensorsPermissionOnce() {
|
||||
val activity = activity ?: return
|
||||
if (bodySensorPermissionRequested) {
|
||||
return
|
||||
}
|
||||
bodySensorPermissionRequested = true
|
||||
activity.requestPermissions(
|
||||
arrayOf(android.Manifest.permission.BODY_SENSORS),
|
||||
BODY_SENSORS_PERMISSION_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
private fun telemetryContext(projection: Map<String, Any?>): Map<String, Any?> {
|
||||
return mapOf(
|
||||
"programIndex" to projection["programIndex"],
|
||||
"exerciseIndex" to projection["exerciseIndex"],
|
||||
"setIndex" to projection["setIndex"],
|
||||
"passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]),
|
||||
"stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]),
|
||||
)
|
||||
}
|
||||
|
||||
private fun zeroBasedNullableIndex(value: Any?): Int? {
|
||||
val index = (value as? Number)?.toInt() ?: return null
|
||||
return if (index > 0) index - 1 else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.toMap(): Map<String, Any?> {
|
||||
|
||||
@ -0,0 +1,186 @@
|
||||
package com.gametime.watch.bridge
|
||||
|
||||
import android.content.Context
|
||||
import androidx.health.services.client.HealthServices
|
||||
import androidx.health.services.client.MeasureCallback
|
||||
import androidx.health.services.client.data.Availability
|
||||
import androidx.health.services.client.data.DataPointContainer
|
||||
import androidx.health.services.client.data.DataType
|
||||
import androidx.health.services.client.data.DeltaDataType
|
||||
import com.google.android.gms.wearable.CapabilityClient
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import org.json.JSONObject
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal class WatchHeartRateCollector(
|
||||
private val phoneCapability: String,
|
||||
private val sensorSummaryPath: String,
|
||||
private val sensorSamplePath: String,
|
||||
private val onLocalSample: (Map<String, Any?>) -> Unit = {},
|
||||
) {
|
||||
private var sessionId: String? = null
|
||||
private var sampleCount = 0
|
||||
private var sampleSum = 0.0
|
||||
private var minBpm: Int? = null
|
||||
private var maxBpm: Int? = null
|
||||
private var sampleSequence = 0
|
||||
private var executionContext: Map<String, Any?> = emptyMap()
|
||||
private var registered = false
|
||||
private var shouldAggregate = false
|
||||
private var appContext: Context? = null
|
||||
|
||||
private val callback = object : MeasureCallback {
|
||||
override fun onAvailabilityChanged(
|
||||
dataType: DeltaDataType<*, *>,
|
||||
availability: Availability,
|
||||
) = Unit
|
||||
|
||||
override fun onDataReceived(data: DataPointContainer) {
|
||||
if (!shouldAggregate) {
|
||||
return
|
||||
}
|
||||
for (point in data.getData(DataType.HEART_RATE_BPM)) {
|
||||
record(point.value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(throwable: Throwable) {
|
||||
registered = false
|
||||
}
|
||||
}
|
||||
|
||||
fun noteActiveSession(
|
||||
nextSessionId: String,
|
||||
shouldAggregate: Boolean,
|
||||
executionContext: Map<String, Any?> = emptyMap(),
|
||||
) {
|
||||
if (sessionId != nextSessionId) {
|
||||
reset(nextSessionId)
|
||||
}
|
||||
this.executionContext = executionContext
|
||||
this.shouldAggregate = shouldAggregate
|
||||
}
|
||||
|
||||
fun onBodySensorsGranted(context: Context) {
|
||||
if (shouldAggregate) {
|
||||
start(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
if (registered || sessionId.isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
appContext = context.applicationContext
|
||||
try {
|
||||
HealthServices.getClient(context)
|
||||
.measureClient
|
||||
.registerMeasureCallback(DataType.HEART_RATE_BPM, callback)
|
||||
registered = true
|
||||
} catch (_: RuntimeException) {
|
||||
registered = false
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(context: Context) {
|
||||
shouldAggregate = false
|
||||
unregister(context)
|
||||
}
|
||||
|
||||
fun finishCurrentSession(context: Context) {
|
||||
unregister(context)
|
||||
val completedSessionId = sessionId
|
||||
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
|
||||
sendSummary(context, completedSessionId)
|
||||
}
|
||||
reset(null)
|
||||
}
|
||||
|
||||
private fun record(bpm: Double) {
|
||||
if (bpm <= 0) {
|
||||
return
|
||||
}
|
||||
sampleCount += 1
|
||||
sampleSum += bpm
|
||||
val rounded = bpm.roundToInt()
|
||||
minBpm = minOf(minBpm ?: rounded, rounded)
|
||||
maxBpm = maxOf(maxBpm ?: rounded, rounded)
|
||||
sendSample(rounded)
|
||||
}
|
||||
|
||||
private fun sendSample(bpm: Int) {
|
||||
val activeSessionId = sessionId ?: return
|
||||
val context = appContext ?: return
|
||||
sampleSequence += 1
|
||||
val capturedAt = System.currentTimeMillis()
|
||||
val sample = mapOf(
|
||||
"schemaVersion" to 4,
|
||||
"sampleId" to "$activeSessionId-$capturedAt-$sampleSequence",
|
||||
"sessionId" to activeSessionId,
|
||||
"capturedAtEpochMs" to capturedAt,
|
||||
"recordedAtEpochMs" to capturedAt,
|
||||
"programIndex" to executionContext["programIndex"],
|
||||
"exerciseIndex" to executionContext["exerciseIndex"],
|
||||
"setIndex" to executionContext["setIndex"],
|
||||
"passageIndex" to executionContext["passageIndex"],
|
||||
"stepIndex" to executionContext["stepIndex"],
|
||||
"heartRateBpm" to bpm,
|
||||
)
|
||||
onLocalSample(sample)
|
||||
val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8)
|
||||
Wearable.getCapabilityClient(context)
|
||||
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
||||
.addOnSuccessListener { capability ->
|
||||
for (node in capability.nodes) {
|
||||
Wearable.getMessageClient(context)
|
||||
.sendMessage(node.id, sensorSamplePath, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSummary(context: Context, completedSessionId: String) {
|
||||
val min = minBpm ?: return
|
||||
val max = maxBpm ?: return
|
||||
val payload = JSONObject(
|
||||
mapOf(
|
||||
"schemaVersion" to 4,
|
||||
"sessionId" to completedSessionId,
|
||||
"sampleCount" to sampleCount,
|
||||
"minHeartRateBpm" to min,
|
||||
"averageHeartRateBpm" to sampleSum / sampleCount,
|
||||
"maxHeartRateBpm" to max,
|
||||
),
|
||||
).toString().toByteArray(StandardCharsets.UTF_8)
|
||||
Wearable.getCapabilityClient(context)
|
||||
.getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE)
|
||||
.addOnSuccessListener { capability ->
|
||||
for (node in capability.nodes) {
|
||||
Wearable.getMessageClient(context)
|
||||
.sendMessage(node.id, sensorSummaryPath, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregister(context: Context) {
|
||||
if (!registered) {
|
||||
return
|
||||
}
|
||||
HealthServices.getClient(context)
|
||||
.measureClient
|
||||
.unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback)
|
||||
registered = false
|
||||
appContext = null
|
||||
}
|
||||
|
||||
private fun reset(nextSessionId: String?) {
|
||||
sessionId = nextSessionId
|
||||
sampleCount = 0
|
||||
sampleSum = 0.0
|
||||
minBpm = null
|
||||
maxBpm = null
|
||||
sampleSequence = 0
|
||||
executionContext = emptyMap()
|
||||
shouldAggregate = false
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package com.gametime.watch.bridge
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.wear.ongoing.OngoingActivity
|
||||
import androidx.wear.ongoing.Status
|
||||
import com.gametime.watch.MainActivity
|
||||
import com.gametime.watch.R
|
||||
|
||||
object WatchOngoingActivityController {
|
||||
private const val CHANNEL_ID = "gametime_watch_session"
|
||||
private const val CHANNEL_NAME = "Séance GameTime"
|
||||
private const val NOTIFICATION_ID = 9101
|
||||
private const val ONGOING_ACTIVITY_ID = 9101
|
||||
private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107
|
||||
private var postNotificationsPermissionRequested = false
|
||||
|
||||
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
|
||||
val phase = projection["phase"] as? String ?: "noActiveSession"
|
||||
val sessionId = projection["deviceSessionId"] as? String ?: ""
|
||||
if (phase == "noActiveSession" || sessionId.isBlank()) {
|
||||
cancel(context)
|
||||
return
|
||||
}
|
||||
if (!hasPostNotificationsPermission(context)) {
|
||||
requestPostNotificationsPermissionOnce(activity)
|
||||
return
|
||||
}
|
||||
post(context, projection)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun post(context: Context, projection: Map<String, Any?>) {
|
||||
ensureNotificationChannel(context)
|
||||
val touchIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val exerciseName = (projection["exerciseName"] as? String)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Séance en cours"
|
||||
val status = Status.Builder()
|
||||
.addPart("activity", Status.TextPart(exerciseName))
|
||||
.addTemplate("#activity#")
|
||||
.build()
|
||||
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_ongoing_gt)
|
||||
.setContentTitle("GameTime")
|
||||
.setContentText(exerciseName)
|
||||
.setContentIntent(touchIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
|
||||
OngoingActivity.Builder(context, NOTIFICATION_ID, notificationBuilder)
|
||||
.setOngoingActivityId(ONGOING_ACTIVITY_ID)
|
||||
.setStaticIcon(R.drawable.ic_ongoing_gt)
|
||||
.setTouchIntent(touchIntent)
|
||||
.setTitle("GameTime")
|
||||
.setStatus(status)
|
||||
.build()
|
||||
.apply(context)
|
||||
|
||||
NotificationManagerCompat.from(context).notify(
|
||||
NOTIFICATION_ID,
|
||||
notificationBuilder.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val existing = manager.getNotificationChannel(CHANNEL_ID)
|
||||
if (existing != null) {
|
||||
return
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun requestPostNotificationsPermissionOnce(activity: Activity?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
activity == null ||
|
||||
postNotificationsPermissionRequested
|
||||
) {
|
||||
return
|
||||
}
|
||||
postNotificationsPermissionRequested = true
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
POST_NOTIFICATIONS_PERMISSION_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,4h14v3H11v3h7v3h-7v7H5z" />
|
||||
</vector>
|
||||
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
BIN
watch_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
watch_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@ -2,10 +2,12 @@
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
<item name="android:windowSwipeToDismiss">false</item>
|
||||
</style>
|
||||
|
||||
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<item name="android:windowBackground">#080A12</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
<item name="android:windowSwipeToDismiss">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
4
watch_app/android/app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#080A12</color>
|
||||
</resources>
|
||||
@ -1,9 +1,11 @@
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSwipeToDismiss">false</item>
|
||||
</style>
|
||||
|
||||
<style name="NormalTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<item name="android:windowBackground">#080A12</item>
|
||||
<item name="android:windowSwipeToDismiss">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -6,9 +6,8 @@ allprojects {
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build/watch_app")
|
||||
.get()
|
||||
rootProject.layout.projectDirectory
|
||||
.dir("../build")
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
|
||||
4
watch_app/android/gradle.properties
Normal file
@ -0,0 +1,4 @@
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
BIN
watch_app/assets/fonts/Anton-Regular.ttf
Normal file
BIN
watch_app/assets/fonts/Archivo-Variable.ttf
Normal file
@ -11,17 +11,31 @@ final class WatchSessionUiState {
|
||||
required this.projection,
|
||||
this.commandPending = false,
|
||||
this.waitingForPhone = false,
|
||||
this.timerTogglePending = false,
|
||||
this.connectionLost = false,
|
||||
this.staleProjection = false,
|
||||
this.scoreCommandPending = false,
|
||||
this.scoreWaitingForPhone = false,
|
||||
this.optimisticManualScoreValue,
|
||||
this.commandFailureMessage,
|
||||
this.commandFailureSerial = 0,
|
||||
this.lastAck,
|
||||
this.sensorSample,
|
||||
});
|
||||
|
||||
final WatchSessionProjection projection;
|
||||
final bool commandPending;
|
||||
final bool waitingForPhone;
|
||||
final bool timerTogglePending;
|
||||
final bool connectionLost;
|
||||
final bool staleProjection;
|
||||
final bool scoreCommandPending;
|
||||
final bool scoreWaitingForPhone;
|
||||
final double? optimisticManualScoreValue;
|
||||
final String? commandFailureMessage;
|
||||
final int commandFailureSerial;
|
||||
final WatchCommandAckEvent? lastAck;
|
||||
final WatchSensorSample? sensorSample;
|
||||
|
||||
bool get actionsEnabled => !commandPending && !connectionLost;
|
||||
|
||||
@ -29,19 +43,50 @@ final class WatchSessionUiState {
|
||||
WatchSessionProjection? projection,
|
||||
bool? commandPending,
|
||||
bool? waitingForPhone,
|
||||
bool? timerTogglePending,
|
||||
bool? connectionLost,
|
||||
bool? staleProjection,
|
||||
bool? scoreCommandPending,
|
||||
bool? scoreWaitingForPhone,
|
||||
double? optimisticManualScoreValue,
|
||||
bool clearOptimisticManualScoreValue = false,
|
||||
String? commandFailureMessage,
|
||||
bool clearCommandFailureMessage = false,
|
||||
int? commandFailureSerial,
|
||||
WatchCommandAckEvent? lastAck,
|
||||
WatchSensorSample? sensorSample,
|
||||
bool clearSensorSample = false,
|
||||
}) {
|
||||
return WatchSessionUiState(
|
||||
projection: projection ?? this.projection,
|
||||
commandPending: commandPending ?? this.commandPending,
|
||||
waitingForPhone: waitingForPhone ?? this.waitingForPhone,
|
||||
timerTogglePending: timerTogglePending ?? this.timerTogglePending,
|
||||
connectionLost: connectionLost ?? this.connectionLost,
|
||||
staleProjection: staleProjection ?? this.staleProjection,
|
||||
scoreCommandPending: scoreCommandPending ?? this.scoreCommandPending,
|
||||
scoreWaitingForPhone: scoreWaitingForPhone ?? this.scoreWaitingForPhone,
|
||||
optimisticManualScoreValue: clearOptimisticManualScoreValue
|
||||
? null
|
||||
: optimisticManualScoreValue ?? this.optimisticManualScoreValue,
|
||||
commandFailureMessage: clearCommandFailureMessage
|
||||
? null
|
||||
: commandFailureMessage ?? this.commandFailureMessage,
|
||||
commandFailureSerial: commandFailureSerial ?? this.commandFailureSerial,
|
||||
lastAck: lastAck ?? this.lastAck,
|
||||
sensorSample: clearSensorSample
|
||||
? null
|
||||
: sensorSample ?? this.sensorSample,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasLiveSensors {
|
||||
final sample = sensorSample;
|
||||
return sample != null &&
|
||||
(sample.heartRateBpm != null ||
|
||||
sample.distanceMeters != null ||
|
||||
sample.caloriesKcal != null);
|
||||
}
|
||||
}
|
||||
|
||||
final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
@ -59,6 +104,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_connectionLostThreshold = connectionLostThreshold,
|
||||
super(WatchSessionUiState(projection: _initialProjection())) {
|
||||
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
|
||||
_subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample));
|
||||
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
|
||||
_subscriptions.add(
|
||||
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
|
||||
@ -79,10 +125,16 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
|
||||
Timer? _waitingTimer;
|
||||
Timer? _commandTimeoutTimer;
|
||||
Timer? _scoreWaitingTimer;
|
||||
Timer? _scoreCommandTimeoutTimer;
|
||||
Timer? _freshnessTimer;
|
||||
Timer? _commandFailureClearTimer;
|
||||
WatchCommandEnvelope? _pendingCommand;
|
||||
final _pendingScoreCommandIds = <String>{};
|
||||
double? _optimisticManualScoreValue;
|
||||
DateTime? _lastProjectionReceivedAt;
|
||||
var _commandCounter = 0;
|
||||
var _commandFailureSerial = 0;
|
||||
|
||||
Future<void> refresh() async {
|
||||
value = value.copyWith(connectionLost: false);
|
||||
@ -119,18 +171,30 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
WatchSecondaryAction.skipCurrentStep => WatchCommandType.skipCurrentStep,
|
||||
WatchSecondaryAction.skipCurrentPassage =>
|
||||
WatchCommandType.skipCurrentPassage,
|
||||
WatchSecondaryAction.finishCurrentSet => WatchCommandType.finishCurrentSet,
|
||||
WatchSecondaryAction.finishCurrentSet =>
|
||||
WatchCommandType.finishCurrentSet,
|
||||
WatchSecondaryAction.skipCurrentSet => WatchCommandType.skipCurrentSet,
|
||||
WatchSecondaryAction.skipCurrentRest => WatchCommandType.skipCurrentRest,
|
||||
};
|
||||
return _sendCommand(command);
|
||||
}
|
||||
|
||||
Future<void> incrementScore() {
|
||||
return _sendScoreCommand(WatchCommandType.incrementScore, 1);
|
||||
}
|
||||
|
||||
Future<void> decrementScore() {
|
||||
return _sendScoreCommand(WatchCommandType.decrementScore, -1);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_waitingTimer?.cancel();
|
||||
_commandTimeoutTimer?.cancel();
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_freshnessTimer?.cancel();
|
||||
_commandFailureClearTimer?.cancel();
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
@ -138,7 +202,9 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
}
|
||||
|
||||
Future<void> _sendCommand(WatchCommandType type) async {
|
||||
if (!value.actionsEnabled || value.projection.deviceSessionId.isEmpty) {
|
||||
if (!value.actionsEnabled ||
|
||||
(_requiresActiveSession(type) &&
|
||||
value.projection.deviceSessionId.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
@ -153,6 +219,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: true,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: _isTimerToggleCommand(type),
|
||||
connectionLost: false,
|
||||
);
|
||||
_waitingTimer?.cancel();
|
||||
@ -165,6 +232,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: false,
|
||||
connectionLost: true,
|
||||
);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
@ -177,25 +245,114 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
waitingForPhone: false,
|
||||
timerTogglePending: false,
|
||||
connectionLost: true,
|
||||
);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendScoreCommand(WatchCommandType type, int delta) async {
|
||||
final projection = value.projection;
|
||||
if (value.connectionLost ||
|
||||
!projection.phoneReachable ||
|
||||
!projection.hasManualScore ||
|
||||
projection.deviceSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final current =
|
||||
_optimisticManualScoreValue ?? projection.currentManualScoreValue ?? 0;
|
||||
final next = (current + delta).clamp(0, double.infinity).toDouble();
|
||||
if (next == current) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
final command = WatchCommandEnvelope(
|
||||
commandId: 'watch-$nowMs-${_commandCounter++}',
|
||||
type: type,
|
||||
sessionId: projection.deviceSessionId,
|
||||
expectedRevision: projection.revision,
|
||||
sentAtEpochMs: nowMs,
|
||||
);
|
||||
_pendingScoreCommandIds.add(command.commandId);
|
||||
_optimisticManualScoreValue = next;
|
||||
value = value.copyWith(
|
||||
scoreCommandPending: true,
|
||||
scoreWaitingForPhone: false,
|
||||
optimisticManualScoreValue: next,
|
||||
connectionLost: false,
|
||||
);
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreWaitingTimer = Timer(_waitingThreshold, () {
|
||||
value = value.copyWith(scoreWaitingForPhone: true);
|
||||
});
|
||||
_scoreCommandTimeoutTimer = Timer(_commandTimeout, () {
|
||||
_clearScorePending(recalibrate: true);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
unawaited(_nativeClient.requestResync());
|
||||
});
|
||||
try {
|
||||
await _nativeClient.sendCommand(command);
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
} on PlatformException {
|
||||
_pendingScoreCommandIds.remove(command.commandId);
|
||||
_clearScorePending(recalibrate: true);
|
||||
value = value.copyWith(connectionLost: true);
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
}
|
||||
}
|
||||
|
||||
void _handleProjection(WatchSessionProjection projection) {
|
||||
final previousProjection = value.projection;
|
||||
_lastProjectionReceivedAt = DateTime.now();
|
||||
_pendingCommand = null;
|
||||
_clearCommandTimers();
|
||||
_syncScorePendingFromProjection(projection);
|
||||
value = WatchSessionUiState(
|
||||
projection: projection,
|
||||
timerTogglePending: false,
|
||||
scoreCommandPending: _pendingScoreCommandIds.isNotEmpty,
|
||||
scoreWaitingForPhone:
|
||||
_pendingScoreCommandIds.isNotEmpty && value.scoreWaitingForPhone,
|
||||
optimisticManualScoreValue: _optimisticManualScoreValue,
|
||||
commandFailureMessage: value.commandFailureMessage,
|
||||
commandFailureSerial: value.commandFailureSerial,
|
||||
lastAck: value.lastAck,
|
||||
sensorSample: projection.deviceSessionId.isEmpty
|
||||
? null
|
||||
: value.sensorSample?.sessionId == projection.deviceSessionId
|
||||
? value.sensorSample
|
||||
: null,
|
||||
);
|
||||
_triggerProjectionHaptic(previousProjection, projection);
|
||||
}
|
||||
|
||||
void _handleSensorSample(WatchSensorSample sample) {
|
||||
final sessionId = sample.sessionId.trim();
|
||||
if (sessionId.isEmpty || sessionId != value.projection.deviceSessionId) {
|
||||
return;
|
||||
}
|
||||
final hasMetric =
|
||||
sample.heartRateBpm != null ||
|
||||
sample.distanceMeters != null ||
|
||||
sample.caloriesKcal != null;
|
||||
if (!hasMetric) {
|
||||
return;
|
||||
}
|
||||
value = value.copyWith(sensorSample: sample);
|
||||
}
|
||||
|
||||
void _handleAck(WatchCommandAckEvent ack) {
|
||||
if (_pendingScoreCommandIds.remove(ack.commandId)) {
|
||||
value = value.copyWith(lastAck: ack);
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
if (_isRejected(ack.status)) {
|
||||
_clearScorePending(recalibrate: true);
|
||||
unawaited(_nativeClient.requestResync());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_pendingCommand?.commandId != ack.commandId) {
|
||||
value = value.copyWith(lastAck: ack);
|
||||
return;
|
||||
@ -208,9 +365,10 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
);
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
if (_isRejected(ack.status)) {
|
||||
final failedType = _pendingCommand?.type;
|
||||
_pendingCommand = null;
|
||||
_clearCommandTimers();
|
||||
value = value.copyWith(commandPending: false);
|
||||
_publishCommandFailure(_commandFailureMessageFor(failedType));
|
||||
unawaited(_nativeClient.requestResync());
|
||||
}
|
||||
}
|
||||
@ -242,12 +400,61 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_commandTimeoutTimer = null;
|
||||
}
|
||||
|
||||
void _publishCommandFailure(String message) {
|
||||
_commandFailureClearTimer?.cancel();
|
||||
value = value.copyWith(
|
||||
commandPending: false,
|
||||
timerTogglePending: false,
|
||||
commandFailureMessage: message,
|
||||
commandFailureSerial: ++_commandFailureSerial,
|
||||
);
|
||||
_commandFailureClearTimer = Timer(const Duration(seconds: 2), () {
|
||||
value = value.copyWith(clearCommandFailureMessage: true);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearScorePending({required bool recalibrate}) {
|
||||
_pendingScoreCommandIds.clear();
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
_optimisticManualScoreValue = null;
|
||||
value = value.copyWith(
|
||||
scoreCommandPending: false,
|
||||
scoreWaitingForPhone: false,
|
||||
clearOptimisticManualScoreValue: recalibrate,
|
||||
);
|
||||
}
|
||||
|
||||
void _syncScorePendingFromProjection(WatchSessionProjection projection) {
|
||||
if (_pendingScoreCommandIds.isEmpty) {
|
||||
_optimisticManualScoreValue = null;
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
return;
|
||||
}
|
||||
final optimistic = _optimisticManualScoreValue;
|
||||
final confirmed = projection.currentManualScoreValue;
|
||||
if (optimistic != null && confirmed == optimistic) {
|
||||
_pendingScoreCommandIds.clear();
|
||||
_optimisticManualScoreValue = null;
|
||||
_scoreWaitingTimer?.cancel();
|
||||
_scoreWaitingTimer = null;
|
||||
_scoreCommandTimeoutTimer?.cancel();
|
||||
_scoreCommandTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerProjectionHaptic(
|
||||
WatchSessionProjection previous,
|
||||
WatchSessionProjection current,
|
||||
) {
|
||||
final phaseChanged = previous.phase != current.phase;
|
||||
final enteredReadyTimer = current.phase == WatchSessionPhase.nextTimerReady &&
|
||||
final enteredReadyTimer =
|
||||
current.phase == WatchSessionPhase.nextTimerReady &&
|
||||
previous.phase != WatchSessionPhase.nextTimerReady;
|
||||
final enteredRestEnd =
|
||||
previous.phase == WatchSessionPhase.restRunning &&
|
||||
@ -255,9 +462,11 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
current.phase != WatchSessionPhase.restPaused;
|
||||
if (phaseChanged && (enteredReadyTimer || enteredRestEnd)) {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
unawaited(Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
||||
return HapticFeedback.mediumImpact();
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -269,6 +478,28 @@ bool _isRejected(WatchCommandAck ack) {
|
||||
};
|
||||
}
|
||||
|
||||
String _commandFailureMessageFor(WatchCommandType? type) {
|
||||
return switch (type) {
|
||||
WatchCommandType.finishCurrentSet ||
|
||||
WatchCommandType.skipCurrentSet => 'Série non modifiée',
|
||||
_ => 'Commande non appliquée',
|
||||
};
|
||||
}
|
||||
|
||||
bool _isTimerToggleCommand(WatchCommandType type) {
|
||||
return switch (type) {
|
||||
WatchCommandType.startCurrentExercise ||
|
||||
WatchCommandType.startPreparedTimedStep ||
|
||||
WatchCommandType.pauseSession ||
|
||||
WatchCommandType.resumeSession => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _requiresActiveSession(WatchCommandType type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
WatchSessionProjection _initialProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
|
||||
@ -30,6 +30,8 @@ final class WatchCommandAckEvent {
|
||||
abstract interface class NativeWatchBridgeClient {
|
||||
Stream<WatchSessionProjection> get projections;
|
||||
|
||||
Stream<WatchSensorSample> get sensorSamples;
|
||||
|
||||
Stream<WatchCommandAckEvent> get acks;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
@ -45,25 +47,28 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
implements NativeWatchBridgeClient {
|
||||
const MethodChannelNativeWatchBridgeClient({
|
||||
MethodChannel methodChannel = const MethodChannel(_methodChannelName),
|
||||
EventChannel projectionChannel = const EventChannel(
|
||||
_projectionChannelName,
|
||||
EventChannel projectionChannel = const EventChannel(_projectionChannelName),
|
||||
EventChannel sensorSampleChannel = const EventChannel(
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel ackChannel = const EventChannel(_ackChannelName),
|
||||
EventChannel connectionChannel = const EventChannel(
|
||||
_connectionChannelName,
|
||||
),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_projectionChannel = projectionChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_ackChannel = ackChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
static const _projectionChannelName = 'gametime.watch_bridge/projections';
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _ackChannelName = 'gametime.watch_bridge/acks';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _projectionChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _ackChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@ -77,6 +82,16 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples {
|
||||
return _sensorSampleChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) {
|
||||
return WatchSensorSample.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchCommandAckEvent> get acks {
|
||||
return _ackChannel
|
||||
@ -144,7 +159,11 @@ String? _nullableStringFromJson(Object? value) {
|
||||
}
|
||||
|
||||
int? _nullableIntFromJson(Object? value) {
|
||||
return value is int ? value : value is num ? value.toInt() : null;
|
||||
return value is int
|
||||
? value
|
||||
: value is num
|
||||
? value.toInt()
|
||||
: null;
|
||||
}
|
||||
|
||||
T _enumFromJson<T extends Enum>(Object? value, List<T> values, T fallback) {
|
||||
|
||||
@ -9,23 +9,41 @@ ThemeData watchTheme() {
|
||||
|
||||
final textTheme = Typography.whiteMountainView.copyWith(
|
||||
labelSmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: muted,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodySmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 11,
|
||||
color: muted,
|
||||
height: 1.15,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodyMedium: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
color: text,
|
||||
height: 1.15,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
bodySmall: const TextStyle(fontSize: 11, color: muted, height: 1.15),
|
||||
bodyMedium: const TextStyle(fontSize: 13, color: text, height: 1.15),
|
||||
titleSmall: const TextStyle(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: text,
|
||||
height: 1.05,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
displayLarge: const TextStyle(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: text,
|
||||
fontFamily: 'Anton',
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xFFC9A24A),
|
||||
height: 0.95,
|
||||
letterSpacing: 0,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
@ -50,8 +68,10 @@ ThemeData watchTheme() {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -62,8 +82,10 @@ ThemeData watchTheme() {
|
||||
side: const BorderSide(color: Color(0xFF303748)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontFamily: 'Archivo',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -9,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -17,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -30,6 +62,35 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -38,6 +99,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -54,11 +123,67 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -67,6 +192,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
watch_bridge_contract:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -76,3 +209,4 @@ packages:
|
||||
version: "0.1.0"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@ -14,7 +14,16 @@ dependencies:
|
||||
path: ../packages/watch_bridge_contract
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
fonts:
|
||||
- family: Anton
|
||||
fonts:
|
||||
- asset: assets/fonts/Anton-Regular.ttf
|
||||
- family: Archivo
|
||||
fonts:
|
||||
- asset: assets/fonts/Archivo-Variable.ttf
|
||||
|
||||
689
watch_app/test/presentation/watch_session_screen_test.dart
Normal file
@ -0,0 +1,689 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime_watch/application/watch_session_view_model.dart';
|
||||
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
|
||||
import 'package:gametime_watch/presentation/watch_session_screen.dart';
|
||||
import 'package:gametime_watch/presentation/watch_theme.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'renders an active session on a compact round-sized viewport without overflow',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
expect(find.text('02:14'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'transitions from no-session to manual score controls without rendering a black screen',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_noSessionStartProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Aucune séance en cours'), findsOneWidget);
|
||||
|
||||
client.emitProjection(_manualScoreProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Lancers francs'), findsOneWidget);
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
expect(find.text('Cible : 8'), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.byTooltip('Ajouter'), findsOneWidget);
|
||||
expect(find.byTooltip('Retirer'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not offer a start action from the no-session screen', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_noSessionStartProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Aucune séance en cours'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
|
||||
expect(client.sentCommands, isEmpty);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('starts a stopped exercise timer from the timer button', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_readyProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Démarrer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
client.sentCommands.single.type,
|
||||
WatchCommandType.startCurrentExercise,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('timer-toggle-pending-dot')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('keeps the no-session start action disabled without phone', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitConnection(const WatchBridgeConnectionEvent(isReachable: false));
|
||||
client.emitProjection(_noSessionStartProjection(phoneReachable: false));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Téléphone indisponible'), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shows compact timer controls with a manual score step', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_manualScoreProjectionWithTimer());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
expect(find.text('Chrono étape 02:14'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('shows live heart rate on session and telemetry on stats page', (
|
||||
tester,
|
||||
) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
expect(find.byTooltip('Stats'), findsNothing);
|
||||
|
||||
client.emitSensorSample(
|
||||
WatchSensorSample(
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
heartRateBpm: 142,
|
||||
distanceMeters: 840,
|
||||
caloriesKcal: 186,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('142 bpm'), findsOneWidget);
|
||||
expect(find.byTooltip('Stats'), findsOneWidget);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('watch-session-page')),
|
||||
const Offset(-220, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('watch-actions-page')),
|
||||
const Offset(-220, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Stats'), findsOneWidget);
|
||||
expect(find.text('FC'), findsOneWidget);
|
||||
expect(find.text('840 m'), findsOneWidget);
|
||||
expect(find.text('186 kcal'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'sends pause command from the icon button when a timer dominates',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Pause'));
|
||||
await tester.pump();
|
||||
|
||||
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows pending feedback inside the pause button', (tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Pause'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('timer-toggle-pending-dot')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(client.sentCommands.single.type, WatchCommandType.pauseSession);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'returns to session and shows a discreet notice when a set command is rejected',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Terminer la série'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
client.sentCommands.single.type,
|
||||
WatchCommandType.finishCurrentSet,
|
||||
);
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
|
||||
client.emitAck(
|
||||
WatchCommandAckEvent(
|
||||
commandId: client.sentCommands.single.commandId,
|
||||
status: WatchCommandAck.rejectedNotApplicable,
|
||||
sessionId: 'session-1',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Série non modifiée'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1900));
|
||||
expect(find.text('Série non modifiée'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps rest skip secondary action open until the session projection updates',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_secondaryRestActionProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
_expectActionsPageVisible(tester);
|
||||
|
||||
await tester.tap(find.text('Passer le repos'));
|
||||
await tester.pump();
|
||||
|
||||
expect(client.sentCommands.single.type, WatchCommandType.skipCurrentRest);
|
||||
_expectActionsPageVisible(tester);
|
||||
|
||||
client.emitProjection(_runningProjection());
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
_expectSessionPageVisible(tester);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps rest skip secondary action open and shows feedback when rejected',
|
||||
(tester) async {
|
||||
final client = _FakeNativeWatchBridgeClient();
|
||||
final viewModel = WatchSessionViewModel(nativeClient: client);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(192, 192);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: watchTheme(),
|
||||
home: WatchSessionScreen(viewModel: viewModel),
|
||||
),
|
||||
);
|
||||
|
||||
client.emitProjection(_secondaryRestActionProjection());
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Actions'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Passer le repos'));
|
||||
await tester.pump();
|
||||
|
||||
client.emitAck(
|
||||
WatchCommandAckEvent(
|
||||
commandId: client.sentCommands.single.commandId,
|
||||
status: WatchCommandAck.rejectedNotApplicable,
|
||||
sessionId: 'session-1',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
_expectActionsPageVisible(tester);
|
||||
expect(find.text('Commande non appliquée'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
final _projectionController =
|
||||
StreamController<WatchSessionProjection>.broadcast();
|
||||
final _sensorSampleController =
|
||||
StreamController<WatchSensorSample>.broadcast();
|
||||
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
|
||||
final _connectionController =
|
||||
StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
|
||||
var resyncRequests = 0;
|
||||
var capabilityRefreshRequests = 0;
|
||||
final sentCommands = <WatchCommandEnvelope>[];
|
||||
|
||||
@override
|
||||
Stream<WatchSessionProjection> get projections =>
|
||||
_projectionController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchSensorSample> get sensorSamples => _sensorSampleController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connectionController.stream;
|
||||
|
||||
void emitProjection(WatchSessionProjection projection) {
|
||||
_projectionController.add(projection);
|
||||
}
|
||||
|
||||
void emitSensorSample(WatchSensorSample sample) {
|
||||
_sensorSampleController.add(sample);
|
||||
}
|
||||
|
||||
void emitAck(WatchCommandAckEvent ack) {
|
||||
_ackController.add(ack);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connectionController.add(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestCapabilityRefresh() async {
|
||||
capabilityRefreshRequests += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestResync() async {
|
||||
resyncRequests += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> sendCommand(WatchCommandEnvelope command) async {
|
||||
sentCommands.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
WatchSessionProjection _runningProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Séance active',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: _runningStepTimer(),
|
||||
secondaryTimers: const [
|
||||
WatchTimerProjection(
|
||||
kind: WatchTimerKind.setTimer,
|
||||
label: 'Série',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: 0,
|
||||
accumulatedMs: 45000,
|
||||
),
|
||||
],
|
||||
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
revision: 0,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
phoneReachable: phoneReachable,
|
||||
seriesIndex: 0,
|
||||
seriesTotal: 0,
|
||||
exerciseName: '',
|
||||
statusLabel: 'Aucune séance',
|
||||
primaryAction: WatchPrimaryAction.none,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _readyProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 1,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.ready,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Prêt à démarrer',
|
||||
primaryAction: WatchPrimaryAction.startCurrentExercise,
|
||||
dominantTimer: const WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
label: 'Chrono étape',
|
||||
displayMode: WatchTimerDisplayMode.countdown,
|
||||
runState: WatchTimerRunState.stopped,
|
||||
referenceEpochMs: 0,
|
||||
accumulatedMs: 0,
|
||||
targetMs: 30000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _secondaryRestActionProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 4,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Séance active',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
secondaryActions: const [WatchSecondaryAction.skipCurrentRest],
|
||||
);
|
||||
}
|
||||
|
||||
void _expectActionsPageVisible(WidgetTester tester) {
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
|
||||
lessThan(96),
|
||||
);
|
||||
}
|
||||
|
||||
void _expectSessionPageVisible(WidgetTester tester) {
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('watch-session-page'))).dx,
|
||||
lessThan(96),
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _manualScoreProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 2,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Score manuel',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 3,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 8,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreScope: WatchManualScoreScope.series,
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _manualScoreProjectionWithTimer() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 3,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Lancers francs',
|
||||
stepName: 'Routine de tir',
|
||||
statusLabel: 'Score manuel',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: _runningStepTimer(),
|
||||
hasManualScore: true,
|
||||
currentManualScoreValue: 3,
|
||||
canDecrementScore: true,
|
||||
manualScoreTargetValue: 8,
|
||||
manualScoreTargetLabel: 'Cible',
|
||||
manualScoreScope: WatchManualScoreScope.step,
|
||||
);
|
||||
}
|
||||
|
||||
WatchTimerProjection _runningStepTimer() {
|
||||
return WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
label: 'Chrono étape',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(minutes: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
accumulatedMs: 134000,
|
||||
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||
targetMs: 180000,
|
||||
);
|
||||
}
|
||||