fix(watch): finalise correctif sync workoutHistory/exercise et distance live montre (#157)
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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 d’actions',
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
onSelected: (action) {
|
||||
if (action == _StepSkipAction.passage) {
|
||||
onSkipPassage();
|
||||
} else {
|
||||
onSkipSequence();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: _StepSkipAction.passage,
|
||||
child: Text('Passer ce passage'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _StepSkipAction.sequence,
|
||||
child: Text('Passer la séquence'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onSkipStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Passer l’étape'),
|
||||
),
|
||||
),
|
||||
if (step.type == ExerciseStepType.reps) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onCompleteStep,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
PopupMenuButton<_StepSkipAction>(
|
||||
tooltip: 'Plus d’actions',
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
onSelected: (action) {
|
||||
if (action == _StepSkipAction.passage) {
|
||||
onSkipPassage();
|
||||
} else {
|
||||
onSkipSequence();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: _StepSkipAction.passage,
|
||||
child: Text('Passer ce passage'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _StepSkipAction.sequence,
|
||||
child: Text('Passer la séquence'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Étape suivante'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -3241,10 +3247,9 @@ bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
|
||||
}
|
||||
|
||||
final class _RepsStepBody extends StatelessWidget {
|
||||
const _RepsStepBody({required this.step, required this.onCompleteStep});
|
||||
const _RepsStepBody({required this.step});
|
||||
|
||||
final ExerciseStep step;
|
||||
final VoidCallback onCompleteStep;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -3257,12 +3262,6 @@ final class _RepsStepBody extends StatelessWidget {
|
||||
).copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
Text('RÉPÉTITIONS', style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onCompleteStep,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Étape suivante'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user