From 30c6259748b59624250cbfc2b4070b74f4879f57 Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 11:22:17 +0200 Subject: [PATCH] fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157) --- .../gametime/app/watch/WatchBridgePlugin.kt | 4 + lib/application/ports.dart | 2 +- lib/application/use_cases.dart | 40 +++- .../local/drift_repositories.dart | 226 +++++++++++++++++- lib/infrastructure/remote/share_api.dart | 111 +++++++-- .../workout_execution_screen.dart | 207 ++++++++-------- .../lib/src/watch_bridge_contract.dart | 16 +- .../test/watch_bridge_contract_test.dart | 5 + test/application/use_cases_test.dart | 83 ++++++- .../watch_companion_command_handler_test.dart | 41 ++++ .../drift_repositories_test.dart | 219 +++++++++++++++++ .../infrastructure/remote/share_api_test.dart | 181 ++++++++++++++ test/presentation/home_screen_test.dart | 5 +- test/presentation/profile_screen_test.dart | 27 ++- test/presentation/program_screen_test.dart | 27 ++- .../presentation/share_inbox_screen_test.dart | 27 ++- .../workout_execution_screen_test.dart | 6 + .../workout_template_screen_test.dart | 27 ++- .../android/app/src/main/AndroidManifest.xml | 7 +- .../kotlin/com/gametime/watch/MainActivity.kt | 13 + .../watch/bridge/WatchBridgePlugin.kt | 88 +++++++ .../watch/bridge/WatchHeartRateCollector.kt | 195 +++++++++++++-- .../bridge/WatchHeartRateForegroundService.kt | 5 +- .../bridge/WatchOngoingActivityController.kt | 6 +- .../application/watch_session_view_model.dart | 89 ++++++- .../native_watch_bridge_client.dart | 7 + .../presentation/watch_session_screen.dart | 73 +++++- .../watch_session_screen_test.dart | 169 +++++++++++-- 28 files changed, 1658 insertions(+), 248 deletions(-) create mode 100644 test/infrastructure/remote/share_api_test.dart diff --git a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt index f500875..fc14d26 100644 --- a/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt +++ b/android/app/src/main/kotlin/com/gametime/app/watch/WatchBridgePlugin.kt @@ -176,6 +176,10 @@ object WatchBridgePlugin { "projectedAtEpochMs", (map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, ) + dataMap.putLong( + "expiresAtEpochMs", + (map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L, + ) }.asPutDataRequest().setUrgent() Wearable.getDataClient(context).putDataItem(request) .addOnSuccessListener { result.success(null) } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 90482c2..3f7ba32 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -765,7 +765,7 @@ abstract interface class RemoteShareApi { }); Future> fetchInbox(String token); - Future acceptShare(String shareId, String token); + Future> acceptShare(String shareId, String token); Future declineShare(String shareId, String token); Future revokeShare(String shareId, String token); } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index bac91f3..4c14150 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -632,15 +632,15 @@ final class ShareUseCases { return; } try { - if (cachedItem?.resourceType == ShareResourceType.pack) { - await remoteShareApi.acceptShare(shareId, token); - await _importSharedPayload(cachedItem!); + final createdResources = await remoteShareApi.acceptShare(shareId, token); + final cachedPackItem = cachedItem; + if (cachedPackItem != null && + cachedPackItem.resourceType == ShareResourceType.pack) { + await _importSharedPayload(cachedPackItem); } else { - final createdResource = await remoteShareApi.acceptShare( - shareId, - token, - ); - await localChanges.applyRemoteItem(createdResource); + for (final createdResource in createdResources) { + await localChanges.applyRemoteItem(createdResource); + } } await inboxRepository.markStatus( shareId, @@ -794,11 +794,13 @@ final class ShareUseCases { if (item?.resourceType == ShareResourceType.pack) { await remoteShareApi.acceptShare(action.shareId!, token); } else { - final created = await remoteShareApi.acceptShare( + final createdResources = await remoteShareApi.acceptShare( action.shareId!, token, ); - await localChanges.applyRemoteItem(created); + for (final createdResource in createdResources) { + await localChanges.applyRemoteItem(createdResource); + } } await inboxRepository.markStatus( action.shareId!, @@ -3505,6 +3507,7 @@ bool _hasSameWatchCommandRevisionState( left.deviceSessionId == right.deviceSessionId && left.phase == right.phase && left.phoneReachable == right.phoneReachable && + left.expiresAtEpochMs == right.expiresAtEpochMs && left.seriesIndex == right.seriesIndex && left.seriesTotal == right.seriesTotal && left.exerciseName == right.exerciseName && @@ -3533,6 +3536,7 @@ bool _hasSameWatchCommandRevisionState( left.canDecrementScore == right.canDecrementScore && left.manualScoreTargetValue == right.manualScoreTargetValue && left.manualScoreTargetLabel == right.manualScoreTargetLabel && + left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue && left.manualScoreScope == right.manualScoreScope; } @@ -4055,6 +4059,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: '', revision: revision, projectedAtEpochMs: _epochMs(now), + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: WatchSessionPhase.noActiveSession, phoneReachable: true, seriesIndex: 0, @@ -4075,6 +4080,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: session.metadata.id, revision: revision, projectedAtEpochMs: _epochMs(now), + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: WatchSessionPhase.noActiveSession, phoneReachable: true, seriesIndex: session.currentSetIndex + 1, @@ -4150,6 +4156,7 @@ final class WatchSessionProjectionProjector { deviceSessionId: session.metadata.id, revision: revision, projectedAtEpochMs: projectedAtEpochMs, + expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now), phase: phase, phoneReachable: true, seriesIndex: session.currentSetIndex + 1, @@ -4202,6 +4209,7 @@ final class WatchSessionProjectionProjector { canDecrementScore: (manualScoreProjection?.value ?? 0) > 0, manualScoreTargetValue: manualScoreProjection?.targetValue, manualScoreTargetLabel: manualScoreProjection?.targetLabel, + manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue, manualScoreScope: manualScoreProjection?.scope, ); } @@ -4274,12 +4282,14 @@ final class _WatchManualScoreProjectionData { required this.value, this.targetValue, this.targetLabel, + this.repsTargetValue, }); final WatchManualScoreScope scope; final double value; final double? targetValue; final String? targetLabel; + final int? repsTargetValue; } _WatchManualScoreProjectionData? _watchManualScoreProjection({ @@ -4304,6 +4314,9 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({ value: result?.actualScore ?? 0, targetValue: step.defaultTargetScore, targetLabel: step.defaultTargetScore == null ? null : 'Cible', + repsTargetValue: step.type == ExerciseStepType.reps + ? step.defaultTargetValue + : null, ); } if (snapshot.scoreEnabled && @@ -4612,6 +4625,13 @@ String? _betweenSetsNextExerciseName( int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch; +int _watchProjectionExpiresAtEpochMs(DateTime projectedAt) { + return projectedAt + .toUtc() + .add(const Duration(seconds: 12)) + .millisecondsSinceEpoch; +} + final class ActiveExerciseStepUseCases { const ActiveExerciseStepUseCases({ required this.sessionRepository, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index cb2364f..2e327ad 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -492,10 +492,14 @@ final class DriftLocalSyncChangeRepository switch (item.resourceType) { case SyncResourceType.exercise: final exercise = _exerciseFromPayload(item); - await database - .into(database.exercises) - .insertOnConflictUpdate(_exerciseCompanion(exercise)); - await _writeExerciseStarterMetadata(database, exercise); + await database.transaction(() async { + await database + .into(database.exercises) + .insertOnConflictUpdate(_exerciseCompanion(exercise)); + await _writeExerciseStarterMetadata(database, exercise); + await _replaceRemoteExerciseImages(exercise); + await _replaceRemoteExerciseSteps(exercise); + }); return true; case SyncResourceType.mediaAsset: await database @@ -542,7 +546,11 @@ final class DriftLocalSyncChangeRepository }); return true; case SyncResourceType.workoutHistory: - return false; + await _replaceRemoteWorkoutHistory( + _workoutHistoryFromLocalBackupPayload(item), + item.clientUpdatedAt, + ); + return true; } } @@ -604,7 +612,7 @@ final class DriftLocalSyncChangeRepository } return _LocalSyncSnapshot.fromMetadata( history.metadata, - _workoutHistoryPayload(history), + _localWorkoutHistoryPayload(history), ); } @@ -627,6 +635,212 @@ final class DriftLocalSyncChangeRepository payload: {'id': id}, ); } + + Future _replaceRemoteWorkoutHistory( + domain.WorkoutHistory history, + DateTime deletedAt, + ) async { + await database.transaction(() async { + await database + .into(database.workoutHistories) + .insertOnConflictUpdate(_workoutHistoryCompanion(history)); + for (final result in history.results) { + await database + .into(database.workoutHistorySetResults) + .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)); + } + for (final result in history.stepResults) { + await database + .into(database.workoutHistoryStepResults) + .insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)); + } + + final activeResultIds = history.results + .map((result) => result.metadata.id) + .toSet(); + await _softDeleteRemoteWorkoutHistoryChildren( + tableName: 'workout_history_set_results', + historyId: history.metadata.id, + keepIds: activeResultIds, + deletedAt: deletedAt, + ); + final activeStepResultIds = history.stepResults + .map((result) => result.metadata.id) + .toSet(); + await _softDeleteRemoteWorkoutHistoryChildren( + tableName: 'workout_history_step_results', + historyId: history.metadata.id, + keepIds: activeStepResultIds, + deletedAt: deletedAt, + ); + }); + } + + Future _softDeleteRemoteWorkoutHistoryChildren({ + required String tableName, + required String historyId, + required Set keepIds, + required DateTime deletedAt, + }) async { + final keepPredicate = keepIds.isEmpty + ? '' + : 'AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})'; + await database.customUpdate( + ''' +UPDATE $tableName +SET deleted_at = ?, updated_at = ?, sync_state = 'deleted' +WHERE workout_history_id = ? + AND deleted_at IS NULL + $keepPredicate +''', + variables: [ + Variable(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + Variable(historyId), + for (final id in keepIds) Variable(id), + ], + ); + } + + Future _replaceRemoteExerciseImages(domain.Exercise exercise) async { + final rows = await (database.select( + database.exerciseImages, + )..where((table) => table.exerciseId.equals(exercise.metadata.id))).get(); + final activeRowsByMediaId = { + for (final row in rows) + if (row.deletedAt == null) row.mediaAssetId: row, + }; + final desiredIds = exercise.imageMediaIds.toSet(); + final removedIds = activeRowsByMediaId.values + .where((row) => !desiredIds.contains(row.mediaAssetId)) + .map((row) => row.id) + .toList(); + await _softDeleteRemoteRows( + tableName: 'exercise_images', + ids: removedIds, + deletedAt: exercise.metadata.updatedAt, + ); + + for (var index = 0; index < exercise.imageMediaIds.length; index++) { + final mediaId = exercise.imageMediaIds[index]; + final existing = activeRowsByMediaId[mediaId]; + final metadata = existing == null + ? domain.EntityMetadata( + id: _exerciseImageId(exercise.metadata.id, mediaId), + createdAt: exercise.metadata.updatedAt, + updatedAt: exercise.metadata.updatedAt, + syncState: exercise.metadata.syncState, + originDeviceId: exercise.metadata.originDeviceId, + ) + : _metadataFromRow(existing).touch(exercise.metadata.updatedAt); + await database + .into(database.exerciseImages) + .insertOnConflictUpdate( + db.ExerciseImagesCompanion( + id: Value(metadata.id), + createdAt: Value(metadata.createdAt.toUtc()), + updatedAt: Value(metadata.updatedAt.toUtc()), + deletedAt: Value(_utcOrNull(metadata.deletedAt)), + schemaVersion: Value(metadata.schemaVersion), + syncState: Value(_syncStateToDb(metadata.syncState)), + localRevision: Value(metadata.localRevision), + originDeviceId: Value(metadata.originDeviceId), + futureOwnerProfileId: Value(metadata.futureOwnerProfileId), + lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)), + remoteRevision: Value(metadata.remoteRevision), + exerciseId: Value(exercise.metadata.id), + mediaAssetId: Value(mediaId), + position: Value(index), + ), + ); + } + } + + Future _replaceRemoteExerciseSteps(domain.Exercise exercise) async { + final rows = await (database.select( + database.exerciseSteps, + )..where((table) => table.exerciseId.equals(exercise.metadata.id))).get(); + final activeRowsByStepId = { + for (final row in rows) + if (row.deletedAt == null) row.id: row, + }; + final desiredIds = exercise.steps.map((step) => step.id).toSet(); + final removedIds = activeRowsByStepId.values + .where((row) => !desiredIds.contains(row.id)) + .map((row) => row.id) + .toList(); + await _softDeleteRemoteRows( + tableName: 'exercise_steps', + ids: removedIds, + deletedAt: exercise.metadata.updatedAt, + ); + + for (final step in exercise.steps) { + final existing = activeRowsByStepId[step.id]; + final metadata = existing == null + ? domain.EntityMetadata( + id: step.id, + createdAt: exercise.metadata.updatedAt, + updatedAt: exercise.metadata.updatedAt, + syncState: exercise.metadata.syncState, + originDeviceId: exercise.metadata.originDeviceId, + ) + : _metadataFromRow(existing).touch(exercise.metadata.updatedAt); + await database + .into(database.exerciseSteps) + .insertOnConflictUpdate( + db.ExerciseStepsCompanion( + id: Value(metadata.id), + createdAt: Value(metadata.createdAt.toUtc()), + updatedAt: Value(metadata.updatedAt.toUtc()), + deletedAt: Value(_utcOrNull(metadata.deletedAt)), + schemaVersion: Value(metadata.schemaVersion), + syncState: Value(_syncStateToDb(metadata.syncState)), + localRevision: Value(metadata.localRevision), + originDeviceId: Value(metadata.originDeviceId), + futureOwnerProfileId: Value(metadata.futureOwnerProfileId), + lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)), + remoteRevision: Value(metadata.remoteRevision), + exerciseId: Value(exercise.metadata.id), + position: Value(step.position), + name: Value(step.name), + type: Value(_exerciseStepTypeToDb(step.type)), + defaultTargetValue: Value(step.defaultTargetValue), + hasScore: Value(step.hasScore), + scoreInputMode: Value( + step.hasScore ? _scoreInputModeToDb(step.scoreInputMode) : null, + ), + scoreLabel: Value(step.scoreLabel), + scoreUnit: Value(step.scoreUnit), + defaultTargetScore: Value(step.defaultTargetScore), + defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs), + linkedToSeriesScore: Value(step.linkedToSeriesScore), + ), + ); + } + } + + Future _softDeleteRemoteRows({ + required String tableName, + required List ids, + required DateTime deletedAt, + }) async { + if (ids.isEmpty) { + return; + } + await database.customUpdate( + ''' +UPDATE $tableName +SET deleted_at = ?, updated_at = ?, sync_state = 'deleted' +WHERE id IN (${List.filled(ids.length, '?').join(', ')}) +''', + variables: [ + Variable(deletedAt.toUtc()), + Variable(deletedAt.toUtc()), + for (final id in ids) Variable(id), + ], + ); + } } final class DriftShareInboxRepository implements ShareInboxRepository { diff --git a/lib/infrastructure/remote/share_api.dart b/lib/infrastructure/remote/share_api.dart index fbd87f4..b3a5c19 100644 --- a/lib/infrastructure/remote/share_api.dart +++ b/lib/infrastructure/remote/share_api.dart @@ -19,11 +19,11 @@ final class HttpRemoteShareApi implements RemoteShareApi { final response = await client.postJson( '/shares', bearerToken: token, - body: { - 'resourceType': _shareResourceTypeToWire(resourceType), - 'payload': payload, - 'recipientEmails': recipientEmails, - }, + body: _shareRequestBody( + resourceType: resourceType, + payload: payload, + recipientEmails: recipientEmails, + ), expectedStatuses: const {201}, ); return RemoteShareSendResult( @@ -43,12 +43,22 @@ final class HttpRemoteShareApi implements RemoteShareApi { } @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { final response = await client.postJson( '/shares/$shareId/accept', bearerToken: token, ); - return _syncedItemFromJson(_map(response['createdResource'])); + final resources = _list( + response, + 'createdResources', + ).map((item) => _syncedItemFromJson(_map(item))).toList(growable: false); + if (resources.isNotEmpty) { + return resources; + } + return [_syncedItemFromJson(_map(response['createdResource']))]; } @override @@ -62,20 +72,91 @@ final class HttpRemoteShareApi implements RemoteShareApi { } } +Map _shareRequestBody({ + required ShareResourceType resourceType, + required Map payload, + required List recipientEmails, +}) { + if (resourceType != ShareResourceType.pack) { + return { + 'shareKind': 'single', + 'resourceType': _shareResourceTypeToWire(resourceType), + 'payload': payload, + 'recipientEmails': recipientEmails, + }; + } + final rawWorkouts = payload['workouts']; + if (rawWorkouts is! List) { + throw const RemoteAuthException( + RemoteAuthFailure.unknown, + 'Pack payload must contain workouts.', + ); + } + return { + 'shareKind': 'pack', + 'packName': _stringFromObject(payload['name'], 'Pack'), + 'items': [ + for (final rawWorkout in rawWorkouts) + {'resourceType': 'workoutTemplate', 'payload': _map(rawWorkout)}, + ], + 'recipientEmails': recipientEmails, + }; +} + ShareInboxItem _inboxItemFromJson(Map json) { + final resourceType = _inboxResourceType(json); return ShareInboxItem( shareId: _requiredString(json, 'shareId'), senderUserId: _requiredString(json, 'senderUserId'), - resourceType: _shareResourceTypeFromWire( - _requiredString(json, 'resourceType'), - ), - payloadJson: _jsonObjectString(json['payload']), + resourceType: resourceType, + payloadJson: _payloadJsonString(json, resourceType), status: _shareInboxStatusFromWire(_requiredString(json, 'status')), createdAt: _requiredDateTime(json, 'createdAt'), respondedAt: _optionalDateTime(json, 'respondedAt'), ); } +ShareResourceType _inboxResourceType(Map json) { + final shareKind = json['shareKind']; + if (shareKind == 'pack') { + return ShareResourceType.pack; + } + return _shareResourceTypeFromWire(_requiredString(json, 'resourceType')); +} + +String _payloadJsonString( + Map json, + ShareResourceType resourceType, +) { + final payload = _map(json['payload']); + if (resourceType != ShareResourceType.pack) { + return jsonEncode(payload); + } + if (payload['workouts'] is List) { + return jsonEncode({ + 'name': json['packName'] as String? ?? payload['name'] ?? 'Pack', + ...payload, + }); + } + final rawItems = payload['items']; + if (rawItems is! List) { + return jsonEncode({ + 'name': json['packName'] as String? ?? 'Pack', + 'workouts': const [], + }); + } + return jsonEncode({ + 'name': json['packName'] as String? ?? 'Pack', + 'workouts': [ + for (final rawItem in rawItems) + if (rawItem is Map && + Map.from(rawItem)['resourceType'] == + 'workoutTemplate') + _map(Map.from(rawItem)['payload']), + ], + }); +} + RemoteSyncedItem _syncedItemFromJson(Map json) { return RemoteSyncedItem( resourceType: _syncResourceTypeFromWire( @@ -163,6 +244,10 @@ String _requiredString(Map json, String key) { ); } +String _stringFromObject(Object? value, String fallback) { + return value is String && value.trim().isNotEmpty ? value : fallback; +} + int _requiredInt(Map json, String key) { final value = json[key]; if (value is int) { @@ -184,7 +269,3 @@ DateTime? _optionalDateTime(Map json, String key) { ? DateTime.parse(value).toUtc() : null; } - -String _jsonObjectString(Object? value) { - return jsonEncode(_map(value)); -} diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index a53a655..4577ba5 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -2570,6 +2570,10 @@ final class _StepSequencePanel extends StatelessWidget { ); } final currentStep = view.currentStep; + final currentStepIsRepsOnly = + currentStep != null && + currentStep.type == ExerciseStepType.reps && + !currentStep.hasScore; final sequenceComplete = view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete; final completedPassages = sequenceComplete @@ -2596,20 +2600,13 @@ final class _StepSequencePanel extends StatelessWidget { ], ), const SizedBox(height: 6), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < view.steps.length; index++) ...[ - _StepProgressChip( - index: index, - status: _stepDisplayStatus(view, index), - ), - if (index < view.steps.length - 1) const SizedBox(width: 6), - ], - ], + if (currentStepIsRepsOnly) + Row(children: _stepProgressChips(view)) + else + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: _stepProgressChips(view)), ), - ), const SizedBox(height: 8), Expanded( child: sequenceComplete || currentStep == null @@ -2645,6 +2642,15 @@ final class _StepSequencePanel extends StatelessWidget { enum _StepSkipAction { passage, sequence } +List _stepProgressChips(ActiveExerciseStepProgressView view) { + return [ + for (var index = 0; index < view.steps.length; index++) ...[ + _StepProgressChip(index: index, status: _stepDisplayStatus(view, index)), + if (index < view.steps.length - 1) const SizedBox(width: 6), + ], + ]; +} + final class _BoundedAccentPanel extends StatelessWidget { const _BoundedAccentPanel({required this.child, required this.padding}); @@ -2802,96 +2808,96 @@ final class _CurrentStepPane extends StatelessWidget { ], ); } - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 8), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - step.name, - style: Theme.of(context).textTheme.headlineSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Expanded( - child: step.type == ExerciseStepType.time - ? _TimedStepBody( - step: step, - remainingLabel: remainingLabel, - running: - view.state.status == - ActiveExerciseStepProgressStatus.runningTimer, - readyToStart: _isNextTimedStepReady(view), - onStartTimer: onStartTimer, - ) - : Align( - alignment: Alignment.topCenter, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topCenter, - child: _RepsStepBody( - step: step, - onCompleteStep: onCompleteStep, - ), - ), - ), - ), - if (step.hasScore) ...[ - const SizedBox(height: 8), - _StepScoreInput( + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + step.name, + style: Theme.of(context).textTheme.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Expanded( + child: step.type == ExerciseStepType.time + ? _TimedStepBody( step: step, - controller: stepScoreController, - elapsedLabel: stepScoreElapsedLabel, - running: stepScoreRunning, - onStart: onStartStepScore, - onStop: onStopStepScore, - onReset: onResetStepScore, + remainingLabel: remainingLabel, + running: + view.state.status == + ActiveExerciseStepProgressStatus.runningTimer, + readyToStart: _isNextTimedStepReady(view), + onStartTimer: onStartTimer, + ) + : Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: _RepsStepBody(step: step), + ), + ), + ), + if (step.hasScore) ...[ + const SizedBox(height: 8), + _StepScoreInput( + step: step, + controller: stepScoreController, + elapsedLabel: stepScoreElapsedLabel, + running: stepScoreRunning, + onStart: onStartStepScore, + onStop: onStopStepScore, + onReset: onResetStepScore, + ), + ], + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onSkipStep, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + child: const Text('Passer l’étape'), + ), + ), + const SizedBox(width: 8), + PopupMenuButton<_StepSkipAction>( + tooltip: 'Plus d’actions', + icon: const Icon(Icons.more_horiz), + onSelected: (action) { + if (action == _StepSkipAction.passage) { + onSkipPassage(); + } else { + onSkipSequence(); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem( + value: _StepSkipAction.passage, + child: Text('Passer ce passage'), + ), + PopupMenuItem( + value: _StepSkipAction.sequence, + child: Text('Passer la séquence'), ), ], - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: onSkipStep, - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), - ), - child: const Text('Passer l’étape'), - ), + ), + if (step.type == ExerciseStepType.reps) ...[ + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: onCompleteStep, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), ), - const SizedBox(width: 8), - PopupMenuButton<_StepSkipAction>( - tooltip: 'Plus d’actions', - icon: const Icon(Icons.more_horiz), - onSelected: (action) { - if (action == _StepSkipAction.passage) { - onSkipPassage(); - } else { - onSkipSequence(); - } - }, - itemBuilder: (context) => const [ - PopupMenuItem( - value: _StepSkipAction.passage, - child: Text('Passer ce passage'), - ), - PopupMenuItem( - value: _StepSkipAction.sequence, - child: Text('Passer la séquence'), - ), - ], - ), - ], + icon: const Icon(Icons.check), + label: const Text('Étape suivante'), + ), ), ], - ), + ], ), - ), + ], ); }, ); @@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) { } final class _RepsStepBody extends StatelessWidget { - const _RepsStepBody({required this.step, required this.onCompleteStep}); + const _RepsStepBody({required this.step}); final ExerciseStep step; - final VoidCallback onCompleteStep; @override Widget build(BuildContext context) { @@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget { ).copyWith(color: Theme.of(context).colorScheme.primary), ), Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: onCompleteStep, - icon: const Icon(Icons.check), - label: const Text('Étape suivante'), - ), ], ); } diff --git a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart index 41507a5..3b0d67d 100644 --- a/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart +++ b/packages/watch_bridge_contract/lib/src/watch_bridge_contract.dart @@ -1,4 +1,4 @@ -const int watchBridgeSchemaVersion = 4; +const int watchBridgeSchemaVersion = 5; enum WatchCommandType { startCurrentExercise, @@ -140,6 +140,7 @@ final class WatchSessionProjection { required this.deviceSessionId, required this.revision, required this.projectedAtEpochMs, + this.expiresAtEpochMs = 0, required this.phase, required this.phoneReachable, required this.seriesIndex, @@ -166,6 +167,7 @@ final class WatchSessionProjection { this.canDecrementScore = false, this.manualScoreTargetValue, this.manualScoreTargetLabel, + this.manualScoreRepsTargetValue, this.manualScoreScope, }); @@ -178,6 +180,7 @@ final class WatchSessionProjection { deviceSessionId: _stringFromJson(json['deviceSessionId']), revision: _intFromJson(json['revision'], 0), projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0), + expiresAtEpochMs: _intFromJson(json['expiresAtEpochMs'], 0), phase: _enumFromJson( json['phase'], WatchSessionPhase.values, @@ -221,6 +224,9 @@ final class WatchSessionProjection { manualScoreTargetLabel: _nullableStringFromJson( json['manualScoreTargetLabel'], ), + manualScoreRepsTargetValue: _nullableIntFromJson( + json['manualScoreRepsTargetValue'], + ), manualScoreScope: _nullableEnumFromJson( json['manualScoreScope'], WatchManualScoreScope.values, @@ -232,6 +238,7 @@ final class WatchSessionProjection { final String deviceSessionId; final int revision; final int projectedAtEpochMs; + final int expiresAtEpochMs; final WatchSessionPhase phase; final bool phoneReachable; final int seriesIndex; @@ -258,6 +265,7 @@ final class WatchSessionProjection { final bool canDecrementScore; final double? manualScoreTargetValue; final String? manualScoreTargetLabel; + final int? manualScoreRepsTargetValue; final WatchManualScoreScope? manualScoreScope; Map toJson() { @@ -266,6 +274,7 @@ final class WatchSessionProjection { 'deviceSessionId': deviceSessionId, 'revision': revision, 'projectedAtEpochMs': projectedAtEpochMs, + 'expiresAtEpochMs': expiresAtEpochMs, 'phase': phase.name, 'phoneReachable': phoneReachable, 'seriesIndex': seriesIndex, @@ -296,6 +305,7 @@ final class WatchSessionProjection { 'canDecrementScore': canDecrementScore, 'manualScoreTargetValue': manualScoreTargetValue, 'manualScoreTargetLabel': manualScoreTargetLabel, + 'manualScoreRepsTargetValue': manualScoreRepsTargetValue, 'manualScoreScope': manualScoreScope?.name, }; } @@ -308,6 +318,7 @@ final class WatchSessionProjection { deviceSessionId == other.deviceSessionId && revision == other.revision && projectedAtEpochMs == other.projectedAtEpochMs && + expiresAtEpochMs == other.expiresAtEpochMs && phase == other.phase && phoneReachable == other.phoneReachable && seriesIndex == other.seriesIndex && @@ -334,6 +345,7 @@ final class WatchSessionProjection { canDecrementScore == other.canDecrementScore && manualScoreTargetValue == other.manualScoreTargetValue && manualScoreTargetLabel == other.manualScoreTargetLabel && + manualScoreRepsTargetValue == other.manualScoreRepsTargetValue && manualScoreScope == other.manualScoreScope; } @@ -344,6 +356,7 @@ final class WatchSessionProjection { deviceSessionId, revision, projectedAtEpochMs, + expiresAtEpochMs, phase, phoneReachable, seriesIndex, @@ -370,6 +383,7 @@ final class WatchSessionProjection { canDecrementScore, manualScoreTargetValue, manualScoreTargetLabel, + manualScoreRepsTargetValue, manualScoreScope, ]); } diff --git a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart index 90e6a7e..08a0f52 100644 --- a/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart +++ b/packages/watch_bridge_contract/test/watch_bridge_contract_test.dart @@ -148,6 +148,7 @@ void main() { deviceSessionId: 'session-${phase.name}-${primaryAction.name}', revision: 4, projectedAtEpochMs: 1710000000100, + expiresAtEpochMs: 1710000012100, phase: phase, phoneReachable: true, seriesIndex: 2, @@ -174,6 +175,7 @@ void main() { canDecrementScore: true, manualScoreTargetValue: 10, manualScoreTargetLabel: 'Cible', + manualScoreRepsTargetValue: 12, manualScoreScope: WatchManualScoreScope.step, ); @@ -220,6 +222,7 @@ void main() { expect(projection.canDecrementScore, isFalse); expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetLabel, isNull); + expect(projection.manualScoreRepsTargetValue, isNull); expect(projection.manualScoreScope, isNull); }); @@ -230,6 +233,7 @@ void main() { expect(projection.deviceSessionId, ''); expect(projection.revision, 0); expect(projection.projectedAtEpochMs, 0); + expect(projection.expiresAtEpochMs, 0); expect(projection.phase, WatchSessionPhase.noActiveSession); expect(projection.phoneReachable, false); expect(projection.seriesIndex, 0); @@ -244,6 +248,7 @@ void main() { expect(projection.canDecrementScore, isFalse); expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetLabel, isNull); + expect(projection.manualScoreRepsTargetValue, isNull); expect(projection.manualScoreScope, isNull); }); }); diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index a23cf71..12e7d24 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2611,6 +2611,68 @@ void main() { expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); }); + test( + 'ShareUseCases acceptShare imports all returned resources locally', + () async { + final remoteShareApi = _FakeRemoteShareApi() + ..acceptResults = [ + _remoteSharedProgramItem(), + _remoteSharedTemplateItem(), + ]; + final localChanges = _FakeLocalSyncChangeRepository(); + final inboxRepository = _FakeShareInboxRepository() + ..items.add(_shareInboxItem(status: ShareInboxStatus.pending)); + + await _shareUseCase( + remoteShareApi: remoteShareApi, + localChanges: localChanges, + inboxRepository: inboxRepository, + ).acceptShare('share-1'); + + expect(localChanges.appliedItems.map((item) => item.resourceType), [ + SyncResourceType.program, + SyncResourceType.workoutTemplate, + ]); + expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); + }, + ); + + test( + 'ShareUseCases acceptShare imports cached pack payload after remote ack', + () async { + final remoteShareApi = _FakeRemoteShareApi(); + final templateRepository = _FakeWorkoutTemplateRepository(); + final inboxRepository = _FakeShareInboxRepository() + ..items.add( + _shareInboxItem( + status: ShareInboxStatus.pending, + resourceType: ShareResourceType.pack, + payloadJson: jsonEncode({ + 'name': 'Pack reprise', + 'workouts': [ + _sharedTemplatePayload(id: 'template-a', name: 'Séance A'), + _sharedTemplatePayload(id: 'template-b', name: 'Séance B'), + ], + }), + ), + ); + + await _shareUseCase( + remoteShareApi: remoteShareApi, + inboxRepository: inboxRepository, + templateRepository: templateRepository, + ).acceptShare('share-1'); + + expect(remoteShareApi.acceptCalls, 1); + expect(templateRepository.templates, hasLength(2)); + expect( + templateRepository.templates.map((template) => template.name), + containsAll(['Séance A', 'Séance B']), + ); + expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); + }, + ); + test( 'ShareUseCases acceptShare sans token importe une copie locale indépendante', () async { @@ -4559,6 +4621,7 @@ ShareUseCases _shareUseCase({ final class _FakeRemoteShareApi implements RemoteShareApi { Exception? exception; RemoteSyncedItem? acceptResult; + List? acceptResults; List inboxItems = const []; var sendCalls = 0; var acceptCalls = 0; @@ -4568,13 +4631,16 @@ final class _FakeRemoteShareApi implements RemoteShareApi { Map? lastPayload; @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { acceptCalls += 1; final error = exception; if (error != null) { throw error; } - return acceptResult ?? _remoteSharedProgramItem(); + return acceptResults ?? [acceptResult ?? _remoteSharedProgramItem()]; } @override @@ -4804,6 +4870,19 @@ RemoteSyncedItem _remoteSharedProgramItem() { ); } +RemoteSyncedItem _remoteSharedTemplateItem() { + return RemoteSyncedItem( + resourceType: SyncResourceType.workoutTemplate, + clientId: 'shared-template-1', + serverId: 'server-template-1', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17, 12, 2), + serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 3), + deletedAt: null, + payload: const {'id': 'shared-template-1', 'name': 'Séance partagée'}, + ); +} + ExerciseUseCases _exerciseUseCase( _FakeExerciseRepository repository, { _FakeProgramRepository? programRepository, diff --git a/test/application/watch_companion_command_handler_test.dart b/test/application/watch_companion_command_handler_test.dart index 8f76417..73c4afa 100644 --- a/test/application/watch_companion_command_handler_test.dart +++ b/test/application/watch_companion_command_handler_test.dart @@ -377,6 +377,43 @@ void main() { expect(env.repository.stepResults.last.actualScore, 0); }); + test('projects reps target for independent step manual score', () async { + final session = _session( + steps: [ + _step( + type: ExerciseStepType.reps, + defaultTargetValue: 10, + hasScore: true, + defaultTargetScore: 8, + ), + ], + ); + final repository = _FakeActiveSessionRepository()..session = session; + repository.stepProgressStates['step-state'] = _stepState( + sessionId: session.metadata.id, + ); + final projectionUseCases = WatchCompanionProjectionUseCases( + sessionRepository: repository, + clock: _FakeClock(_now), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final projection = await projectionUseCases.emitCurrentProjection(); + + expect(projection.hasManualScore, isTrue); + expect(projection.manualScoreScope, WatchManualScoreScope.step); + expect(projection.manualScoreRepsTargetValue, 10); + expect(projection.manualScoreTargetValue, 8); + expect(projection.manualScoreTargetLabel, 'Cible'); + expect( + projection.expiresAtEpochMs - projection.projectedAtEpochMs, + const Duration(seconds: 12).inMilliseconds, + ); + + await projectionUseCases.dispose(); + }); + test('decrementScore at zero is accepted no-op', () async { final env = _env( session: _session(scoreEnabled: true), @@ -740,6 +777,7 @@ final class _FakeProjectionSource implements WatchProjectionSource { deviceSessionId: projection.deviceSessionId, revision: projection.revision + 1, projectedAtEpochMs: projection.projectedAtEpochMs, + expiresAtEpochMs: projection.expiresAtEpochMs, phase: projection.phase, phoneReachable: projection.phoneReachable, seriesIndex: projection.seriesIndex, @@ -752,6 +790,9 @@ final class _FakeProjectionSource implements WatchProjectionSource { hasManualScore: projection.hasManualScore, currentManualScoreValue: projection.currentManualScoreValue, canDecrementScore: projection.canDecrementScore, + manualScoreTargetValue: projection.manualScoreTargetValue, + manualScoreTargetLabel: projection.manualScoreTargetLabel, + manualScoreRepsTargetValue: projection.manualScoreRepsTargetValue, manualScoreScope: projection.manualScoreScope, ); return projection; diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 74b9695..7cfdb99 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -10,6 +10,7 @@ import 'package:gametime/infrastructure/local/local.dart' as local; void main() { late local.AppDatabase database; + late local.DriftMediaAssetRepository mediaAssetRepository; late local.DriftExerciseRepository exerciseRepository; late local.DriftProgramRepository programRepository; late local.DriftActiveSessionRepository activeRepository; @@ -24,6 +25,7 @@ void main() { setUp(() { database = local.AppDatabase(NativeDatabase.memory()); + mediaAssetRepository = local.DriftMediaAssetRepository(database); exerciseRepository = local.DriftExerciseRepository(database); programRepository = local.DriftProgramRepository(database); activeRepository = local.DriftActiveSessionRepository(database); @@ -482,6 +484,209 @@ CREATE TABLE pending_share_actions ( expect(payloadsById['template-sync-tags']!['tags'], ['routine']); }); + test('local sync payload includes full workout history aggregate', () async { + final now = DateTime.utc(2026, 7, 22, 10, 45); + await historyRepository.save( + _history( + id: 'sync-history-full', + startedAt: now, + result: _historySetResult( + id: 'sync-history-set-result', + historyId: 'sync-history-full', + sourceExerciseId: 'exercise-sync-full', + setIndex: 0, + startedAt: now, + actualScore: 12, + ), + stepResults: [ + _historyStepResult( + id: 'sync-history-step-result', + historyId: 'sync-history-full', + sourceExerciseId: 'exercise-sync-full', + startedAt: now, + ), + ], + minHeartRateBpm: 90, + averageHeartRateBpm: 120, + maxHeartRateBpm: 150, + totalDistanceMeters: 42, + totalCaloriesKcal: 12, + ), + ); + + final changes = await syncChangeRepository.listPendingChanges(); + final payload = changes + .singleWhere((change) => change.item.clientId == 'sync-history-full') + .item + .payload; + + expect(payload['minHeartRateBpm'], 90); + expect(payload['averageHeartRateBpm'], 120); + expect(payload['maxHeartRateBpm'], 150); + expect(payload['totalDistanceMeters'], 42); + expect(payload['totalCaloriesKcal'], 12); + expect(payload['results'], hasLength(1)); + expect(payload['stepResults'], hasLength(1)); + }); + + test('local sync pull restores exercise images and steps', () async { + final now = DateTime.utc(2026, 7, 22, 10, 50); + await mediaAssetRepository.save( + MediaAsset( + metadata: _metadata('remote-image', now), + kind: MediaKind.image, + localUri: 'file:///remote-image.png', + ), + ); + + final applied = await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.exercise, + clientId: 'remote-exercise-with-children', + serverId: 'server-exercise-with-children', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: { + 'id': 'remote-exercise-with-children', + 'name': 'Remote exercise', + 'imageMediaIds': const ['remote-image'], + 'iconMediaId': 'remote-image', + 'hasTimeMeasure': false, + 'hasRepsMeasure': true, + 'hasScoreMeasure': true, + 'scoreInputMode': 'manual', + 'scoreLabel': 'Paniers', + 'scoreUnit': 'pts', + 'steps': [ + _exerciseStep( + id: 'remote-step', + position: 0, + name: 'Tir main droite', + type: ExerciseStepType.reps, + defaultTargetValue: 10, + hasScore: true, + scoreLabel: 'Paniers', + scoreUnit: 'pts', + linkedToSeriesScore: true, + ).toSnapshotJson(), + ], + }, + ), + ); + + final exercise = await exerciseRepository.findById( + 'remote-exercise-with-children', + ); + + expect(applied, isTrue); + expect(exercise!.imageMediaIds, ['remote-image']); + expect(exercise.steps, hasLength(1)); + expect(exercise.steps.single.linkedToSeriesScore, isTrue); + }); + + test('local sync pull restores full workout history aggregate', () async { + final now = DateTime.utc(2026, 7, 22, 10, 55); + final payload = { + 'metadata': { + 'id': 'remote-history-full', + 'createdAt': now.toUtc().toIso8601String(), + 'updatedAt': now.toUtc().toIso8601String(), + 'schemaVersion': 1, + 'syncState': 'synced', + 'localRevision': 0, + 'originDeviceId': 'device-remote', + }, + 'id': 'remote-history-full', + 'nameSnapshot': 'Remote history', + 'startedAt': now.toUtc().toIso8601String(), + 'endedAt': now.add(const Duration(minutes: 5)).toUtc().toIso8601String(), + 'totalActiveMs': 300000, + 'completed': true, + 'historySnapshotJson': '{"name":"remote-history-full"}', + 'minHeartRateBpm': 95, + 'averageHeartRateBpm': 125, + 'maxHeartRateBpm': 155, + 'totalDistanceMeters': 84, + 'totalCaloriesKcal': 24, + 'results': [ + { + 'id': 'remote-history-set-result', + 'workoutHistoryId': 'remote-history-full', + 'programSnapshotId': 'program-snapshot', + 'exerciseSnapshotId': 'exercise-snapshot-remote-exercise', + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'programNameSnapshot': 'Program', + 'exerciseNameSnapshot': 'Exercise', + 'timeEnabledSnapshot': false, + 'repsEnabledSnapshot': false, + 'scoreEnabledSnapshot': true, + 'scoreInputModeSnapshot': 'stopwatch', + 'actualScoreTimeMs': 12000, + 'sourceExerciseIdSnapshot': 'remote-exercise', + 'completedAt': now + .add(const Duration(minutes: 1)) + .toUtc() + .toIso8601String(), + 'status': 'completed', + }, + ], + 'stepResults': [ + { + 'id': 'remote-history-step-result', + 'workoutHistoryId': 'remote-history-full', + 'programSnapshotId': 'program-snapshot', + 'exerciseSnapshotId': 'exercise-snapshot-remote-exercise', + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'passageIndex': 0, + 'stepIndex': 0, + 'stepSnapshotId': 'step-snapshot', + 'stepNameSnapshot': 'Step', + 'stepTypeSnapshot': 'reps', + 'targetValueSnapshot': 10, + 'hasScoreSnapshot': false, + 'status': 'completed', + 'startedAt': now.toUtc().toIso8601String(), + 'completedAt': now + .add(const Duration(seconds: 10)) + .toUtc() + .toIso8601String(), + 'actualReps': 10, + 'sourceExerciseIdSnapshot': 'remote-exercise', + }, + ], + }; + + final applied = await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.workoutHistory, + clientId: 'remote-history-full', + serverId: 'server-history-full', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: payload, + ), + ); + + final restored = await historyRepository.findById('remote-history-full'); + + expect(applied, isTrue); + expect(restored!.minHeartRateBpm, 95); + expect(restored.averageHeartRateBpm, 125); + expect(restored.maxHeartRateBpm, 155); + expect(restored.totalDistanceMeters, 84); + expect(restored.totalCaloriesKcal, 24); + expect(restored.results.single.actualScoreTimeMs, 12000); + expect(restored.stepResults.single.actualReps, 10); + }); + test('local sync pull defaults missing tags to empty lists', () async { final now = DateTime.utc(2026, 7, 22, 11); await syncChangeRepository.applyRemoteItem( @@ -2616,8 +2821,14 @@ WorkoutHistory _history({ required DateTime startedAt, WorkoutHistorySetResult? result, List? results, + List stepResults = const [], bool completed = true, int totalActiveMs = 300000, + int? minHeartRateBpm, + double? averageHeartRateBpm, + int? maxHeartRateBpm, + double? totalDistanceMeters, + double? totalCaloriesKcal, }) { return WorkoutHistory( metadata: _metadata(id, startedAt), @@ -2628,6 +2839,12 @@ WorkoutHistory _history({ completed: completed, historySnapshotJson: '{"name":"$id"}', results: results ?? [result!], + stepResults: stepResults, + minHeartRateBpm: minHeartRateBpm, + averageHeartRateBpm: averageHeartRateBpm, + maxHeartRateBpm: maxHeartRateBpm, + totalDistanceMeters: totalDistanceMeters, + totalCaloriesKcal: totalCaloriesKcal, ); } @@ -2744,6 +2961,7 @@ ExerciseStep _exerciseStep({ String? scoreUnit, double? defaultTargetScore, int? defaultTargetScoreTimeMs, + bool linkedToSeriesScore = false, }) { return ExerciseStep( id: id, @@ -2757,6 +2975,7 @@ ExerciseStep _exerciseStep({ scoreUnit: scoreUnit, defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, + linkedToSeriesScore: linkedToSeriesScore, ); } diff --git a/test/infrastructure/remote/share_api_test.dart b/test/infrastructure/remote/share_api_test.dart new file mode 100644 index 0000000..7ed3039 --- /dev/null +++ b/test/infrastructure/remote/share_api_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:gametime/infrastructure/remote/remote.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test( + 'sendShare maps local workout pack payload to server pack contract', + () async { + Map? capturedBody; + final api = HttpRemoteShareApi( + HttpApiClient( + baseUrl: Uri.parse('http://api.example.test'), + client: MockClient((request) async { + capturedBody = _jsonMap(request.body); + expect(request.method, 'POST'); + expect(request.url.path, '/shares'); + expect(request.headers['authorization'], 'Bearer token-1'); + return http.Response( + jsonEncode({ + 'shareId': 'share-1', + 'recipientUserIds': ['user-2'], + 'unresolvedEmails': const [], + }), + 201, + ); + }), + ), + ); + + await api.sendShare( + resourceType: ShareResourceType.pack, + payload: const { + 'name': 'Pack reprise', + 'workouts': [ + {'id': 'template-1', 'name': 'Séance 1'}, + {'id': 'template-2', 'name': 'Séance 2'}, + ], + }, + recipientEmails: const ['friend@example.com'], + token: 'token-1', + ); + + expect(capturedBody, { + 'shareKind': 'pack', + 'packName': 'Pack reprise', + 'items': [ + { + 'resourceType': 'workoutTemplate', + 'payload': {'id': 'template-1', 'name': 'Séance 1'}, + }, + { + 'resourceType': 'workoutTemplate', + 'payload': {'id': 'template-2', 'name': 'Séance 2'}, + }, + ], + 'recipientEmails': ['friend@example.com'], + }); + }, + ); + + test( + 'fetchInbox maps server pack payload without resourceType to local pack', + () async { + final api = HttpRemoteShareApi( + HttpApiClient( + baseUrl: Uri.parse('http://api.example.test'), + client: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/shares/inbox'); + return http.Response( + jsonEncode({ + 'items': [ + { + 'shareId': 'share-pack-1', + 'senderUserId': 'sender-1', + 'shareKind': 'pack', + 'packName': 'Pack été', + 'resourceType': null, + 'payload': { + 'items': [ + { + 'resourceType': 'workoutTemplate', + 'payload': {'id': 'template-1', 'name': 'Séance 1'}, + }, + ], + }, + 'status': 'pending', + 'createdAt': '2026-07-17T12:00:00.000Z', + 'respondedAt': null, + }, + ], + }), + 200, + ); + }), + ), + ); + + final items = await api.fetchInbox('token-1'); + final payload = _jsonMap(items.single.payloadJson); + + expect(items.single.resourceType, ShareResourceType.pack); + expect(payload, { + 'name': 'Pack été', + 'workouts': [ + {'id': 'template-1', 'name': 'Séance 1'}, + ], + }); + }, + ); + + test( + 'acceptShare returns every created resource from server packs', + () async { + final api = HttpRemoteShareApi( + HttpApiClient( + baseUrl: Uri.parse('http://api.example.test'), + client: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/shares/share-pack-1/accept'); + return http.Response( + jsonEncode({ + 'createdResources': [ + _createdResourceJson( + resourceType: 'program', + clientId: 'program-1', + serverId: 'server-program-1', + ), + _createdResourceJson( + resourceType: 'workoutTemplate', + clientId: 'template-1', + serverId: 'server-template-1', + ), + ], + }), + 200, + ); + }), + ), + ); + + final resources = await api.acceptShare('share-pack-1', 'token-1'); + + expect(resources.map((item) => item.resourceType), [ + SyncResourceType.program, + SyncResourceType.workoutTemplate, + ]); + expect(resources.map((item) => item.clientId), [ + 'program-1', + 'template-1', + ]); + }, + ); +} + +Map _createdResourceJson({ + required String resourceType, + required String clientId, + required String serverId, +}) { + return { + 'resourceType': resourceType, + 'clientId': clientId, + 'serverId': serverId, + 'schemaVersion': 1, + 'clientUpdatedAt': '2026-07-17T12:00:00.000Z', + 'serverUpdatedAt': '2026-07-17T12:01:00.000Z', + 'deletedAt': null, + 'payload': {'id': clientId}, + }; +} + +Map _jsonMap(String source) { + final decoded = jsonDecode(source); + return Map.from(decoded as Map); +} diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 599f82b..6ecf485 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -520,7 +520,10 @@ final class _FakeLocalSyncChangeRepository final class _FakeRemoteShareApi implements RemoteShareApi { @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { throw const RemoteAuthException(RemoteAuthFailure.network); } diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index 4c6c42f..9138b71 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -887,17 +887,22 @@ final class _FakeLocalSyncChangeRepository final class _FakeRemoteShareApi implements RemoteShareApi { @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'program-remote', - serverId: 'program-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme partagé'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'program-remote', + serverId: 'program-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme partagé'}, + ), + ]; } @override diff --git a/test/presentation/program_screen_test.dart b/test/presentation/program_screen_test.dart index d5e784f..dd63292 100644 --- a/test/presentation/program_screen_test.dart +++ b/test/presentation/program_screen_test.dart @@ -1241,17 +1241,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi { List lastRecipientEmails = const []; @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'program-remote', - serverId: 'program-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme partagé'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'program-remote', + serverId: 'program-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme partagé'}, + ), + ]; } @override diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart index 3a9bcce..c91cd3e 100644 --- a/test/presentation/share_inbox_screen_test.dart +++ b/test/presentation/share_inbox_screen_test.dart @@ -365,19 +365,24 @@ final class _FakeRemoteShareApi implements RemoteShareApi { Map? lastPayload; @override - Future acceptShare(String shareId, String token) async { + Future> acceptShare( + String shareId, + String token, + ) async { acceptCalls += 1; _mark(shareId, ShareInboxStatus.accepted); - return RemoteSyncedItem( - resourceType: SyncResourceType.program, - clientId: 'shared-program-1', - serverId: 'server-program-1', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Programme tirs'}, - ); + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'shared-program-1', + serverId: 'server-program-1', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Programme tirs'}, + ), + ]; } @override diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 4349256..5c71526 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -1942,6 +1942,12 @@ void main() { await tester.pump(); expect(find.text('8'), findsWidgets); + expect(find.byType(SingleChildScrollView), findsNothing); + expect( + find.widgetWithText(OutlinedButton, 'Passer l’étape'), + findsOneWidget, + ); + expect(find.widgetWithText(FilledButton, 'Étape suivante'), findsOneWidget); await tester.tap(find.text('Étape suivante')); await tester.pump(); diff --git a/test/presentation/workout_template_screen_test.dart b/test/presentation/workout_template_screen_test.dart index 2641a5e..50f2890 100644 --- a/test/presentation/workout_template_screen_test.dart +++ b/test/presentation/workout_template_screen_test.dart @@ -1272,17 +1272,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi { List lastRecipientEmails = const []; @override - Future acceptShare(String shareId, String token) async { - return RemoteSyncedItem( - resourceType: SyncResourceType.workoutTemplate, - clientId: 'template-remote', - serverId: 'template-server', - schemaVersion: 1, - clientUpdatedAt: DateTime.utc(2026, 7, 17), - serverUpdatedAt: DateTime.utc(2026, 7, 17), - deletedAt: null, - payload: const {'name': 'Séance partagée'}, - ); + Future> acceptShare( + String shareId, + String token, + ) async { + return [ + RemoteSyncedItem( + resourceType: SyncResourceType.workoutTemplate, + clientId: 'template-remote', + serverId: 'template-server', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17), + serverUpdatedAt: DateTime.utc(2026, 7, 17), + deletedAt: null, + payload: const {'name': 'Séance partagée'}, + ), + ]; } @override diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 095d192..38fbd1f 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -36,7 +37,7 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:exported="true" android:hardwareAccelerated="true" - android:launchMode="singleTop" + android:launchMode="singleTask" android:taskAffinity="" android:theme="@style/LaunchTheme" android:windowSoftInputMode="adjustResize"> @@ -47,6 +48,10 @@ + + + + ? = null + private var lastActiveProjection: Map? = null + private var lastActiveProjectionReceivedAtEpochMs = 0L + private var activeProjectionExpiryRunnable: Runnable? = null fun attachApplicationContext(context: Context) { appContext = context.applicationContext @@ -123,6 +127,16 @@ object WatchBridgePlugin { requestPendingSensorPermissionIfPossible() } + fun handleActivityReentry(activity: Activity, intent: android.content.Intent?) { + attachActivity(activity) + val context = activity.applicationContext + requestCapabilityRefresh(context) + requestLatestProjection(context) + if (intent?.action == ACTION_OPEN_ACTIVE_SESSION && hasFreshActiveProjection()) { + emitProjection(lastActiveProjection ?: return) + } + } + fun detachActivity(activity: Activity) { if (this.activity === activity) { this.activity = null @@ -159,7 +173,9 @@ object WatchBridgePlugin { } fun emitProjection(payload: Map): Boolean { + rememberActiveProjection(payload) appContext?.let { + scheduleActiveProjectionExpiry(it, payload) WatchOngoingActivityController.update(it, payload, activity) updateHeartRateCollection(it, payload) } @@ -229,10 +245,24 @@ object WatchBridgePlugin { requestCapabilityRefresh(context) result.success(null) } + "invalidateActiveProjection" -> { + invalidateActiveProjection(context) + result.success(null) + } else -> result.notImplemented() } } + fun invalidateActiveProjection(context: Context) { + lastActiveProjection = null + lastActiveProjectionReceivedAtEpochMs = 0L + activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) } + activeProjectionExpiryRunnable = null + WatchOngoingActivityController.cancel(context) + WatchHeartRateForegroundService.stop(context) + heartRateCollector.finishCurrentSession(context) + } + private fun sendCommand( context: Context, arguments: Any?, @@ -318,6 +348,63 @@ object WatchBridgePlugin { } } + fun openActiveSessionIntent(context: Context): android.content.Intent { + return android.content.Intent(context, com.gametime.watch.MainActivity::class.java).apply { + action = ACTION_OPEN_ACTIVE_SESSION + flags = android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or + android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + } + } + + private fun rememberActiveProjection(projection: Map) { + val phase = projection["phase"] as? String ?: "noActiveSession" + val sessionId = projection["deviceSessionId"] as? String ?: "" + if (phase == "noActiveSession" || sessionId.isBlank()) { + lastActiveProjection = null + lastActiveProjectionReceivedAtEpochMs = 0L + activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) } + activeProjectionExpiryRunnable = null + return + } + lastActiveProjection = projection + lastActiveProjectionReceivedAtEpochMs = System.currentTimeMillis() + } + + private fun scheduleActiveProjectionExpiry(context: Context, projection: Map) { + activeProjectionExpiryRunnable?.let { mainHandler.removeCallbacks(it) } + val phase = projection["phase"] as? String ?: "noActiveSession" + val sessionId = projection["deviceSessionId"] as? String ?: "" + if (phase == "noActiveSession" || sessionId.isBlank()) { + activeProjectionExpiryRunnable = null + return + } + val now = System.currentTimeMillis() + val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L + val delayMs = if (expiresAt > 0L) { + (expiresAt - now).coerceAtLeast(0L) + } else { + 12000L + } + val appContext = context.applicationContext + activeProjectionExpiryRunnable = Runnable { + if (!hasFreshActiveProjection()) { + invalidateActiveProjection(appContext) + } + }.also { runnable -> + mainHandler.postDelayed(runnable, delayMs + 250L) + } + } + + private fun hasFreshActiveProjection(): Boolean { + val projection = lastActiveProjection ?: return false + val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L + val now = System.currentTimeMillis() + if (expiresAt > 0L) { + return now < expiresAt + } + return now - lastActiveProjectionReceivedAtEpochMs <= 12000L + } + private fun updateHeartRateCollection(context: Context, projection: Map) { val phase = projection["phase"] as? String ?: "noActiveSession" val sessionId = projection["deviceSessionId"] as? String ?: "" @@ -416,6 +503,7 @@ object WatchBridgePlugin { return listOf( heartRatePermission, android.Manifest.permission.ACTIVITY_RECOGNITION, + android.Manifest.permission.ACCESS_FINE_LOCATION, ) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index a6cc1ef..ba604a9 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -2,6 +2,8 @@ package com.gametime.watch.bridge import android.content.Context import android.util.Log +import androidx.health.services.client.ExerciseClient +import androidx.health.services.client.ExerciseUpdateCallback import androidx.health.services.client.HealthServices import androidx.health.services.client.MeasureClient import androidx.health.services.client.MeasureCallback @@ -9,6 +11,10 @@ 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 androidx.health.services.client.data.ExerciseConfig +import androidx.health.services.client.data.ExerciseEvent +import androidx.health.services.client.data.ExerciseLapSummary +import androidx.health.services.client.data.ExerciseType import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.Wearable import org.json.JSONObject @@ -35,10 +41,12 @@ internal class WatchHeartRateCollector( private var sampleSequence = 0 private var executionContext: Map = emptyMap() private val registeredDataTypes = mutableSetOf>() + private var exerciseMetricsStarted = false + private var exerciseMetricsStartInFlight = false private var shouldAggregate = false private var appContext: Context? = null - private val callback = object : MeasureCallback { + private val measureCallback = object : MeasureCallback { override fun onAvailabilityChanged( dataType: DeltaDataType<*, *>, availability: Availability, @@ -54,21 +62,11 @@ internal class WatchHeartRateCollector( for (point in data.getData(DataType.HEART_RATE_BPM)) { latestHeartRateBpm = recordHeartRate(point.value) } - var updatedDistance = false - for (point in data.getData(DataType.DISTANCE)) { - if (point.value > 0) { - distanceMeters = (distanceMeters ?: 0.0) + point.value - updatedDistance = true - } - } - var updatedCalories = false - for (point in data.getData(DataType.CALORIES)) { - if (point.value > 0) { - caloriesKcal = (caloriesKcal ?: 0.0) + point.value - updatedCalories = true - } - } - if (latestHeartRateBpm != null || updatedDistance || updatedCalories) { + if (latestHeartRateBpm != null) { + Log.d( + TAG, + "heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm", + ) sendSample(latestHeartRateBpm) } } @@ -78,6 +76,57 @@ internal class WatchHeartRateCollector( } } + private val exerciseCallback = object : ExerciseUpdateCallback { + override fun onRegistered() { + Log.d(TAG, "exercise update callback registered sessionId=$sessionId") + } + + override fun onRegistrationFailed(throwable: Throwable) { + Log.w(TAG, "exercise update callback registration failed", throwable) + } + + override fun onExerciseUpdateReceived(update: androidx.health.services.client.data.ExerciseUpdate) { + if (!shouldAggregate) { + return + } + var updated = false + for (point in update.latestMetrics.getData(DataType.DISTANCE)) { + val value = point.value + if (value > 0) { + distanceMeters = (distanceMeters ?: 0.0) + value + updated = true + } + } + for (point in update.latestMetrics.getData(DataType.CALORIES)) { + val value = point.value + if (value > 0) { + caloriesKcal = (caloriesKcal ?: 0.0) + value + updated = true + } + } + if (updated) { + Log.d( + TAG, + "exercise metrics received sessionId=$sessionId distance=$distanceMeters calories=$caloriesKcal", + ) + sendSample(null) + } + } + + override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {} + + override fun onAvailabilityChanged( + dataType: androidx.health.services.client.data.DataType<*, *>, + availability: Availability, + ) { + Log.d(TAG, "exercise availability dataType=$dataType availability=$availability") + } + + override fun onExerciseEventReceived(event: ExerciseEvent) { + Log.d(TAG, "exercise event sessionId=$sessionId event=$event") + } + } + fun noteActiveSession( nextSessionId: String, shouldAggregate: Boolean, @@ -103,17 +152,18 @@ internal class WatchHeartRateCollector( appContext = context.applicationContext val measureClient = HealthServices.getClient(context).measureClient registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) - registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) - registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES) + startExerciseMetrics(context) } fun pause(context: Context) { shouldAggregate = false unregister(context) + stopExerciseMetrics(context) } fun finishCurrentSession(context: Context) { unregister(context) + stopExerciseMetrics(context) val completedSessionId = sessionId if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { sendSummary(context, completedSessionId) @@ -188,6 +238,8 @@ internal class WatchHeartRateCollector( "minHeartRateBpm" to min, "averageHeartRateBpm" to sampleSum / sampleCount, "maxHeartRateBpm" to max, + "distanceMeters" to distanceMeters, + "caloriesKcal" to caloriesKcal, ), ).toString().toByteArray(StandardCharsets.UTF_8) Wearable.getCapabilityClient(context) @@ -213,10 +265,9 @@ internal class WatchHeartRateCollector( } val measureClient = HealthServices.getClient(context).measureClient for (dataType in registeredDataTypes.toList()) { - measureClient.unregisterMeasureCallbackAsync(dataType, callback) + measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback) } registeredDataTypes.clear() - appContext = null } private fun registerMeasureCallbackIfNeeded( @@ -227,7 +278,7 @@ internal class WatchHeartRateCollector( return } try { - measureClient.registerMeasureCallback(dataType, callback) + measureClient.registerMeasureCallback(dataType, measureCallback) registeredDataTypes.add(dataType) Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId") } catch (error: RuntimeException) { @@ -235,6 +286,108 @@ internal class WatchHeartRateCollector( } } + private fun startExerciseMetrics(context: Context) { + if (exerciseMetricsStarted || exerciseMetricsStartInFlight) { + return + } + val exerciseClient = HealthServices.getClient(context).exerciseClient + exerciseMetricsStartInFlight = true + val capabilitiesFuture = exerciseClient.getCapabilitiesAsync() + capabilitiesFuture.addListener( + { + try { + val capabilities = capabilitiesFuture.get() + val config = exerciseConfigFromCapabilities(capabilities) + if (config == null) { + exerciseMetricsStartInFlight = false + Log.w(TAG, "no exercise type supports distance metrics sessionId=$sessionId") + return@addListener + } + exerciseClient.setUpdateCallback(context.mainExecutor, exerciseCallback) + val startFuture = exerciseClient.startExerciseAsync(config) + startFuture.addListener( + { + exerciseMetricsStartInFlight = false + try { + startFuture.get() + exerciseMetricsStarted = true + Log.d( + TAG, + "exercise metrics started sessionId=$sessionId type=${config.exerciseType} dataTypes=${config.dataTypes}", + ) + } catch (error: Exception) { + Log.w(TAG, "exercise metrics start failed", error) + clearExerciseCallback(exerciseClient) + } + }, + context.mainExecutor, + ) + } catch (error: Exception) { + exerciseMetricsStartInFlight = false + Log.w(TAG, "exercise capabilities lookup failed", error) + } + }, + context.mainExecutor, + ) + } + + private fun exerciseConfigFromCapabilities( + capabilities: androidx.health.services.client.data.ExerciseCapabilities, + ): ExerciseConfig? { + val requestedTypes = listOf( + ExerciseType.WORKOUT, + ExerciseType.RUNNING, + ExerciseType.WALKING, + ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING, + ) + for (exerciseType in requestedTypes) { + if (exerciseType !in capabilities.supportedExerciseTypes) { + continue + } + val supported = capabilities.getExerciseTypeCapabilities(exerciseType) + .supportedDataTypes + val dataTypes = mutableSetOf>() + if (DataType.DISTANCE !in supported) { + Log.w( + TAG, + "exercise type lacks distance type=$exerciseType supported=$supported", + ) + continue + } + dataTypes.add(DataType.DISTANCE) + if (DataType.CALORIES in supported) { + dataTypes.add(DataType.CALORIES) + } + return ExerciseConfig.builder(exerciseType) + .setDataTypes(dataTypes) + .setIsAutoPauseAndResumeEnabled(false) + .setIsGpsEnabled(true) + .build() + } + return null + } + + private fun stopExerciseMetrics(context: Context) { + if (!exerciseMetricsStarted && !exerciseMetricsStartInFlight) { + return + } + val exerciseClient = HealthServices.getClient(context).exerciseClient + clearExerciseCallback(exerciseClient) + if (exerciseMetricsStarted) { + exerciseClient.endExerciseAsync() + } + exerciseMetricsStarted = false + exerciseMetricsStartInFlight = false + } + + private fun clearExerciseCallback(exerciseClient: ExerciseClient) { + try { + exerciseClient.clearUpdateCallbackAsync(exerciseCallback) + } catch (error: RuntimeException) { + Log.w(TAG, "clear exercise update callback failed", error) + } + } + private fun reset(nextSessionId: String?) { sessionId = nextSessionId sampleCount = 0 diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt index 37ebc9c..6f425e2 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateForegroundService.kt @@ -13,7 +13,6 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat -import com.gametime.watch.MainActivity import com.gametime.watch.R internal class WatchHeartRateForegroundService : Service() { @@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() { val touchIntent = PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, + WatchBridgePlugin.openActiveSessionIntent(this), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this, CHANNEL_ID) diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt index be15e33..51517b9 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchOngoingActivityController.kt @@ -6,14 +6,12 @@ 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 { @@ -47,9 +45,7 @@ object WatchOngoingActivityController { val touchIntent = PendingIntent.getActivity( context, 0, - Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP - }, + WatchBridgePlugin.openActiveSessionIntent(context), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val exerciseName = (projection["exerciseName"] as? String) diff --git a/watch_app/lib/application/watch_session_view_model.dart b/watch_app/lib/application/watch_session_view_model.dart index 1606514..db83c58 100644 --- a/watch_app/lib/application/watch_session_view_model.dart +++ b/watch_app/lib/application/watch_session_view_model.dart @@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier { Timer? _scoreWaitingTimer; Timer? _scoreCommandTimeoutTimer; Timer? _freshnessTimer; + Timer? _projectionExpiryTimer; Timer? _commandFailureClearTimer; WatchCommandEnvelope? _pendingCommand; final _pendingScoreCommandIds = {}; @@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier { _scoreWaitingTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel(); _freshnessTimer?.cancel(); + _projectionExpiryTimer?.cancel(); _commandFailureClearTimer?.cancel(); for (final subscription in _subscriptions) { unawaited(subscription.cancel()); @@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier { void _handleProjection(WatchSessionProjection projection) { final previousProjection = value.projection; _lastProjectionReceivedAt = DateTime.now(); + _scheduleProjectionExpiry(projection); _pendingCommand = null; _clearCommandTimers(); _syncScorePendingFromProjection(projection); @@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier { if (receivedAt == null) { return; } - final age = DateTime.now().difference(receivedAt); + final now = DateTime.now(); + final expiresAtEpochMs = value.projection.expiresAtEpochMs; + final fallbackExpired = + expiresAtEpochMs <= 0 && + now.difference(receivedAt) >= const Duration(seconds: 12); + final expired = + value.projection.deviceSessionId.isNotEmpty && + (fallbackExpired || + (expiresAtEpochMs > 0 && + now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) && + _pendingCommand == null && + _pendingScoreCommandIds.isEmpty; + if (expired) { + _invalidateExpiredProjection(); + return; + } + final age = now.difference(receivedAt); final stale = age >= _staleProjectionThreshold; final lost = age >= _connectionLostThreshold; if (stale != value.staleProjection || lost != value.connectionLost) { @@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier { } } + void _invalidateExpiredProjection() { + _projectionExpiryTimer?.cancel(); + _projectionExpiryTimer = null; + _pendingCommand = null; + _pendingScoreCommandIds.clear(); + _optimisticManualScoreValue = null; + _clearCommandTimers(); + _scoreWaitingTimer?.cancel(); + _scoreWaitingTimer = null; + _scoreCommandTimeoutTimer?.cancel(); + _scoreCommandTimeoutTimer = null; + _lastProjectionReceivedAt = null; + value = WatchSessionUiState( + projection: _expiredProjection(), + connectionLost: true, + staleProjection: true, + commandFailureMessage: value.commandFailureMessage, + commandFailureSerial: value.commandFailureSerial, + lastAck: value.lastAck, + ); + unawaited(_nativeClient.invalidateActiveProjection()); + } + + void _scheduleProjectionExpiry(WatchSessionProjection projection) { + _projectionExpiryTimer?.cancel(); + _projectionExpiryTimer = null; + if (projection.deviceSessionId.isEmpty) { + return; + } + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + final expiresAtEpochMs = projection.expiresAtEpochMs > 0 + ? projection.expiresAtEpochMs + : nowMs + const Duration(seconds: 12).inMilliseconds; + final delayMs = expiresAtEpochMs - nowMs; + _projectionExpiryTimer = Timer( + Duration(milliseconds: delayMs <= 0 ? 0 : delayMs), + () { + if (value.projection.deviceSessionId.isEmpty || + _pendingCommand != null || + _pendingScoreCommandIds.isNotEmpty) { + return; + } + _invalidateExpiredProjection(); + }, + ); + } + void _clearCommandTimers() { _waitingTimer?.cancel(); _waitingTimer = null; @@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) { } WatchSessionProjection _initialProjection() { + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: '', revision: 0, - projectedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch, + projectedAtEpochMs: nowMs, + expiresAtEpochMs: nowMs, + phase: WatchSessionPhase.noActiveSession, + phoneReachable: false, + seriesIndex: 0, + seriesTotal: 0, + exerciseName: '', + primaryAction: WatchPrimaryAction.none, + statusLabel: 'Téléphone indisponible', + ); +} + +WatchSessionProjection _expiredProjection() { + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + return WatchSessionProjection( + deviceSessionId: '', + revision: 0, + projectedAtEpochMs: nowMs, + expiresAtEpochMs: nowMs, phase: WatchSessionPhase.noActiveSession, phoneReachable: false, seriesIndex: 0, diff --git a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart index 5f71810..2db538e 100644 --- a/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart +++ b/watch_app/lib/infrastructure/watch_bridge/native_watch_bridge_client.dart @@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient { Future requestResync(); Future requestCapabilityRefresh(); + + Future invalidateActiveProjection(); } final class MethodChannelNativeWatchBridgeClient @@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient Future requestResync() { return _methodChannel.invokeMethod('requestResync'); } + + @override + Future invalidateActiveProjection() { + return _methodChannel.invokeMethod('invalidateActiveProjection'); + } } Map _stringObjectMap(Object? value) { diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index f86a053..7a84008 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State { ), ); } - return PageView(controller: _pageController, children: pages); + return PageView( + controller: _pageController, + physics: const _WatchPageScrollPhysics(), + children: pages, + ); }, ); } @@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State { !_completionHapticTimerKeys.add(key)) { return; } + _triggerTimerCompletionHaptic(); + } + + void _triggerTimerCompletionHaptic() { unawaited(HapticFeedback.heavyImpact()); + unawaited( + Future.delayed(const Duration(milliseconds: 140), () { + return HapticFeedback.heavyImpact(); + }), + ); + unawaited( + Future.delayed(const Duration(milliseconds: 320), () { + return HapticFeedback.heavyImpact(); + }), + ); } Future _confirm({ @@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State { } } +final class _WatchPageScrollPhysics extends PageScrollPhysics { + const _WatchPageScrollPhysics({super.parent}); + + @override + _WatchPageScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _WatchPageScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) || + (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) { + return super.createBallisticSimulation(position, velocity); + } + final viewport = position is PageMetrics + ? position.viewportDimension * position.viewportFraction + : position.viewportDimension; + if (viewport <= 0) { + return null; + } + final target = (position.pixels / viewport).roundToDouble() * viewport; + final clampedTarget = target.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (clampedTarget == position.pixels) { + return null; + } + return ScrollSpringSimulation( + spring, + position.pixels, + clampedTarget, + velocity, + tolerance: toleranceFor(position), + ); + } +} + final class _RoundScaffold extends StatelessWidget { const _RoundScaffold({required this.child, this.notice, super.key}); @@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget { ); final target = projection.manualScoreTargetValue; final targetLabel = projection.manualScoreTargetLabel; + final captionSegments = [ + if (projection.manualScoreRepsTargetValue != null) + 'Répétitions : ${projection.manualScoreRepsTargetValue}', + if (target != null && targetLabel != null && targetLabel.isNotEmpty) + '$targetLabel : ${_scoreText(target)}', + ]; return _ScaledContent( child: Column( mainAxisSize: MainAxisSize.min, children: [ _ExerciseName(projection.exerciseName), _StepNameBand(projection.stepName), - if (target != null && - targetLabel != null && - targetLabel.isNotEmpty) ...[ + if (captionSegments.isNotEmpty) ...[ Text( - '$targetLabel : ${_scoreText(target)}', + captionSegments.join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index 81e0a3c..98ed1d1 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -87,6 +87,36 @@ void main() { }, ); + testWidgets('shows reps target on step manual score content', (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(_manualScoreProjectionWithRepsTarget()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Répétitions : 10 · Cible : 8'), findsOneWidget); + expect(find.text('SCORE'), findsOneWidget); + expect(find.byTooltip('Ajouter'), findsOneWidget); + expect(find.byTooltip('Valider l’étape'), findsNothing); + expect(tester.takeException(), isNull); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }); + testWidgets('hides set timer even when it is projected as dominant', ( tester, ) async { @@ -683,22 +713,63 @@ void main() { }, ); - testWidgets('vibrates once when a countdown timer reaches zero', ( + testWidgets( + 'uses a strong pulse sequence when a countdown timer reaches zero', + (tester) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + hapticCalls.add(call); + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + 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(_countdownProjection(accumulatedMs: 29000)); + await tester.pump(); + expect(hapticCalls, isEmpty); + + client.emitProjection(_countdownProjection(accumulatedMs: 30000)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(hapticCalls, hasLength(3)); + expect( + hapticCalls.map((call) => call.arguments), + everyElement('HapticFeedbackType.heavyImpact'), + ); + + client.emitProjection(_countdownProjection(accumulatedMs: 30000)); + await tester.pump(); + expect(hapticCalls, hasLength(3)); + + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); + + testWidgets('expires an orphaned active projection after its TTL', ( tester, ) async { - final hapticCalls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'HapticFeedback.vibrate') { - hapticCalls.add(call); - } - return null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null); - }); - final client = _FakeNativeWatchBridgeClient(); final viewModel = WatchSessionViewModel(nativeClient: client); @@ -714,20 +785,18 @@ void main() { ), ); - client.emitProjection(_countdownProjection(accumulatedMs: 29000)); + client.emitProjection( + _expiringProjection(expiresIn: const Duration(seconds: 1)), + ); await tester.pump(); - expect(hapticCalls, isEmpty); + expect(find.text('Squat jump'), findsOneWidget); - client.emitProjection(_countdownProjection(accumulatedMs: 30000)); - await tester.pump(); - await tester.pump(); + await tester.pump(const Duration(seconds: 2)); - expect(hapticCalls, hasLength(1)); - expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact'); - - client.emitProjection(_countdownProjection(accumulatedMs: 30000)); - await tester.pump(); - expect(hapticCalls, hasLength(1)); + expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession); + expect(viewModel.value.connectionLost, isTrue); + expect(client.invalidatedProjectionCount, 1); + expect(find.text('Téléphone indisponible'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); viewModel.dispose(); @@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { var resyncRequests = 0; var capabilityRefreshRequests = 0; + var invalidatedProjectionCount = 0; final sentCommands = []; @override @@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { capabilityRefreshRequests += 1; } + @override + Future invalidateActiveProjection() async { + invalidatedProjectionCount += 1; + } + @override Future requestResync() async { resyncRequests += 1; @@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient { } WatchSessionProjection _runningProjection() { + final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch; return WatchSessionProjection( deviceSessionId: 'session-1', revision: 1, - projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + projectedAtEpochMs: projectedAt, + expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds, phase: WatchSessionPhase.running, phoneReachable: true, seriesIndex: 2, @@ -820,6 +897,23 @@ WatchSessionProjection _runningProjection() { ); } +WatchSessionProjection _expiringProjection({required Duration expiresIn}) { + final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch; + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: 99, + projectedAtEpochMs: nowMs, + expiresAtEpochMs: nowMs + expiresIn.inMilliseconds, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Squat jump', + statusLabel: 'Chrono étape', + primaryAction: WatchPrimaryAction.pauseSession, + ); +} + WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) { return WatchSessionProjection( deviceSessionId: '', @@ -980,6 +1074,29 @@ WatchSessionProjection _manualScoreProjectionWithTimer() { ); } +WatchSessionProjection _manualScoreProjectionWithRepsTarget() { + return WatchSessionProjection( + deviceSessionId: 'session-1', + revision: 4, + projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, + phase: WatchSessionPhase.running, + phoneReachable: true, + seriesIndex: 1, + seriesTotal: 3, + exerciseName: 'Pompes tempo', + stepName: 'Score libre', + statusLabel: 'Score manuel', + primaryAction: WatchPrimaryAction.pauseSession, + hasManualScore: true, + currentManualScoreValue: 3, + canDecrementScore: true, + manualScoreTargetValue: 8, + manualScoreTargetLabel: 'Cible', + manualScoreRepsTargetValue: 10, + manualScoreScope: WatchManualScoreScope.step, + ); +} + WatchSessionProjection _restProjection() { return WatchSessionProjection( deviceSessionId: 'session-1',