merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre

Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

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

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
This commit is contained in:
2026-07-28 11:35:22 +02:00
80 changed files with 12296 additions and 891 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', () {
@ -374,6 +375,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(
@ -649,6 +684,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(
@ -692,6 +747,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(
@ -943,6 +1046,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()
@ -1509,6 +1758,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 {
@ -2346,6 +2667,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'),
@ -3135,6 +3815,7 @@ ExerciseStep _step({
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) {
return ExerciseStep(
id: id,
@ -3148,6 +3829,7 @@ ExerciseStep _step({
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -3809,6 +4491,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>[];
@ -4059,6 +4778,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,
@ -4078,6 +4863,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>[];
@ -4111,6 +4897,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,
@ -4169,6 +4974,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
@ -4225,6 +5043,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,
@ -4266,6 +5089,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(

View File

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

View File

@ -69,10 +69,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.setTimer,
@ -110,9 +110,111 @@ void main() {
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.setTimer);
expect(projection.secondaryTimers, isEmpty);
expect(projection.primaryAction, WatchPrimaryAction.pauseSession);
expect(
projection.secondaryActions,
contains(WatchSecondaryAction.finishCurrentSet),
);
},
);
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(
@ -307,14 +409,22 @@ void main() {
);
test(
'emits projections through stream and publisher with incremented revision',
'keeps revision stable when refresh only changes volatile timing fields',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final clock = _clock(now);
final session = _session(steps: [_step(defaultTargetValue: 30)]);
final repository = _FakeActiveSessionRepository()
..session = _session(steps: [_step()]);
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
@ -323,21 +433,54 @@ void main() {
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
clock.value = now.add(const Duration(seconds: 2));
final second = await useCases.emitCurrentProjection();
await Future<void>.delayed(Duration.zero);
expect(first.revision, 1);
expect(second.revision, 2);
expect(emitted.map((projection) => projection.revision), [1, 2]);
expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(
second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
);
expect(emitted.map((projection) => projection.revision), [1, 1]);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
1,
]);
await subscription.cancel();
await useCases.dispose();
},
);
test('increments revision when session command state changes', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(setsCount: 2);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
);
final first = await useCases.emitCurrentProjection();
repository.session = repository.session!.copyWith(currentSetIndex: 1);
final second = await useCases.emitCurrentProjection();
expect(first.revision, 1);
expect(second.revision, 2);
expect(second.seriesIndex, 2);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
]);
await useCases.dispose();
});
}
WatchSessionProjectionProjector _projector(
@ -366,6 +509,7 @@ ActiveWorkoutSession _session({
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
double? targetScore,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
@ -380,6 +524,7 @@ ActiveWorkoutSession _session({
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? setsCount : null,
'targetScore': targetScore,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -428,6 +573,10 @@ ExerciseStep _step({
String id = 'step-1',
int position = 0,
int defaultTargetValue = 1,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
@ -435,6 +584,10 @@ ExerciseStep _step({
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -539,6 +692,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -553,6 +707,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -596,6 +761,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -645,6 +825,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -686,6 +875,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);

View File

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

View File

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

View File

@ -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 denregistrer lexercice. Vérifie les champs puis réessaie.',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNotNull,
);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(tester) async {
@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
final exercises = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
var throwOnSave = false;
@override
Future<Exercise?> findById(String id) async {
@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<void> save(Exercise exercise) async {
if (throwOnSave) {
throw Exception('save failed');
}
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
@ -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('naffiche aucun indicateur capteur sans donnée FC', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.textContaining('bpm'), findsNothing);
expect(find.textContaining('kcal'), findsNothing);
expect(find.byIcon(Icons.favorite), findsNothing);
expect(find.byIcon(Icons.local_fire_department), findsNothing);
});
testWidgets('ne fabrique pas de calories sans mesure montre native', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing);
});
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(seconds: 30))
.millisecondsSinceEpoch,
heartRateBpm: 118,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 118 bpm'), findsOneWidget);
expect(
find.ancestor(
of: find.text('FC 118 bpm'),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.opacity == 0.45,
),
),
findsOneWidget,
);
});
testWidgets('les références de temps sont formatées sans valeurs brutes', (
tester,
) async {
@ -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: (_) {},
),
),
@ -870,6 +1125,63 @@ void main() {
},
);
testWidgets(
'la saisie du score téléphone alimente le score vivant de la montre',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final activeUseCases = _activeUseCases(repository, clock);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
restSeconds: 0,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'7.5',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(repository.manualScoreStates.single.value, 7.5);
final incremented = await activeUseCases.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
},
);
testWidgets('la flèche de retour met la séance active en pause', (
tester,
) async {
@ -1248,11 +1560,75 @@ void main() {
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Dribble main droite'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
});
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
targetReps: 20,
scoreEnabled: true,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Tirs après dribble',
ExerciseStepType.time,
10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'paniers',
defaultTargetScore: 8,
),
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Tirs après dribble'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('Passer létape'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsWidgets);
});
testWidgets('lécran actif avec séquence longue reste sans scroll', (
tester,
) async {
@ -1527,7 +1903,7 @@ void main() {
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.completed);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(
@ -1588,7 +1964,7 @@ void main() {
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono prêt'), findsOneWidget);
@ -1909,6 +2285,20 @@ String _sessionSnapshot({
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
Map<String, Object?> _step(
String id,
int position,
@ -2022,6 +2412,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final manualScoreStates = <ActiveManualScoreState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -2063,6 +2454,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in manualScoreStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -2117,6 +2527,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -2176,6 +2595,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
final index = manualScoreStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
manualScoreStates.add(state);
} else {
manualScoreStates[index] = state;
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
@ -2245,6 +2676,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(
@ -2299,6 +2747,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

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

View File

@ -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")
}

View File

@ -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" />

View File

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

View File

@ -1,7 +1,11 @@
package com.gametime.watch.bridge
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem
@ -17,18 +21,32 @@ import java.nio.charset.StandardCharsets
object WatchBridgePlugin {
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
private const val ACK_CHANNEL = "gametime.watch_bridge/acks"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
private const val BODY_SENSORS_PERMISSION_REQUEST = 4106
private var appContext: Context? = null
private var activity: Activity? = null
private var projectionSink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val heartRateCollector = WatchHeartRateCollector(
phoneCapability = PHONE_CAPABILITY,
sensorSummaryPath = SENSOR_SUMMARY_PATH,
sensorSamplePath = SENSOR_SAMPLE_PATH,
onLocalSample = ::emitSensorSample,
)
private var bodySensorPermissionRequested = false
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -59,6 +77,18 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sensorSampleSink = events
}
override fun onCancel(arguments: Any?) {
sensorSampleSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, CONNECTION_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
@ -74,25 +104,63 @@ object WatchBridgePlugin {
)
}
fun attachActivity(activity: Activity) {
this.activity = activity
}
fun detachActivity(activity: Activity) {
if (this.activity === activity) {
this.activity = null
}
}
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) {
return
}
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
}
}
fun emitProjection(payload: Map<String, Any?>): Boolean {
appContext?.let {
WatchOngoingActivityController.update(it, payload, activity)
updateHeartRateCollection(it, payload)
}
val sink = projectionSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitAck(payload: Map<String, Any?>): Boolean {
val sink = ackSink ?: return false
sink.success(payload)
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun emitConnection(isReachable: Boolean, requestsResync: Boolean) {
connectionSink?.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
val sink = connectionSink ?: return
mainHandler.post {
sink.success(
mapOf(
"isReachable" to isReachable,
"requestsResync" to requestsResync,
),
)
}
}
fun handleDataEvent(event: DataEvent) {
@ -105,7 +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?> {

View File

@ -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
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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,
);
}