fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)

This commit is contained in:
2026-07-29 11:22:17 +02:00
parent 6f913e4e8d
commit 30c6259748
28 changed files with 1658 additions and 248 deletions

View File

@ -176,6 +176,10 @@ object WatchBridgePlugin {
"projectedAtEpochMs", "projectedAtEpochMs",
(map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L, (map["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L,
) )
dataMap.putLong(
"expiresAtEpochMs",
(map["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L,
)
}.asPutDataRequest().setUrgent() }.asPutDataRequest().setUrgent()
Wearable.getDataClient(context).putDataItem(request) Wearable.getDataClient(context).putDataItem(request)
.addOnSuccessListener { result.success(null) } .addOnSuccessListener { result.success(null) }

View File

@ -765,7 +765,7 @@ abstract interface class RemoteShareApi {
}); });
Future<List<ShareInboxItem>> fetchInbox(String token); Future<List<ShareInboxItem>> fetchInbox(String token);
Future<RemoteSyncedItem> acceptShare(String shareId, String token); Future<List<RemoteSyncedItem>> acceptShare(String shareId, String token);
Future<void> declineShare(String shareId, String token); Future<void> declineShare(String shareId, String token);
Future<void> revokeShare(String shareId, String token); Future<void> revokeShare(String shareId, String token);
} }

View File

@ -632,15 +632,15 @@ final class ShareUseCases {
return; return;
} }
try { try {
if (cachedItem?.resourceType == ShareResourceType.pack) { final createdResources = await remoteShareApi.acceptShare(shareId, token);
await remoteShareApi.acceptShare(shareId, token); final cachedPackItem = cachedItem;
await _importSharedPayload(cachedItem!); if (cachedPackItem != null &&
cachedPackItem.resourceType == ShareResourceType.pack) {
await _importSharedPayload(cachedPackItem);
} else { } else {
final createdResource = await remoteShareApi.acceptShare( for (final createdResource in createdResources) {
shareId, await localChanges.applyRemoteItem(createdResource);
token, }
);
await localChanges.applyRemoteItem(createdResource);
} }
await inboxRepository.markStatus( await inboxRepository.markStatus(
shareId, shareId,
@ -794,11 +794,13 @@ final class ShareUseCases {
if (item?.resourceType == ShareResourceType.pack) { if (item?.resourceType == ShareResourceType.pack) {
await remoteShareApi.acceptShare(action.shareId!, token); await remoteShareApi.acceptShare(action.shareId!, token);
} else { } else {
final created = await remoteShareApi.acceptShare( final createdResources = await remoteShareApi.acceptShare(
action.shareId!, action.shareId!,
token, token,
); );
await localChanges.applyRemoteItem(created); for (final createdResource in createdResources) {
await localChanges.applyRemoteItem(createdResource);
}
} }
await inboxRepository.markStatus( await inboxRepository.markStatus(
action.shareId!, action.shareId!,
@ -3505,6 +3507,7 @@ bool _hasSameWatchCommandRevisionState(
left.deviceSessionId == right.deviceSessionId && left.deviceSessionId == right.deviceSessionId &&
left.phase == right.phase && left.phase == right.phase &&
left.phoneReachable == right.phoneReachable && left.phoneReachable == right.phoneReachable &&
left.expiresAtEpochMs == right.expiresAtEpochMs &&
left.seriesIndex == right.seriesIndex && left.seriesIndex == right.seriesIndex &&
left.seriesTotal == right.seriesTotal && left.seriesTotal == right.seriesTotal &&
left.exerciseName == right.exerciseName && left.exerciseName == right.exerciseName &&
@ -3533,6 +3536,7 @@ bool _hasSameWatchCommandRevisionState(
left.canDecrementScore == right.canDecrementScore && left.canDecrementScore == right.canDecrementScore &&
left.manualScoreTargetValue == right.manualScoreTargetValue && left.manualScoreTargetValue == right.manualScoreTargetValue &&
left.manualScoreTargetLabel == right.manualScoreTargetLabel && left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
left.manualScoreScope == right.manualScoreScope; left.manualScoreScope == right.manualScoreScope;
} }
@ -4055,6 +4059,7 @@ final class WatchSessionProjectionProjector {
deviceSessionId: '', deviceSessionId: '',
revision: revision, revision: revision,
projectedAtEpochMs: _epochMs(now), projectedAtEpochMs: _epochMs(now),
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
phase: WatchSessionPhase.noActiveSession, phase: WatchSessionPhase.noActiveSession,
phoneReachable: true, phoneReachable: true,
seriesIndex: 0, seriesIndex: 0,
@ -4075,6 +4080,7 @@ final class WatchSessionProjectionProjector {
deviceSessionId: session.metadata.id, deviceSessionId: session.metadata.id,
revision: revision, revision: revision,
projectedAtEpochMs: _epochMs(now), projectedAtEpochMs: _epochMs(now),
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
phase: WatchSessionPhase.noActiveSession, phase: WatchSessionPhase.noActiveSession,
phoneReachable: true, phoneReachable: true,
seriesIndex: session.currentSetIndex + 1, seriesIndex: session.currentSetIndex + 1,
@ -4150,6 +4156,7 @@ final class WatchSessionProjectionProjector {
deviceSessionId: session.metadata.id, deviceSessionId: session.metadata.id,
revision: revision, revision: revision,
projectedAtEpochMs: projectedAtEpochMs, projectedAtEpochMs: projectedAtEpochMs,
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
phase: phase, phase: phase,
phoneReachable: true, phoneReachable: true,
seriesIndex: session.currentSetIndex + 1, seriesIndex: session.currentSetIndex + 1,
@ -4202,6 +4209,7 @@ final class WatchSessionProjectionProjector {
canDecrementScore: (manualScoreProjection?.value ?? 0) > 0, canDecrementScore: (manualScoreProjection?.value ?? 0) > 0,
manualScoreTargetValue: manualScoreProjection?.targetValue, manualScoreTargetValue: manualScoreProjection?.targetValue,
manualScoreTargetLabel: manualScoreProjection?.targetLabel, manualScoreTargetLabel: manualScoreProjection?.targetLabel,
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
manualScoreScope: manualScoreProjection?.scope, manualScoreScope: manualScoreProjection?.scope,
); );
} }
@ -4274,12 +4282,14 @@ final class _WatchManualScoreProjectionData {
required this.value, required this.value,
this.targetValue, this.targetValue,
this.targetLabel, this.targetLabel,
this.repsTargetValue,
}); });
final WatchManualScoreScope scope; final WatchManualScoreScope scope;
final double value; final double value;
final double? targetValue; final double? targetValue;
final String? targetLabel; final String? targetLabel;
final int? repsTargetValue;
} }
_WatchManualScoreProjectionData? _watchManualScoreProjection({ _WatchManualScoreProjectionData? _watchManualScoreProjection({
@ -4304,6 +4314,9 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({
value: result?.actualScore ?? 0, value: result?.actualScore ?? 0,
targetValue: step.defaultTargetScore, targetValue: step.defaultTargetScore,
targetLabel: step.defaultTargetScore == null ? null : 'Cible', targetLabel: step.defaultTargetScore == null ? null : 'Cible',
repsTargetValue: step.type == ExerciseStepType.reps
? step.defaultTargetValue
: null,
); );
} }
if (snapshot.scoreEnabled && if (snapshot.scoreEnabled &&
@ -4612,6 +4625,13 @@ String? _betweenSetsNextExerciseName(
int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch; int _epochMs(DateTime value) => value.toUtc().millisecondsSinceEpoch;
int _watchProjectionExpiresAtEpochMs(DateTime projectedAt) {
return projectedAt
.toUtc()
.add(const Duration(seconds: 12))
.millisecondsSinceEpoch;
}
final class ActiveExerciseStepUseCases { final class ActiveExerciseStepUseCases {
const ActiveExerciseStepUseCases({ const ActiveExerciseStepUseCases({
required this.sessionRepository, required this.sessionRepository,

View File

@ -492,10 +492,14 @@ final class DriftLocalSyncChangeRepository
switch (item.resourceType) { switch (item.resourceType) {
case SyncResourceType.exercise: case SyncResourceType.exercise:
final exercise = _exerciseFromPayload(item); final exercise = _exerciseFromPayload(item);
await database await database.transaction(() async {
.into(database.exercises) await database
.insertOnConflictUpdate(_exerciseCompanion(exercise)); .into(database.exercises)
await _writeExerciseStarterMetadata(database, exercise); .insertOnConflictUpdate(_exerciseCompanion(exercise));
await _writeExerciseStarterMetadata(database, exercise);
await _replaceRemoteExerciseImages(exercise);
await _replaceRemoteExerciseSteps(exercise);
});
return true; return true;
case SyncResourceType.mediaAsset: case SyncResourceType.mediaAsset:
await database await database
@ -542,7 +546,11 @@ final class DriftLocalSyncChangeRepository
}); });
return true; return true;
case SyncResourceType.workoutHistory: case SyncResourceType.workoutHistory:
return false; await _replaceRemoteWorkoutHistory(
_workoutHistoryFromLocalBackupPayload(item),
item.clientUpdatedAt,
);
return true;
} }
} }
@ -604,7 +612,7 @@ final class DriftLocalSyncChangeRepository
} }
return _LocalSyncSnapshot.fromMetadata( return _LocalSyncSnapshot.fromMetadata(
history.metadata, history.metadata,
_workoutHistoryPayload(history), _localWorkoutHistoryPayload(history),
); );
} }
@ -627,6 +635,212 @@ final class DriftLocalSyncChangeRepository
payload: {'id': id}, payload: {'id': id},
); );
} }
Future<void> _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<void> _softDeleteRemoteWorkoutHistoryChildren({
required String tableName,
required String historyId,
required Set<String> 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<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
Variable<String>(historyId),
for (final id in keepIds) Variable<String>(id),
],
);
}
Future<void> _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<void> _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<void> _softDeleteRemoteRows({
required String tableName,
required List<String> 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<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
for (final id in ids) Variable<String>(id),
],
);
}
} }
final class DriftShareInboxRepository implements ShareInboxRepository { final class DriftShareInboxRepository implements ShareInboxRepository {

View File

@ -19,11 +19,11 @@ final class HttpRemoteShareApi implements RemoteShareApi {
final response = await client.postJson( final response = await client.postJson(
'/shares', '/shares',
bearerToken: token, bearerToken: token,
body: { body: _shareRequestBody(
'resourceType': _shareResourceTypeToWire(resourceType), resourceType: resourceType,
'payload': payload, payload: payload,
'recipientEmails': recipientEmails, recipientEmails: recipientEmails,
}, ),
expectedStatuses: const {201}, expectedStatuses: const {201},
); );
return RemoteShareSendResult( return RemoteShareSendResult(
@ -43,12 +43,22 @@ final class HttpRemoteShareApi implements RemoteShareApi {
} }
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
final response = await client.postJson( final response = await client.postJson(
'/shares/$shareId/accept', '/shares/$shareId/accept',
bearerToken: token, 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 @override
@ -62,20 +72,91 @@ final class HttpRemoteShareApi implements RemoteShareApi {
} }
} }
Map<String, Object?> _shareRequestBody({
required ShareResourceType resourceType,
required Map<String, Object?> payload,
required List<String> 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<String, Object?> json) { ShareInboxItem _inboxItemFromJson(Map<String, Object?> json) {
final resourceType = _inboxResourceType(json);
return ShareInboxItem( return ShareInboxItem(
shareId: _requiredString(json, 'shareId'), shareId: _requiredString(json, 'shareId'),
senderUserId: _requiredString(json, 'senderUserId'), senderUserId: _requiredString(json, 'senderUserId'),
resourceType: _shareResourceTypeFromWire( resourceType: resourceType,
_requiredString(json, 'resourceType'), payloadJson: _payloadJsonString(json, resourceType),
),
payloadJson: _jsonObjectString(json['payload']),
status: _shareInboxStatusFromWire(_requiredString(json, 'status')), status: _shareInboxStatusFromWire(_requiredString(json, 'status')),
createdAt: _requiredDateTime(json, 'createdAt'), createdAt: _requiredDateTime(json, 'createdAt'),
respondedAt: _optionalDateTime(json, 'respondedAt'), respondedAt: _optionalDateTime(json, 'respondedAt'),
); );
} }
ShareResourceType _inboxResourceType(Map<String, Object?> json) {
final shareKind = json['shareKind'];
if (shareKind == 'pack') {
return ShareResourceType.pack;
}
return _shareResourceTypeFromWire(_requiredString(json, 'resourceType'));
}
String _payloadJsonString(
Map<String, Object?> 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 <Object?>[],
});
}
return jsonEncode({
'name': json['packName'] as String? ?? 'Pack',
'workouts': [
for (final rawItem in rawItems)
if (rawItem is Map &&
Map<String, Object?>.from(rawItem)['resourceType'] ==
'workoutTemplate')
_map(Map<String, Object?>.from(rawItem)['payload']),
],
});
}
RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) { RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
return RemoteSyncedItem( return RemoteSyncedItem(
resourceType: _syncResourceTypeFromWire( resourceType: _syncResourceTypeFromWire(
@ -163,6 +244,10 @@ String _requiredString(Map<String, Object?> json, String key) {
); );
} }
String _stringFromObject(Object? value, String fallback) {
return value is String && value.trim().isNotEmpty ? value : fallback;
}
int _requiredInt(Map<String, Object?> json, String key) { int _requiredInt(Map<String, Object?> json, String key) {
final value = json[key]; final value = json[key];
if (value is int) { if (value is int) {
@ -184,7 +269,3 @@ DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
? DateTime.parse(value).toUtc() ? DateTime.parse(value).toUtc()
: null; : null;
} }
String _jsonObjectString(Object? value) {
return jsonEncode(_map(value));
}

View File

@ -2570,6 +2570,10 @@ final class _StepSequencePanel extends StatelessWidget {
); );
} }
final currentStep = view.currentStep; final currentStep = view.currentStep;
final currentStepIsRepsOnly =
currentStep != null &&
currentStep.type == ExerciseStepType.reps &&
!currentStep.hasScore;
final sequenceComplete = final sequenceComplete =
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete; view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
final completedPassages = sequenceComplete final completedPassages = sequenceComplete
@ -2596,20 +2600,13 @@ final class _StepSequencePanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
SingleChildScrollView( if (currentStepIsRepsOnly)
scrollDirection: Axis.horizontal, Row(children: _stepProgressChips(view))
child: Row( else
children: [ SingleChildScrollView(
for (var index = 0; index < view.steps.length; index++) ...[ scrollDirection: Axis.horizontal,
_StepProgressChip( child: Row(children: _stepProgressChips(view)),
index: index,
status: _stepDisplayStatus(view, index),
),
if (index < view.steps.length - 1) const SizedBox(width: 6),
],
],
), ),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: sequenceComplete || currentStep == null child: sequenceComplete || currentStep == null
@ -2645,6 +2642,15 @@ final class _StepSequencePanel extends StatelessWidget {
enum _StepSkipAction { passage, sequence } enum _StepSkipAction { passage, sequence }
List<Widget> _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 { final class _BoundedAccentPanel extends StatelessWidget {
const _BoundedAccentPanel({required this.child, required this.padding}); const _BoundedAccentPanel({required this.child, required this.padding});
@ -2802,96 +2808,96 @@ final class _CurrentStepPane extends StatelessWidget {
], ],
); );
} }
return SingleChildScrollView( return Column(
padding: const EdgeInsets.only(bottom: 8), crossAxisAlignment: CrossAxisAlignment.stretch,
child: ConstrainedBox( children: [
constraints: BoxConstraints(minHeight: constraints.maxHeight), Text(
child: IntrinsicHeight( step.name,
child: Column( style: Theme.of(context).textTheme.headlineSmall,
crossAxisAlignment: CrossAxisAlignment.stretch, maxLines: 1,
children: [ overflow: TextOverflow.ellipsis,
Text( ),
step.name, const SizedBox(height: 4),
style: Theme.of(context).textTheme.headlineSmall, Expanded(
maxLines: 1, child: step.type == ExerciseStepType.time
overflow: TextOverflow.ellipsis, ? _TimedStepBody(
),
const SizedBox(height: 4),
Expanded(
child: step.type == ExerciseStepType.time
? _TimedStepBody(
step: step,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer,
)
: Align(
alignment: Alignment.topCenter,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: _RepsStepBody(
step: step,
onCompleteStep: onCompleteStep,
),
),
),
),
if (step.hasScore) ...[
const SizedBox(height: 8),
_StepScoreInput(
step: step, step: step,
controller: stepScoreController, remainingLabel: remainingLabel,
elapsedLabel: stepScoreElapsedLabel, running:
running: stepScoreRunning, view.state.status ==
onStart: onStartStepScore, ActiveExerciseStepProgressStatus.runningTimer,
onStop: onStopStepScore, readyToStart: _isNextTimedStepReady(view),
onReset: onResetStepScore, 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 dactions',
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( if (step.type == ExerciseStepType.reps) ...[
children: [ const SizedBox(width: 8),
Expanded( Expanded(
child: OutlinedButton( child: FilledButton.icon(
onPressed: onSkipStep, onPressed: onCompleteStep,
style: OutlinedButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(44), minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
), ),
const SizedBox(width: 8), icon: const Icon(Icons.check),
PopupMenuButton<_StepSkipAction>( label: const Text('Étape suivante'),
tooltip: 'Plus dactions', ),
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'),
),
],
),
],
), ),
], ],
), ],
), ),
), ],
); );
}, },
); );
@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
} }
final class _RepsStepBody extends StatelessWidget { final class _RepsStepBody extends StatelessWidget {
const _RepsStepBody({required this.step, required this.onCompleteStep}); const _RepsStepBody({required this.step});
final ExerciseStep step; final ExerciseStep step;
final VoidCallback onCompleteStep;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget {
).copyWith(color: Theme.of(context).colorScheme.primary), ).copyWith(color: Theme.of(context).colorScheme.primary),
), ),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge), 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'),
),
], ],
); );
} }

View File

@ -1,4 +1,4 @@
const int watchBridgeSchemaVersion = 4; const int watchBridgeSchemaVersion = 5;
enum WatchCommandType { enum WatchCommandType {
startCurrentExercise, startCurrentExercise,
@ -140,6 +140,7 @@ final class WatchSessionProjection {
required this.deviceSessionId, required this.deviceSessionId,
required this.revision, required this.revision,
required this.projectedAtEpochMs, required this.projectedAtEpochMs,
this.expiresAtEpochMs = 0,
required this.phase, required this.phase,
required this.phoneReachable, required this.phoneReachable,
required this.seriesIndex, required this.seriesIndex,
@ -166,6 +167,7 @@ final class WatchSessionProjection {
this.canDecrementScore = false, this.canDecrementScore = false,
this.manualScoreTargetValue, this.manualScoreTargetValue,
this.manualScoreTargetLabel, this.manualScoreTargetLabel,
this.manualScoreRepsTargetValue,
this.manualScoreScope, this.manualScoreScope,
}); });
@ -178,6 +180,7 @@ final class WatchSessionProjection {
deviceSessionId: _stringFromJson(json['deviceSessionId']), deviceSessionId: _stringFromJson(json['deviceSessionId']),
revision: _intFromJson(json['revision'], 0), revision: _intFromJson(json['revision'], 0),
projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0), projectedAtEpochMs: _intFromJson(json['projectedAtEpochMs'], 0),
expiresAtEpochMs: _intFromJson(json['expiresAtEpochMs'], 0),
phase: _enumFromJson( phase: _enumFromJson(
json['phase'], json['phase'],
WatchSessionPhase.values, WatchSessionPhase.values,
@ -221,6 +224,9 @@ final class WatchSessionProjection {
manualScoreTargetLabel: _nullableStringFromJson( manualScoreTargetLabel: _nullableStringFromJson(
json['manualScoreTargetLabel'], json['manualScoreTargetLabel'],
), ),
manualScoreRepsTargetValue: _nullableIntFromJson(
json['manualScoreRepsTargetValue'],
),
manualScoreScope: _nullableEnumFromJson( manualScoreScope: _nullableEnumFromJson(
json['manualScoreScope'], json['manualScoreScope'],
WatchManualScoreScope.values, WatchManualScoreScope.values,
@ -232,6 +238,7 @@ final class WatchSessionProjection {
final String deviceSessionId; final String deviceSessionId;
final int revision; final int revision;
final int projectedAtEpochMs; final int projectedAtEpochMs;
final int expiresAtEpochMs;
final WatchSessionPhase phase; final WatchSessionPhase phase;
final bool phoneReachable; final bool phoneReachable;
final int seriesIndex; final int seriesIndex;
@ -258,6 +265,7 @@ final class WatchSessionProjection {
final bool canDecrementScore; final bool canDecrementScore;
final double? manualScoreTargetValue; final double? manualScoreTargetValue;
final String? manualScoreTargetLabel; final String? manualScoreTargetLabel;
final int? manualScoreRepsTargetValue;
final WatchManualScoreScope? manualScoreScope; final WatchManualScoreScope? manualScoreScope;
Map<String, Object?> toJson() { Map<String, Object?> toJson() {
@ -266,6 +274,7 @@ final class WatchSessionProjection {
'deviceSessionId': deviceSessionId, 'deviceSessionId': deviceSessionId,
'revision': revision, 'revision': revision,
'projectedAtEpochMs': projectedAtEpochMs, 'projectedAtEpochMs': projectedAtEpochMs,
'expiresAtEpochMs': expiresAtEpochMs,
'phase': phase.name, 'phase': phase.name,
'phoneReachable': phoneReachable, 'phoneReachable': phoneReachable,
'seriesIndex': seriesIndex, 'seriesIndex': seriesIndex,
@ -296,6 +305,7 @@ final class WatchSessionProjection {
'canDecrementScore': canDecrementScore, 'canDecrementScore': canDecrementScore,
'manualScoreTargetValue': manualScoreTargetValue, 'manualScoreTargetValue': manualScoreTargetValue,
'manualScoreTargetLabel': manualScoreTargetLabel, 'manualScoreTargetLabel': manualScoreTargetLabel,
'manualScoreRepsTargetValue': manualScoreRepsTargetValue,
'manualScoreScope': manualScoreScope?.name, 'manualScoreScope': manualScoreScope?.name,
}; };
} }
@ -308,6 +318,7 @@ final class WatchSessionProjection {
deviceSessionId == other.deviceSessionId && deviceSessionId == other.deviceSessionId &&
revision == other.revision && revision == other.revision &&
projectedAtEpochMs == other.projectedAtEpochMs && projectedAtEpochMs == other.projectedAtEpochMs &&
expiresAtEpochMs == other.expiresAtEpochMs &&
phase == other.phase && phase == other.phase &&
phoneReachable == other.phoneReachable && phoneReachable == other.phoneReachable &&
seriesIndex == other.seriesIndex && seriesIndex == other.seriesIndex &&
@ -334,6 +345,7 @@ final class WatchSessionProjection {
canDecrementScore == other.canDecrementScore && canDecrementScore == other.canDecrementScore &&
manualScoreTargetValue == other.manualScoreTargetValue && manualScoreTargetValue == other.manualScoreTargetValue &&
manualScoreTargetLabel == other.manualScoreTargetLabel && manualScoreTargetLabel == other.manualScoreTargetLabel &&
manualScoreRepsTargetValue == other.manualScoreRepsTargetValue &&
manualScoreScope == other.manualScoreScope; manualScoreScope == other.manualScoreScope;
} }
@ -344,6 +356,7 @@ final class WatchSessionProjection {
deviceSessionId, deviceSessionId,
revision, revision,
projectedAtEpochMs, projectedAtEpochMs,
expiresAtEpochMs,
phase, phase,
phoneReachable, phoneReachable,
seriesIndex, seriesIndex,
@ -370,6 +383,7 @@ final class WatchSessionProjection {
canDecrementScore, canDecrementScore,
manualScoreTargetValue, manualScoreTargetValue,
manualScoreTargetLabel, manualScoreTargetLabel,
manualScoreRepsTargetValue,
manualScoreScope, manualScoreScope,
]); ]);
} }

View File

@ -148,6 +148,7 @@ void main() {
deviceSessionId: 'session-${phase.name}-${primaryAction.name}', deviceSessionId: 'session-${phase.name}-${primaryAction.name}',
revision: 4, revision: 4,
projectedAtEpochMs: 1710000000100, projectedAtEpochMs: 1710000000100,
expiresAtEpochMs: 1710000012100,
phase: phase, phase: phase,
phoneReachable: true, phoneReachable: true,
seriesIndex: 2, seriesIndex: 2,
@ -174,6 +175,7 @@ void main() {
canDecrementScore: true, canDecrementScore: true,
manualScoreTargetValue: 10, manualScoreTargetValue: 10,
manualScoreTargetLabel: 'Cible', manualScoreTargetLabel: 'Cible',
manualScoreRepsTargetValue: 12,
manualScoreScope: WatchManualScoreScope.step, manualScoreScope: WatchManualScoreScope.step,
); );
@ -220,6 +222,7 @@ void main() {
expect(projection.canDecrementScore, isFalse); expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull); expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreRepsTargetValue, isNull);
expect(projection.manualScoreScope, isNull); expect(projection.manualScoreScope, isNull);
}); });
@ -230,6 +233,7 @@ void main() {
expect(projection.deviceSessionId, ''); expect(projection.deviceSessionId, '');
expect(projection.revision, 0); expect(projection.revision, 0);
expect(projection.projectedAtEpochMs, 0); expect(projection.projectedAtEpochMs, 0);
expect(projection.expiresAtEpochMs, 0);
expect(projection.phase, WatchSessionPhase.noActiveSession); expect(projection.phase, WatchSessionPhase.noActiveSession);
expect(projection.phoneReachable, false); expect(projection.phoneReachable, false);
expect(projection.seriesIndex, 0); expect(projection.seriesIndex, 0);
@ -244,6 +248,7 @@ void main() {
expect(projection.canDecrementScore, isFalse); expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull); expect(projection.manualScoreTargetValue, isNull);
expect(projection.manualScoreTargetLabel, isNull); expect(projection.manualScoreTargetLabel, isNull);
expect(projection.manualScoreRepsTargetValue, isNull);
expect(projection.manualScoreScope, isNull); expect(projection.manualScoreScope, isNull);
}); });
}); });

View File

@ -2611,6 +2611,68 @@ void main() {
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted); 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( test(
'ShareUseCases acceptShare sans token importe une copie locale indépendante', 'ShareUseCases acceptShare sans token importe une copie locale indépendante',
() async { () async {
@ -4559,6 +4621,7 @@ ShareUseCases _shareUseCase({
final class _FakeRemoteShareApi implements RemoteShareApi { final class _FakeRemoteShareApi implements RemoteShareApi {
Exception? exception; Exception? exception;
RemoteSyncedItem? acceptResult; RemoteSyncedItem? acceptResult;
List<RemoteSyncedItem>? acceptResults;
List<ShareInboxItem> inboxItems = const []; List<ShareInboxItem> inboxItems = const [];
var sendCalls = 0; var sendCalls = 0;
var acceptCalls = 0; var acceptCalls = 0;
@ -4568,13 +4631,16 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
Map<String, Object?>? lastPayload; Map<String, Object?>? lastPayload;
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
acceptCalls += 1; acceptCalls += 1;
final error = exception; final error = exception;
if (error != null) { if (error != null) {
throw error; throw error;
} }
return acceptResult ?? _remoteSharedProgramItem(); return acceptResults ?? [acceptResult ?? _remoteSharedProgramItem()];
} }
@override @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( ExerciseUseCases _exerciseUseCase(
_FakeExerciseRepository repository, { _FakeExerciseRepository repository, {
_FakeProgramRepository? programRepository, _FakeProgramRepository? programRepository,

View File

@ -377,6 +377,43 @@ void main() {
expect(env.repository.stepResults.last.actualScore, 0); 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 { test('decrementScore at zero is accepted no-op', () async {
final env = _env( final env = _env(
session: _session(scoreEnabled: true), session: _session(scoreEnabled: true),
@ -740,6 +777,7 @@ final class _FakeProjectionSource implements WatchProjectionSource {
deviceSessionId: projection.deviceSessionId, deviceSessionId: projection.deviceSessionId,
revision: projection.revision + 1, revision: projection.revision + 1,
projectedAtEpochMs: projection.projectedAtEpochMs, projectedAtEpochMs: projection.projectedAtEpochMs,
expiresAtEpochMs: projection.expiresAtEpochMs,
phase: projection.phase, phase: projection.phase,
phoneReachable: projection.phoneReachable, phoneReachable: projection.phoneReachable,
seriesIndex: projection.seriesIndex, seriesIndex: projection.seriesIndex,
@ -752,6 +790,9 @@ final class _FakeProjectionSource implements WatchProjectionSource {
hasManualScore: projection.hasManualScore, hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue, currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore, canDecrementScore: projection.canDecrementScore,
manualScoreTargetValue: projection.manualScoreTargetValue,
manualScoreTargetLabel: projection.manualScoreTargetLabel,
manualScoreRepsTargetValue: projection.manualScoreRepsTargetValue,
manualScoreScope: projection.manualScoreScope, manualScoreScope: projection.manualScoreScope,
); );
return projection; return projection;

View File

@ -10,6 +10,7 @@ import 'package:gametime/infrastructure/local/local.dart' as local;
void main() { void main() {
late local.AppDatabase database; late local.AppDatabase database;
late local.DriftMediaAssetRepository mediaAssetRepository;
late local.DriftExerciseRepository exerciseRepository; late local.DriftExerciseRepository exerciseRepository;
late local.DriftProgramRepository programRepository; late local.DriftProgramRepository programRepository;
late local.DriftActiveSessionRepository activeRepository; late local.DriftActiveSessionRepository activeRepository;
@ -24,6 +25,7 @@ void main() {
setUp(() { setUp(() {
database = local.AppDatabase(NativeDatabase.memory()); database = local.AppDatabase(NativeDatabase.memory());
mediaAssetRepository = local.DriftMediaAssetRepository(database);
exerciseRepository = local.DriftExerciseRepository(database); exerciseRepository = local.DriftExerciseRepository(database);
programRepository = local.DriftProgramRepository(database); programRepository = local.DriftProgramRepository(database);
activeRepository = local.DriftActiveSessionRepository(database); activeRepository = local.DriftActiveSessionRepository(database);
@ -482,6 +484,209 @@ CREATE TABLE pending_share_actions (
expect(payloadsById['template-sync-tags']!['tags'], ['routine']); 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 = <String, Object?>{
'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 { test('local sync pull defaults missing tags to empty lists', () async {
final now = DateTime.utc(2026, 7, 22, 11); final now = DateTime.utc(2026, 7, 22, 11);
await syncChangeRepository.applyRemoteItem( await syncChangeRepository.applyRemoteItem(
@ -2616,8 +2821,14 @@ WorkoutHistory _history({
required DateTime startedAt, required DateTime startedAt,
WorkoutHistorySetResult? result, WorkoutHistorySetResult? result,
List<WorkoutHistorySetResult>? results, List<WorkoutHistorySetResult>? results,
List<WorkoutHistoryStepResult> stepResults = const [],
bool completed = true, bool completed = true,
int totalActiveMs = 300000, int totalActiveMs = 300000,
int? minHeartRateBpm,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
}) { }) {
return WorkoutHistory( return WorkoutHistory(
metadata: _metadata(id, startedAt), metadata: _metadata(id, startedAt),
@ -2628,6 +2839,12 @@ WorkoutHistory _history({
completed: completed, completed: completed,
historySnapshotJson: '{"name":"$id"}', historySnapshotJson: '{"name":"$id"}',
results: results ?? [result!], results: results ?? [result!],
stepResults: stepResults,
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: totalDistanceMeters,
totalCaloriesKcal: totalCaloriesKcal,
); );
} }
@ -2744,6 +2961,7 @@ ExerciseStep _exerciseStep({
String? scoreUnit, String? scoreUnit,
double? defaultTargetScore, double? defaultTargetScore,
int? defaultTargetScoreTimeMs, int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) { }) {
return ExerciseStep( return ExerciseStep(
id: id, id: id,
@ -2757,6 +2975,7 @@ ExerciseStep _exerciseStep({
scoreUnit: scoreUnit, scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
); );
} }

View File

@ -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<String, Object?>? 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 <String>[],
}),
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<String, Object?> _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<String, Object?> _jsonMap(String source) {
final decoded = jsonDecode(source);
return Map<String, Object?>.from(decoded as Map);
}

View File

@ -520,7 +520,10 @@ final class _FakeLocalSyncChangeRepository
final class _FakeRemoteShareApi implements RemoteShareApi { final class _FakeRemoteShareApi implements RemoteShareApi {
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
throw const RemoteAuthException(RemoteAuthFailure.network); throw const RemoteAuthException(RemoteAuthFailure.network);
} }

View File

@ -887,17 +887,22 @@ final class _FakeLocalSyncChangeRepository
final class _FakeRemoteShareApi implements RemoteShareApi { final class _FakeRemoteShareApi implements RemoteShareApi {
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
return RemoteSyncedItem( String shareId,
resourceType: SyncResourceType.program, String token,
clientId: 'program-remote', ) async {
serverId: 'program-server', return [
schemaVersion: 1, RemoteSyncedItem(
clientUpdatedAt: DateTime.utc(2026, 7, 17), resourceType: SyncResourceType.program,
serverUpdatedAt: DateTime.utc(2026, 7, 17), clientId: 'program-remote',
deletedAt: null, serverId: 'program-server',
payload: const {'name': 'Programme partagé'}, schemaVersion: 1,
); clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
),
];
} }
@override @override

View File

@ -1241,17 +1241,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
List<String> lastRecipientEmails = const []; List<String> lastRecipientEmails = const [];
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
return RemoteSyncedItem( String shareId,
resourceType: SyncResourceType.program, String token,
clientId: 'program-remote', ) async {
serverId: 'program-server', return [
schemaVersion: 1, RemoteSyncedItem(
clientUpdatedAt: DateTime.utc(2026, 7, 17), resourceType: SyncResourceType.program,
serverUpdatedAt: DateTime.utc(2026, 7, 17), clientId: 'program-remote',
deletedAt: null, serverId: 'program-server',
payload: const {'name': 'Programme partagé'}, schemaVersion: 1,
); clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme partagé'},
),
];
} }
@override @override

View File

@ -365,19 +365,24 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
Map<String, Object?>? lastPayload; Map<String, Object?>? lastPayload;
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
acceptCalls += 1; acceptCalls += 1;
_mark(shareId, ShareInboxStatus.accepted); _mark(shareId, ShareInboxStatus.accepted);
return RemoteSyncedItem( return [
resourceType: SyncResourceType.program, RemoteSyncedItem(
clientId: 'shared-program-1', resourceType: SyncResourceType.program,
serverId: 'server-program-1', clientId: 'shared-program-1',
schemaVersion: 1, serverId: 'server-program-1',
clientUpdatedAt: DateTime.utc(2026, 7, 17), schemaVersion: 1,
serverUpdatedAt: DateTime.utc(2026, 7, 17), clientUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null, serverUpdatedAt: DateTime.utc(2026, 7, 17),
payload: const {'name': 'Programme tirs'}, deletedAt: null,
); payload: const {'name': 'Programme tirs'},
),
];
} }
@override @override

View File

@ -1942,6 +1942,12 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('8'), findsWidgets); 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.tap(find.text('Étape suivante'));
await tester.pump(); await tester.pump();

View File

@ -1272,17 +1272,22 @@ final class _FakeRemoteShareApi implements RemoteShareApi {
List<String> lastRecipientEmails = const []; List<String> lastRecipientEmails = const [];
@override @override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async { Future<List<RemoteSyncedItem>> acceptShare(
return RemoteSyncedItem( String shareId,
resourceType: SyncResourceType.workoutTemplate, String token,
clientId: 'template-remote', ) async {
serverId: 'template-server', return [
schemaVersion: 1, RemoteSyncedItem(
clientUpdatedAt: DateTime.utc(2026, 7, 17), resourceType: SyncResourceType.workoutTemplate,
serverUpdatedAt: DateTime.utc(2026, 7, 17), clientId: 'template-remote',
deletedAt: null, serverId: 'template-server',
payload: const {'name': 'Séance partagée'}, schemaVersion: 1,
); clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Séance partagée'},
),
];
} }
@override @override

View File

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission <uses-permission
android:name="android.permission.BODY_SENSORS" android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" /> android:maxSdkVersion="35" />
@ -36,7 +37,7 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true" android:exported="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@ -47,6 +48,10 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="com.gametime.watch.OPEN_ACTIVE_SESSION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"

View File

@ -1,5 +1,6 @@
package com.gametime.watch package com.gametime.watch
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.wear.ambient.AmbientModeSupport import androidx.wear.ambient.AmbientModeSupport
import com.gametime.watch.bridge.WatchBridgePlugin import com.gametime.watch.bridge.WatchBridgePlugin
@ -13,6 +14,18 @@ class MainActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
AmbientModeSupport.attach(this) AmbientModeSupport.attach(this)
WatchBridgePlugin.attachActivity(this) WatchBridgePlugin.attachActivity(this)
WatchBridgePlugin.handleActivityReentry(this, intent)
}
override fun onResume() {
super.onResume()
WatchBridgePlugin.handleActivityReentry(this, intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
WatchBridgePlugin.handleActivityReentry(this, intent)
} }
override fun onDestroy() { override fun onDestroy() {

View File

@ -34,6 +34,7 @@ object WatchBridgePlugin {
const val ACK_PATH = "/gametime/phone/ack" const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection" const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion" const val PHONE_CAPABILITY = "gametime_phone_companion"
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
private const val SENSOR_PERMISSION_REQUEST = 4106 private const val SENSOR_PERMISSION_REQUEST = 4106
private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L private const val SENSOR_PERMISSION_RETRY_DELAY_MS = 30000L
private const val READ_HEART_RATE_PERMISSION = private const val READ_HEART_RATE_PERMISSION =
@ -56,6 +57,9 @@ object WatchBridgePlugin {
private var lastSensorPermissionRequestEpochMs = 0L private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false private var pendingSensorPermissionRequest = false
private var lastSensorProjection: Map<String, Any?>? = null private var lastSensorProjection: Map<String, Any?>? = null
private var lastActiveProjection: Map<String, Any?>? = null
private var lastActiveProjectionReceivedAtEpochMs = 0L
private var activeProjectionExpiryRunnable: Runnable? = null
fun attachApplicationContext(context: Context) { fun attachApplicationContext(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
@ -123,6 +127,16 @@ object WatchBridgePlugin {
requestPendingSensorPermissionIfPossible() 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) { fun detachActivity(activity: Activity) {
if (this.activity === activity) { if (this.activity === activity) {
this.activity = null this.activity = null
@ -159,7 +173,9 @@ object WatchBridgePlugin {
} }
fun emitProjection(payload: Map<String, Any?>): Boolean { fun emitProjection(payload: Map<String, Any?>): Boolean {
rememberActiveProjection(payload)
appContext?.let { appContext?.let {
scheduleActiveProjectionExpiry(it, payload)
WatchOngoingActivityController.update(it, payload, activity) WatchOngoingActivityController.update(it, payload, activity)
updateHeartRateCollection(it, payload) updateHeartRateCollection(it, payload)
} }
@ -229,10 +245,24 @@ object WatchBridgePlugin {
requestCapabilityRefresh(context) requestCapabilityRefresh(context)
result.success(null) result.success(null)
} }
"invalidateActiveProjection" -> {
invalidateActiveProjection(context)
result.success(null)
}
else -> result.notImplemented() 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( private fun sendCommand(
context: Context, context: Context,
arguments: Any?, 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<String, Any?>) {
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<String, Any?>) {
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<String, Any?>) { private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
val phase = projection["phase"] as? String ?: "noActiveSession" val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: "" val sessionId = projection["deviceSessionId"] as? String ?: ""
@ -416,6 +503,7 @@ object WatchBridgePlugin {
return listOf( return listOf(
heartRatePermission, heartRatePermission,
android.Manifest.permission.ACTIVITY_RECOGNITION, android.Manifest.permission.ACTIVITY_RECOGNITION,
android.Manifest.permission.ACCESS_FINE_LOCATION,
) )
} }

View File

@ -2,6 +2,8 @@ package com.gametime.watch.bridge
import android.content.Context import android.content.Context
import android.util.Log 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.HealthServices
import androidx.health.services.client.MeasureClient import androidx.health.services.client.MeasureClient
import androidx.health.services.client.MeasureCallback 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.DataPointContainer
import androidx.health.services.client.data.DataType import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.DeltaDataType 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.CapabilityClient
import com.google.android.gms.wearable.Wearable import com.google.android.gms.wearable.Wearable
import org.json.JSONObject import org.json.JSONObject
@ -35,10 +41,12 @@ internal class WatchHeartRateCollector(
private var sampleSequence = 0 private var sampleSequence = 0
private var executionContext: Map<String, Any?> = emptyMap() private var executionContext: Map<String, Any?> = emptyMap()
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>() private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var exerciseMetricsStarted = false
private var exerciseMetricsStartInFlight = false
private var shouldAggregate = false private var shouldAggregate = false
private var appContext: Context? = null private var appContext: Context? = null
private val callback = object : MeasureCallback { private val measureCallback = object : MeasureCallback {
override fun onAvailabilityChanged( override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>, dataType: DeltaDataType<*, *>,
availability: Availability, availability: Availability,
@ -54,21 +62,11 @@ internal class WatchHeartRateCollector(
for (point in data.getData(DataType.HEART_RATE_BPM)) { for (point in data.getData(DataType.HEART_RATE_BPM)) {
latestHeartRateBpm = recordHeartRate(point.value) latestHeartRateBpm = recordHeartRate(point.value)
} }
var updatedDistance = false if (latestHeartRateBpm != null) {
for (point in data.getData(DataType.DISTANCE)) { Log.d(
if (point.value > 0) { TAG,
distanceMeters = (distanceMeters ?: 0.0) + point.value "heart rate data received sessionId=$sessionId bpm=$latestHeartRateBpm",
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) {
sendSample(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( fun noteActiveSession(
nextSessionId: String, nextSessionId: String,
shouldAggregate: Boolean, shouldAggregate: Boolean,
@ -103,17 +152,18 @@ internal class WatchHeartRateCollector(
appContext = context.applicationContext appContext = context.applicationContext
val measureClient = HealthServices.getClient(context).measureClient val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) startExerciseMetrics(context)
registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES)
} }
fun pause(context: Context) { fun pause(context: Context) {
shouldAggregate = false shouldAggregate = false
unregister(context) unregister(context)
stopExerciseMetrics(context)
} }
fun finishCurrentSession(context: Context) { fun finishCurrentSession(context: Context) {
unregister(context) unregister(context)
stopExerciseMetrics(context)
val completedSessionId = sessionId val completedSessionId = sessionId
if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) {
sendSummary(context, completedSessionId) sendSummary(context, completedSessionId)
@ -188,6 +238,8 @@ internal class WatchHeartRateCollector(
"minHeartRateBpm" to min, "minHeartRateBpm" to min,
"averageHeartRateBpm" to sampleSum / sampleCount, "averageHeartRateBpm" to sampleSum / sampleCount,
"maxHeartRateBpm" to max, "maxHeartRateBpm" to max,
"distanceMeters" to distanceMeters,
"caloriesKcal" to caloriesKcal,
), ),
).toString().toByteArray(StandardCharsets.UTF_8) ).toString().toByteArray(StandardCharsets.UTF_8)
Wearable.getCapabilityClient(context) Wearable.getCapabilityClient(context)
@ -213,10 +265,9 @@ internal class WatchHeartRateCollector(
} }
val measureClient = HealthServices.getClient(context).measureClient val measureClient = HealthServices.getClient(context).measureClient
for (dataType in registeredDataTypes.toList()) { for (dataType in registeredDataTypes.toList()) {
measureClient.unregisterMeasureCallbackAsync(dataType, callback) measureClient.unregisterMeasureCallbackAsync(dataType, measureCallback)
} }
registeredDataTypes.clear() registeredDataTypes.clear()
appContext = null
} }
private fun registerMeasureCallbackIfNeeded( private fun registerMeasureCallbackIfNeeded(
@ -227,7 +278,7 @@ internal class WatchHeartRateCollector(
return return
} }
try { try {
measureClient.registerMeasureCallback(dataType, callback) measureClient.registerMeasureCallback(dataType, measureCallback)
registeredDataTypes.add(dataType) registeredDataTypes.add(dataType)
Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId") Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId")
} catch (error: RuntimeException) { } 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<androidx.health.services.client.data.DataType<*, *>>()
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?) { private fun reset(nextSessionId: String?) {
sessionId = nextSessionId sessionId = nextSessionId
sampleCount = 0 sampleCount = 0

View File

@ -13,7 +13,6 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.gametime.watch.MainActivity
import com.gametime.watch.R import com.gametime.watch.R
internal class WatchHeartRateForegroundService : Service() { internal class WatchHeartRateForegroundService : Service() {
@ -71,9 +70,7 @@ internal class WatchHeartRateForegroundService : Service() {
val touchIntent = PendingIntent.getActivity( val touchIntent = PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, MainActivity::class.java).apply { WatchBridgePlugin.openActiveSessionIntent(this),
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)

View File

@ -6,14 +6,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity import androidx.wear.ongoing.OngoingActivity
import androidx.wear.ongoing.Status import androidx.wear.ongoing.Status
import com.gametime.watch.MainActivity
import com.gametime.watch.R import com.gametime.watch.R
object WatchOngoingActivityController { object WatchOngoingActivityController {
@ -47,9 +45,7 @@ object WatchOngoingActivityController {
val touchIntent = PendingIntent.getActivity( val touchIntent = PendingIntent.getActivity(
context, context,
0, 0,
Intent(context, MainActivity::class.java).apply { WatchBridgePlugin.openActiveSessionIntent(context),
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val exerciseName = (projection["exerciseName"] as? String) val exerciseName = (projection["exerciseName"] as? String)

View File

@ -128,6 +128,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
Timer? _scoreWaitingTimer; Timer? _scoreWaitingTimer;
Timer? _scoreCommandTimeoutTimer; Timer? _scoreCommandTimeoutTimer;
Timer? _freshnessTimer; Timer? _freshnessTimer;
Timer? _projectionExpiryTimer;
Timer? _commandFailureClearTimer; Timer? _commandFailureClearTimer;
WatchCommandEnvelope? _pendingCommand; WatchCommandEnvelope? _pendingCommand;
final _pendingScoreCommandIds = <String>{}; final _pendingScoreCommandIds = <String>{};
@ -198,6 +199,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
_scoreWaitingTimer?.cancel(); _scoreWaitingTimer?.cancel();
_scoreCommandTimeoutTimer?.cancel(); _scoreCommandTimeoutTimer?.cancel();
_freshnessTimer?.cancel(); _freshnessTimer?.cancel();
_projectionExpiryTimer?.cancel();
_commandFailureClearTimer?.cancel(); _commandFailureClearTimer?.cancel();
for (final subscription in _subscriptions) { for (final subscription in _subscriptions) {
unawaited(subscription.cancel()); unawaited(subscription.cancel());
@ -309,6 +311,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
void _handleProjection(WatchSessionProjection projection) { void _handleProjection(WatchSessionProjection projection) {
final previousProjection = value.projection; final previousProjection = value.projection;
_lastProjectionReceivedAt = DateTime.now(); _lastProjectionReceivedAt = DateTime.now();
_scheduleProjectionExpiry(projection);
_pendingCommand = null; _pendingCommand = null;
_clearCommandTimers(); _clearCommandTimers();
_syncScorePendingFromProjection(projection); _syncScorePendingFromProjection(projection);
@ -388,7 +391,23 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
if (receivedAt == null) { if (receivedAt == null) {
return; 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 stale = age >= _staleProjectionThreshold;
final lost = age >= _connectionLostThreshold; final lost = age >= _connectionLostThreshold;
if (stale != value.staleProjection || lost != value.connectionLost) { if (stale != value.staleProjection || lost != value.connectionLost) {
@ -396,6 +415,53 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
} }
} }
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() { void _clearCommandTimers() {
_waitingTimer?.cancel(); _waitingTimer?.cancel();
_waitingTimer = null; _waitingTimer = null;
@ -504,10 +570,29 @@ bool _requiresActiveSession(WatchCommandType type) {
} }
WatchSessionProjection _initialProjection() { WatchSessionProjection _initialProjection() {
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: '', deviceSessionId: '',
revision: 0, 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, phase: WatchSessionPhase.noActiveSession,
phoneReachable: false, phoneReachable: false,
seriesIndex: 0, seriesIndex: 0,

View File

@ -41,6 +41,8 @@ abstract interface class NativeWatchBridgeClient {
Future<void> requestResync(); Future<void> requestResync();
Future<void> requestCapabilityRefresh(); Future<void> requestCapabilityRefresh();
Future<void> invalidateActiveProjection();
} }
final class MethodChannelNativeWatchBridgeClient final class MethodChannelNativeWatchBridgeClient
@ -141,6 +143,11 @@ final class MethodChannelNativeWatchBridgeClient
Future<void> requestResync() { Future<void> requestResync() {
return _methodChannel.invokeMethod<void>('requestResync'); return _methodChannel.invokeMethod<void>('requestResync');
} }
@override
Future<void> invalidateActiveProjection() {
return _methodChannel.invokeMethod<void>('invalidateActiveProjection');
}
} }
Map<String, Object?> _stringObjectMap(Object? value) { Map<String, Object?> _stringObjectMap(Object? value) {

View File

@ -99,7 +99,11 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
), ),
); );
} }
return PageView(controller: _pageController, children: pages); return PageView(
controller: _pageController,
physics: const _WatchPageScrollPhysics(),
children: pages,
);
}, },
); );
} }
@ -220,7 +224,21 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
!_completionHapticTimerKeys.add(key)) { !_completionHapticTimerKeys.add(key)) {
return; return;
} }
_triggerTimerCompletionHaptic();
}
void _triggerTimerCompletionHaptic() {
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
unawaited(
Future<void>.delayed(const Duration(milliseconds: 140), () {
return HapticFeedback.heavyImpact();
}),
);
unawaited(
Future<void>.delayed(const Duration(milliseconds: 320), () {
return HapticFeedback.heavyImpact();
}),
);
} }
Future<bool> _confirm({ Future<bool> _confirm({
@ -242,6 +260,47 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
} }
} }
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 { final class _RoundScaffold extends StatelessWidget {
const _RoundScaffold({required this.child, this.notice, super.key}); const _RoundScaffold({required this.child, this.notice, super.key});
@ -927,17 +986,21 @@ final class _ManualScoreContent extends StatelessWidget {
); );
final target = projection.manualScoreTargetValue; final target = projection.manualScoreTargetValue;
final targetLabel = projection.manualScoreTargetLabel; 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( return _ScaledContent(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_ExerciseName(projection.exerciseName), _ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName), _StepNameBand(projection.stepName),
if (target != null && if (captionSegments.isNotEmpty) ...[
targetLabel != null &&
targetLabel.isNotEmpty) ...[
Text( Text(
'$targetLabel : ${_scoreText(target)}', captionSegments.join(' · '),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.center,

View File

@ -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', ( testWidgets('hides set timer even when it is projected as dominant', (
tester, tester,
) async { ) 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 = <MethodCall>[];
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, tester,
) async { ) async {
final hapticCalls = <MethodCall>[];
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 client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(nativeClient: client); 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(); await tester.pump();
expect(hapticCalls, isEmpty); expect(find.text('Squat jump'), findsOneWidget);
client.emitProjection(_countdownProjection(accumulatedMs: 30000)); await tester.pump(const Duration(seconds: 2));
await tester.pump();
await tester.pump();
expect(hapticCalls, hasLength(1)); expect(viewModel.value.projection.phase, WatchSessionPhase.noActiveSession);
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact'); expect(viewModel.value.connectionLost, isTrue);
expect(client.invalidatedProjectionCount, 1);
client.emitProjection(_countdownProjection(accumulatedMs: 30000)); expect(find.text('Téléphone indisponible'), findsOneWidget);
await tester.pump();
expect(hapticCalls, hasLength(1));
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose(); viewModel.dispose();
@ -745,6 +814,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
var resyncRequests = 0; var resyncRequests = 0;
var capabilityRefreshRequests = 0; var capabilityRefreshRequests = 0;
var invalidatedProjectionCount = 0;
final sentCommands = <WatchCommandEnvelope>[]; final sentCommands = <WatchCommandEnvelope>[];
@override @override
@ -782,6 +852,11 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
capabilityRefreshRequests += 1; capabilityRefreshRequests += 1;
} }
@override
Future<void> invalidateActiveProjection() async {
invalidatedProjectionCount += 1;
}
@override @override
Future<void> requestResync() async { Future<void> requestResync() async {
resyncRequests += 1; resyncRequests += 1;
@ -794,10 +869,12 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
} }
WatchSessionProjection _runningProjection() { WatchSessionProjection _runningProjection() {
final projectedAt = DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch;
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: 'session-1', deviceSessionId: 'session-1',
revision: 1, revision: 1,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, projectedAtEpochMs: projectedAt,
expiresAtEpochMs: projectedAt + const Duration(seconds: 12).inMilliseconds,
phase: WatchSessionPhase.running, phase: WatchSessionPhase.running,
phoneReachable: true, phoneReachable: true,
seriesIndex: 2, 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}) { WatchSessionProjection _noSessionStartProjection({bool phoneReachable = true}) {
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: '', 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() { WatchSessionProjection _restProjection() {
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: 'session-1', deviceSessionId: 'session-1',