Corrige l'acceptation de partage hors-ligne (acceptShare, _acceptCachedShareAndQueueAck, _importSharedPayload dans application/use_cases.dart) : crée bien une copie locale indépendante avec de nouveaux IDs avant de mettre l'accusé serveur en file d'attente. Ajuste app_bootstrap.dart, profile_screen.dart et share_screen.dart en conséquence. flutter pub get OK, dart format appliqué (aucun changement), analyze propre (mêmes infos préexistantes), 137/137 tests verts, build APK debug validé. Dernier ticket du chantier "Ajouter les features serveur au client" (#63, tickets #64 à #70 tous terminés). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3620 lines
117 KiB
Dart
3620 lines
117 KiB
Dart
import 'dart:convert';
|
|
|
|
import '../domain/domain.dart';
|
|
import 'ports.dart';
|
|
|
|
const Object _useCaseUnchanged = Object();
|
|
|
|
final class AuthUseCases {
|
|
const AuthUseCases({
|
|
required this.tokenStore,
|
|
required this.accountRepository,
|
|
required this.remoteAuthApi,
|
|
required this.clock,
|
|
required this.ids,
|
|
});
|
|
|
|
final AuthTokenStore tokenStore;
|
|
final OnlineAccountRepository accountRepository;
|
|
final RemoteAuthApi remoteAuthApi;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
|
|
Future<UserAccountSession> register({
|
|
required String email,
|
|
required String password,
|
|
String? displayName,
|
|
}) async {
|
|
_validateAuthInput(email: email, password: password);
|
|
final result = await remoteAuthApi.register(
|
|
email: email.trim(),
|
|
password: password,
|
|
displayName: displayName,
|
|
);
|
|
return _persistAuthenticatedSession(result, displayName: displayName);
|
|
}
|
|
|
|
Future<UserAccountSession> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
_validateAuthInput(email: email, password: password);
|
|
final result = await remoteAuthApi.login(
|
|
email: email.trim(),
|
|
password: password,
|
|
);
|
|
return _persistAuthenticatedSession(result);
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
final token = await tokenStore.readToken();
|
|
if (token != null) {
|
|
try {
|
|
await remoteAuthApi.logout(token);
|
|
} on RemoteAuthException {
|
|
// Local logout must not depend on remote reachability.
|
|
}
|
|
}
|
|
await tokenStore.clearToken();
|
|
final session = await accountRepository.currentSession();
|
|
if (session != null) {
|
|
final now = clock.now();
|
|
await accountRepository.saveSession(
|
|
session.copyWith(isLoggedIn: false, updatedAt: now),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<UserAccountSession?> currentSession() async {
|
|
final session = await accountRepository.currentSession();
|
|
if (session == null) {
|
|
return null;
|
|
}
|
|
final token = await tokenStore.readToken();
|
|
if (token != null || !session.isLoggedIn) {
|
|
return session;
|
|
}
|
|
final updated = session.copyWith(isLoggedIn: false, updatedAt: clock.now());
|
|
await accountRepository.saveSession(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<UserAccountSession> _persistAuthenticatedSession(
|
|
RemoteAuthResult result, {
|
|
String? displayName,
|
|
}) async {
|
|
await tokenStore.saveToken(result.token, result.expiresAt);
|
|
final now = clock.now();
|
|
final existing = await accountRepository.currentSession();
|
|
final session = UserAccountSession(
|
|
id: existing?.id ?? ids.newId(),
|
|
serverUserId:
|
|
existing != null &&
|
|
existing.email.trim().toLowerCase() ==
|
|
result.email.trim().toLowerCase()
|
|
? existing.serverUserId
|
|
: result.userId,
|
|
email: result.email,
|
|
displayName: displayName ?? existing?.displayName,
|
|
isLoggedIn: true,
|
|
createdAt: existing?.createdAt ?? now,
|
|
updatedAt: now,
|
|
lastAuthenticatedAt: now,
|
|
);
|
|
await accountRepository.saveSession(session);
|
|
return session;
|
|
}
|
|
}
|
|
|
|
final class SyncUseCases {
|
|
const SyncUseCases({
|
|
required this.tokenStore,
|
|
required this.remoteSyncApi,
|
|
required this.metadataRepository,
|
|
required this.mappingRepository,
|
|
required this.localChanges,
|
|
required this.clock,
|
|
required this.deviceId,
|
|
});
|
|
|
|
final AuthTokenStore tokenStore;
|
|
final RemoteSyncApi remoteSyncApi;
|
|
final SyncMetadataRepository metadataRepository;
|
|
final RemoteResourceMappingRepository mappingRepository;
|
|
final LocalSyncChangeRepository localChanges;
|
|
final Clock clock;
|
|
final String deviceId;
|
|
|
|
Future<SyncMetadataSnapshot> currentStatus() {
|
|
return metadataRepository.read();
|
|
}
|
|
|
|
Future<SyncRunSummary> synchronize({required bool manual}) async {
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return const SyncRunSummary(
|
|
pushedChanges: 0,
|
|
pulledChanges: 0,
|
|
skipped: true,
|
|
message: 'Not connected.',
|
|
);
|
|
}
|
|
|
|
final previousMetadata = await metadataRepository.read();
|
|
final attemptAt = clock.now();
|
|
await metadataRepository.save(
|
|
previousMetadata.copyWith(
|
|
lastAttemptAt: attemptAt,
|
|
status: OnlineSyncStatus.syncing,
|
|
),
|
|
);
|
|
|
|
try {
|
|
final pendingChanges = await localChanges.listPendingChanges();
|
|
var pushedCount = 0;
|
|
if (pendingChanges.isNotEmpty) {
|
|
final pushResult = await remoteSyncApi.push(
|
|
deviceId: deviceId,
|
|
items: pendingChanges.map((change) => change.item).toList(),
|
|
token: token,
|
|
);
|
|
final changesByKey = {
|
|
for (final change in pendingChanges)
|
|
_syncKey(change.item.resourceType, change.item.clientId): change,
|
|
};
|
|
final syncedChangeLogIds = <String>[];
|
|
for (final result in pushResult.results) {
|
|
final change =
|
|
changesByKey[_syncKey(result.resourceType, result.clientId)];
|
|
if (change == null || result.status == RemoteSyncPushStatus.error) {
|
|
continue;
|
|
}
|
|
pushedCount += result.status == RemoteSyncPushStatus.accepted ? 1 : 0;
|
|
syncedChangeLogIds.addAll(change.changeLogIds);
|
|
if (result.serverId != null && result.serverUpdatedAt != null) {
|
|
await mappingRepository.save(
|
|
RemoteResourceMapping(
|
|
resourceType: result.resourceType,
|
|
clientId: result.clientId,
|
|
serverId: result.serverId!,
|
|
serverUpdatedAt: result.serverUpdatedAt!,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (syncedChangeLogIds.isNotEmpty) {
|
|
await localChanges.markChangesSynced(syncedChangeLogIds, clock.now());
|
|
}
|
|
}
|
|
|
|
final metadataBeforePull = await metadataRepository.read();
|
|
final pullResult = await remoteSyncApi.pull(
|
|
since: metadataBeforePull.serverCursor,
|
|
token: token,
|
|
);
|
|
var pulledCount = 0;
|
|
for (final item in pullResult.items) {
|
|
await mappingRepository.save(
|
|
RemoteResourceMapping(
|
|
resourceType: item.resourceType,
|
|
clientId: item.clientId,
|
|
serverId: item.serverId,
|
|
serverUpdatedAt: item.serverUpdatedAt,
|
|
),
|
|
);
|
|
final applied = await localChanges.applyRemoteItem(item);
|
|
if (applied) {
|
|
pulledCount += 1;
|
|
}
|
|
}
|
|
|
|
final completedAt = clock.now();
|
|
await metadataRepository.save(
|
|
(await metadataRepository.read()).copyWith(
|
|
serverCursor: pullResult.serverCursor,
|
|
lastSuccessfulSyncAt: completedAt,
|
|
lastFailureAt: null,
|
|
status: OnlineSyncStatus.success,
|
|
pendingPushCount: 0,
|
|
),
|
|
);
|
|
return SyncRunSummary(
|
|
pushedChanges: pushedCount,
|
|
pulledChanges: pulledCount,
|
|
);
|
|
} catch (error) {
|
|
await metadataRepository.save(
|
|
(await metadataRepository.read()).copyWith(
|
|
lastFailureAt: clock.now(),
|
|
status: OnlineSyncStatus.failure,
|
|
),
|
|
);
|
|
return SyncRunSummary(
|
|
pushedChanges: 0,
|
|
pulledChanges: 0,
|
|
failed: true,
|
|
message: error.toString(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ShareUseCases {
|
|
const ShareUseCases({
|
|
required this.tokenStore,
|
|
required this.remoteShareApi,
|
|
required this.inboxRepository,
|
|
required this.pendingActionRepository,
|
|
required this.localChanges,
|
|
required this.programRepository,
|
|
required this.templateRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final AuthTokenStore tokenStore;
|
|
final RemoteShareApi remoteShareApi;
|
|
final ShareInboxRepository inboxRepository;
|
|
final PendingShareActionRepository pendingActionRepository;
|
|
final LocalSyncChangeRepository localChanges;
|
|
final ProgramRepository programRepository;
|
|
final WorkoutTemplateRepository templateRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<ShareSendResult> sendShare({
|
|
required ShareResourceType resourceType,
|
|
required String localResourceId,
|
|
required List<String> recipientEmails,
|
|
}) async {
|
|
final emails = recipientEmails
|
|
.map((email) => email.trim())
|
|
.where((email) => email.isNotEmpty)
|
|
.toList(growable: false);
|
|
if (emails.isEmpty) {
|
|
throw const DomainException('At least one recipient email is required.');
|
|
}
|
|
final payload = await _sharePayload(resourceType, localResourceId);
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return const ShareSendResult(status: ShareSendStatus.notConnected);
|
|
}
|
|
try {
|
|
final result = await remoteShareApi.sendShare(
|
|
resourceType: resourceType,
|
|
payload: payload,
|
|
recipientEmails: emails,
|
|
token: token,
|
|
);
|
|
return ShareSendResult(
|
|
status: ShareSendStatus.sent,
|
|
shareId: result.shareId,
|
|
recipientUserIds: result.recipientUserIds,
|
|
unresolvedEmails: result.unresolvedEmails,
|
|
);
|
|
} catch (_) {
|
|
final action = _pendingShareAction(
|
|
actionType: PendingShareActionType.send,
|
|
resourceType: resourceType,
|
|
payload: payload,
|
|
recipientEmails: emails,
|
|
);
|
|
await pendingActionRepository.add(action);
|
|
return ShareSendResult(
|
|
status: ShareSendStatus.queued,
|
|
pendingActionId: action.id,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<ShareInboxItem>> refreshInbox() async {
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return inboxRepository.listAll();
|
|
}
|
|
try {
|
|
final items = await remoteShareApi.fetchInbox(token);
|
|
await inboxRepository.upsertAll(items);
|
|
return inboxRepository.listAll();
|
|
} catch (_) {
|
|
return inboxRepository.listAll();
|
|
}
|
|
}
|
|
|
|
Future<void> acceptShare(String shareId) async {
|
|
final cachedItem = await inboxRepository.findByShareId(shareId);
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
await _acceptCachedShareAndQueueAck(shareId, cachedItem);
|
|
return;
|
|
}
|
|
try {
|
|
final createdResource = await remoteShareApi.acceptShare(shareId, token);
|
|
await localChanges.applyRemoteItem(createdResource);
|
|
await inboxRepository.markStatus(
|
|
shareId,
|
|
ShareInboxStatus.accepted,
|
|
clock.now(),
|
|
);
|
|
} catch (_) {
|
|
await _acceptCachedShareAndQueueAck(shareId, cachedItem);
|
|
}
|
|
}
|
|
|
|
Future<void> declineShare(String shareId) async {
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await remoteShareApi.declineShare(shareId, token);
|
|
await inboxRepository.markStatus(
|
|
shareId,
|
|
ShareInboxStatus.declined,
|
|
clock.now(),
|
|
);
|
|
} catch (_) {
|
|
await pendingActionRepository.add(
|
|
_pendingShareAction(
|
|
actionType: PendingShareActionType.decline,
|
|
shareId: shareId,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> revokeShare(String shareId) async {
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await remoteShareApi.revokeShare(shareId, token);
|
|
await inboxRepository.markStatus(
|
|
shareId,
|
|
ShareInboxStatus.revoked,
|
|
clock.now(),
|
|
);
|
|
} catch (_) {
|
|
await pendingActionRepository.add(
|
|
_pendingShareAction(
|
|
actionType: PendingShareActionType.revoke,
|
|
shareId: shareId,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> processPendingShareActions() async {
|
|
final token = await tokenStore.readToken();
|
|
if (token == null) {
|
|
return;
|
|
}
|
|
final actions = await pendingActionRepository.listPending();
|
|
for (final action in actions) {
|
|
final attemptedAt = clock.now();
|
|
try {
|
|
await _processPendingShareAction(action, token);
|
|
await pendingActionRepository.markSucceeded(action.id, attemptedAt);
|
|
} catch (_) {
|
|
await pendingActionRepository.markFailed(action.id, attemptedAt);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Map<String, Object?>> _sharePayload(
|
|
ShareResourceType resourceType,
|
|
String localResourceId,
|
|
) async {
|
|
switch (resourceType) {
|
|
case ShareResourceType.program:
|
|
final program = await programRepository.findById(localResourceId);
|
|
if (program == null) {
|
|
throw const DomainException('Program not found.');
|
|
}
|
|
return _programSharePayload(program);
|
|
case ShareResourceType.workoutTemplate:
|
|
final template = await templateRepository.findById(localResourceId);
|
|
if (template == null) {
|
|
throw const DomainException('Workout template not found.');
|
|
}
|
|
return _workoutTemplateSharePayload(template);
|
|
}
|
|
}
|
|
|
|
PendingShareAction _pendingShareAction({
|
|
required PendingShareActionType actionType,
|
|
String? shareId,
|
|
ShareResourceType? resourceType,
|
|
Map<String, Object?>? payload,
|
|
List<String>? recipientEmails,
|
|
}) {
|
|
return PendingShareAction(
|
|
id: ids.newId(),
|
|
actionType: actionType,
|
|
shareId: shareId,
|
|
resourceType: resourceType,
|
|
payloadJson: payload == null ? null : jsonEncode(payload),
|
|
recipientEmailsJson: recipientEmails == null
|
|
? null
|
|
: jsonEncode(recipientEmails),
|
|
createdAt: clock.now(),
|
|
);
|
|
}
|
|
|
|
Future<void> _processPendingShareAction(
|
|
PendingShareAction action,
|
|
String token,
|
|
) async {
|
|
switch (action.actionType) {
|
|
case PendingShareActionType.send:
|
|
await remoteShareApi.sendShare(
|
|
resourceType: action.resourceType!,
|
|
payload: _jsonObject(action.payloadJson),
|
|
recipientEmails: _jsonStringList(action.recipientEmailsJson),
|
|
token: token,
|
|
);
|
|
return;
|
|
case PendingShareActionType.accept:
|
|
final created = await remoteShareApi.acceptShare(
|
|
action.shareId!,
|
|
token,
|
|
);
|
|
await localChanges.applyRemoteItem(created);
|
|
await inboxRepository.markStatus(
|
|
action.shareId!,
|
|
ShareInboxStatus.accepted,
|
|
clock.now(),
|
|
);
|
|
return;
|
|
case PendingShareActionType.decline:
|
|
await remoteShareApi.declineShare(action.shareId!, token);
|
|
await inboxRepository.markStatus(
|
|
action.shareId!,
|
|
ShareInboxStatus.declined,
|
|
clock.now(),
|
|
);
|
|
return;
|
|
case PendingShareActionType.revoke:
|
|
await remoteShareApi.revokeShare(action.shareId!, token);
|
|
await inboxRepository.markStatus(
|
|
action.shareId!,
|
|
ShareInboxStatus.revoked,
|
|
clock.now(),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<void> _acceptCachedShareAndQueueAck(
|
|
String shareId,
|
|
ShareInboxItem? item,
|
|
) async {
|
|
if (item != null && item.status == ShareInboxStatus.pending) {
|
|
await _importSharedPayload(item);
|
|
await inboxRepository.markStatus(
|
|
shareId,
|
|
ShareInboxStatus.accepted,
|
|
clock.now(),
|
|
);
|
|
}
|
|
await pendingActionRepository.add(
|
|
_pendingShareAction(
|
|
actionType: PendingShareActionType.accept,
|
|
shareId: shareId,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _importSharedPayload(ShareInboxItem item) async {
|
|
final payload = _jsonObject(item.payloadJson);
|
|
final now = clock.now();
|
|
switch (item.resourceType) {
|
|
case ShareResourceType.program:
|
|
await programRepository.save(
|
|
_programCopyFromSharePayload(payload, now),
|
|
);
|
|
return;
|
|
case ShareResourceType.workoutTemplate:
|
|
await templateRepository.save(
|
|
_workoutTemplateCopyFromSharePayload(payload, now),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Program _programCopyFromSharePayload(
|
|
Map<String, Object?> payload,
|
|
DateTime now,
|
|
) {
|
|
final programId = ids.newId();
|
|
final programMetadata = EntityMetadata(
|
|
id: programId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
return Program(
|
|
metadata: programMetadata,
|
|
name: _stringFromSharePayload(payload, 'name', 'Programme partagé'),
|
|
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
|
|
exercises: _programExerciseCopiesFromSharePayload(
|
|
payload['exercises'],
|
|
programId,
|
|
now,
|
|
),
|
|
);
|
|
}
|
|
|
|
List<ProgramExercise> _programExerciseCopiesFromSharePayload(
|
|
Object? value,
|
|
String programId,
|
|
DateTime now,
|
|
) {
|
|
if (value is! List) {
|
|
return const [];
|
|
}
|
|
final exercises = <ProgramExercise>[];
|
|
for (var index = 0; index < value.length; index++) {
|
|
final raw = value[index];
|
|
if (raw is! Map) {
|
|
continue;
|
|
}
|
|
final map = Map<String, Object?>.from(raw);
|
|
exercises.add(
|
|
ProgramExercise(
|
|
metadata: EntityMetadata(
|
|
id: ids.newId(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
programId: programId,
|
|
sourceExerciseId: null,
|
|
position: map['position'] as int? ?? index,
|
|
exerciseNameSnapshot: _stringFromSharePayload(
|
|
map,
|
|
'exerciseNameSnapshot',
|
|
'Exercice partagé',
|
|
),
|
|
exerciseDescriptionSnapshot:
|
|
map['exerciseDescriptionSnapshot'] as String?,
|
|
exerciseImageMediaIdSnapshot:
|
|
map['exerciseImageMediaIdSnapshot'] as String?,
|
|
exerciseImageMediaIdsSnapshot: _stringListFromSharePayload(
|
|
map['exerciseImageMediaIdsSnapshot'] ??
|
|
map['imageMediaIdsSnapshot'],
|
|
),
|
|
exerciseVideoMediaIdSnapshot:
|
|
map['exerciseVideoMediaIdSnapshot'] as String?,
|
|
exerciseStepsSnapshot: _exerciseStepsFromSnapshot(
|
|
map['exerciseStepsSnapshot'],
|
|
),
|
|
exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
|
|
availableTimeSnapshot: map['availableTimeSnapshot'] == true,
|
|
availableRepsSnapshot: map['availableRepsSnapshot'] == true,
|
|
availableScoreSnapshot: map['availableScoreSnapshot'] == true,
|
|
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
|
map['scoreInputModeSnapshot'],
|
|
),
|
|
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
|
|
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
|
|
setsCount: map['setsCount'] as int? ?? 1,
|
|
timeEnabled: map['timeEnabled'] == true,
|
|
repsEnabled: map['repsEnabled'] == true,
|
|
scoreEnabled: map['scoreEnabled'] == true,
|
|
targetTimeSeconds: map['targetTimeSeconds'] as int?,
|
|
targetReps: map['targetReps'] as int?,
|
|
targetScore: (map['targetScore'] as num?)?.toDouble(),
|
|
targetScoreTimeMs: map['targetScoreTimeMs'] as int?,
|
|
restSecondsOverride: map['restSecondsOverride'] as int?,
|
|
),
|
|
);
|
|
}
|
|
return exercises;
|
|
}
|
|
|
|
WorkoutTemplate _workoutTemplateCopyFromSharePayload(
|
|
Map<String, Object?> payload,
|
|
DateTime now,
|
|
) {
|
|
final templateId = ids.newId();
|
|
final programIdBySharedId = <String, String>{};
|
|
final programs = _workoutTemplateProgramCopiesFromSharePayload(
|
|
payload['programs'],
|
|
templateId,
|
|
programIdBySharedId,
|
|
now,
|
|
);
|
|
return WorkoutTemplate(
|
|
metadata: EntityMetadata(
|
|
id: templateId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
name: _stringFromSharePayload(payload, 'name', 'Séance partagée'),
|
|
lastStartedAt: null,
|
|
programs: programs,
|
|
overrides: _workoutTemplateOverrideCopiesFromSharePayload(
|
|
payload['overrides'],
|
|
programIdBySharedId,
|
|
now,
|
|
),
|
|
);
|
|
}
|
|
|
|
List<WorkoutTemplateProgram> _workoutTemplateProgramCopiesFromSharePayload(
|
|
Object? value,
|
|
String templateId,
|
|
Map<String, String> programIdBySharedId,
|
|
DateTime now,
|
|
) {
|
|
if (value is! List) {
|
|
return const [];
|
|
}
|
|
final programs = <WorkoutTemplateProgram>[];
|
|
for (var index = 0; index < value.length; index++) {
|
|
final raw = value[index];
|
|
if (raw is! Map) {
|
|
continue;
|
|
}
|
|
final map = Map<String, Object?>.from(raw);
|
|
final copiedId = ids.newId();
|
|
final sharedId = map['id'] as String?;
|
|
if (sharedId != null) {
|
|
programIdBySharedId[sharedId] = copiedId;
|
|
}
|
|
programs.add(
|
|
WorkoutTemplateProgram(
|
|
metadata: EntityMetadata(
|
|
id: copiedId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
workoutTemplateId: templateId,
|
|
sourceProgramId: null,
|
|
position: map['position'] as int? ?? index,
|
|
programNameSnapshot: _stringFromSharePayload(
|
|
map,
|
|
'programNameSnapshot',
|
|
'Programme partagé',
|
|
),
|
|
defaultRestSecondsSnapshot:
|
|
map['defaultRestSecondsSnapshot'] as int? ?? 0,
|
|
programSnapshotJson:
|
|
map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
|
|
),
|
|
);
|
|
}
|
|
return programs;
|
|
}
|
|
|
|
List<WorkoutTemplateExerciseOverride>
|
|
_workoutTemplateOverrideCopiesFromSharePayload(
|
|
Object? value,
|
|
Map<String, String> programIdBySharedId,
|
|
DateTime now,
|
|
) {
|
|
if (value is! List) {
|
|
return const [];
|
|
}
|
|
final overrides = <WorkoutTemplateExerciseOverride>[];
|
|
for (final raw in value) {
|
|
if (raw is! Map) {
|
|
continue;
|
|
}
|
|
final map = Map<String, Object?>.from(raw);
|
|
final sharedProgramId = map['workoutTemplateProgramId'] as String?;
|
|
final copiedProgramId = programIdBySharedId[sharedProgramId];
|
|
if (copiedProgramId == null) {
|
|
continue;
|
|
}
|
|
final snapshotProgramExerciseId = map['snapshotProgramExerciseId'];
|
|
if (snapshotProgramExerciseId is! String ||
|
|
snapshotProgramExerciseId.trim().isEmpty) {
|
|
continue;
|
|
}
|
|
overrides.add(
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata: EntityMetadata(
|
|
id: ids.newId(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
workoutTemplateProgramId: copiedProgramId,
|
|
snapshotProgramExerciseId: snapshotProgramExerciseId,
|
|
setsCountOverride: map['setsCountOverride'] as int?,
|
|
targetTimeSecondsOverride: map['targetTimeSecondsOverride'] as int?,
|
|
targetRepsOverride: map['targetRepsOverride'] as int?,
|
|
targetScoreOverride: (map['targetScoreOverride'] as num?)?.toDouble(),
|
|
targetScoreTimeMsOverride: map['targetScoreTimeMsOverride'] as int?,
|
|
),
|
|
);
|
|
}
|
|
return overrides;
|
|
}
|
|
}
|
|
|
|
final class ExerciseUseCases {
|
|
const ExerciseUseCases({
|
|
required this.repository,
|
|
required this.programRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ExerciseRepository repository;
|
|
final ProgramRepository programRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<Exercise> create({
|
|
required String name,
|
|
String? description,
|
|
String? imageMediaId,
|
|
List<String> imageMediaIds = const [],
|
|
String? iconMediaId,
|
|
String? videoMediaId,
|
|
required bool hasTimeMeasure,
|
|
required bool hasRepsMeasure,
|
|
required bool hasScoreMeasure,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
int? defaultTargetTimeSeconds,
|
|
int? defaultTargetReps,
|
|
double? defaultTargetScore,
|
|
int? defaultTargetScoreTimeMs,
|
|
List<ExerciseStep> steps = const [],
|
|
}) async {
|
|
_validateExerciseSteps(steps);
|
|
_validateExerciseDefaultTargets(
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
|
|
defaultTargetReps: defaultTargetReps,
|
|
defaultTargetScore: defaultTargetScore,
|
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
|
);
|
|
final now = clock.now();
|
|
final exercise = Exercise(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
name: name,
|
|
description: description,
|
|
imageMediaIds: _resolveExerciseImageIds(imageMediaId, imageMediaIds),
|
|
iconMediaId: iconMediaId,
|
|
videoMediaId: videoMediaId,
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
|
|
defaultTargetReps: defaultTargetReps,
|
|
defaultTargetScore: defaultTargetScore,
|
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
|
steps: steps,
|
|
);
|
|
await repository.save(exercise);
|
|
return exercise;
|
|
}
|
|
|
|
Future<void> save(Exercise exercise) => repository.save(exercise);
|
|
|
|
Future<List<Exercise>> listActive() => repository.listActive();
|
|
|
|
Future<bool> isUsedInPrograms(String id) {
|
|
return repository.isReferencedByProgram(id);
|
|
}
|
|
|
|
Future<Exercise> update({
|
|
required String id,
|
|
required String name,
|
|
String? description,
|
|
String? imageMediaId,
|
|
Object? imageMediaIds = _useCaseUnchanged,
|
|
Object? iconMediaId = _useCaseUnchanged,
|
|
String? videoMediaId,
|
|
required bool hasTimeMeasure,
|
|
required bool hasRepsMeasure,
|
|
required bool hasScoreMeasure,
|
|
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
|
String? scoreLabel,
|
|
String? scoreUnit,
|
|
Object? defaultTargetTimeSeconds = _useCaseUnchanged,
|
|
Object? defaultTargetReps = _useCaseUnchanged,
|
|
Object? defaultTargetScore = _useCaseUnchanged,
|
|
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
|
|
Object? steps = _useCaseUnchanged,
|
|
}) async {
|
|
final exercise = await repository.findById(id);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final resolvedDefaultTargetTimeSeconds =
|
|
defaultTargetTimeSeconds == _useCaseUnchanged
|
|
? exercise.defaultTargetTimeSeconds
|
|
: defaultTargetTimeSeconds as int?;
|
|
final resolvedDefaultTargetReps = defaultTargetReps == _useCaseUnchanged
|
|
? exercise.defaultTargetReps
|
|
: defaultTargetReps as int?;
|
|
final resolvedDefaultTargetScore = defaultTargetScore == _useCaseUnchanged
|
|
? exercise.defaultTargetScore
|
|
: (defaultTargetScore as num?)?.toDouble();
|
|
final resolvedDefaultTargetScoreTimeMs =
|
|
defaultTargetScoreTimeMs == _useCaseUnchanged
|
|
? exercise.defaultTargetScoreTimeMs
|
|
: defaultTargetScoreTimeMs as int?;
|
|
_validateExerciseDefaultTargets(
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
|
|
defaultTargetReps: resolvedDefaultTargetReps,
|
|
defaultTargetScore: resolvedDefaultTargetScore,
|
|
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
|
);
|
|
final resolvedSteps = steps == _useCaseUnchanged
|
|
? exercise.steps
|
|
: steps as List<ExerciseStep>;
|
|
_validateExerciseSteps(resolvedSteps);
|
|
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
|
|
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
|
|
: imageMediaIds as List<String>;
|
|
final resolvedIconMediaId = iconMediaId == _useCaseUnchanged
|
|
? exercise.iconMediaId != null &&
|
|
resolvedImageMediaIds.contains(exercise.iconMediaId)
|
|
? exercise.iconMediaId
|
|
: null
|
|
: iconMediaId as String?;
|
|
final updated = exercise.copyWith(
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
name: name,
|
|
description: description,
|
|
imageMediaIds: resolvedImageMediaIds,
|
|
iconMediaId: resolvedIconMediaId,
|
|
videoMediaId: videoMediaId,
|
|
hasTimeMeasure: hasTimeMeasure,
|
|
hasRepsMeasure: hasRepsMeasure,
|
|
hasScoreMeasure: hasScoreMeasure,
|
|
scoreInputMode: scoreInputMode,
|
|
scoreLabel: scoreLabel,
|
|
scoreUnit: scoreUnit,
|
|
defaultTargetTimeSeconds: resolvedDefaultTargetTimeSeconds,
|
|
defaultTargetReps: resolvedDefaultTargetReps,
|
|
defaultTargetScore: resolvedDefaultTargetScore,
|
|
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
|
steps: resolvedSteps,
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> attachImage({
|
|
required String exerciseId,
|
|
required String mediaAssetId,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> addImage({
|
|
required String exerciseId,
|
|
required String mediaAssetId,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
imageMediaIds: _appendExerciseImage(exercise.imageMediaIds, mediaAssetId),
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> removeImage({
|
|
required String exerciseId,
|
|
required String mediaAssetId,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
imageMediaIds: exercise.imageMediaIds
|
|
.where((imageId) => imageId != mediaAssetId)
|
|
.toList(),
|
|
iconMediaId: exercise.iconMediaId == mediaAssetId
|
|
? null
|
|
: exercise.iconMediaId,
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> reorderImages({
|
|
required String exerciseId,
|
|
required List<String> orderedMediaIds,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
if (!_sameImageSet(exercise.imageMediaIds, orderedMediaIds)) {
|
|
throw const DomainException(
|
|
'Reordered exercise images must match existing images.',
|
|
);
|
|
}
|
|
final updated = exercise.copyWith(
|
|
imageMediaIds: orderedMediaIds,
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> setIcon({
|
|
required String exerciseId,
|
|
required String? mediaAssetId,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
iconMediaId: mediaAssetId,
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> attachVideo({
|
|
required String exerciseId,
|
|
required String mediaAssetId,
|
|
}) async {
|
|
final exercise = await repository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final updated = exercise.copyWith(
|
|
videoMediaId: mediaAssetId,
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
);
|
|
await repository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<Exercise> archive(String id) async {
|
|
final exercise = await repository.findById(id);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final archived = exercise.archive(clock.now());
|
|
await repository.save(archived);
|
|
return archived;
|
|
}
|
|
|
|
Future<Exercise> delete(String id) async {
|
|
final exercise = await repository.findById(id);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final now = clock.now();
|
|
final deleted = exercise.copyWith(
|
|
metadata: exercise.metadata.markDeleted(now),
|
|
);
|
|
await repository.save(deleted);
|
|
return deleted;
|
|
}
|
|
}
|
|
|
|
final class MediaUseCases {
|
|
const MediaUseCases({
|
|
required this.mediaRepository,
|
|
required this.exerciseRepository,
|
|
required this.storage,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final MediaAssetRepository mediaRepository;
|
|
final ExerciseRepository exerciseRepository;
|
|
final LocalMediaStorage storage;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<MediaAsset> importMedia({
|
|
required String sourcePath,
|
|
required MediaKind kind,
|
|
}) async {
|
|
final now = clock.now();
|
|
final id = ids.newId();
|
|
final stored = await storage.importFile(
|
|
sourcePath: sourcePath,
|
|
kind: kind,
|
|
stableFileName: id,
|
|
);
|
|
final asset = MediaAsset(
|
|
metadata: EntityMetadata(
|
|
id: id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
kind: kind,
|
|
localUri: stored.localUri,
|
|
mimeType: stored.mimeType,
|
|
sizeBytes: stored.sizeBytes,
|
|
width: stored.width,
|
|
height: stored.height,
|
|
durationMs: stored.durationMs,
|
|
);
|
|
await mediaRepository.save(asset);
|
|
return asset;
|
|
}
|
|
|
|
Future<MediaAsset?> findById(String id) {
|
|
return mediaRepository.findById(id);
|
|
}
|
|
|
|
Future<Exercise> attachToExercise({
|
|
required String exerciseId,
|
|
required String mediaAssetId,
|
|
}) async {
|
|
final exercise = await exerciseRepository.findById(exerciseId);
|
|
if (exercise == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final media = await mediaRepository.findById(mediaAssetId);
|
|
if (media == null) {
|
|
throw const DomainException('Media asset not found.');
|
|
}
|
|
final updated = switch (media.kind) {
|
|
MediaKind.image => exercise.copyWith(
|
|
imageMediaIds: _appendExerciseImage(
|
|
exercise.imageMediaIds,
|
|
media.metadata.id,
|
|
),
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
),
|
|
MediaKind.video => exercise.copyWith(
|
|
videoMediaId: media.metadata.id,
|
|
metadata: exercise.metadata.touch(clock.now()),
|
|
),
|
|
};
|
|
await exerciseRepository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<int> deleteOrphanedManagedFiles() async {
|
|
final activeAssets = await mediaRepository.listActive();
|
|
final referencedUris = activeAssets.map((asset) => asset.localUri).toSet();
|
|
final managedUris = await storage.listManagedLocalUris();
|
|
final orphanedUris = managedUris.difference(referencedUris);
|
|
for (final uri in orphanedUris) {
|
|
await storage.deleteByLocalUri(uri);
|
|
}
|
|
return orphanedUris.length;
|
|
}
|
|
}
|
|
|
|
final class ProgramUseCases {
|
|
const ProgramUseCases({
|
|
required this.programRepository,
|
|
required this.exerciseRepository,
|
|
required this.templateRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ProgramRepository programRepository;
|
|
final ExerciseRepository exerciseRepository;
|
|
final WorkoutTemplateRepository templateRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<Program?> findById(String id) => programRepository.findById(id);
|
|
|
|
Future<List<Program>> listActive() => programRepository.listActive();
|
|
|
|
Future<bool> isUsedInWorkoutTemplates(String id) async {
|
|
final templates = await templateRepository.listActive();
|
|
return templates.any(
|
|
(template) =>
|
|
template.programs.any((program) => program.sourceProgramId == id),
|
|
);
|
|
}
|
|
|
|
Future<Program> create({
|
|
required String name,
|
|
required int defaultRestSeconds,
|
|
}) async {
|
|
final now = clock.now();
|
|
final program = Program(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
name: name,
|
|
defaultRestSeconds: defaultRestSeconds,
|
|
);
|
|
await programRepository.save(program);
|
|
return program;
|
|
}
|
|
|
|
Future<Program> saveConfigured({
|
|
String? id,
|
|
required String name,
|
|
required int defaultRestSeconds,
|
|
required List<ProgramExerciseConfig> exercises,
|
|
}) async {
|
|
final now = clock.now();
|
|
final existing = id == null ? null : await programRepository.findById(id);
|
|
if (id != null && existing == null) {
|
|
throw const DomainException('Program not found.');
|
|
}
|
|
final programId = id ?? ids.newId();
|
|
final metadata =
|
|
existing?.metadata.touch(now) ??
|
|
EntityMetadata(
|
|
id: programId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
final programExercises = <ProgramExercise>[];
|
|
for (var index = 0; index < exercises.length; index++) {
|
|
final input = exercises[index];
|
|
final exerciseMetadata =
|
|
input.existingMetadata?.touch(now) ??
|
|
_newMetadata(ids, originDeviceId, now);
|
|
programExercises.add(
|
|
ProgramExercise(
|
|
metadata: exerciseMetadata,
|
|
programId: programId,
|
|
sourceExerciseId: input.sourceExerciseId,
|
|
position: index,
|
|
exerciseNameSnapshot: input.exerciseNameSnapshot,
|
|
exerciseDescriptionSnapshot: input.exerciseDescriptionSnapshot,
|
|
exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
|
|
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
|
|
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
|
|
exerciseStepsSnapshot: input.exerciseStepsSnapshot,
|
|
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
|
|
availableTimeSnapshot: input.availableTimeSnapshot,
|
|
availableRepsSnapshot: input.availableRepsSnapshot,
|
|
availableScoreSnapshot: input.availableScoreSnapshot,
|
|
scoreInputModeSnapshot: input.scoreInputModeSnapshot,
|
|
scoreLabelSnapshot: input.scoreLabelSnapshot,
|
|
scoreUnitSnapshot: input.scoreUnitSnapshot,
|
|
setsCount: input.setsCount,
|
|
timeEnabled: input.enabledMeasures.contains(WorkoutMeasure.time),
|
|
repsEnabled: input.enabledMeasures.contains(WorkoutMeasure.reps),
|
|
scoreEnabled: input.enabledMeasures.contains(WorkoutMeasure.score),
|
|
targetTimeSeconds: input.targetTimeSeconds,
|
|
targetReps: input.targetReps,
|
|
targetScore: input.targetScore,
|
|
targetScoreTimeMs: input.targetScoreTimeMs,
|
|
restSecondsOverride: input.restSecondsOverride,
|
|
),
|
|
);
|
|
}
|
|
final program = Program(
|
|
metadata: metadata,
|
|
name: name,
|
|
defaultRestSeconds: defaultRestSeconds,
|
|
exercises: programExercises,
|
|
);
|
|
await programRepository.replaceExercises(program, now);
|
|
return program;
|
|
}
|
|
|
|
Future<ProgramExercise> addExercise({
|
|
required String programId,
|
|
required String exerciseId,
|
|
required int position,
|
|
required int setsCount,
|
|
required Set<WorkoutMeasure> enabledMeasures,
|
|
int? targetTimeSeconds,
|
|
int? targetReps,
|
|
double? targetScore,
|
|
int? targetScoreTimeMs,
|
|
int? restSecondsOverride,
|
|
}) async {
|
|
final source = await exerciseRepository.findById(exerciseId);
|
|
if (source == null) {
|
|
throw const DomainException('Exercise not found.');
|
|
}
|
|
final now = clock.now();
|
|
final programExercise = ProgramExercise.snapshotFromExercise(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
programId: programId,
|
|
exercise: source,
|
|
position: position,
|
|
setsCount: setsCount,
|
|
enabledMeasures: enabledMeasures,
|
|
targetTimeSeconds: targetTimeSeconds,
|
|
targetReps: targetReps,
|
|
targetScore: targetScore,
|
|
targetScoreTimeMs: targetScoreTimeMs,
|
|
restSecondsOverride: restSecondsOverride,
|
|
);
|
|
await programRepository.saveExercise(programExercise);
|
|
return programExercise;
|
|
}
|
|
|
|
Future<Program> delete(String id) async {
|
|
final program = await programRepository.findById(id);
|
|
if (program == null) {
|
|
throw const DomainException('Program not found.');
|
|
}
|
|
final now = clock.now();
|
|
final deleted = program.copyWith(
|
|
metadata: program.metadata.markDeleted(now),
|
|
);
|
|
final templates = await templateRepository.listActive();
|
|
for (final template in templates) {
|
|
final keptPrograms = template.programs
|
|
.where((templateProgram) => templateProgram.sourceProgramId != id)
|
|
.toList();
|
|
if (keptPrograms.length == template.programs.length) {
|
|
continue;
|
|
}
|
|
final repositionedPrograms = _repositionWorkoutTemplatePrograms(
|
|
keptPrograms,
|
|
now,
|
|
);
|
|
final keptProgramIds = repositionedPrograms
|
|
.map((templateProgram) => templateProgram.metadata.id)
|
|
.toSet();
|
|
await templateRepository.replaceComposition(
|
|
WorkoutTemplate(
|
|
metadata: template.metadata.touch(now),
|
|
name: template.name,
|
|
lastStartedAt: template.lastStartedAt,
|
|
programs: repositionedPrograms,
|
|
overrides: template.overrides
|
|
.where(
|
|
(override) =>
|
|
keptProgramIds.contains(override.workoutTemplateProgramId),
|
|
)
|
|
.toList(),
|
|
),
|
|
now,
|
|
);
|
|
}
|
|
await programRepository.save(deleted);
|
|
return deleted;
|
|
}
|
|
}
|
|
|
|
final class ProgramExerciseConfig {
|
|
const ProgramExerciseConfig({
|
|
this.existingMetadata,
|
|
this.sourceExerciseId,
|
|
required this.exerciseNameSnapshot,
|
|
this.exerciseDescriptionSnapshot,
|
|
this.exerciseImageMediaIdSnapshot,
|
|
this.exerciseImageMediaIdsSnapshot = const [],
|
|
this.exerciseVideoMediaIdSnapshot,
|
|
this.exerciseStepsSnapshot = const [],
|
|
this.exerciseArchivedSnapshot = false,
|
|
required this.availableTimeSnapshot,
|
|
required this.availableRepsSnapshot,
|
|
required this.availableScoreSnapshot,
|
|
this.scoreInputModeSnapshot = ScoreInputMode.manual,
|
|
this.scoreLabelSnapshot,
|
|
this.scoreUnitSnapshot,
|
|
required this.setsCount,
|
|
required this.enabledMeasures,
|
|
this.targetTimeSeconds,
|
|
this.targetReps,
|
|
this.targetScore,
|
|
this.targetScoreTimeMs,
|
|
this.restSecondsOverride,
|
|
});
|
|
|
|
final EntityMetadata? existingMetadata;
|
|
final String? sourceExerciseId;
|
|
final String exerciseNameSnapshot;
|
|
final String? exerciseDescriptionSnapshot;
|
|
final String? exerciseImageMediaIdSnapshot;
|
|
final List<String> exerciseImageMediaIdsSnapshot;
|
|
final String? exerciseVideoMediaIdSnapshot;
|
|
final List<ExerciseStep> exerciseStepsSnapshot;
|
|
final bool exerciseArchivedSnapshot;
|
|
final bool availableTimeSnapshot;
|
|
final bool availableRepsSnapshot;
|
|
final bool availableScoreSnapshot;
|
|
final ScoreInputMode scoreInputModeSnapshot;
|
|
final String? scoreLabelSnapshot;
|
|
final String? scoreUnitSnapshot;
|
|
final int setsCount;
|
|
final Set<WorkoutMeasure> enabledMeasures;
|
|
final int? targetTimeSeconds;
|
|
final int? targetReps;
|
|
final double? targetScore;
|
|
final int? targetScoreTimeMs;
|
|
final int? restSecondsOverride;
|
|
}
|
|
|
|
final class WorkoutTemplateUseCases {
|
|
const WorkoutTemplateUseCases({
|
|
required this.templateRepository,
|
|
required this.programRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final WorkoutTemplateRepository templateRepository;
|
|
final ProgramRepository programRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<WorkoutTemplate?> findById(String id) {
|
|
return templateRepository.findById(id);
|
|
}
|
|
|
|
Future<List<WorkoutTemplate>> listActive() {
|
|
return templateRepository.listActive();
|
|
}
|
|
|
|
Future<WorkoutTemplate> create({required String name}) async {
|
|
final now = clock.now();
|
|
final template = WorkoutTemplate(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
name: name,
|
|
);
|
|
await templateRepository.save(template);
|
|
return template;
|
|
}
|
|
|
|
Future<WorkoutTemplate> saveConfigured({
|
|
String? id,
|
|
required String name,
|
|
required List<WorkoutTemplateProgramConfig> programs,
|
|
required List<WorkoutTemplateExerciseOverrideConfig> overrides,
|
|
}) async {
|
|
final now = clock.now();
|
|
final existing = id == null ? null : await templateRepository.findById(id);
|
|
if (id != null && existing == null) {
|
|
throw const DomainException('Workout template not found.');
|
|
}
|
|
final templateId = id ?? ids.newId();
|
|
final metadata =
|
|
existing?.metadata.touch(now) ??
|
|
EntityMetadata(
|
|
id: templateId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
final templatePrograms = <WorkoutTemplateProgram>[];
|
|
final programIdsByClientKey = <String, String>{};
|
|
for (var index = 0; index < programs.length; index++) {
|
|
final input = programs[index];
|
|
final programMetadata =
|
|
input.existingMetadata?.touch(now) ??
|
|
_newMetadata(ids, originDeviceId, now);
|
|
programIdsByClientKey[input.clientKey] = programMetadata.id;
|
|
templatePrograms.add(
|
|
WorkoutTemplateProgram(
|
|
metadata: programMetadata,
|
|
workoutTemplateId: templateId,
|
|
sourceProgramId: input.sourceProgramId,
|
|
position: index,
|
|
programNameSnapshot: input.programNameSnapshot,
|
|
defaultRestSecondsSnapshot: input.defaultRestSecondsSnapshot,
|
|
programSnapshotJson: input.programSnapshotJson,
|
|
),
|
|
);
|
|
}
|
|
final templateOverrides = <WorkoutTemplateExerciseOverride>[];
|
|
for (final input in overrides) {
|
|
final programInput = programs.firstWhere(
|
|
(program) => program.clientKey == input.workoutTemplateProgramClientKey,
|
|
orElse: () =>
|
|
throw const DomainException('Workout template program not found.'),
|
|
);
|
|
final workoutTemplateProgramId =
|
|
programIdsByClientKey[input.workoutTemplateProgramClientKey];
|
|
if (workoutTemplateProgramId == null) {
|
|
throw const DomainException('Workout template program not found.');
|
|
}
|
|
_validateOverrideTargets(programInput.programSnapshotJson, input);
|
|
templateOverrides.add(
|
|
WorkoutTemplateExerciseOverride(
|
|
metadata:
|
|
input.existingMetadata?.touch(now) ??
|
|
_newMetadata(ids, originDeviceId, now),
|
|
workoutTemplateProgramId: workoutTemplateProgramId,
|
|
snapshotProgramExerciseId: input.snapshotProgramExerciseId,
|
|
setsCountOverride: input.setsCountOverride,
|
|
targetTimeSecondsOverride: input.targetTimeSecondsOverride,
|
|
targetRepsOverride: input.targetRepsOverride,
|
|
targetScoreOverride: input.targetScoreOverride,
|
|
targetScoreTimeMsOverride: input.targetScoreTimeMsOverride,
|
|
),
|
|
);
|
|
}
|
|
final template = WorkoutTemplate(
|
|
metadata: metadata,
|
|
name: name,
|
|
lastStartedAt: existing?.lastStartedAt,
|
|
programs: templatePrograms,
|
|
overrides: templateOverrides,
|
|
);
|
|
await templateRepository.replaceComposition(template, now);
|
|
return template;
|
|
}
|
|
|
|
Future<WorkoutTemplateProgram> addProgramSnapshot({
|
|
required String workoutTemplateId,
|
|
required String programId,
|
|
required int position,
|
|
}) async {
|
|
final program = await programRepository.findById(programId);
|
|
if (program == null) {
|
|
throw const DomainException('Program not found.');
|
|
}
|
|
final now = clock.now();
|
|
final snapshot = WorkoutTemplateProgram.snapshotFromProgram(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
workoutTemplateId: workoutTemplateId,
|
|
program: program,
|
|
position: position,
|
|
);
|
|
await templateRepository.saveProgram(snapshot);
|
|
return snapshot;
|
|
}
|
|
|
|
Future<WorkoutTemplateExerciseOverride> overrideExerciseTargets({
|
|
required String workoutTemplateProgramId,
|
|
required String snapshotProgramExerciseId,
|
|
int? setsCountOverride,
|
|
int? targetTimeSecondsOverride,
|
|
int? targetRepsOverride,
|
|
double? targetScoreOverride,
|
|
int? targetScoreTimeMsOverride,
|
|
Set<WorkoutMeasure>? attemptedMeasureOverride,
|
|
}) async {
|
|
if (attemptedMeasureOverride != null) {
|
|
throw const DomainException(
|
|
'Workout templates cannot override active measures.',
|
|
);
|
|
}
|
|
final now = clock.now();
|
|
final override = WorkoutTemplateExerciseOverride(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
workoutTemplateProgramId: workoutTemplateProgramId,
|
|
snapshotProgramExerciseId: snapshotProgramExerciseId,
|
|
setsCountOverride: setsCountOverride,
|
|
targetTimeSecondsOverride: targetTimeSecondsOverride,
|
|
targetRepsOverride: targetRepsOverride,
|
|
targetScoreOverride: targetScoreOverride,
|
|
targetScoreTimeMsOverride: targetScoreTimeMsOverride,
|
|
);
|
|
await templateRepository.saveOverride(override);
|
|
return override;
|
|
}
|
|
|
|
Future<WorkoutTemplate> delete(String id) async {
|
|
final template = await templateRepository.findById(id);
|
|
if (template == null) {
|
|
throw const DomainException('Workout template not found.');
|
|
}
|
|
final now = clock.now();
|
|
final deleted = WorkoutTemplate(
|
|
metadata: template.metadata.markDeleted(now),
|
|
name: template.name,
|
|
lastStartedAt: template.lastStartedAt,
|
|
);
|
|
await templateRepository.save(deleted);
|
|
return deleted;
|
|
}
|
|
}
|
|
|
|
final class WorkoutTemplateProgramConfig {
|
|
const WorkoutTemplateProgramConfig({
|
|
required this.clientKey,
|
|
this.existingMetadata,
|
|
this.sourceProgramId,
|
|
required this.programNameSnapshot,
|
|
required this.defaultRestSecondsSnapshot,
|
|
required this.programSnapshotJson,
|
|
});
|
|
|
|
final String clientKey;
|
|
final EntityMetadata? existingMetadata;
|
|
final String? sourceProgramId;
|
|
final String programNameSnapshot;
|
|
final int defaultRestSecondsSnapshot;
|
|
final String programSnapshotJson;
|
|
}
|
|
|
|
final class WorkoutTemplateExerciseOverrideConfig {
|
|
const WorkoutTemplateExerciseOverrideConfig({
|
|
this.existingMetadata,
|
|
required this.workoutTemplateProgramClientKey,
|
|
required this.snapshotProgramExerciseId,
|
|
this.setsCountOverride,
|
|
this.targetTimeSecondsOverride,
|
|
this.targetRepsOverride,
|
|
this.targetScoreOverride,
|
|
this.targetScoreTimeMsOverride,
|
|
});
|
|
|
|
final EntityMetadata? existingMetadata;
|
|
final String workoutTemplateProgramClientKey;
|
|
final String snapshotProgramExerciseId;
|
|
final int? setsCountOverride;
|
|
final int? targetTimeSecondsOverride;
|
|
final int? targetRepsOverride;
|
|
final double? targetScoreOverride;
|
|
final int? targetScoreTimeMsOverride;
|
|
}
|
|
|
|
final class ActiveWorkoutSessionUseCases {
|
|
const ActiveWorkoutSessionUseCases({
|
|
required this.sessionRepository,
|
|
required this.templateRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ActiveSessionRepository sessionRepository;
|
|
final WorkoutTemplateRepository templateRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<ActiveWorkoutSession?> findOpen() => sessionRepository.findOpen();
|
|
|
|
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
|
|
final template = await templateRepository.findById(templateId);
|
|
if (template == null) {
|
|
throw const DomainException('Workout template not found.');
|
|
}
|
|
final now = clock.now();
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
sourceWorkoutTemplateId: templateId,
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: jsonEncode({
|
|
'templateId': template.metadata.id,
|
|
'name': template.name,
|
|
'programs': template.programs
|
|
.map(
|
|
(program) => {
|
|
'id': program.metadata.id,
|
|
'programNameSnapshot': program.programNameSnapshot,
|
|
'programSnapshotJson': program.programSnapshotJson,
|
|
},
|
|
)
|
|
.toList(),
|
|
'overrides': template.overrides
|
|
.map(
|
|
(override) => {
|
|
'workoutTemplateProgramId': override.workoutTemplateProgramId,
|
|
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
|
|
'setsCountOverride': override.setsCountOverride,
|
|
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
|
|
'targetRepsOverride': override.targetRepsOverride,
|
|
'targetScoreOverride': override.targetScoreOverride,
|
|
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
|
|
},
|
|
)
|
|
.toList(),
|
|
}),
|
|
);
|
|
await sessionRepository.save(session);
|
|
return session;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> startFromHistory(WorkoutHistory history) async {
|
|
final snapshot =
|
|
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
|
|
final resolvedTemplateSnapshotJson =
|
|
snapshot['resolvedTemplateSnapshotJson'] as String;
|
|
final now = clock.now();
|
|
final session = ActiveWorkoutSession(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
sourceWorkoutTemplateId: null,
|
|
status: ActiveWorkoutStatus.running,
|
|
startedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: 0,
|
|
currentProgramIndex: 0,
|
|
currentExerciseIndex: 0,
|
|
currentSetIndex: 0,
|
|
resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson,
|
|
);
|
|
await sessionRepository.save(session);
|
|
return session;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> pause(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final now = clock.now();
|
|
final runningStopwatches =
|
|
(await sessionRepository.listScoreStopwatchStates(
|
|
sessionId,
|
|
)).where((state) => state.status == ActiveScoreStopwatchStatus.running);
|
|
for (final state in runningStopwatches) {
|
|
final stopped = state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveScoreStopwatchStatus.stopped,
|
|
accumulatedMs: state.elapsedMillisecondsAt(now),
|
|
stoppedAt: now,
|
|
);
|
|
await sessionRepository.saveScoreStopwatchState(stopped);
|
|
}
|
|
final runningStepTimers =
|
|
(await sessionRepository.listExerciseStepProgressStates(
|
|
sessionId,
|
|
)).where(
|
|
(state) =>
|
|
state.status == ActiveExerciseStepProgressStatus.runningTimer,
|
|
);
|
|
for (final state in runningStepTimers) {
|
|
final pausedStep = state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveExerciseStepProgressStatus.pausedTimer,
|
|
accumulatedMs: state.elapsedMillisecondsAt(now),
|
|
startedAt: null,
|
|
lastTransitionAt: now,
|
|
);
|
|
await sessionRepository.saveExerciseStepProgressState(pausedStep);
|
|
}
|
|
final paused = session.pause(now);
|
|
await sessionRepository.save(paused);
|
|
return paused;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> resume(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final resumed = session.resume(clock.now());
|
|
await sessionRepository.save(resumed);
|
|
return resumed;
|
|
}
|
|
|
|
int elapsedActiveMilliseconds(ActiveWorkoutSession session) {
|
|
return session.elapsedActiveMillisecondsAt(clock.now());
|
|
}
|
|
|
|
Future<ActiveSetResult> recordSetResult(ActiveSetResult result) async {
|
|
await sessionRepository.saveSetResult(result);
|
|
return result;
|
|
}
|
|
|
|
Future<ActiveSetResult> recordCurrentSetResult({
|
|
required String sessionId,
|
|
required String programSnapshotId,
|
|
required String exerciseSnapshotId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
int? actualTimeMs,
|
|
int? actualReps,
|
|
double? actualScore,
|
|
int? actualScoreTimeMs,
|
|
ScoreInputMode scoreInputModeSnapshot = ScoreInputMode.manual,
|
|
String? scoreLabelSnapshot,
|
|
String? scoreUnitSnapshot,
|
|
}) async {
|
|
final now = clock.now();
|
|
final resolvedActualScoreTimeMs = await _resolveStopwatchScoreTimeMs(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
scoreInputMode: scoreInputModeSnapshot,
|
|
explicitActualScoreTimeMs: actualScoreTimeMs,
|
|
now: now,
|
|
);
|
|
final result = ActiveSetResult(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
activeWorkoutSessionId: sessionId,
|
|
programSnapshotId: programSnapshotId,
|
|
exerciseSnapshotId: exerciseSnapshotId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
completedAt: now,
|
|
actualTimeMs: actualTimeMs,
|
|
actualReps: actualReps,
|
|
actualScore: actualScore,
|
|
actualScoreTimeMs: resolvedActualScoreTimeMs,
|
|
scoreInputModeSnapshot: scoreInputModeSnapshot,
|
|
scoreLabelSnapshot: scoreLabelSnapshot,
|
|
scoreUnitSnapshot: scoreUnitSnapshot,
|
|
status: SetResultStatus.completed,
|
|
);
|
|
await sessionRepository.saveSetResult(result);
|
|
return result;
|
|
}
|
|
|
|
Future<ActiveSetResult> upsertSetResultAtPosition({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required SetResultStatus status,
|
|
int? actualTimeMs,
|
|
int? actualReps,
|
|
double? actualScore,
|
|
int? actualScoreTimeMs,
|
|
ScoreInputMode? scoreInputModeSnapshot,
|
|
String? scoreLabelSnapshot,
|
|
String? scoreUnitSnapshot,
|
|
String? note,
|
|
}) async {
|
|
final session = await _requiredSession(sessionId);
|
|
_ensureEditablePastPosition(
|
|
session: session,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final snapshot = _findSetSnapshot(
|
|
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (snapshot == null) {
|
|
throw const DomainException(
|
|
'Set position not found in session snapshot.',
|
|
);
|
|
}
|
|
final existingResults = await sessionRepository.listSetResults(sessionId);
|
|
ActiveSetResult? existing;
|
|
for (final result in existingResults) {
|
|
if (result.programIndex == programIndex &&
|
|
result.exerciseIndex == exerciseIndex &&
|
|
result.setIndex == setIndex) {
|
|
existing = result;
|
|
break;
|
|
}
|
|
}
|
|
final now = clock.now();
|
|
final isSkipped = status == SetResultStatus.skipped;
|
|
final resolvedScoreInputMode =
|
|
scoreInputModeSnapshot ?? snapshot.scoreInputModeSnapshot;
|
|
final upserted = ActiveSetResult(
|
|
metadata: existing == null
|
|
? _newMetadata(ids, originDeviceId, now)
|
|
: existing.metadata.touch(now),
|
|
activeWorkoutSessionId: sessionId,
|
|
programSnapshotId: snapshot.programSnapshotId,
|
|
exerciseSnapshotId: snapshot.exerciseSnapshotId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
startedAt: existing?.startedAt,
|
|
completedAt: isSkipped ? null : now,
|
|
actualTimeMs: isSkipped ? null : actualTimeMs,
|
|
actualReps: isSkipped ? null : actualReps,
|
|
actualScore: isSkipped ? null : actualScore,
|
|
actualScoreTimeMs: isSkipped ? null : actualScoreTimeMs,
|
|
scoreInputModeSnapshot: resolvedScoreInputMode,
|
|
scoreLabelSnapshot: isSkipped
|
|
? null
|
|
: scoreLabelSnapshot ?? snapshot.scoreLabelSnapshot,
|
|
scoreUnitSnapshot: isSkipped
|
|
? null
|
|
: scoreUnitSnapshot ?? snapshot.scoreUnitSnapshot,
|
|
note: note,
|
|
status: status,
|
|
);
|
|
await sessionRepository.saveSetResult(upserted);
|
|
return upserted;
|
|
}
|
|
|
|
Future<ActiveScoreStopwatchState> startScoreStopwatch({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
await _requiredSession(sessionId);
|
|
final now = clock.now();
|
|
final existing = await sessionRepository.findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (existing != null) {
|
|
if (existing.status == ActiveScoreStopwatchStatus.running) {
|
|
return existing;
|
|
}
|
|
final resumed = existing.copyWith(
|
|
metadata: existing.metadata.touch(now),
|
|
status: ActiveScoreStopwatchStatus.running,
|
|
startedAt: now,
|
|
stoppedAt: null,
|
|
);
|
|
await sessionRepository.saveScoreStopwatchState(resumed);
|
|
return resumed;
|
|
}
|
|
final state = ActiveScoreStopwatchState(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
activeWorkoutSessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
status: ActiveScoreStopwatchStatus.running,
|
|
startedAt: now,
|
|
accumulatedMs: 0,
|
|
);
|
|
await sessionRepository.saveScoreStopwatchState(state);
|
|
return state;
|
|
}
|
|
|
|
Future<ActiveScoreStopwatchState> stopScoreStopwatch({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final state = await _requiredStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state.status == ActiveScoreStopwatchStatus.stopped) {
|
|
return state;
|
|
}
|
|
final now = clock.now();
|
|
final stopped = state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveScoreStopwatchStatus.stopped,
|
|
accumulatedMs: state.elapsedMillisecondsAt(now),
|
|
stoppedAt: now,
|
|
);
|
|
await sessionRepository.saveScoreStopwatchState(stopped);
|
|
return stopped;
|
|
}
|
|
|
|
Future<ActiveScoreStopwatchState> resumeScoreStopwatch({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) {
|
|
return startScoreStopwatch(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
}
|
|
|
|
Future<void> resetScoreStopwatch({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
await sessionRepository.deleteScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
deletedAt: clock.now(),
|
|
);
|
|
}
|
|
|
|
Future<ActiveScoreStopwatchState?> findScoreStopwatch({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) {
|
|
return sessionRepository.findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
}
|
|
|
|
int scoreStopwatchElapsedMilliseconds(ActiveScoreStopwatchState state) {
|
|
return state.elapsedMillisecondsAt(clock.now());
|
|
}
|
|
|
|
Future<List<SetResultPositionState>> listSetResults(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final snapshots = _listSetSnapshots(session.resolvedTemplateSnapshotJson);
|
|
final results = await sessionRepository.listSetResults(sessionId);
|
|
final resultsByPosition = {
|
|
for (final result in results)
|
|
_positionKey(
|
|
result.programIndex,
|
|
result.exerciseIndex,
|
|
result.setIndex,
|
|
): result,
|
|
};
|
|
return snapshots.map((snapshot) {
|
|
final result =
|
|
resultsByPosition[_positionKey(
|
|
snapshot.programIndex,
|
|
snapshot.exerciseIndex,
|
|
snapshot.setIndex,
|
|
)];
|
|
return SetResultPositionState(
|
|
programIndex: snapshot.programIndex,
|
|
exerciseIndex: snapshot.exerciseIndex,
|
|
setIndex: snapshot.setIndex,
|
|
status: result == null
|
|
? SetPositionStatus.pending
|
|
: switch (result.status) {
|
|
SetResultStatus.completed => SetPositionStatus.completed,
|
|
SetResultStatus.skipped => SetPositionStatus.skipped,
|
|
},
|
|
result: result,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> updateProgress({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final updated = session.copyWith(
|
|
currentProgramIndex: programIndex,
|
|
currentExerciseIndex: exerciseIndex,
|
|
currentSetIndex: setIndex,
|
|
metadata: session.metadata.touch(clock.now()),
|
|
);
|
|
await sessionRepository.save(updated);
|
|
return updated;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> quitAndSave(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final now = clock.now();
|
|
final saved = session.copyWith(
|
|
status: ActiveWorkoutStatus.savedExit,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
|
metadata: session.metadata.touch(now),
|
|
);
|
|
await sessionRepository.save(saved);
|
|
return saved;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> abandon(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final now = clock.now();
|
|
final abandoned = session.copyWith(
|
|
status: ActiveWorkoutStatus.abandoned,
|
|
endedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
|
metadata: session.metadata.touch(now),
|
|
);
|
|
await sessionRepository.save(abandoned);
|
|
return abandoned;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> complete(String sessionId) async {
|
|
final session = await _requiredSession(sessionId);
|
|
final now = clock.now();
|
|
final completed = session.copyWith(
|
|
status: ActiveWorkoutStatus.completed,
|
|
endedAt: now,
|
|
lastPersistedAt: now,
|
|
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
|
metadata: session.metadata.touch(now),
|
|
);
|
|
await sessionRepository.save(completed);
|
|
return completed;
|
|
}
|
|
|
|
Future<ActiveRestState> startRestAfterSet({
|
|
required String sessionId,
|
|
required int afterProgramIndex,
|
|
required int afterExerciseIndex,
|
|
required int afterSetIndex,
|
|
required int plannedRestSeconds,
|
|
int? adjustedRestSeconds,
|
|
}) async {
|
|
final now = clock.now();
|
|
final rest = ActiveRestState(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
activeWorkoutSessionId: sessionId,
|
|
afterProgramIndex: afterProgramIndex,
|
|
afterExerciseIndex: afterExerciseIndex,
|
|
afterSetIndex: afterSetIndex,
|
|
plannedRestSeconds: plannedRestSeconds,
|
|
adjustedRestSeconds: adjustedRestSeconds ?? plannedRestSeconds,
|
|
startedAt: now,
|
|
);
|
|
await sessionRepository.saveRestState(rest);
|
|
return rest;
|
|
}
|
|
|
|
Future<ActiveRestState> adjustRestSeconds({
|
|
required String restStateId,
|
|
required int deltaSeconds,
|
|
}) async {
|
|
final rest = await sessionRepository.findRestStateById(restStateId);
|
|
if (rest == null) {
|
|
throw const DomainException('Active rest state not found.');
|
|
}
|
|
final now = clock.now();
|
|
final adjusted = rest.copyWith(
|
|
metadata: rest.metadata.touch(now),
|
|
adjustedRestSeconds: (rest.adjustedRestSeconds + deltaSeconds).clamp(
|
|
0,
|
|
1 << 31,
|
|
),
|
|
);
|
|
await sessionRepository.saveRestState(adjusted);
|
|
return adjusted;
|
|
}
|
|
|
|
Future<ActiveRestState> skipRest({required String restStateId}) async {
|
|
final rest = await sessionRepository.findRestStateById(restStateId);
|
|
if (rest == null) {
|
|
throw const DomainException('Active rest state not found.');
|
|
}
|
|
final now = clock.now();
|
|
final skipped = rest.copyWith(
|
|
metadata: rest.metadata.touch(now),
|
|
skippedAt: now,
|
|
);
|
|
await sessionRepository.saveRestState(skipped);
|
|
return skipped;
|
|
}
|
|
|
|
Future<ActiveRestState?> findActiveRest({required String sessionId}) async {
|
|
final restStates = await sessionRepository.listRestStates(sessionId);
|
|
final active =
|
|
restStates
|
|
.where((rest) => rest.endedAt == null && rest.skippedAt == null)
|
|
.toList()
|
|
..sort((left, right) => right.startedAt.compareTo(left.startedAt));
|
|
return active.isEmpty ? null : active.first;
|
|
}
|
|
|
|
Future<ActiveWorkoutSession> _requiredSession(String sessionId) async {
|
|
final session = await sessionRepository.findById(sessionId);
|
|
if (session == null) {
|
|
throw const DomainException('Active workout session not found.');
|
|
}
|
|
return session;
|
|
}
|
|
|
|
Future<ActiveScoreStopwatchState> _requiredStopwatchState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final state = await sessionRepository.findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
throw const DomainException('Active score stopwatch state not found.');
|
|
}
|
|
return state;
|
|
}
|
|
|
|
Future<int?> _resolveStopwatchScoreTimeMs({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
required ScoreInputMode scoreInputMode,
|
|
required int? explicitActualScoreTimeMs,
|
|
required DateTime now,
|
|
}) async {
|
|
if (scoreInputMode != ScoreInputMode.stopwatch) {
|
|
return explicitActualScoreTimeMs;
|
|
}
|
|
final state = await sessionRepository.findScoreStopwatchState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
return explicitActualScoreTimeMs;
|
|
}
|
|
if (state.status == ActiveScoreStopwatchStatus.stopped) {
|
|
return explicitActualScoreTimeMs ?? state.accumulatedMs;
|
|
}
|
|
final stopped = state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveScoreStopwatchStatus.stopped,
|
|
accumulatedMs: state.elapsedMillisecondsAt(now),
|
|
stoppedAt: now,
|
|
);
|
|
await sessionRepository.saveScoreStopwatchState(stopped);
|
|
return explicitActualScoreTimeMs ?? stopped.accumulatedMs;
|
|
}
|
|
}
|
|
|
|
final class ActiveExerciseStepProgressView {
|
|
const ActiveExerciseStepProgressView({
|
|
required this.state,
|
|
required this.steps,
|
|
required this.expectedPassages,
|
|
required this.results,
|
|
});
|
|
|
|
final ActiveExerciseStepProgressState state;
|
|
final List<ExerciseStep> steps;
|
|
final int expectedPassages;
|
|
final List<ActiveExerciseStepResult> results;
|
|
|
|
ExerciseStep? get currentStep =>
|
|
state.status == ActiveExerciseStepProgressStatus.sequenceComplete
|
|
? null
|
|
: steps[state.currentStepIndex];
|
|
}
|
|
|
|
final class ActiveExerciseStepUseCases {
|
|
const ActiveExerciseStepUseCases({
|
|
required this.sessionRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ActiveSessionRepository sessionRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<ActiveExerciseStepProgressView> startOrResumeProgress({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final context = await _stepContext(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final now = clock.now();
|
|
final existing = await sessionRepository.findExerciseStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final state =
|
|
existing ??
|
|
_initialStepProgressState(
|
|
sessionId: sessionId,
|
|
context: context,
|
|
now: now,
|
|
);
|
|
if (existing == null) {
|
|
await sessionRepository.saveExerciseStepProgressState(state);
|
|
}
|
|
final advanced = await _autoAdvanceElapsedTimers(context, state, now);
|
|
return ActiveExerciseStepProgressView(
|
|
state: advanced,
|
|
steps: context.steps,
|
|
expectedPassages: context.expectedPassages,
|
|
results: await _stepResultsForPosition(context),
|
|
);
|
|
}
|
|
|
|
Future<ActiveExerciseStepProgressView> readProgress({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) => startOrResumeProgress(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
|
|
Future<ActiveExerciseStepProgressState> startTimer({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final view = await startOrResumeProgress(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final step = view.currentStep;
|
|
if (step == null || step.type != ExerciseStepType.time) {
|
|
throw const DomainException('Current exercise step is not timed.');
|
|
}
|
|
if (view.state.status == ActiveExerciseStepProgressStatus.runningTimer) {
|
|
return view.state;
|
|
}
|
|
final now = clock.now();
|
|
final running = view.state.copyWith(
|
|
metadata: view.state.metadata.touch(now),
|
|
status: ActiveExerciseStepProgressStatus.runningTimer,
|
|
startedAt: now,
|
|
lastTransitionAt: now,
|
|
);
|
|
await sessionRepository.saveExerciseStepProgressState(running);
|
|
return running;
|
|
}
|
|
|
|
Future<ActiveExerciseStepProgressState> pauseTimer({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final state = await _requiredStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state.status != ActiveExerciseStepProgressStatus.runningTimer) {
|
|
throw const DomainException('Exercise step timer is not running.');
|
|
}
|
|
final now = clock.now();
|
|
final paused = state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveExerciseStepProgressStatus.pausedTimer,
|
|
startedAt: null,
|
|
accumulatedMs: state.elapsedMillisecondsAt(now),
|
|
lastTransitionAt: now,
|
|
);
|
|
await sessionRepository.saveExerciseStepProgressState(paused);
|
|
return paused;
|
|
}
|
|
|
|
Future<ActiveExerciseStepProgressState> resumeTimer({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) {
|
|
return startTimer(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
}
|
|
|
|
int elapsedMilliseconds(ActiveExerciseStepProgressState state) {
|
|
return state.elapsedMillisecondsAt(clock.now());
|
|
}
|
|
|
|
int remainingMilliseconds({
|
|
required ActiveExerciseStepProgressState state,
|
|
required ExerciseStep step,
|
|
}) {
|
|
final targetMs = step.defaultTargetValue * 1000;
|
|
final remaining = targetMs - elapsedMilliseconds(state);
|
|
return remaining < 0 ? 0 : remaining;
|
|
}
|
|
|
|
Future<ActiveExerciseStepResult> completeCurrentStep({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
int? actualReps,
|
|
double? actualScore,
|
|
int? actualScoreTimeMs,
|
|
String? note,
|
|
}) async {
|
|
final context = await _stepContext(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final now = clock.now();
|
|
final state = await _autoAdvanceElapsedTimers(
|
|
context,
|
|
await _requiredStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
),
|
|
now,
|
|
);
|
|
final step = _currentStep(context, state);
|
|
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
|
|
throw const DomainException('Exercise step sequence is complete.');
|
|
}
|
|
final completed = _stepResult(
|
|
context: context,
|
|
state: state,
|
|
step: step,
|
|
status: SetResultStatus.completed,
|
|
now: now,
|
|
actualTimeMs: step.type == ExerciseStepType.time
|
|
? state.elapsedMillisecondsAt(now)
|
|
: null,
|
|
actualReps: step.type == ExerciseStepType.reps
|
|
? actualReps ?? step.defaultTargetValue
|
|
: null,
|
|
actualScore: actualScore,
|
|
actualScoreTimeMs: actualScoreTimeMs,
|
|
note: note,
|
|
);
|
|
await sessionRepository.saveExerciseStepResult(completed);
|
|
await sessionRepository.saveExerciseStepProgressState(
|
|
_advanceState(context, state, now),
|
|
);
|
|
return completed;
|
|
}
|
|
|
|
Future<ActiveExerciseStepResult> skipCurrentStep({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final context = await _stepContext(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final state = await _requiredStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final now = clock.now();
|
|
final step = _currentStep(context, state);
|
|
final skipped = _stepResult(
|
|
context: context,
|
|
state: state,
|
|
step: step,
|
|
status: SetResultStatus.skipped,
|
|
now: now,
|
|
);
|
|
await sessionRepository.saveExerciseStepResult(skipped);
|
|
await sessionRepository.saveExerciseStepProgressState(
|
|
_advanceState(context, state, now),
|
|
);
|
|
return skipped;
|
|
}
|
|
|
|
Future<List<ActiveExerciseStepResult>> skipCurrentPassage({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final context = await _stepContext(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
var state = await _requiredStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final now = clock.now();
|
|
final skipped = <ActiveExerciseStepResult>[];
|
|
final passage = state.currentPassageIndex;
|
|
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete &&
|
|
state.currentPassageIndex == passage) {
|
|
final step = _currentStep(context, state);
|
|
final result = _stepResult(
|
|
context: context,
|
|
state: state,
|
|
step: step,
|
|
status: SetResultStatus.skipped,
|
|
now: now,
|
|
);
|
|
skipped.add(result);
|
|
await sessionRepository.saveExerciseStepResult(result);
|
|
state = _advanceState(context, state, now);
|
|
}
|
|
await sessionRepository.saveExerciseStepProgressState(state);
|
|
return skipped;
|
|
}
|
|
|
|
Future<List<ActiveExerciseStepResult>> skipSequence({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final context = await _stepContext(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
var state = await _requiredStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
final now = clock.now();
|
|
final skipped = <ActiveExerciseStepResult>[];
|
|
while (state.status != ActiveExerciseStepProgressStatus.sequenceComplete) {
|
|
final step = _currentStep(context, state);
|
|
final result = _stepResult(
|
|
context: context,
|
|
state: state,
|
|
step: step,
|
|
status: SetResultStatus.skipped,
|
|
now: now,
|
|
);
|
|
skipped.add(result);
|
|
await sessionRepository.saveExerciseStepResult(result);
|
|
state = _advanceState(context, state, now);
|
|
}
|
|
await sessionRepository.saveExerciseStepProgressState(state);
|
|
return skipped;
|
|
}
|
|
|
|
Future<ActiveExerciseStepProgressState> _requiredStepProgressState({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final state = await sessionRepository.findExerciseStepProgressState(
|
|
sessionId: sessionId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
);
|
|
if (state == null) {
|
|
throw const DomainException('Exercise step progress state not found.');
|
|
}
|
|
return state;
|
|
}
|
|
|
|
Future<_StepSequenceContext> _stepContext({
|
|
required String sessionId,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) async {
|
|
final session = await sessionRepository.findById(sessionId);
|
|
if (session == null) {
|
|
throw const DomainException('Active workout session not found.');
|
|
}
|
|
final snapshot = _findExerciseSnapshot(
|
|
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
);
|
|
if (snapshot == null || snapshot.steps.isEmpty) {
|
|
throw const DomainException('Exercise step sequence not found.');
|
|
}
|
|
return _StepSequenceContext(
|
|
sessionId: sessionId,
|
|
programSnapshotId: snapshot.programSnapshotId,
|
|
exerciseSnapshotId: snapshot.exerciseSnapshotId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
steps: snapshot.steps,
|
|
expectedPassages: snapshot.repsEnabled
|
|
? (snapshot.targetReps ?? 1).clamp(1, 1 << 31)
|
|
: 1,
|
|
);
|
|
}
|
|
|
|
ActiveExerciseStepProgressState _initialStepProgressState({
|
|
required String sessionId,
|
|
required _StepSequenceContext context,
|
|
required DateTime now,
|
|
}) {
|
|
final firstStep = context.steps.first;
|
|
return ActiveExerciseStepProgressState(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
activeWorkoutSessionId: sessionId,
|
|
programIndex: context.programIndex,
|
|
exerciseIndex: context.exerciseIndex,
|
|
setIndex: context.setIndex,
|
|
currentPassageIndex: 0,
|
|
currentStepIndex: 0,
|
|
currentStepSnapshotId: firstStep.id,
|
|
status: firstStep.type == ExerciseStepType.time
|
|
? ActiveExerciseStepProgressStatus.stoppedTimer
|
|
: ActiveExerciseStepProgressStatus.waitingManual,
|
|
accumulatedMs: 0,
|
|
lastTransitionAt: now,
|
|
);
|
|
}
|
|
|
|
Future<ActiveExerciseStepProgressState> _autoAdvanceElapsedTimers(
|
|
_StepSequenceContext context,
|
|
ActiveExerciseStepProgressState state,
|
|
DateTime now,
|
|
) async {
|
|
var current = state;
|
|
while (current.status == ActiveExerciseStepProgressStatus.runningTimer) {
|
|
final step = _currentStep(context, current);
|
|
if (step.type != ExerciseStepType.time) {
|
|
break;
|
|
}
|
|
final targetMs = step.defaultTargetValue * 1000;
|
|
final elapsed = current.elapsedMillisecondsAt(now);
|
|
if (elapsed < targetMs) {
|
|
break;
|
|
}
|
|
final result = _stepResult(
|
|
context: context,
|
|
state: current,
|
|
step: step,
|
|
status: SetResultStatus.completed,
|
|
now: now,
|
|
actualTimeMs: targetMs,
|
|
);
|
|
await sessionRepository.saveExerciseStepResult(result);
|
|
final overflowMs = elapsed - targetMs;
|
|
current = _advanceState(
|
|
context,
|
|
current,
|
|
now,
|
|
runningTimerOverflowMs: overflowMs,
|
|
);
|
|
await sessionRepository.saveExerciseStepProgressState(current);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
ActiveExerciseStepProgressState _advanceState(
|
|
_StepSequenceContext context,
|
|
ActiveExerciseStepProgressState state,
|
|
DateTime now, {
|
|
int runningTimerOverflowMs = 0,
|
|
}) {
|
|
var nextPassage = state.currentPassageIndex;
|
|
var nextStep = state.currentStepIndex + 1;
|
|
if (nextStep >= context.steps.length) {
|
|
nextStep = 0;
|
|
nextPassage++;
|
|
}
|
|
if (nextPassage >= context.expectedPassages) {
|
|
return state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
status: ActiveExerciseStepProgressStatus.sequenceComplete,
|
|
startedAt: null,
|
|
accumulatedMs: 0,
|
|
lastTransitionAt: now,
|
|
);
|
|
}
|
|
final step = context.steps[nextStep];
|
|
final status = step.type == ExerciseStepType.time
|
|
? runningTimerOverflowMs > 0
|
|
? ActiveExerciseStepProgressStatus.runningTimer
|
|
: ActiveExerciseStepProgressStatus.stoppedTimer
|
|
: ActiveExerciseStepProgressStatus.waitingManual;
|
|
return state.copyWith(
|
|
metadata: state.metadata.touch(now),
|
|
currentPassageIndex: nextPassage,
|
|
currentStepIndex: nextStep,
|
|
currentStepSnapshotId: step.id,
|
|
status: status,
|
|
startedAt: status == ActiveExerciseStepProgressStatus.runningTimer
|
|
? now.subtract(Duration(milliseconds: runningTimerOverflowMs))
|
|
: null,
|
|
accumulatedMs: 0,
|
|
lastTransitionAt: now,
|
|
);
|
|
}
|
|
|
|
ExerciseStep _currentStep(
|
|
_StepSequenceContext context,
|
|
ActiveExerciseStepProgressState state,
|
|
) {
|
|
if (state.status == ActiveExerciseStepProgressStatus.sequenceComplete) {
|
|
throw const DomainException('Exercise step sequence is complete.');
|
|
}
|
|
return context.steps[state.currentStepIndex];
|
|
}
|
|
|
|
Future<List<ActiveExerciseStepResult>> _stepResultsForPosition(
|
|
_StepSequenceContext context,
|
|
) async {
|
|
return (await sessionRepository.listExerciseStepResults(context.sessionId))
|
|
.where(
|
|
(result) =>
|
|
result.programIndex == context.programIndex &&
|
|
result.exerciseIndex == context.exerciseIndex &&
|
|
result.setIndex == context.setIndex,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
ActiveExerciseStepResult _stepResult({
|
|
required _StepSequenceContext context,
|
|
required ActiveExerciseStepProgressState state,
|
|
required ExerciseStep step,
|
|
required SetResultStatus status,
|
|
required DateTime now,
|
|
int? actualTimeMs,
|
|
int? actualReps,
|
|
double? actualScore,
|
|
int? actualScoreTimeMs,
|
|
String? note,
|
|
}) {
|
|
return ActiveExerciseStepResult(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
activeWorkoutSessionId: context.sessionId,
|
|
programSnapshotId: context.programSnapshotId,
|
|
exerciseSnapshotId: context.exerciseSnapshotId,
|
|
programIndex: context.programIndex,
|
|
exerciseIndex: context.exerciseIndex,
|
|
setIndex: context.setIndex,
|
|
passageIndex: state.currentPassageIndex,
|
|
stepIndex: state.currentStepIndex,
|
|
stepSnapshotId: step.id,
|
|
stepNameSnapshot: step.name,
|
|
stepTypeSnapshot: step.type,
|
|
targetValueSnapshot: step.defaultTargetValue,
|
|
hasScoreSnapshot: step.hasScore,
|
|
scoreInputModeSnapshot: step.hasScore ? step.scoreInputMode : null,
|
|
scoreLabelSnapshot: step.scoreLabel,
|
|
scoreUnitSnapshot: step.scoreUnit,
|
|
targetScoreSnapshot: step.defaultTargetScore,
|
|
targetScoreTimeMsSnapshot: step.defaultTargetScoreTimeMs,
|
|
status: status,
|
|
startedAt: state.startedAt,
|
|
completedAt: status == SetResultStatus.completed ? now : null,
|
|
actualTimeMs: status == SetResultStatus.completed ? actualTimeMs : null,
|
|
actualReps: status == SetResultStatus.completed ? actualReps : null,
|
|
actualScore: status == SetResultStatus.completed ? actualScore : null,
|
|
actualScoreTimeMs: status == SetResultStatus.completed
|
|
? actualScoreTimeMs
|
|
: null,
|
|
note: note,
|
|
);
|
|
}
|
|
}
|
|
|
|
final class CloseWorkoutSessionUseCase {
|
|
const CloseWorkoutSessionUseCase({
|
|
required this.sessionRepository,
|
|
required this.historyRepository,
|
|
required this.clock,
|
|
required this.ids,
|
|
required this.originDeviceId,
|
|
});
|
|
|
|
final ActiveSessionRepository sessionRepository;
|
|
final WorkoutHistoryRepository historyRepository;
|
|
final Clock clock;
|
|
final IdGenerator ids;
|
|
final String originDeviceId;
|
|
|
|
Future<WorkoutHistory> close({
|
|
required String sessionId,
|
|
required String nameSnapshot,
|
|
required bool completed,
|
|
}) async {
|
|
final session = await sessionRepository.findById(sessionId);
|
|
if (session == null) {
|
|
throw const DomainException('Active workout session not found.');
|
|
}
|
|
final now = clock.now();
|
|
final results = await sessionRepository.listSetResults(sessionId);
|
|
final stepResults = await sessionRepository.listExerciseStepResults(
|
|
sessionId,
|
|
);
|
|
final historyId = ids.newId();
|
|
final historyResults = _historyResultsFromActiveResults(
|
|
historyId: historyId,
|
|
results: results,
|
|
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
|
now: now,
|
|
ids: ids,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
final historyStepResults = _historyStepResultsFromActiveResults(
|
|
historyId: historyId,
|
|
results: stepResults,
|
|
now: now,
|
|
ids: ids,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
final history = WorkoutHistory(
|
|
metadata: EntityMetadata(
|
|
id: historyId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
),
|
|
sourceWorkoutTemplateId: session.sourceWorkoutTemplateId,
|
|
sourceActiveWorkoutSessionId: session.metadata.id,
|
|
nameSnapshot: nameSnapshot,
|
|
startedAt: session.startedAt,
|
|
endedAt: now,
|
|
totalActiveMs: session.elapsedActiveMillisecondsAt(now),
|
|
completed: completed,
|
|
historySnapshotJson: jsonEncode({
|
|
'sessionId': session.metadata.id,
|
|
'resolvedTemplateSnapshotJson': session.resolvedTemplateSnapshotJson,
|
|
'results': results
|
|
.map(
|
|
(result) => {
|
|
'programSnapshotId': result.programSnapshotId,
|
|
'exerciseSnapshotId': result.exerciseSnapshotId,
|
|
'programIndex': result.programIndex,
|
|
'exerciseIndex': result.exerciseIndex,
|
|
'setIndex': result.setIndex,
|
|
'actualTimeMs': result.actualTimeMs,
|
|
'actualReps': result.actualReps,
|
|
'actualScore': result.actualScore,
|
|
'actualScoreTimeMs': result.actualScoreTimeMs,
|
|
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
|
|
'scoreLabelSnapshot': result.scoreLabelSnapshot,
|
|
'scoreUnitSnapshot': result.scoreUnitSnapshot,
|
|
'status': result.status.name,
|
|
},
|
|
)
|
|
.toList(),
|
|
'stepResults': stepResults
|
|
.map(
|
|
(result) => {
|
|
'programSnapshotId': result.programSnapshotId,
|
|
'exerciseSnapshotId': result.exerciseSnapshotId,
|
|
'programIndex': result.programIndex,
|
|
'exerciseIndex': result.exerciseIndex,
|
|
'setIndex': result.setIndex,
|
|
'passageIndex': result.passageIndex,
|
|
'stepIndex': result.stepIndex,
|
|
'stepSnapshotId': result.stepSnapshotId,
|
|
'stepNameSnapshot': result.stepNameSnapshot,
|
|
'stepTypeSnapshot': result.stepTypeSnapshot.name,
|
|
'targetValueSnapshot': result.targetValueSnapshot,
|
|
'hasScoreSnapshot': result.hasScoreSnapshot,
|
|
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
|
|
'scoreLabelSnapshot': result.scoreLabelSnapshot,
|
|
'scoreUnitSnapshot': result.scoreUnitSnapshot,
|
|
'targetScoreSnapshot': result.targetScoreSnapshot,
|
|
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
|
|
'status': result.status.name,
|
|
'actualTimeMs': result.actualTimeMs,
|
|
'actualReps': result.actualReps,
|
|
'actualScore': result.actualScore,
|
|
'actualScoreTimeMs': result.actualScoreTimeMs,
|
|
'note': result.note,
|
|
},
|
|
)
|
|
.toList(),
|
|
}),
|
|
results: historyResults,
|
|
stepResults: historyStepResults,
|
|
);
|
|
await historyRepository.save(history);
|
|
return history;
|
|
}
|
|
}
|
|
|
|
final class WorkoutHistoryUseCases {
|
|
const WorkoutHistoryUseCases({required this.repository, required this.clock});
|
|
|
|
final WorkoutHistoryRepository repository;
|
|
final Clock clock;
|
|
|
|
Future<WorkoutHistory?> findById(String id) => repository.findById(id);
|
|
|
|
Future<List<WorkoutHistory>> listActive() => repository.listActive();
|
|
|
|
Future<void> delete(String id) => repository.delete(id, clock.now());
|
|
}
|
|
|
|
enum SetPositionStatus { pending, completed, skipped }
|
|
|
|
final class SetResultPositionState {
|
|
const SetResultPositionState({
|
|
required this.programIndex,
|
|
required this.exerciseIndex,
|
|
required this.setIndex,
|
|
required this.status,
|
|
this.result,
|
|
});
|
|
|
|
final int programIndex;
|
|
final int exerciseIndex;
|
|
final int setIndex;
|
|
final SetPositionStatus status;
|
|
final ActiveSetResult? result;
|
|
}
|
|
|
|
List<WorkoutTemplateProgram> _repositionWorkoutTemplatePrograms(
|
|
List<WorkoutTemplateProgram> programs,
|
|
DateTime now,
|
|
) {
|
|
return [
|
|
for (var index = 0; index < programs.length; index++)
|
|
WorkoutTemplateProgram(
|
|
metadata: programs[index].metadata.touch(now),
|
|
workoutTemplateId: programs[index].workoutTemplateId,
|
|
sourceProgramId: programs[index].sourceProgramId,
|
|
position: index,
|
|
programNameSnapshot: programs[index].programNameSnapshot,
|
|
defaultRestSecondsSnapshot: programs[index].defaultRestSecondsSnapshot,
|
|
programSnapshotJson: programs[index].programSnapshotJson,
|
|
),
|
|
];
|
|
}
|
|
|
|
List<String> _resolveExerciseImageIds(
|
|
String? imageMediaId,
|
|
List<String> imageMediaIds,
|
|
) {
|
|
if (imageMediaId != null) {
|
|
return [imageMediaId];
|
|
}
|
|
return imageMediaIds;
|
|
}
|
|
|
|
List<String> _appendExerciseImage(List<String> currentIds, String mediaId) {
|
|
if (currentIds.contains(mediaId)) {
|
|
return currentIds;
|
|
}
|
|
if (currentIds.length >= 5) {
|
|
throw const DomainException('An exercise cannot have more than 5 images.');
|
|
}
|
|
return [...currentIds, mediaId];
|
|
}
|
|
|
|
bool _sameImageSet(List<String> left, List<String> right) {
|
|
if (left.length != right.length) {
|
|
return false;
|
|
}
|
|
return left.toSet().containsAll(right) && right.toSet().containsAll(left);
|
|
}
|
|
|
|
void _validateExerciseSteps(List<ExerciseStep> steps) {
|
|
if (steps.length > 8) {
|
|
throw const DomainException('An exercise cannot have more than 8 steps.');
|
|
}
|
|
for (var index = 0; index < steps.length; index++) {
|
|
final step = steps[index];
|
|
if (step.position != index) {
|
|
throw const DomainException(
|
|
'Exercise step positions must be contiguous and ordered.',
|
|
);
|
|
}
|
|
if (step.name.trim().isEmpty) {
|
|
throw const DomainException('Exercise step name must not be blank.');
|
|
}
|
|
if (step.defaultTargetValue <= 0) {
|
|
throw const DomainException(
|
|
'Exercise step default target value must be positive.',
|
|
);
|
|
}
|
|
if (!step.hasScore) {
|
|
if (step.scoreLabel != null ||
|
|
step.scoreUnit != null ||
|
|
step.defaultTargetScore != null ||
|
|
step.defaultTargetScoreTimeMs != null) {
|
|
throw const DomainException(
|
|
'Disabled exercise step score must not define score values.',
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (step.defaultTargetScore != null &&
|
|
step.defaultTargetScoreTimeMs != null) {
|
|
throw const DomainException(
|
|
'Manual and stopwatch step score targets cannot both be set.',
|
|
);
|
|
}
|
|
switch (step.scoreInputMode) {
|
|
case ScoreInputMode.manual:
|
|
if (step.scoreLabel == null || step.scoreLabel!.trim().isEmpty) {
|
|
throw const DomainException(
|
|
'Exercise step score label must not be blank.',
|
|
);
|
|
}
|
|
if (step.scoreUnit == null || step.scoreUnit!.trim().isEmpty) {
|
|
throw const DomainException(
|
|
'Exercise step score unit must not be blank.',
|
|
);
|
|
}
|
|
if (step.defaultTargetScore != null && step.defaultTargetScore! < 0) {
|
|
throw const DomainException(
|
|
'Exercise step default target score must not be negative.',
|
|
);
|
|
}
|
|
if (step.defaultTargetScoreTimeMs != null) {
|
|
throw const DomainException(
|
|
'Manual exercise step score cannot define stopwatch target.',
|
|
);
|
|
}
|
|
case ScoreInputMode.stopwatch:
|
|
if (step.scoreLabel != null ||
|
|
step.scoreUnit != null ||
|
|
step.defaultTargetScore != null) {
|
|
throw const DomainException(
|
|
'Stopwatch exercise step score cannot define manual score values.',
|
|
);
|
|
}
|
|
if (step.defaultTargetScoreTimeMs != null &&
|
|
step.defaultTargetScoreTimeMs! <= 0) {
|
|
throw const DomainException(
|
|
'Exercise step default target score time ms must be positive.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _validateAuthInput({required String email, required String password}) {
|
|
final normalizedEmail = email.trim();
|
|
final hasBasicEmailShape = RegExp(
|
|
r'^[^@\s]+@[^@\s]+\.[^@\s]+$',
|
|
).hasMatch(normalizedEmail);
|
|
if (!hasBasicEmailShape) {
|
|
throw const DomainException('Email format is invalid.');
|
|
}
|
|
if (password.length < 8) {
|
|
throw const DomainException('Password must contain at least 8 characters.');
|
|
}
|
|
}
|
|
|
|
void _validateExerciseDefaultTargets({
|
|
required bool hasTimeMeasure,
|
|
required bool hasRepsMeasure,
|
|
required bool hasScoreMeasure,
|
|
required ScoreInputMode scoreInputMode,
|
|
required int? defaultTargetTimeSeconds,
|
|
required int? defaultTargetReps,
|
|
required double? defaultTargetScore,
|
|
required int? defaultTargetScoreTimeMs,
|
|
}) {
|
|
if (hasTimeMeasure &&
|
|
(defaultTargetTimeSeconds == null || defaultTargetTimeSeconds <= 0)) {
|
|
throw const DomainException(
|
|
'Default target time seconds must be positive.',
|
|
);
|
|
}
|
|
if (hasRepsMeasure && (defaultTargetReps == null || defaultTargetReps <= 0)) {
|
|
throw const DomainException('Default target reps must be positive.');
|
|
}
|
|
if (hasScoreMeasure &&
|
|
scoreInputMode == ScoreInputMode.manual &&
|
|
(defaultTargetScore == null || defaultTargetScore < 0)) {
|
|
throw const DomainException('Default target score must not be negative.');
|
|
}
|
|
if (defaultTargetScore != null &&
|
|
(!hasScoreMeasure || scoreInputMode != ScoreInputMode.manual)) {
|
|
throw const DomainException(
|
|
'Default target score requires manual score mode.',
|
|
);
|
|
}
|
|
if (defaultTargetScoreTimeMs != null && defaultTargetScoreTimeMs <= 0) {
|
|
throw const DomainException(
|
|
'Default target score time ms must be positive.',
|
|
);
|
|
}
|
|
if (defaultTargetScoreTimeMs != null &&
|
|
(!hasScoreMeasure || scoreInputMode != ScoreInputMode.stopwatch)) {
|
|
throw const DomainException(
|
|
'Default target score time ms requires stopwatch score mode.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _validateOverrideTargets(
|
|
String programSnapshotJson,
|
|
WorkoutTemplateExerciseOverrideConfig input,
|
|
) {
|
|
final snapshot = jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
|
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const [])
|
|
.cast<Map<String, dynamic>>();
|
|
final exercise = exercises.cast<Map<String, dynamic>?>().firstWhere(
|
|
(item) =>
|
|
item?['id'] == input.snapshotProgramExerciseId ||
|
|
item?['snapshotProgramExerciseId'] == input.snapshotProgramExerciseId,
|
|
orElse: () => null,
|
|
);
|
|
if (exercise == null) {
|
|
throw const DomainException('Snapshot exercise not found.');
|
|
}
|
|
if (input.targetTimeSecondsOverride != null &&
|
|
exercise['timeEnabled'] != true) {
|
|
throw const DomainException('Cannot override inactive time target.');
|
|
}
|
|
if (input.targetRepsOverride != null && exercise['repsEnabled'] != true) {
|
|
throw const DomainException('Cannot override inactive reps target.');
|
|
}
|
|
if (input.targetScoreOverride != null && exercise['scoreEnabled'] != true) {
|
|
throw const DomainException('Cannot override inactive score target.');
|
|
}
|
|
if (input.targetScoreTimeMsOverride != null &&
|
|
exercise['scoreEnabled'] != true) {
|
|
throw const DomainException('Cannot override inactive score target.');
|
|
}
|
|
final mode = _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
|
|
if (input.targetScoreOverride != null && mode != ScoreInputMode.manual) {
|
|
throw const DomainException(
|
|
'Cannot override manual score target on stopwatch score mode.',
|
|
);
|
|
}
|
|
if (input.targetScoreTimeMsOverride != null &&
|
|
mode != ScoreInputMode.stopwatch) {
|
|
throw const DomainException(
|
|
'Cannot override stopwatch score target on manual score mode.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _ensureEditablePastPosition({
|
|
required ActiveWorkoutSession session,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) {
|
|
final comparison = _comparePositions(
|
|
programIndex,
|
|
exerciseIndex,
|
|
setIndex,
|
|
session.currentProgramIndex,
|
|
session.currentExerciseIndex,
|
|
session.currentSetIndex,
|
|
);
|
|
if (comparison >= 0) {
|
|
throw const DomainException(
|
|
'Only past set results can be edited outside the normal execution flow.',
|
|
);
|
|
}
|
|
}
|
|
|
|
int _comparePositions(
|
|
int leftProgram,
|
|
int leftExercise,
|
|
int leftSet,
|
|
int rightProgram,
|
|
int rightExercise,
|
|
int rightSet,
|
|
) {
|
|
final programComparison = leftProgram.compareTo(rightProgram);
|
|
if (programComparison != 0) {
|
|
return programComparison;
|
|
}
|
|
final exerciseComparison = leftExercise.compareTo(rightExercise);
|
|
if (exerciseComparison != 0) {
|
|
return exerciseComparison;
|
|
}
|
|
return leftSet.compareTo(rightSet);
|
|
}
|
|
|
|
String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
|
|
return '$programIndex:$exerciseIndex:$setIndex';
|
|
}
|
|
|
|
String _syncKey(SyncResourceType resourceType, String clientId) {
|
|
return '${resourceType.name}:$clientId';
|
|
}
|
|
|
|
Map<String, Object?> _programSharePayload(Program program) => {
|
|
'metadata': _metadataSharePayload(program.metadata),
|
|
'id': program.metadata.id,
|
|
'name': program.name,
|
|
'defaultRestSeconds': program.defaultRestSeconds,
|
|
'exercises': program.exercises
|
|
.map((exercise) => exercise.toSnapshotJson())
|
|
.toList(),
|
|
};
|
|
|
|
Map<String, Object?> _workoutTemplateSharePayload(WorkoutTemplate template) => {
|
|
'metadata': _metadataSharePayload(template.metadata),
|
|
'id': template.metadata.id,
|
|
'name': template.name,
|
|
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
|
|
'programs': template.programs
|
|
.map(
|
|
(program) => {
|
|
'id': program.metadata.id,
|
|
'sourceProgramId': program.sourceProgramId,
|
|
'position': program.position,
|
|
'programNameSnapshot': program.programNameSnapshot,
|
|
'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot,
|
|
'programSnapshotJson': program.programSnapshotJson,
|
|
},
|
|
)
|
|
.toList(),
|
|
'overrides': template.overrides
|
|
.map(
|
|
(override) => {
|
|
'id': override.metadata.id,
|
|
'workoutTemplateProgramId': override.workoutTemplateProgramId,
|
|
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
|
|
'setsCountOverride': override.setsCountOverride,
|
|
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
|
|
'targetRepsOverride': override.targetRepsOverride,
|
|
'targetScoreOverride': override.targetScoreOverride,
|
|
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
|
|
},
|
|
)
|
|
.toList(),
|
|
};
|
|
|
|
Map<String, Object?> _metadataSharePayload(EntityMetadata metadata) => {
|
|
'id': metadata.id,
|
|
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
|
|
'updatedAt': metadata.updatedAt.toUtc().toIso8601String(),
|
|
'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(),
|
|
'schemaVersion': metadata.schemaVersion,
|
|
'syncState': metadata.syncState.name,
|
|
'localRevision': metadata.localRevision,
|
|
'originDeviceId': metadata.originDeviceId,
|
|
'futureOwnerProfileId': metadata.futureOwnerProfileId,
|
|
'lastSyncedAt': metadata.lastSyncedAt?.toUtc().toIso8601String(),
|
|
'remoteRevision': metadata.remoteRevision,
|
|
};
|
|
|
|
Map<String, Object?> _jsonObject(String? json) {
|
|
if (json == null) {
|
|
return const {};
|
|
}
|
|
final decoded = jsonDecode(json);
|
|
if (decoded is Map) {
|
|
return Map<String, Object?>.from(decoded);
|
|
}
|
|
return const {};
|
|
}
|
|
|
|
String _stringFromSharePayload(
|
|
Map<String, Object?> payload,
|
|
String key,
|
|
String fallback,
|
|
) {
|
|
final value = payload[key];
|
|
return value is String && value.trim().isNotEmpty ? value : fallback;
|
|
}
|
|
|
|
List<String> _stringListFromSharePayload(Object? value) {
|
|
if (value is! List) {
|
|
return const [];
|
|
}
|
|
return value.whereType<String>().toList(growable: false);
|
|
}
|
|
|
|
List<String> _jsonStringList(String? json) {
|
|
if (json == null) {
|
|
return const [];
|
|
}
|
|
final decoded = jsonDecode(json);
|
|
if (decoded is List) {
|
|
return decoded.whereType<String>().toList(growable: false);
|
|
}
|
|
return const [];
|
|
}
|
|
|
|
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
|
|
return switch (value) {
|
|
'stopwatch' => ScoreInputMode.stopwatch,
|
|
_ => ScoreInputMode.manual,
|
|
};
|
|
}
|
|
|
|
_SetPositionSnapshot? _findSetSnapshot({
|
|
required String resolvedTemplateSnapshotJson,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
required int setIndex,
|
|
}) {
|
|
for (final snapshot in _listSetSnapshots(resolvedTemplateSnapshotJson)) {
|
|
if (snapshot.programIndex == programIndex &&
|
|
snapshot.exerciseIndex == exerciseIndex &&
|
|
snapshot.setIndex == setIndex) {
|
|
return snapshot;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_ResolvedExerciseSnapshot? _findExerciseSnapshot({
|
|
required String resolvedTemplateSnapshotJson,
|
|
required int programIndex,
|
|
required int exerciseIndex,
|
|
}) {
|
|
final decoded =
|
|
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
|
|
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
|
|
if (programIndex < 0 || programIndex >= programs.length) {
|
|
return null;
|
|
}
|
|
final program = programs[programIndex] as Map<String, dynamic>;
|
|
final programSnapshotId = program['id'] as String? ?? 'program-$programIndex';
|
|
final programName = program['programNameSnapshot'] as String? ?? '';
|
|
final programSnapshotJson = program['programSnapshotJson'] as String?;
|
|
if (programSnapshotJson == null) {
|
|
return null;
|
|
}
|
|
final programSnapshot =
|
|
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
|
final exercises =
|
|
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
|
|
if (exerciseIndex < 0 || exerciseIndex >= exercises.length) {
|
|
return null;
|
|
}
|
|
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
|
|
final id = exercise['id'] as String? ?? 'exercise-$exerciseIndex';
|
|
return _ResolvedExerciseSnapshot(
|
|
programSnapshotId: programSnapshotId,
|
|
exerciseSnapshotId: id,
|
|
programNameSnapshot: programName,
|
|
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
|
timeEnabled: exercise['timeEnabled'] == true,
|
|
repsEnabled: exercise['repsEnabled'] == true,
|
|
scoreEnabled: exercise['scoreEnabled'] == true,
|
|
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
|
|
targetReps: exercise['targetReps'] as int?,
|
|
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
|
|
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
|
|
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
|
exercise['scoreInputModeSnapshot'],
|
|
),
|
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
|
);
|
|
}
|
|
|
|
List<_SetPositionSnapshot> _listSetSnapshots(
|
|
String resolvedTemplateSnapshotJson,
|
|
) {
|
|
final decoded =
|
|
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
|
|
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
|
|
final snapshots = <_SetPositionSnapshot>[];
|
|
for (var programIndex = 0; programIndex < programs.length; programIndex++) {
|
|
final program = programs[programIndex] as Map<String, dynamic>;
|
|
final programSnapshotId =
|
|
program['id'] as String? ?? 'program-$programIndex';
|
|
final programSnapshotJson = program['programSnapshotJson'] as String?;
|
|
if (programSnapshotJson == null) {
|
|
continue;
|
|
}
|
|
final programSnapshot =
|
|
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
|
final exercises =
|
|
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
|
|
for (
|
|
var exerciseIndex = 0;
|
|
exerciseIndex < exercises.length;
|
|
exerciseIndex++
|
|
) {
|
|
final exercise = exercises[exerciseIndex] as Map<String, dynamic>;
|
|
final exerciseSnapshotId =
|
|
exercise['id'] as String? ?? 'exercise-$exerciseIndex';
|
|
final setsCount = exercise['setsCount'] as int? ?? 0;
|
|
for (var setIndex = 0; setIndex < setsCount; setIndex++) {
|
|
snapshots.add(
|
|
_SetPositionSnapshot(
|
|
programSnapshotId: programSnapshotId,
|
|
exerciseSnapshotId: exerciseSnapshotId,
|
|
programIndex: programIndex,
|
|
exerciseIndex: exerciseIndex,
|
|
setIndex: setIndex,
|
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
|
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
|
exercise['scoreInputModeSnapshot'],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return snapshots;
|
|
}
|
|
|
|
final class _SetPositionSnapshot {
|
|
const _SetPositionSnapshot({
|
|
required this.programSnapshotId,
|
|
required this.exerciseSnapshotId,
|
|
required this.programIndex,
|
|
required this.exerciseIndex,
|
|
required this.setIndex,
|
|
this.scoreLabelSnapshot,
|
|
this.scoreUnitSnapshot,
|
|
required this.scoreInputModeSnapshot,
|
|
});
|
|
|
|
final String programSnapshotId;
|
|
final String exerciseSnapshotId;
|
|
final int programIndex;
|
|
final int exerciseIndex;
|
|
final int setIndex;
|
|
final String? scoreLabelSnapshot;
|
|
final String? scoreUnitSnapshot;
|
|
final ScoreInputMode scoreInputModeSnapshot;
|
|
}
|
|
|
|
final class _StepSequenceContext {
|
|
const _StepSequenceContext({
|
|
required this.sessionId,
|
|
required this.programSnapshotId,
|
|
required this.exerciseSnapshotId,
|
|
required this.programIndex,
|
|
required this.exerciseIndex,
|
|
required this.setIndex,
|
|
required this.steps,
|
|
required this.expectedPassages,
|
|
});
|
|
|
|
final String sessionId;
|
|
final String programSnapshotId;
|
|
final String exerciseSnapshotId;
|
|
final int programIndex;
|
|
final int exerciseIndex;
|
|
final int setIndex;
|
|
final List<ExerciseStep> steps;
|
|
final int expectedPassages;
|
|
}
|
|
|
|
List<WorkoutHistorySetResult> _historyResultsFromActiveResults({
|
|
required String historyId,
|
|
required List<ActiveSetResult> results,
|
|
required String resolvedTemplateSnapshotJson,
|
|
required DateTime now,
|
|
required IdGenerator ids,
|
|
required String originDeviceId,
|
|
}) {
|
|
final snapshots = _exerciseSnapshotsById(resolvedTemplateSnapshotJson);
|
|
return results.map((result) {
|
|
final snapshot = snapshots[result.exerciseSnapshotId];
|
|
return WorkoutHistorySetResult(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
workoutHistoryId: historyId,
|
|
programSnapshotId: result.programSnapshotId,
|
|
exerciseSnapshotId: result.exerciseSnapshotId,
|
|
programIndex: result.programIndex,
|
|
exerciseIndex: result.exerciseIndex,
|
|
setIndex: result.setIndex,
|
|
programNameSnapshot:
|
|
snapshot?.programNameSnapshot ?? result.programSnapshotId,
|
|
exerciseNameSnapshot:
|
|
snapshot?.exerciseNameSnapshot ?? result.exerciseSnapshotId,
|
|
timeEnabledSnapshot: snapshot?.timeEnabled ?? result.actualTimeMs != null,
|
|
repsEnabledSnapshot: snapshot?.repsEnabled ?? result.actualReps != null,
|
|
scoreEnabledSnapshot:
|
|
snapshot?.scoreEnabled ??
|
|
(result.actualScore != null || result.actualScoreTimeMs != null),
|
|
targetTimeSecondsSnapshot: snapshot?.targetTimeSeconds,
|
|
targetRepsSnapshot: snapshot?.targetReps,
|
|
targetScoreSnapshot: snapshot?.targetScore,
|
|
targetScoreTimeMsSnapshot: snapshot?.targetScoreTimeMs,
|
|
actualTimeMs: result.actualTimeMs,
|
|
actualReps: result.actualReps,
|
|
actualScore: result.actualScore,
|
|
actualScoreTimeMs: result.actualScoreTimeMs,
|
|
scoreInputModeSnapshot:
|
|
snapshot?.scoreInputModeSnapshot ?? result.scoreInputModeSnapshot,
|
|
scoreLabelSnapshot:
|
|
result.scoreLabelSnapshot ?? snapshot?.scoreLabelSnapshot,
|
|
scoreUnitSnapshot:
|
|
result.scoreUnitSnapshot ?? snapshot?.scoreUnitSnapshot,
|
|
startedAt: result.startedAt,
|
|
completedAt: result.completedAt,
|
|
status: result.status,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Map<String, _ResolvedExerciseSnapshot> _exerciseSnapshotsById(
|
|
String resolvedTemplateSnapshotJson,
|
|
) {
|
|
final decoded =
|
|
jsonDecode(resolvedTemplateSnapshotJson) as Map<String, dynamic>;
|
|
final programs = (decoded['programs'] as List<dynamic>? ?? const []);
|
|
final snapshots = <String, _ResolvedExerciseSnapshot>{};
|
|
for (final program in programs.cast<Map<String, dynamic>>()) {
|
|
final programName = program['programNameSnapshot'] as String? ?? '';
|
|
final programSnapshotJson = program['programSnapshotJson'] as String?;
|
|
if (programSnapshotJson == null) {
|
|
continue;
|
|
}
|
|
final programSnapshot =
|
|
jsonDecode(programSnapshotJson) as Map<String, dynamic>;
|
|
final exercises =
|
|
(programSnapshot['exercises'] as List<dynamic>? ?? const []);
|
|
for (final exercise in exercises.cast<Map<String, dynamic>>()) {
|
|
final id = exercise['id'] as String?;
|
|
if (id == null) {
|
|
continue;
|
|
}
|
|
snapshots[id] = _ResolvedExerciseSnapshot(
|
|
programSnapshotId: program['id'] as String? ?? id,
|
|
exerciseSnapshotId: id,
|
|
programNameSnapshot: programName,
|
|
exerciseNameSnapshot: exercise['exerciseNameSnapshot'] as String? ?? id,
|
|
timeEnabled: exercise['timeEnabled'] == true,
|
|
repsEnabled: exercise['repsEnabled'] == true,
|
|
scoreEnabled: exercise['scoreEnabled'] == true,
|
|
targetTimeSeconds: exercise['targetTimeSeconds'] as int?,
|
|
targetReps: exercise['targetReps'] as int?,
|
|
targetScore: (exercise['targetScore'] as num?)?.toDouble(),
|
|
targetScoreTimeMs: exercise['targetScoreTimeMs'] as int?,
|
|
scoreInputModeSnapshot: _scoreInputModeFromSnapshot(
|
|
exercise['scoreInputModeSnapshot'],
|
|
),
|
|
scoreLabelSnapshot: exercise['scoreLabelSnapshot'] as String?,
|
|
scoreUnitSnapshot: exercise['scoreUnitSnapshot'] as String?,
|
|
steps: _exerciseStepsFromSnapshot(exercise['exerciseStepsSnapshot']),
|
|
);
|
|
}
|
|
}
|
|
return snapshots;
|
|
}
|
|
|
|
final class _ResolvedExerciseSnapshot {
|
|
const _ResolvedExerciseSnapshot({
|
|
required this.programSnapshotId,
|
|
required this.exerciseSnapshotId,
|
|
required this.programNameSnapshot,
|
|
required this.exerciseNameSnapshot,
|
|
required this.timeEnabled,
|
|
required this.repsEnabled,
|
|
required this.scoreEnabled,
|
|
this.targetTimeSeconds,
|
|
this.targetReps,
|
|
this.targetScore,
|
|
this.targetScoreTimeMs,
|
|
required this.scoreInputModeSnapshot,
|
|
this.scoreLabelSnapshot,
|
|
this.scoreUnitSnapshot,
|
|
this.steps = const [],
|
|
});
|
|
|
|
final String programSnapshotId;
|
|
final String exerciseSnapshotId;
|
|
final String programNameSnapshot;
|
|
final String exerciseNameSnapshot;
|
|
final bool timeEnabled;
|
|
final bool repsEnabled;
|
|
final bool scoreEnabled;
|
|
final int? targetTimeSeconds;
|
|
final int? targetReps;
|
|
final double? targetScore;
|
|
final int? targetScoreTimeMs;
|
|
final ScoreInputMode scoreInputModeSnapshot;
|
|
final String? scoreLabelSnapshot;
|
|
final String? scoreUnitSnapshot;
|
|
final List<ExerciseStep> steps;
|
|
}
|
|
|
|
List<ExerciseStep> _exerciseStepsFromSnapshot(Object? value) {
|
|
if (value is! List) {
|
|
return const [];
|
|
}
|
|
return value
|
|
.map((item) {
|
|
final json = Map<String, dynamic>.from(item as Map);
|
|
return ExerciseStep(
|
|
id: json['id'] as String,
|
|
position: json['position'] as int,
|
|
name: json['name'] as String,
|
|
type: switch (json['type']) {
|
|
'time' => ExerciseStepType.time,
|
|
'reps' => ExerciseStepType.reps,
|
|
_ => throw const DomainException('Unknown exercise step type.'),
|
|
},
|
|
defaultTargetValue: json['defaultTargetValue'] as int,
|
|
hasScore: json['hasScore'] == true,
|
|
scoreInputMode: _scoreInputModeFromSnapshot(json['scoreInputMode']),
|
|
scoreLabel: json['scoreLabel'] as String?,
|
|
scoreUnit: json['scoreUnit'] as String?,
|
|
defaultTargetScore: (json['defaultTargetScore'] as num?)?.toDouble(),
|
|
defaultTargetScoreTimeMs: json['defaultTargetScoreTimeMs'] as int?,
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
}
|
|
|
|
List<WorkoutHistoryStepResult> _historyStepResultsFromActiveResults({
|
|
required String historyId,
|
|
required List<ActiveExerciseStepResult> results,
|
|
required DateTime now,
|
|
required IdGenerator ids,
|
|
required String originDeviceId,
|
|
}) {
|
|
return results.map((result) {
|
|
return WorkoutHistoryStepResult(
|
|
metadata: _newMetadata(ids, originDeviceId, now),
|
|
workoutHistoryId: historyId,
|
|
programSnapshotId: result.programSnapshotId,
|
|
exerciseSnapshotId: result.exerciseSnapshotId,
|
|
programIndex: result.programIndex,
|
|
exerciseIndex: result.exerciseIndex,
|
|
setIndex: result.setIndex,
|
|
passageIndex: result.passageIndex,
|
|
stepIndex: result.stepIndex,
|
|
stepSnapshotId: result.stepSnapshotId,
|
|
stepNameSnapshot: result.stepNameSnapshot,
|
|
stepTypeSnapshot: result.stepTypeSnapshot,
|
|
targetValueSnapshot: result.targetValueSnapshot,
|
|
hasScoreSnapshot: result.hasScoreSnapshot,
|
|
scoreInputModeSnapshot: result.scoreInputModeSnapshot,
|
|
scoreLabelSnapshot: result.scoreLabelSnapshot,
|
|
scoreUnitSnapshot: result.scoreUnitSnapshot,
|
|
targetScoreSnapshot: result.targetScoreSnapshot,
|
|
targetScoreTimeMsSnapshot: result.targetScoreTimeMsSnapshot,
|
|
status: result.status,
|
|
startedAt: result.startedAt,
|
|
completedAt: result.completedAt,
|
|
actualTimeMs: result.actualTimeMs,
|
|
actualReps: result.actualReps,
|
|
actualScore: result.actualScore,
|
|
actualScoreTimeMs: result.actualScoreTimeMs,
|
|
note: result.note,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
EntityMetadata _newMetadata(
|
|
IdGenerator ids,
|
|
String originDeviceId,
|
|
DateTime now,
|
|
) {
|
|
return EntityMetadata(
|
|
id: ids.newId(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
originDeviceId: originDeviceId,
|
|
);
|
|
}
|