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

@ -765,7 +765,7 @@ abstract interface class RemoteShareApi {
});
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> revokeShare(String shareId, String token);
}

View File

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

View File

@ -492,10 +492,14 @@ final class DriftLocalSyncChangeRepository
switch (item.resourceType) {
case SyncResourceType.exercise:
final exercise = _exerciseFromPayload(item);
await database
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise));
await _writeExerciseStarterMetadata(database, exercise);
await database.transaction(() async {
await database
.into(database.exercises)
.insertOnConflictUpdate(_exerciseCompanion(exercise));
await _writeExerciseStarterMetadata(database, exercise);
await _replaceRemoteExerciseImages(exercise);
await _replaceRemoteExerciseSteps(exercise);
});
return true;
case SyncResourceType.mediaAsset:
await database
@ -542,7 +546,11 @@ final class DriftLocalSyncChangeRepository
});
return true;
case SyncResourceType.workoutHistory:
return false;
await _replaceRemoteWorkoutHistory(
_workoutHistoryFromLocalBackupPayload(item),
item.clientUpdatedAt,
);
return true;
}
}
@ -604,7 +612,7 @@ final class DriftLocalSyncChangeRepository
}
return _LocalSyncSnapshot.fromMetadata(
history.metadata,
_workoutHistoryPayload(history),
_localWorkoutHistoryPayload(history),
);
}
@ -627,6 +635,212 @@ final class DriftLocalSyncChangeRepository
payload: {'id': id},
);
}
Future<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 {

View File

@ -19,11 +19,11 @@ final class HttpRemoteShareApi implements RemoteShareApi {
final response = await client.postJson(
'/shares',
bearerToken: token,
body: {
'resourceType': _shareResourceTypeToWire(resourceType),
'payload': payload,
'recipientEmails': recipientEmails,
},
body: _shareRequestBody(
resourceType: resourceType,
payload: payload,
recipientEmails: recipientEmails,
),
expectedStatuses: const {201},
);
return RemoteShareSendResult(
@ -43,12 +43,22 @@ final class HttpRemoteShareApi implements RemoteShareApi {
}
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
Future<List<RemoteSyncedItem>> acceptShare(
String shareId,
String token,
) async {
final response = await client.postJson(
'/shares/$shareId/accept',
bearerToken: token,
);
return _syncedItemFromJson(_map(response['createdResource']));
final resources = _list(
response,
'createdResources',
).map((item) => _syncedItemFromJson(_map(item))).toList(growable: false);
if (resources.isNotEmpty) {
return resources;
}
return [_syncedItemFromJson(_map(response['createdResource']))];
}
@override
@ -62,20 +72,91 @@ final class HttpRemoteShareApi implements RemoteShareApi {
}
}
Map<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) {
final resourceType = _inboxResourceType(json);
return ShareInboxItem(
shareId: _requiredString(json, 'shareId'),
senderUserId: _requiredString(json, 'senderUserId'),
resourceType: _shareResourceTypeFromWire(
_requiredString(json, 'resourceType'),
),
payloadJson: _jsonObjectString(json['payload']),
resourceType: resourceType,
payloadJson: _payloadJsonString(json, resourceType),
status: _shareInboxStatusFromWire(_requiredString(json, 'status')),
createdAt: _requiredDateTime(json, 'createdAt'),
respondedAt: _optionalDateTime(json, 'respondedAt'),
);
}
ShareResourceType _inboxResourceType(Map<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) {
return RemoteSyncedItem(
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) {
final value = json[key];
if (value is int) {
@ -184,7 +269,3 @@ DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
? DateTime.parse(value).toUtc()
: 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 currentStepIsRepsOnly =
currentStep != null &&
currentStep.type == ExerciseStepType.reps &&
!currentStep.hasScore;
final sequenceComplete =
view.state.status == ActiveExerciseStepProgressStatus.sequenceComplete;
final completedPassages = sequenceComplete
@ -2596,20 +2600,13 @@ final class _StepSequencePanel extends StatelessWidget {
],
),
const SizedBox(height: 6),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < view.steps.length; index++) ...[
_StepProgressChip(
index: index,
status: _stepDisplayStatus(view, index),
),
if (index < view.steps.length - 1) const SizedBox(width: 6),
],
],
if (currentStepIsRepsOnly)
Row(children: _stepProgressChips(view))
else
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: _stepProgressChips(view)),
),
),
const SizedBox(height: 8),
Expanded(
child: sequenceComplete || currentStep == null
@ -2645,6 +2642,15 @@ final class _StepSequencePanel extends StatelessWidget {
enum _StepSkipAction { passage, sequence }
List<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 {
const _BoundedAccentPanel({required this.child, required this.padding});
@ -2802,96 +2808,96 @@ final class _CurrentStepPane extends StatelessWidget {
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 8),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
step.name,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Expanded(
child: step.type == ExerciseStepType.time
? _TimedStepBody(
step: step,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer,
)
: Align(
alignment: Alignment.topCenter,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topCenter,
child: _RepsStepBody(
step: step,
onCompleteStep: onCompleteStep,
),
),
),
),
if (step.hasScore) ...[
const SizedBox(height: 8),
_StepScoreInput(
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
step.name,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Expanded(
child: step.type == ExerciseStepType.time
? _TimedStepBody(
step: step,
controller: stepScoreController,
elapsedLabel: stepScoreElapsedLabel,
running: stepScoreRunning,
onStart: onStartStepScore,
onStop: onStopStepScore,
onReset: onResetStepScore,
remainingLabel: remainingLabel,
running:
view.state.status ==
ActiveExerciseStepProgressStatus.runningTimer,
readyToStart: _isNextTimedStepReady(view),
onStartTimer: onStartTimer,
)
: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: _RepsStepBody(step: step),
),
),
),
if (step.hasScore) ...[
const SizedBox(height: 8),
_StepScoreInput(
step: step,
controller: stepScoreController,
elapsedLabel: stepScoreElapsedLabel,
running: stepScoreRunning,
onStart: onStartStepScore,
onStop: onStopStepScore,
onReset: onResetStepScore,
),
],
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onSkipStep,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
),
const SizedBox(width: 8),
PopupMenuButton<_StepSkipAction>(
tooltip: 'Plus 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(
children: [
Expanded(
child: OutlinedButton(
onPressed: onSkipStep,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Passer létape'),
),
),
if (step.type == ExerciseStepType.reps) ...[
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: onCompleteStep,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
const SizedBox(width: 8),
PopupMenuButton<_StepSkipAction>(
tooltip: 'Plus 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'),
),
],
),
],
icon: const Icon(Icons.check),
label: const Text('Étape suivante'),
),
),
],
),
],
),
),
],
);
},
);
@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
}
final class _RepsStepBody extends StatelessWidget {
const _RepsStepBody({required this.step, required this.onCompleteStep});
const _RepsStepBody({required this.step});
final ExerciseStep step;
final VoidCallback onCompleteStep;
@override
Widget build(BuildContext context) {
@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget {
).copyWith(color: Theme.of(context).colorScheme.primary),
),
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onCompleteStep,
icon: const Icon(Icons.check),
label: const Text('Étape suivante'),
),
],
);
}