chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,6 +14,7 @@ abstract interface class AppDependencies {
|
||||
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
|
||||
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
|
||||
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
|
||||
WorkoutTelemetryUseCases get workoutTelemetryUseCases;
|
||||
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
|
||||
WorkoutHistoryUseCases get workoutHistoryUseCases;
|
||||
ProgressionStatsUseCase get progressionStatsUseCase;
|
||||
@ -35,6 +36,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
required this.activeWorkoutSessionUseCases,
|
||||
required this.activeExerciseStepUseCases,
|
||||
required this.activeWorkoutSensorUseCases,
|
||||
required this.workoutTelemetryUseCases,
|
||||
required this.watchCompanionProjectionUseCases,
|
||||
required this.watchCompanionCommandHandler,
|
||||
required this.watchWearDataLayerAdapter,
|
||||
@ -67,6 +69,8 @@ final class AppBootstrap implements AppDependencies {
|
||||
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
|
||||
@override
|
||||
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
|
||||
@override
|
||||
final WorkoutTelemetryUseCases workoutTelemetryUseCases;
|
||||
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
|
||||
final WatchCompanionCommandHandler watchCompanionCommandHandler;
|
||||
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
|
||||
@ -107,6 +111,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
final templateRepository = DriftWorkoutTemplateRepository(database);
|
||||
final activeSessionRepository = DriftActiveSessionRepository(database);
|
||||
final historyRepository = DriftWorkoutHistoryRepository(database);
|
||||
final telemetryRepository = DriftWorkoutTelemetryRepository(database);
|
||||
final progressionStatsRepository = DriftProgressionStatsRepository(
|
||||
database,
|
||||
);
|
||||
@ -134,6 +139,11 @@ final class AppBootstrap implements AppDependencies {
|
||||
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
|
||||
clock: clock,
|
||||
);
|
||||
final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
|
||||
repository: telemetryRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
);
|
||||
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
|
||||
sessionRepository: activeSessionRepository,
|
||||
clock: clock,
|
||||
@ -156,6 +166,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
projectionSource: watchCompanionProjectionUseCases,
|
||||
workoutHistoryUseCases: workoutHistoryUseCases,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
workoutTelemetryUseCases: workoutTelemetryUseCases,
|
||||
);
|
||||
final sessionNotificationCoordinator = SessionNotificationCoordinator(
|
||||
projections: watchCompanionProjectionUseCases.projections,
|
||||
@ -221,6 +232,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
|
||||
activeExerciseStepUseCases: activeExerciseStepUseCases,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
workoutTelemetryUseCases: workoutTelemetryUseCases,
|
||||
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
|
||||
watchCompanionCommandHandler: watchCompanionCommandHandler,
|
||||
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
|
||||
@ -228,6 +240,7 @@ final class AppBootstrap implements AppDependencies {
|
||||
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
|
||||
sessionRepository: activeSessionRepository,
|
||||
historyRepository: historyRepository,
|
||||
telemetryRepository: telemetryRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
|
||||
@ -845,6 +845,7 @@ abstract interface class WorkoutTemplateRepository {
|
||||
Future<WorkoutTemplate?> findById(String id);
|
||||
Future<List<WorkoutTemplate>> listActive();
|
||||
Future<void> save(WorkoutTemplate template);
|
||||
Future<void> saveAll(List<WorkoutTemplate> templates);
|
||||
Future<void> saveProgram(WorkoutTemplateProgram program);
|
||||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
|
||||
Future<void> replaceComposition(WorkoutTemplate template, DateTime deletedAt);
|
||||
@ -923,8 +924,11 @@ abstract interface class WorkoutHistoryRepository {
|
||||
Future<void> save(WorkoutHistory history);
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
});
|
||||
Future<void> saveSetResult(WorkoutHistorySetResult result);
|
||||
@ -932,6 +936,25 @@ abstract interface class WorkoutHistoryRepository {
|
||||
Future<void> delete(String id, DateTime deletedAt);
|
||||
}
|
||||
|
||||
abstract interface class WorkoutTelemetryRepository {
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample);
|
||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<WorkoutTelemetryAggregate> aggregates,
|
||||
});
|
||||
Future<List<WorkoutTelemetryAggregate>> listAggregates(String sessionId);
|
||||
Future<WorkoutTelemetryAggregate?> findAggregate({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
});
|
||||
}
|
||||
|
||||
final class ActivePerformanceMeasures {
|
||||
const ActivePerformanceMeasures({
|
||||
required this.timeEnabled,
|
||||
|
||||
@ -558,6 +558,8 @@ final class ShareUseCases {
|
||||
required ShareResourceType resourceType,
|
||||
required String localResourceId,
|
||||
required List<String> recipientEmails,
|
||||
String? packName,
|
||||
List<String> localResourceIds = const [],
|
||||
}) async {
|
||||
final emails = recipientEmails
|
||||
.map((email) => email.trim())
|
||||
@ -566,7 +568,12 @@ final class ShareUseCases {
|
||||
if (emails.isEmpty) {
|
||||
throw const DomainException('At least one recipient email is required.');
|
||||
}
|
||||
final payload = await _sharePayload(resourceType, localResourceId);
|
||||
final payload = await _sharePayload(
|
||||
resourceType,
|
||||
localResourceId,
|
||||
packName: packName,
|
||||
localResourceIds: localResourceIds,
|
||||
);
|
||||
final token = await tokenStore.readToken();
|
||||
if (token == null) {
|
||||
return const ShareSendResult(status: ShareSendStatus.notConnected);
|
||||
@ -613,6 +620,10 @@ final class ShareUseCases {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<WorkoutTemplate>> listShareableWorkoutTemplates() {
|
||||
return templateRepository.listActive();
|
||||
}
|
||||
|
||||
Future<void> acceptShare(String shareId) async {
|
||||
final cachedItem = await inboxRepository.findByShareId(shareId);
|
||||
final token = await tokenStore.readToken();
|
||||
@ -621,8 +632,16 @@ final class ShareUseCases {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final createdResource = await remoteShareApi.acceptShare(shareId, token);
|
||||
await localChanges.applyRemoteItem(createdResource);
|
||||
if (cachedItem?.resourceType == ShareResourceType.pack) {
|
||||
await remoteShareApi.acceptShare(shareId, token);
|
||||
await _importSharedPayload(cachedItem!);
|
||||
} else {
|
||||
final createdResource = await remoteShareApi.acceptShare(
|
||||
shareId,
|
||||
token,
|
||||
);
|
||||
await localChanges.applyRemoteItem(createdResource);
|
||||
}
|
||||
await inboxRepository.markStatus(
|
||||
shareId,
|
||||
ShareInboxStatus.accepted,
|
||||
@ -696,8 +715,10 @@ final class ShareUseCases {
|
||||
|
||||
Future<Map<String, Object?>> _sharePayload(
|
||||
ShareResourceType resourceType,
|
||||
String localResourceId,
|
||||
) async {
|
||||
String localResourceId, {
|
||||
String? packName,
|
||||
List<String> localResourceIds = const [],
|
||||
}) async {
|
||||
switch (resourceType) {
|
||||
case ShareResourceType.program:
|
||||
final program = await programRepository.findById(localResourceId);
|
||||
@ -711,6 +732,27 @@ final class ShareUseCases {
|
||||
throw const DomainException('Workout template not found.');
|
||||
}
|
||||
return _workoutTemplateSharePayload(template);
|
||||
case ShareResourceType.pack:
|
||||
final name = packName?.trim();
|
||||
if (name == null || name.isEmpty) {
|
||||
throw const DomainException('Pack name is required.');
|
||||
}
|
||||
final ids = localResourceIds
|
||||
.map((id) => id.trim())
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (ids.isEmpty) {
|
||||
throw const DomainException('At least one workout is required.');
|
||||
}
|
||||
final templates = <WorkoutTemplate>[];
|
||||
for (final id in ids) {
|
||||
final template = await templateRepository.findById(id);
|
||||
if (template == null) {
|
||||
throw const DomainException('Workout template not found.');
|
||||
}
|
||||
templates.add(template);
|
||||
}
|
||||
return _packSharePayload(name, templates);
|
||||
}
|
||||
}
|
||||
|
||||
@ -748,11 +790,16 @@ final class ShareUseCases {
|
||||
);
|
||||
return;
|
||||
case PendingShareActionType.accept:
|
||||
final created = await remoteShareApi.acceptShare(
|
||||
action.shareId!,
|
||||
token,
|
||||
);
|
||||
await localChanges.applyRemoteItem(created);
|
||||
final item = await inboxRepository.findByShareId(action.shareId!);
|
||||
if (item?.resourceType == ShareResourceType.pack) {
|
||||
await remoteShareApi.acceptShare(action.shareId!, token);
|
||||
} else {
|
||||
final created = await remoteShareApi.acceptShare(
|
||||
action.shareId!,
|
||||
token,
|
||||
);
|
||||
await localChanges.applyRemoteItem(created);
|
||||
}
|
||||
await inboxRepository.markStatus(
|
||||
action.shareId!,
|
||||
ShareInboxStatus.accepted,
|
||||
@ -812,9 +859,39 @@ final class ShareUseCases {
|
||||
_workoutTemplateCopyFromSharePayload(payload, now),
|
||||
);
|
||||
return;
|
||||
case ShareResourceType.pack:
|
||||
final templates = _workoutTemplateCopiesFromPackSharePayload(
|
||||
payload,
|
||||
now,
|
||||
);
|
||||
await templateRepository.saveAll(templates);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
List<WorkoutTemplate> _workoutTemplateCopiesFromPackSharePayload(
|
||||
Map<String, Object?> payload,
|
||||
DateTime now,
|
||||
) {
|
||||
final rawItems = payload['workouts'];
|
||||
if (rawItems is! List || rawItems.isEmpty) {
|
||||
throw const DomainException('Pack payload is empty.');
|
||||
}
|
||||
final templates = <WorkoutTemplate>[];
|
||||
for (final raw in rawItems) {
|
||||
if (raw is! Map) {
|
||||
throw const DomainException('Invalid pack payload.');
|
||||
}
|
||||
templates.add(
|
||||
_workoutTemplateCopyFromSharePayload(
|
||||
Map<String, Object?>.from(raw),
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
Program _programCopyFromSharePayload(
|
||||
Map<String, Object?> payload,
|
||||
DateTime now,
|
||||
@ -4030,28 +4107,27 @@ final class WatchSessionProjectionProjector {
|
||||
final setTimerProjection = setTimer == null
|
||||
? null
|
||||
: _setTimerProjection(setTimer, now);
|
||||
final hideScoreStopwatchTimer = _shouldHideScoreStopwatchForSetTimer(
|
||||
snapshot: snapshot,
|
||||
setTimerProjection: setTimerProjection,
|
||||
);
|
||||
final timers = <WatchTimerProjection>[
|
||||
final allTimers = <WatchTimerProjection>[
|
||||
if (activeRest != null) _restTimerProjection(activeRest, now),
|
||||
if (currentStep != null && currentStep.type == ExerciseStepType.time)
|
||||
_stepTimerProjection(stepState, currentStep, now),
|
||||
?setTimerProjection,
|
||||
if (!hideScoreStopwatchTimer) ?scoreStopwatchTimer,
|
||||
?scoreStopwatchTimer,
|
||||
];
|
||||
final dominantTimer = timers.isEmpty ? null : timers.first;
|
||||
final displayTimers = allTimers
|
||||
.where((timer) => timer.kind != WatchTimerKind.setTimer)
|
||||
.toList(growable: false);
|
||||
final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
|
||||
final secondaryTimers = dominantTimer == null
|
||||
? const <WatchTimerProjection>[]
|
||||
: timers.skip(1).toList(growable: false);
|
||||
: displayTimers.skip(1).toList(growable: false);
|
||||
final phase = _phase(
|
||||
session: session,
|
||||
snapshot: snapshot,
|
||||
activeRest: activeRest,
|
||||
stepState: stepState,
|
||||
currentStep: currentStep,
|
||||
timers: timers,
|
||||
timers: allTimers,
|
||||
);
|
||||
|
||||
return WatchSessionProjection(
|
||||
@ -4414,16 +4490,6 @@ WatchTimerProjection? _setTimerProjection(
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldHideScoreStopwatchForSetTimer({
|
||||
required _ResolvedExerciseSnapshot snapshot,
|
||||
required WatchTimerProjection? setTimerProjection,
|
||||
}) {
|
||||
return snapshot.timeEnabled &&
|
||||
snapshot.scoreEnabled &&
|
||||
snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch &&
|
||||
setTimerProjection != null;
|
||||
}
|
||||
|
||||
ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) {
|
||||
return snapshot.steps.isEmpty ? null : snapshot.steps.first;
|
||||
}
|
||||
@ -5335,6 +5401,7 @@ final class CloseWorkoutSessionUseCase {
|
||||
const CloseWorkoutSessionUseCase({
|
||||
required this.sessionRepository,
|
||||
required this.historyRepository,
|
||||
this.telemetryRepository,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
@ -5342,6 +5409,7 @@ final class CloseWorkoutSessionUseCase {
|
||||
|
||||
final ActiveSessionRepository sessionRepository;
|
||||
final WorkoutHistoryRepository historyRepository;
|
||||
final WorkoutTelemetryRepository? telemetryRepository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
@ -5380,6 +5448,10 @@ final class CloseWorkoutSessionUseCase {
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
);
|
||||
final telemetryAggregate = await telemetryRepository?.findAggregate(
|
||||
sessionId: session.metadata.id,
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
final history = WorkoutHistory(
|
||||
metadata: EntityMetadata(
|
||||
id: historyId,
|
||||
@ -5450,6 +5522,11 @@ final class CloseWorkoutSessionUseCase {
|
||||
)
|
||||
.toList(),
|
||||
}),
|
||||
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
|
||||
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
|
||||
maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
|
||||
totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
|
||||
totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
|
||||
results: historyResults,
|
||||
stepResults: historyStepResults,
|
||||
);
|
||||
@ -5458,6 +5535,247 @@ final class CloseWorkoutSessionUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutTelemetryUseCases {
|
||||
const WorkoutTelemetryUseCases({
|
||||
required this.repository,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
final WorkoutTelemetryRepository repository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
|
||||
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
|
||||
WatchTelemetrySample sample,
|
||||
) async {
|
||||
final domainSample = _telemetrySampleFromWatch(sample);
|
||||
if (domainSample == null) {
|
||||
return const [];
|
||||
}
|
||||
final inserted = await repository.saveSample(domainSample);
|
||||
if (!inserted) {
|
||||
return const [];
|
||||
}
|
||||
final samples = await repository.listSamples(domainSample.sessionId);
|
||||
final aggregates = _telemetryAggregatesFromSamples(samples);
|
||||
await repository.replaceAggregatesForSession(
|
||||
sessionId: domainSample.sessionId,
|
||||
aggregates: aggregates,
|
||||
);
|
||||
return aggregates;
|
||||
}
|
||||
|
||||
WorkoutTelemetrySample? _telemetrySampleFromWatch(
|
||||
WatchTelemetrySample sample,
|
||||
) {
|
||||
final sessionId = sample.sessionId.trim();
|
||||
final hasHeartRate =
|
||||
sample.heartRateBpm != null && sample.heartRateBpm! > 0;
|
||||
final hasDistance =
|
||||
sample.distanceMeters != null && sample.distanceMeters! >= 0;
|
||||
final hasCalories =
|
||||
sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
|
||||
if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) {
|
||||
return null;
|
||||
}
|
||||
final capturedAt = sample.capturedAtEpochMs > 0
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
sample.capturedAtEpochMs,
|
||||
isUtc: true,
|
||||
)
|
||||
: clock.now();
|
||||
return WorkoutTelemetrySample(
|
||||
id: _telemetrySampleId(sample),
|
||||
sessionId: sessionId,
|
||||
capturedAt: capturedAt,
|
||||
programIndex: sample.programIndex,
|
||||
exerciseIndex: sample.exerciseIndex,
|
||||
setIndex: sample.setIndex,
|
||||
passageIndex: sample.passageIndex,
|
||||
stepIndex: sample.stepIndex,
|
||||
programSnapshotId: sample.programSnapshotId,
|
||||
exerciseSnapshotId: sample.exerciseSnapshotId,
|
||||
stepSnapshotId: sample.stepSnapshotId,
|
||||
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
|
||||
distanceMeters: hasDistance ? sample.distanceMeters : null,
|
||||
caloriesKcal: hasCalories ? sample.caloriesKcal : null,
|
||||
);
|
||||
}
|
||||
|
||||
String _telemetrySampleId(WatchTelemetrySample sample) {
|
||||
final stableId = sample.sampleId?.trim();
|
||||
return stableId == null || stableId.isEmpty ? ids.newId() : stableId;
|
||||
}
|
||||
}
|
||||
|
||||
List<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
|
||||
List<WorkoutTelemetrySample> samples,
|
||||
) {
|
||||
final builders = <_TelemetryScopeKey, _TelemetryAggregateBuilder>{};
|
||||
for (final sample in samples) {
|
||||
for (final key in _telemetryScopeKeys(sample)) {
|
||||
builders
|
||||
.putIfAbsent(key, () => _TelemetryAggregateBuilder(key))
|
||||
.add(sample);
|
||||
}
|
||||
}
|
||||
return [for (final builder in builders.values) builder.build()];
|
||||
}
|
||||
|
||||
List<_TelemetryScopeKey> _telemetryScopeKeys(WorkoutTelemetrySample sample) {
|
||||
final keys = [
|
||||
_TelemetryScopeKey(
|
||||
sessionId: sample.sessionId,
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
),
|
||||
];
|
||||
if (sample.programIndex != null && sample.exerciseIndex != null) {
|
||||
keys.add(
|
||||
_TelemetryScopeKey(
|
||||
sessionId: sample.sessionId,
|
||||
scope: WorkoutTelemetryAggregateScope.exercise,
|
||||
programIndex: sample.programIndex,
|
||||
exerciseIndex: sample.exerciseIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sample.programIndex != null &&
|
||||
sample.exerciseIndex != null &&
|
||||
sample.setIndex != null) {
|
||||
keys.add(
|
||||
_TelemetryScopeKey(
|
||||
sessionId: sample.sessionId,
|
||||
scope: WorkoutTelemetryAggregateScope.set,
|
||||
programIndex: sample.programIndex,
|
||||
exerciseIndex: sample.exerciseIndex,
|
||||
setIndex: sample.setIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sample.programIndex != null &&
|
||||
sample.exerciseIndex != null &&
|
||||
sample.setIndex != null &&
|
||||
sample.stepIndex != null) {
|
||||
keys.add(
|
||||
_TelemetryScopeKey(
|
||||
sessionId: sample.sessionId,
|
||||
scope: WorkoutTelemetryAggregateScope.step,
|
||||
programIndex: sample.programIndex,
|
||||
exerciseIndex: sample.exerciseIndex,
|
||||
setIndex: sample.setIndex,
|
||||
passageIndex: sample.passageIndex,
|
||||
stepIndex: sample.stepIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
final class _TelemetryScopeKey {
|
||||
const _TelemetryScopeKey({
|
||||
required this.sessionId,
|
||||
required this.scope,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.stepIndex,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final WorkoutTelemetryAggregateScope scope;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? stepIndex;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is _TelemetryScopeKey &&
|
||||
sessionId == other.sessionId &&
|
||||
scope == other.scope &&
|
||||
programIndex == other.programIndex &&
|
||||
exerciseIndex == other.exerciseIndex &&
|
||||
setIndex == other.setIndex &&
|
||||
passageIndex == other.passageIndex &&
|
||||
stepIndex == other.stepIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
sessionId,
|
||||
scope,
|
||||
programIndex,
|
||||
exerciseIndex,
|
||||
setIndex,
|
||||
passageIndex,
|
||||
stepIndex,
|
||||
);
|
||||
}
|
||||
|
||||
final class _TelemetryAggregateBuilder {
|
||||
_TelemetryAggregateBuilder(this.key);
|
||||
|
||||
final _TelemetryScopeKey key;
|
||||
var sampleCount = 0;
|
||||
var heartRateCount = 0;
|
||||
var heartRateSum = 0.0;
|
||||
int? minHeartRateBpm;
|
||||
int? maxHeartRateBpm;
|
||||
double? maxDistanceMeters;
|
||||
double? maxCaloriesKcal;
|
||||
|
||||
void add(WorkoutTelemetrySample sample) {
|
||||
sampleCount += 1;
|
||||
final heartRate = sample.heartRateBpm;
|
||||
if (heartRate != null) {
|
||||
heartRateCount += 1;
|
||||
heartRateSum += heartRate;
|
||||
minHeartRateBpm = minHeartRateBpm == null
|
||||
? heartRate
|
||||
: (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm);
|
||||
maxHeartRateBpm = maxHeartRateBpm == null
|
||||
? heartRate
|
||||
: (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm);
|
||||
}
|
||||
final distance = sample.distanceMeters;
|
||||
if (distance != null) {
|
||||
maxDistanceMeters = maxDistanceMeters == null
|
||||
? distance
|
||||
: (distance > maxDistanceMeters! ? distance : maxDistanceMeters);
|
||||
}
|
||||
final calories = sample.caloriesKcal;
|
||||
if (calories != null) {
|
||||
maxCaloriesKcal = maxCaloriesKcal == null
|
||||
? calories
|
||||
: (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal);
|
||||
}
|
||||
}
|
||||
|
||||
WorkoutTelemetryAggregate build() {
|
||||
return WorkoutTelemetryAggregate(
|
||||
sessionId: key.sessionId,
|
||||
scope: key.scope,
|
||||
programIndex: key.programIndex,
|
||||
exerciseIndex: key.exerciseIndex,
|
||||
setIndex: key.setIndex,
|
||||
passageIndex: key.passageIndex,
|
||||
stepIndex: key.stepIndex,
|
||||
sampleCount: sampleCount,
|
||||
minHeartRateBpm: minHeartRateBpm,
|
||||
averageHeartRateBpm: heartRateCount == 0
|
||||
? null
|
||||
: heartRateSum / heartRateCount,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
totalDistanceMeters: maxDistanceMeters,
|
||||
totalCaloriesKcal: maxCaloriesKcal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveWorkoutSensorState {
|
||||
const ActiveWorkoutSensorState({
|
||||
required this.sessionId,
|
||||
@ -5707,13 +6025,18 @@ final class WorkoutHistoryUseCases {
|
||||
final history = _findHistoryForSummary(histories, summary);
|
||||
if (history == null ||
|
||||
history.averageHeartRateBpm != null ||
|
||||
history.maxHeartRateBpm != null) {
|
||||
history.maxHeartRateBpm != null ||
|
||||
history.totalDistanceMeters != null ||
|
||||
history.totalCaloriesKcal != null) {
|
||||
return false;
|
||||
}
|
||||
await repository.patchHeartRateSummary(
|
||||
historyId: history.metadata.id,
|
||||
minHeartRateBpm: summary.minHeartRateBpm,
|
||||
averageHeartRateBpm: summary.averageHeartRateBpm!,
|
||||
maxHeartRateBpm: summary.maxHeartRateBpm!,
|
||||
totalDistanceMeters: summary.totalDistanceMeters,
|
||||
totalCaloriesKcal: summary.totalCaloriesKcal,
|
||||
patchedAt: clock.now(),
|
||||
);
|
||||
return true;
|
||||
@ -5746,7 +6069,11 @@ final class WorkoutHistoryUseCases {
|
||||
summary.averageHeartRateBpm != null &&
|
||||
summary.averageHeartRateBpm! > 0 &&
|
||||
summary.maxHeartRateBpm != null &&
|
||||
summary.maxHeartRateBpm! > 0;
|
||||
summary.maxHeartRateBpm! > 0 &&
|
||||
(summary.minHeartRateBpm == null || summary.minHeartRateBpm! > 0) &&
|
||||
(summary.totalDistanceMeters == null ||
|
||||
summary.totalDistanceMeters! >= 0) &&
|
||||
(summary.totalCaloriesKcal == null || summary.totalCaloriesKcal! >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6355,6 +6682,14 @@ Map<String, Object?> _workoutTemplateSharePayload(WorkoutTemplate template) => {
|
||||
.toList(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _packSharePayload(
|
||||
String name,
|
||||
List<WorkoutTemplate> templates,
|
||||
) => {
|
||||
'name': name,
|
||||
'workouts': templates.map(_workoutTemplateSharePayload).toList(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _metadataSharePayload(EntityMetadata metadata) => {
|
||||
'id': metadata.id,
|
||||
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
|
||||
|
||||
@ -29,7 +29,7 @@ enum ActiveSetTimerStatus { running, paused, stopped, skipped }
|
||||
|
||||
enum ActiveScoreStopwatchStatus { running, paused, stopped }
|
||||
|
||||
enum ShareResourceType { program, workoutTemplate }
|
||||
enum ShareResourceType { program, workoutTemplate, pack }
|
||||
|
||||
enum ShareInboxStatus { pending, accepted, declined, revoked }
|
||||
|
||||
@ -1476,16 +1476,25 @@ final class WorkoutHistory {
|
||||
required this.totalActiveMs,
|
||||
required this.completed,
|
||||
required this.historySnapshotJson,
|
||||
this.minHeartRateBpm,
|
||||
this.averageHeartRateBpm,
|
||||
this.maxHeartRateBpm,
|
||||
this.totalDistanceMeters,
|
||||
this.totalCaloriesKcal,
|
||||
this.results = const [],
|
||||
this.stepResults = const [],
|
||||
}) {
|
||||
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
|
||||
_requireNullablePositiveDouble(
|
||||
averageHeartRateBpm,
|
||||
'Average heart rate bpm',
|
||||
);
|
||||
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
|
||||
_requireNullableNonNegativeDouble(
|
||||
totalDistanceMeters,
|
||||
'Total distance meters',
|
||||
);
|
||||
_requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal');
|
||||
}
|
||||
|
||||
final EntityMetadata metadata;
|
||||
@ -1497,15 +1506,21 @@ final class WorkoutHistory {
|
||||
final int totalActiveMs;
|
||||
final bool completed;
|
||||
final String historySnapshotJson;
|
||||
final int? minHeartRateBpm;
|
||||
final double? averageHeartRateBpm;
|
||||
final int? maxHeartRateBpm;
|
||||
final double? totalDistanceMeters;
|
||||
final double? totalCaloriesKcal;
|
||||
final List<WorkoutHistorySetResult> results;
|
||||
final List<WorkoutHistoryStepResult> stepResults;
|
||||
|
||||
WorkoutHistory copyWith({
|
||||
EntityMetadata? metadata,
|
||||
Object? minHeartRateBpm = _unchanged,
|
||||
Object? averageHeartRateBpm = _unchanged,
|
||||
Object? maxHeartRateBpm = _unchanged,
|
||||
Object? totalDistanceMeters = _unchanged,
|
||||
Object? totalCaloriesKcal = _unchanged,
|
||||
}) {
|
||||
return WorkoutHistory(
|
||||
metadata: metadata ?? this.metadata,
|
||||
@ -1517,18 +1532,149 @@ final class WorkoutHistory {
|
||||
totalActiveMs: totalActiveMs,
|
||||
completed: completed,
|
||||
historySnapshotJson: historySnapshotJson,
|
||||
minHeartRateBpm: minHeartRateBpm == _unchanged
|
||||
? this.minHeartRateBpm
|
||||
: minHeartRateBpm as int?,
|
||||
averageHeartRateBpm: averageHeartRateBpm == _unchanged
|
||||
? this.averageHeartRateBpm
|
||||
: averageHeartRateBpm as double?,
|
||||
maxHeartRateBpm: maxHeartRateBpm == _unchanged
|
||||
? this.maxHeartRateBpm
|
||||
: maxHeartRateBpm as int?,
|
||||
totalDistanceMeters: totalDistanceMeters == _unchanged
|
||||
? this.totalDistanceMeters
|
||||
: totalDistanceMeters as double?,
|
||||
totalCaloriesKcal: totalCaloriesKcal == _unchanged
|
||||
? this.totalCaloriesKcal
|
||||
: totalCaloriesKcal as double?,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkoutTelemetryAggregateScope {
|
||||
session('session'),
|
||||
exercise('exercise'),
|
||||
set('set'),
|
||||
step('step');
|
||||
|
||||
const WorkoutTelemetryAggregateScope(this.wireName);
|
||||
|
||||
final String wireName;
|
||||
|
||||
static WorkoutTelemetryAggregateScope parse(String value) {
|
||||
for (final scope in values) {
|
||||
if (scope.wireName == value) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
throw DomainException('Unsupported telemetry scope: $value.');
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkoutTelemetrySample {
|
||||
WorkoutTelemetrySample({
|
||||
required String id,
|
||||
required String sessionId,
|
||||
required DateTime capturedAt,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.stepIndex,
|
||||
this.programSnapshotId,
|
||||
this.exerciseSnapshotId,
|
||||
this.stepSnapshotId,
|
||||
this.heartRateBpm,
|
||||
this.distanceMeters,
|
||||
this.caloriesKcal,
|
||||
}) : id = _nonBlank(id, 'Telemetry sample id'),
|
||||
sessionId = _nonBlank(sessionId, 'Telemetry session id'),
|
||||
capturedAt = capturedAt.toUtc() {
|
||||
_requireNullableNonNegative(programIndex, 'Program index');
|
||||
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
|
||||
_requireNullableNonNegative(setIndex, 'Set index');
|
||||
_requireNullableNonNegative(passageIndex, 'Passage index');
|
||||
_requireNullableNonNegative(stepIndex, 'Step index');
|
||||
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
|
||||
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
|
||||
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
|
||||
if (heartRateBpm == null &&
|
||||
distanceMeters == null &&
|
||||
caloriesKcal == null) {
|
||||
throw const DomainException(
|
||||
'Telemetry sample must contain at least one metric.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String sessionId;
|
||||
final DateTime capturedAt;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? stepIndex;
|
||||
final String? programSnapshotId;
|
||||
final String? exerciseSnapshotId;
|
||||
final String? stepSnapshotId;
|
||||
final int? heartRateBpm;
|
||||
final double? distanceMeters;
|
||||
final double? caloriesKcal;
|
||||
}
|
||||
|
||||
final class WorkoutTelemetryAggregate {
|
||||
WorkoutTelemetryAggregate({
|
||||
required String sessionId,
|
||||
required this.scope,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.stepIndex,
|
||||
required this.sampleCount,
|
||||
this.minHeartRateBpm,
|
||||
this.averageHeartRateBpm,
|
||||
this.maxHeartRateBpm,
|
||||
this.totalDistanceMeters,
|
||||
this.totalCaloriesKcal,
|
||||
}) : sessionId = _nonBlank(sessionId, 'Telemetry aggregate session id') {
|
||||
_requireNullableNonNegative(programIndex, 'Program index');
|
||||
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
|
||||
_requireNullableNonNegative(setIndex, 'Set index');
|
||||
_requireNullableNonNegative(passageIndex, 'Passage index');
|
||||
_requireNullableNonNegative(stepIndex, 'Step index');
|
||||
_requireNonNegative(sampleCount, 'Telemetry sample count');
|
||||
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
|
||||
_requireNullablePositiveDouble(
|
||||
averageHeartRateBpm,
|
||||
'Average heart rate bpm',
|
||||
);
|
||||
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
|
||||
_requireNullableNonNegativeDouble(
|
||||
totalDistanceMeters,
|
||||
'Total distance meters',
|
||||
);
|
||||
_requireNullableNonNegativeDouble(totalCaloriesKcal, 'Total calories kcal');
|
||||
}
|
||||
|
||||
final String sessionId;
|
||||
final WorkoutTelemetryAggregateScope scope;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? stepIndex;
|
||||
final int sampleCount;
|
||||
final int? minHeartRateBpm;
|
||||
final double? averageHeartRateBpm;
|
||||
final int? maxHeartRateBpm;
|
||||
final double? totalDistanceMeters;
|
||||
final double? totalCaloriesKcal;
|
||||
}
|
||||
|
||||
final class WorkoutHistorySetResult {
|
||||
WorkoutHistorySetResult({
|
||||
required this.metadata,
|
||||
|
||||
@ -28,6 +28,8 @@ part 'app_database.g.dart';
|
||||
RemoteResourceMappings,
|
||||
ShareInboxItems,
|
||||
SyncMetadataEntries,
|
||||
WorkoutTelemetryAggregates,
|
||||
WorkoutTelemetrySamples,
|
||||
WorkoutHistories,
|
||||
WorkoutHistorySetResults,
|
||||
WorkoutHistoryStepResults,
|
||||
@ -49,7 +51,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 22;
|
||||
int get schemaVersion => 24;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -129,6 +131,12 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 22) {
|
||||
await _migrateToSchema22();
|
||||
}
|
||||
if (from < 23) {
|
||||
await _migrateToSchema23();
|
||||
}
|
||||
if (from < 24) {
|
||||
await _migrateToSchema24(migrator);
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -138,6 +146,9 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
Future<void> _createIndexes() async {
|
||||
if (!await _hasTable(_syncableTableNames.first)) {
|
||||
return;
|
||||
}
|
||||
for (final tableName in _syncableTableNames) {
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_${tableName}_deleted_at '
|
||||
@ -250,6 +261,14 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'ON workout_history (completed, started_at) '
|
||||
'WHERE deleted_at IS NULL',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_samples_session '
|
||||
'ON workout_telemetry_samples (session_id, captured_at)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_telemetry_aggregates_session '
|
||||
'ON workout_telemetry_aggregates (session_id, scope)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
|
||||
'ON workout_history_set_results (workout_history_id)',
|
||||
@ -816,6 +835,127 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states (
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema23() async {
|
||||
await customStatement('''
|
||||
CREATE TABLE IF NOT EXISTS share_inbox_items_v23 (
|
||||
share_id TEXT NOT NULL PRIMARY KEY,
|
||||
sender_user_id TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL CHECK (
|
||||
resource_type IN ('program', 'workoutTemplate', 'pack')
|
||||
),
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN ('pending', 'accepted', 'declined', 'revoked')
|
||||
),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
responded_at INTEGER
|
||||
)
|
||||
''');
|
||||
await customStatement('''
|
||||
INSERT INTO share_inbox_items_v23 (
|
||||
share_id,
|
||||
sender_user_id,
|
||||
resource_type,
|
||||
payload_json,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
responded_at
|
||||
)
|
||||
SELECT
|
||||
share_id,
|
||||
sender_user_id,
|
||||
resource_type,
|
||||
payload_json,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
responded_at
|
||||
FROM share_inbox_items
|
||||
''');
|
||||
await customStatement('DROP TABLE share_inbox_items');
|
||||
await customStatement(
|
||||
'ALTER TABLE share_inbox_items_v23 RENAME TO share_inbox_items',
|
||||
);
|
||||
|
||||
await customStatement('''
|
||||
CREATE TABLE IF NOT EXISTS pending_share_actions_v23 (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
action_type TEXT NOT NULL CHECK (
|
||||
action_type IN ('send', 'accept', 'decline', 'revoke')
|
||||
),
|
||||
share_id TEXT,
|
||||
resource_type TEXT CHECK (
|
||||
resource_type IS NULL OR
|
||||
resource_type IN ('program', 'workoutTemplate', 'pack')
|
||||
),
|
||||
payload_json TEXT,
|
||||
recipient_emails_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt_at INTEGER,
|
||||
attempt_count INTEGER NOT NULL CHECK (attempt_count >= 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed'))
|
||||
)
|
||||
''');
|
||||
await customStatement('''
|
||||
INSERT INTO pending_share_actions_v23 (
|
||||
id,
|
||||
action_type,
|
||||
share_id,
|
||||
resource_type,
|
||||
payload_json,
|
||||
recipient_emails_json,
|
||||
created_at,
|
||||
last_attempt_at,
|
||||
attempt_count,
|
||||
status
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
action_type,
|
||||
share_id,
|
||||
resource_type,
|
||||
payload_json,
|
||||
recipient_emails_json,
|
||||
created_at,
|
||||
last_attempt_at,
|
||||
attempt_count,
|
||||
status
|
||||
FROM pending_share_actions
|
||||
''');
|
||||
await customStatement('DROP TABLE pending_share_actions');
|
||||
await customStatement(
|
||||
'ALTER TABLE pending_share_actions_v23 RENAME TO pending_share_actions',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema24(Migrator migrator) async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'min_heart_rate_bpm',
|
||||
definition:
|
||||
'min_heart_rate_bpm INTEGER CHECK '
|
||||
'(min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)',
|
||||
);
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'total_distance_meters',
|
||||
definition:
|
||||
'total_distance_meters REAL CHECK '
|
||||
'(total_distance_meters IS NULL OR total_distance_meters >= 0)',
|
||||
);
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'workout_history',
|
||||
columnName: 'total_calories_kcal',
|
||||
definition:
|
||||
'total_calories_kcal REAL CHECK '
|
||||
'(total_calories_kcal IS NULL OR total_calories_kcal >= 0)',
|
||||
);
|
||||
await migrator.createTable(workoutTelemetrySamples);
|
||||
await migrator.createTable(workoutTelemetryAggregates);
|
||||
}
|
||||
|
||||
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
|
||||
await customStatement(r'''
|
||||
UPDATE workout_history_set_results AS result
|
||||
@ -890,4 +1030,12 @@ WHERE result.source_exercise_id_snapshot IS NULL
|
||||
final rows = await customSelect('PRAGMA table_info($tableName)').get();
|
||||
return rows.any((row) => row.data['name'] == columnName);
|
||||
}
|
||||
|
||||
Future<bool> _hasTable(String tableName) async {
|
||||
final rows = await customSelect(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
variables: [Variable<String>(tableName)],
|
||||
).get();
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -950,25 +950,38 @@ final class DriftWorkoutTemplateRepository
|
||||
@override
|
||||
Future<void> save(domain.WorkoutTemplate template) async {
|
||||
await database.transaction(() async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'workout_templates',
|
||||
entityType: 'WorkoutTemplate',
|
||||
metadata: template.metadata,
|
||||
write: () => database
|
||||
.into(database.workoutTemplates)
|
||||
.insertOnConflictUpdate(_workoutTemplateCompanion(template)),
|
||||
);
|
||||
await _writeWorkoutTemplateStarterMetadata(database, template);
|
||||
for (final program in template.programs) {
|
||||
await saveProgram(program);
|
||||
}
|
||||
for (final override in template.overrides) {
|
||||
await saveOverride(override);
|
||||
await _saveTemplate(template);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAll(List<domain.WorkoutTemplate> templates) async {
|
||||
await database.transaction(() async {
|
||||
for (final template in templates) {
|
||||
await _saveTemplate(template);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveTemplate(domain.WorkoutTemplate template) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'workout_templates',
|
||||
entityType: 'WorkoutTemplate',
|
||||
metadata: template.metadata,
|
||||
write: () => database
|
||||
.into(database.workoutTemplates)
|
||||
.insertOnConflictUpdate(_workoutTemplateCompanion(template)),
|
||||
);
|
||||
await _writeWorkoutTemplateStarterMetadata(database, template);
|
||||
for (final program in template.programs) {
|
||||
await saveProgram(program);
|
||||
}
|
||||
for (final override in template.overrides) {
|
||||
await saveOverride(override);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveProgram(domain.WorkoutTemplateProgram program) async {
|
||||
await _upsertWithChangeLog(
|
||||
@ -1614,11 +1627,18 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
@override
|
||||
Future<void> patchHeartRateSummary({
|
||||
required String historyId,
|
||||
int? minHeartRateBpm,
|
||||
required double averageHeartRateBpm,
|
||||
required int maxHeartRateBpm,
|
||||
double? totalDistanceMeters,
|
||||
double? totalCaloriesKcal,
|
||||
required DateTime patchedAt,
|
||||
}) async {
|
||||
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
|
||||
if ((minHeartRateBpm != null && minHeartRateBpm <= 0) ||
|
||||
averageHeartRateBpm <= 0 ||
|
||||
maxHeartRateBpm <= 0 ||
|
||||
(totalDistanceMeters != null && totalDistanceMeters < 0) ||
|
||||
(totalCaloriesKcal != null && totalCaloriesKcal < 0)) {
|
||||
return;
|
||||
}
|
||||
final row =
|
||||
@ -1626,8 +1646,11 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
(table) =>
|
||||
table.id.equals(historyId) &
|
||||
table.deletedAt.isNull() &
|
||||
table.minHeartRateBpm.isNull() &
|
||||
table.averageHeartRateBpm.isNull() &
|
||||
table.maxHeartRateBpm.isNull(),
|
||||
table.maxHeartRateBpm.isNull() &
|
||||
table.totalDistanceMeters.isNull() &
|
||||
table.totalCaloriesKcal.isNull(),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
@ -1641,8 +1664,11 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
updatedAt: Value(patchedAt.toUtc()),
|
||||
syncState: const Value('dirty'),
|
||||
localRevision: Value(revision),
|
||||
minHeartRateBpm: Value(minHeartRateBpm),
|
||||
averageHeartRateBpm: Value(averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(maxHeartRateBpm),
|
||||
totalDistanceMeters: Value(totalDistanceMeters),
|
||||
totalCaloriesKcal: Value(totalCaloriesKcal),
|
||||
),
|
||||
);
|
||||
await _writeChangeLog(
|
||||
@ -1736,6 +1762,103 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftWorkoutTelemetryRepository
|
||||
implements WorkoutTelemetryRepository {
|
||||
const DriftWorkoutTelemetryRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<bool> saveSample(domain.WorkoutTelemetrySample sample) async {
|
||||
final existing = await (database.select(
|
||||
database.workoutTelemetrySamples,
|
||||
)..where((table) => table.id.equals(sample.id))).getSingleOrNull();
|
||||
if (existing != null) {
|
||||
return false;
|
||||
}
|
||||
final inserted = await database
|
||||
.into(database.workoutTelemetrySamples)
|
||||
.insert(
|
||||
_workoutTelemetrySampleCompanion(sample),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
return inserted > 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.WorkoutTelemetrySample>> listSamples(
|
||||
String sessionId,
|
||||
) async {
|
||||
final rows =
|
||||
await (database.select(database.workoutTelemetrySamples)
|
||||
..where((table) => table.sessionId.equals(sessionId))
|
||||
..orderBy([(table) => OrderingTerm.asc(table.capturedAt)]))
|
||||
.get();
|
||||
return rows.map(_workoutTelemetrySampleFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<domain.WorkoutTelemetryAggregate> aggregates,
|
||||
}) async {
|
||||
await database.transaction(() async {
|
||||
await (database.delete(
|
||||
database.workoutTelemetryAggregates,
|
||||
)..where((table) => table.sessionId.equals(sessionId))).go();
|
||||
if (aggregates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await database.batch((batch) {
|
||||
batch.insertAll(
|
||||
database.workoutTelemetryAggregates,
|
||||
aggregates.map(_workoutTelemetryAggregateCompanion).toList(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.WorkoutTelemetryAggregate>> listAggregates(
|
||||
String sessionId,
|
||||
) async {
|
||||
final rows = await (database.select(
|
||||
database.workoutTelemetryAggregates,
|
||||
)..where((table) => table.sessionId.equals(sessionId))).get();
|
||||
return rows.map(_workoutTelemetryAggregateFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.WorkoutTelemetryAggregate?> findAggregate({
|
||||
required String sessionId,
|
||||
required domain.WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
final rows =
|
||||
await (database.select(database.workoutTelemetryAggregates)..where(
|
||||
(table) =>
|
||||
table.sessionId.equals(sessionId) &
|
||||
table.scope.equals(scope.wireName),
|
||||
))
|
||||
.get();
|
||||
for (final row in rows) {
|
||||
final aggregate = _workoutTelemetryAggregateFromRow(row);
|
||||
if (aggregate.programIndex == programIndex &&
|
||||
aggregate.exerciseIndex == exerciseIndex &&
|
||||
aggregate.setIndex == setIndex &&
|
||||
aggregate.passageIndex == passageIndex &&
|
||||
aggregate.stepIndex == stepIndex) {
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftLocalDataBackupRepository
|
||||
implements LocalDataBackupRepository {
|
||||
const DriftLocalDataBackupRepository(this.database);
|
||||
@ -4363,8 +4486,52 @@ db.WorkoutHistoriesCompanion _workoutHistoryCompanion(
|
||||
totalActiveMs: Value(history.totalActiveMs),
|
||||
completed: Value(history.completed),
|
||||
historySnapshotJson: Value(history.historySnapshotJson),
|
||||
minHeartRateBpm: Value(history.minHeartRateBpm),
|
||||
averageHeartRateBpm: Value(history.averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(history.maxHeartRateBpm),
|
||||
totalDistanceMeters: Value(history.totalDistanceMeters),
|
||||
totalCaloriesKcal: Value(history.totalCaloriesKcal),
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutTelemetrySamplesCompanion _workoutTelemetrySampleCompanion(
|
||||
domain.WorkoutTelemetrySample sample,
|
||||
) {
|
||||
return db.WorkoutTelemetrySamplesCompanion.insert(
|
||||
id: sample.id,
|
||||
sessionId: sample.sessionId,
|
||||
capturedAt: sample.capturedAt.toUtc(),
|
||||
programIndex: Value(sample.programIndex),
|
||||
exerciseIndex: Value(sample.exerciseIndex),
|
||||
setIndex: Value(sample.setIndex),
|
||||
passageIndex: Value(sample.passageIndex),
|
||||
stepIndex: Value(sample.stepIndex),
|
||||
programSnapshotId: Value(sample.programSnapshotId),
|
||||
exerciseSnapshotId: Value(sample.exerciseSnapshotId),
|
||||
stepSnapshotId: Value(sample.stepSnapshotId),
|
||||
heartRateBpm: Value(sample.heartRateBpm),
|
||||
distanceMeters: Value(sample.distanceMeters),
|
||||
caloriesKcal: Value(sample.caloriesKcal),
|
||||
);
|
||||
}
|
||||
|
||||
db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion(
|
||||
domain.WorkoutTelemetryAggregate aggregate,
|
||||
) {
|
||||
return db.WorkoutTelemetryAggregatesCompanion.insert(
|
||||
sessionId: aggregate.sessionId,
|
||||
scope: aggregate.scope.wireName,
|
||||
programIndex: Value(aggregate.programIndex),
|
||||
exerciseIndex: Value(aggregate.exerciseIndex),
|
||||
setIndex: Value(aggregate.setIndex),
|
||||
passageIndex: Value(aggregate.passageIndex),
|
||||
stepIndex: Value(aggregate.stepIndex),
|
||||
sampleCount: aggregate.sampleCount,
|
||||
minHeartRateBpm: Value(aggregate.minHeartRateBpm),
|
||||
averageHeartRateBpm: Value(aggregate.averageHeartRateBpm),
|
||||
maxHeartRateBpm: Value(aggregate.maxHeartRateBpm),
|
||||
totalDistanceMeters: Value(aggregate.totalDistanceMeters),
|
||||
totalCaloriesKcal: Value(aggregate.totalCaloriesKcal),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4480,13 +4647,57 @@ domain.WorkoutHistory _workoutHistoryFromRow(
|
||||
totalActiveMs: row.totalActiveMs,
|
||||
completed: row.completed,
|
||||
historySnapshotJson: row.historySnapshotJson,
|
||||
minHeartRateBpm: row.minHeartRateBpm,
|
||||
averageHeartRateBpm: row.averageHeartRateBpm,
|
||||
maxHeartRateBpm: row.maxHeartRateBpm,
|
||||
totalDistanceMeters: row.totalDistanceMeters,
|
||||
totalCaloriesKcal: row.totalCaloriesKcal,
|
||||
results: results,
|
||||
stepResults: stepResults,
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow(
|
||||
db.WorkoutTelemetrySample row,
|
||||
) {
|
||||
return domain.WorkoutTelemetrySample(
|
||||
id: row.id,
|
||||
sessionId: row.sessionId,
|
||||
capturedAt: _utc(row.capturedAt),
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
passageIndex: row.passageIndex,
|
||||
stepIndex: row.stepIndex,
|
||||
programSnapshotId: row.programSnapshotId,
|
||||
exerciseSnapshotId: row.exerciseSnapshotId,
|
||||
stepSnapshotId: row.stepSnapshotId,
|
||||
heartRateBpm: row.heartRateBpm,
|
||||
distanceMeters: row.distanceMeters,
|
||||
caloriesKcal: row.caloriesKcal,
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow(
|
||||
db.WorkoutTelemetryAggregate row,
|
||||
) {
|
||||
return domain.WorkoutTelemetryAggregate(
|
||||
sessionId: row.sessionId,
|
||||
scope: domain.WorkoutTelemetryAggregateScope.parse(row.scope),
|
||||
programIndex: row.programIndex,
|
||||
exerciseIndex: row.exerciseIndex,
|
||||
setIndex: row.setIndex,
|
||||
passageIndex: row.passageIndex,
|
||||
stepIndex: row.stepIndex,
|
||||
sampleCount: row.sampleCount,
|
||||
minHeartRateBpm: row.minHeartRateBpm,
|
||||
averageHeartRateBpm: row.averageHeartRateBpm,
|
||||
maxHeartRateBpm: row.maxHeartRateBpm,
|
||||
totalDistanceMeters: row.totalDistanceMeters,
|
||||
totalCaloriesKcal: row.totalCaloriesKcal,
|
||||
);
|
||||
}
|
||||
|
||||
domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow(
|
||||
db.WorkoutHistorySetResult row,
|
||||
) {
|
||||
@ -4739,8 +4950,11 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
|
||||
'totalActiveMs': history.totalActiveMs,
|
||||
'completed': history.completed,
|
||||
'historySnapshotJson': history.historySnapshotJson,
|
||||
'minHeartRateBpm': history.minHeartRateBpm,
|
||||
'averageHeartRateBpm': history.averageHeartRateBpm,
|
||||
'maxHeartRateBpm': history.maxHeartRateBpm,
|
||||
'totalDistanceMeters': history.totalDistanceMeters,
|
||||
'totalCaloriesKcal': history.totalCaloriesKcal,
|
||||
};
|
||||
|
||||
Map<String, Object?> _localWorkoutHistoryPayload(
|
||||
@ -4895,8 +5109,11 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
|
||||
completed: payload['completed'] as bool? ?? false,
|
||||
historySnapshotJson:
|
||||
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
|
||||
minHeartRateBpm: payload['minHeartRateBpm'] as int?,
|
||||
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
|
||||
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
|
||||
totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(),
|
||||
totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(),
|
||||
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
|
||||
stepResults: _workoutHistoryStepResultsFromPayload(
|
||||
payload['stepResults'],
|
||||
@ -5412,12 +5629,14 @@ OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) {
|
||||
String _shareResourceTypeToDb(domain.ShareResourceType type) => switch (type) {
|
||||
domain.ShareResourceType.program => 'program',
|
||||
domain.ShareResourceType.workoutTemplate => 'workoutTemplate',
|
||||
domain.ShareResourceType.pack => 'pack',
|
||||
};
|
||||
|
||||
domain.ShareResourceType _shareResourceTypeFromDb(String value) =>
|
||||
switch (value) {
|
||||
'program' => domain.ShareResourceType.program,
|
||||
'workoutTemplate' => domain.ShareResourceType.workoutTemplate,
|
||||
'pack' => domain.ShareResourceType.pack,
|
||||
_ => throw domain.DomainException('Unknown share resource type: $value'),
|
||||
};
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ class ShareInboxItems extends Table {
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (resource_type IN ('program', 'workoutTemplate'))",
|
||||
"CHECK (resource_type IN ('program', 'workoutTemplate', 'pack'))",
|
||||
"CHECK (status IN ('pending', 'accepted', 'declined', 'revoked'))",
|
||||
];
|
||||
}
|
||||
@ -138,7 +138,7 @@ class PendingShareActions extends Table {
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (action_type IN ('send', 'accept', 'decline', 'revoke'))",
|
||||
'CHECK (resource_type IS NULL OR resource_type IN '
|
||||
"('program', 'workoutTemplate'))",
|
||||
"('program', 'workoutTemplate', 'pack'))",
|
||||
"CHECK (status IN ('pending', 'succeeded', 'failed'))",
|
||||
];
|
||||
}
|
||||
@ -750,14 +750,94 @@ class WorkoutHistories extends SyncableTable {
|
||||
IntColumn get totalActiveMs => integer()();
|
||||
BoolColumn get completed => boolean()();
|
||||
TextColumn get historySnapshotJson => text().withLength(min: 1)();
|
||||
IntColumn get minHeartRateBpm => integer().nullable()();
|
||||
RealColumn get averageHeartRateBpm => real().nullable()();
|
||||
IntColumn get maxHeartRateBpm => integer().nullable()();
|
||||
RealColumn get totalDistanceMeters => real().nullable()();
|
||||
RealColumn get totalCaloriesKcal => real().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'CHECK (total_active_ms >= 0)',
|
||||
'CHECK (min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)',
|
||||
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
'CHECK (total_distance_meters IS NULL OR total_distance_meters >= 0)',
|
||||
'CHECK (total_calories_kcal IS NULL OR total_calories_kcal >= 0)',
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutTelemetrySamples extends Table {
|
||||
@override
|
||||
String get tableName => 'workout_telemetry_samples';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get sessionId => text().withLength(min: 1)();
|
||||
DateTimeColumn get capturedAt => dateTime()();
|
||||
IntColumn get programIndex => integer().nullable()();
|
||||
IntColumn get exerciseIndex => integer().nullable()();
|
||||
IntColumn get setIndex => integer().nullable()();
|
||||
IntColumn get passageIndex => integer().nullable()();
|
||||
IntColumn get stepIndex => integer().nullable()();
|
||||
TextColumn get programSnapshotId => text().nullable()();
|
||||
TextColumn get exerciseSnapshotId => text().nullable()();
|
||||
TextColumn get stepSnapshotId => text().nullable()();
|
||||
IntColumn get heartRateBpm => integer().nullable()();
|
||||
RealColumn get distanceMeters => real().nullable()();
|
||||
RealColumn get caloriesKcal => real().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'CHECK (program_index IS NULL OR program_index >= 0)',
|
||||
'CHECK (exercise_index IS NULL OR exercise_index >= 0)',
|
||||
'CHECK (set_index IS NULL OR set_index >= 0)',
|
||||
'CHECK (passage_index IS NULL OR passage_index >= 0)',
|
||||
'CHECK (step_index IS NULL OR step_index >= 0)',
|
||||
'CHECK (heart_rate_bpm IS NULL OR heart_rate_bpm > 0)',
|
||||
'CHECK (distance_meters IS NULL OR distance_meters >= 0)',
|
||||
'CHECK (calories_kcal IS NULL OR calories_kcal >= 0)',
|
||||
'CHECK (heart_rate_bpm IS NOT NULL OR distance_meters IS NOT NULL OR '
|
||||
'calories_kcal IS NOT NULL)',
|
||||
];
|
||||
}
|
||||
|
||||
class WorkoutTelemetryAggregates extends Table {
|
||||
@override
|
||||
String get tableName => 'workout_telemetry_aggregates';
|
||||
|
||||
TextColumn get sessionId => text().withLength(min: 1)();
|
||||
TextColumn get scope => text()();
|
||||
IntColumn get programIndex => integer().nullable()();
|
||||
IntColumn get exerciseIndex => integer().nullable()();
|
||||
IntColumn get setIndex => integer().nullable()();
|
||||
IntColumn get passageIndex => integer().nullable()();
|
||||
IntColumn get stepIndex => integer().nullable()();
|
||||
IntColumn get sampleCount => integer()();
|
||||
IntColumn get minHeartRateBpm => integer().nullable()();
|
||||
RealColumn get averageHeartRateBpm => real().nullable()();
|
||||
IntColumn get maxHeartRateBpm => integer().nullable()();
|
||||
RealColumn get totalDistanceMeters => real().nullable()();
|
||||
RealColumn get totalCaloriesKcal => real().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (scope IN ('session', 'exercise', 'set', 'step'))",
|
||||
'CHECK (program_index IS NULL OR program_index >= 0)',
|
||||
'CHECK (exercise_index IS NULL OR exercise_index >= 0)',
|
||||
'CHECK (set_index IS NULL OR set_index >= 0)',
|
||||
'CHECK (passage_index IS NULL OR passage_index >= 0)',
|
||||
'CHECK (step_index IS NULL OR step_index >= 0)',
|
||||
'CHECK (sample_count >= 0)',
|
||||
'CHECK (min_heart_rate_bpm IS NULL OR min_heart_rate_bpm > 0)',
|
||||
'CHECK (average_heart_rate_bpm IS NULL OR average_heart_rate_bpm > 0)',
|
||||
'CHECK (max_heart_rate_bpm IS NULL OR max_heart_rate_bpm > 0)',
|
||||
'CHECK (total_distance_meters IS NULL OR total_distance_meters >= 0)',
|
||||
'CHECK (total_calories_kcal IS NULL OR total_calories_kcal >= 0)',
|
||||
'UNIQUE (session_id, scope, program_index, exercise_index, set_index, '
|
||||
'passage_index, step_index)',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -94,11 +94,13 @@ RemoteSyncedItem _syncedItemFromJson(Map<String, Object?> json) {
|
||||
String _shareResourceTypeToWire(ShareResourceType type) => switch (type) {
|
||||
ShareResourceType.program => 'program',
|
||||
ShareResourceType.workoutTemplate => 'workoutTemplate',
|
||||
ShareResourceType.pack => 'pack',
|
||||
};
|
||||
|
||||
ShareResourceType _shareResourceTypeFromWire(String value) => switch (value) {
|
||||
'program' => ShareResourceType.program,
|
||||
'workoutTemplate' => ShareResourceType.workoutTemplate,
|
||||
'pack' => ShareResourceType.pack,
|
||||
_ => throw RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unknown share resource type: $value',
|
||||
|
||||
@ -13,12 +13,14 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
required WatchProjectionSource projectionSource,
|
||||
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||
WorkoutTelemetryUseCases? workoutTelemetryUseCases,
|
||||
Duration projectionRefreshInterval = const Duration(seconds: 2),
|
||||
}) : _nativeChannel = nativeChannel,
|
||||
_commandIngress = commandIngress,
|
||||
_projectionSource = projectionSource,
|
||||
_workoutHistoryUseCases = workoutHistoryUseCases,
|
||||
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
|
||||
_workoutTelemetryUseCases = workoutTelemetryUseCases,
|
||||
_projectionRefreshInterval = projectionRefreshInterval;
|
||||
|
||||
final WatchBridgeNativeChannel _nativeChannel;
|
||||
@ -26,6 +28,7 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
final WatchProjectionSource _projectionSource;
|
||||
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
|
||||
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
|
||||
final WorkoutTelemetryUseCases? _workoutTelemetryUseCases;
|
||||
final Duration _projectionRefreshInterval;
|
||||
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
@ -60,10 +63,13 @@ final class WatchWearDataLayerAdapter implements WatchProjectionPublisher {
|
||||
);
|
||||
}
|
||||
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
|
||||
if (activeWorkoutSensorUseCases != null) {
|
||||
final workoutTelemetryUseCases = _workoutTelemetryUseCases;
|
||||
if (activeWorkoutSensorUseCases != null ||
|
||||
workoutTelemetryUseCases != null) {
|
||||
_subscriptions.add(
|
||||
_nativeChannel.sensorSamples.listen((sample) {
|
||||
activeWorkoutSensorUseCases.recordTelemetrySample(sample);
|
||||
activeWorkoutSensorUseCases?.recordTelemetrySample(sample);
|
||||
unawaited(workoutTelemetryUseCases?.recordTelemetrySample(sample));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract interface class ExerciseStepAudioCuePlayer {
|
||||
Future<void> playShortCountdownBeep();
|
||||
@ -13,9 +14,7 @@ final class AudioplayersExerciseStepAudioCuePlayer
|
||||
AudioplayersExerciseStepAudioCuePlayer();
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
late final Future<void> _audioContextReady = _player.setAudioContext(
|
||||
_stepCueAudioContext,
|
||||
);
|
||||
late final Future<void> _audioContextReady = _configureAudioContext();
|
||||
|
||||
@override
|
||||
Future<void> playShortCountdownBeep() {
|
||||
@ -33,6 +32,14 @@ final class AudioplayersExerciseStepAudioCuePlayer
|
||||
await _player.play(AssetSource(assetPath));
|
||||
}
|
||||
|
||||
Future<void> _configureAudioContext() async {
|
||||
try {
|
||||
await _player.setAudioContext(_stepCueAudioContext);
|
||||
} on Object catch (error) {
|
||||
debugPrint('Audio cue context ignored: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
return _player.dispose();
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'share_inbox_screen.dart';
|
||||
import 'share_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
final class ProfileScreen extends StatefulWidget {
|
||||
@ -69,6 +70,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
syncUseCases: widget.syncUseCases,
|
||||
onLogout: () => _confirmLogout(context),
|
||||
onShares: () => _openReceivedShares(context),
|
||||
onSharePack: () => _openSharePack(context),
|
||||
dataExportUseCase: widget.dataExportUseCase,
|
||||
dataImportUseCase: widget.dataImportUseCase,
|
||||
backupFileExporter: widget.backupFileExporter,
|
||||
@ -153,6 +155,18 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _openSharePack(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SharePackSelectionScreen(
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reloadSession() {
|
||||
setState(() {
|
||||
_session = widget.authUseCases.currentSession();
|
||||
@ -258,6 +272,7 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
required this.syncUseCases,
|
||||
required this.onLogout,
|
||||
required this.onShares,
|
||||
required this.onSharePack,
|
||||
required this.dataExportUseCase,
|
||||
required this.dataImportUseCase,
|
||||
required this.backupFileExporter,
|
||||
@ -268,6 +283,7 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
final SyncUseCases syncUseCases;
|
||||
final VoidCallback onLogout;
|
||||
final VoidCallback onShares;
|
||||
final VoidCallback onSharePack;
|
||||
final DataExportUseCase dataExportUseCase;
|
||||
final DataImportUseCase dataImportUseCase;
|
||||
final LocalBackupFileExporter backupFileExporter;
|
||||
@ -320,18 +336,34 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CourtBlazerAccentPanel(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inbox_outlined),
|
||||
title: const Text('Partages reçus'),
|
||||
subtitle: const Text(
|
||||
"Programmes et séances reçus d'autres comptes",
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: const Text('Partager un pack'),
|
||||
subtitle: const Text('Envoyer plusieurs séances ensemble'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onSharePack,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onShares,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inbox_outlined),
|
||||
title: const Text('Partages reçus'),
|
||||
subtitle: const Text(
|
||||
"Programmes, séances et packs reçus d'autres comptes",
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onShares,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -46,6 +46,9 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
final item = items[index];
|
||||
return _ShareInboxTile(
|
||||
item: item,
|
||||
onOpen: item.resourceType == ShareResourceType.pack
|
||||
? () => _openPack(item)
|
||||
: null,
|
||||
onAccept: item.status == ShareInboxStatus.pending
|
||||
? () => _accept(item)
|
||||
: null,
|
||||
@ -80,6 +83,22 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPack(ShareInboxItem item) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _SharePackDetailScreen(
|
||||
item: item,
|
||||
onImport: item.status == ShareInboxStatus.pending
|
||||
? () => _accept(item)
|
||||
: null,
|
||||
onDecline: item.status == ShareInboxStatus.pending
|
||||
? () => _decline(item)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _decline(ShareInboxItem item) async {
|
||||
await widget.shareUseCases.declineShare(item.shareId);
|
||||
if (!mounted) return;
|
||||
@ -94,11 +113,13 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
final class _ShareInboxTile extends StatelessWidget {
|
||||
const _ShareInboxTile({
|
||||
required this.item,
|
||||
required this.onOpen,
|
||||
required this.onAccept,
|
||||
required this.onDecline,
|
||||
});
|
||||
|
||||
final ShareInboxItem item;
|
||||
final VoidCallback? onOpen;
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
@ -112,11 +133,7 @@ final class _ShareInboxTile extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
item.resourceType == ShareResourceType.program
|
||||
? Icons.list_alt
|
||||
: Icons.event_note,
|
||||
),
|
||||
Icon(_resourceIcon(item.resourceType)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -140,31 +157,46 @@ final class _ShareInboxTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_statusLabel(item.status)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_statusLabel(item.status)),
|
||||
),
|
||||
if (item.resourceType == ShareResourceType.pack) ...[
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_packBadge(summary.itemCount)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.status == ShareInboxStatus.pending) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onDecline,
|
||||
child: const Text('Refuser'),
|
||||
if (item.resourceType == ShareResourceType.pack)
|
||||
FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onDecline,
|
||||
child: const Text('Refuser'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: onAccept,
|
||||
child: const Text('Accepter'),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: onAccept,
|
||||
child: const Text('Accepter'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -196,11 +228,91 @@ final class _EmptyInbox extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _SharePackDetailScreen extends StatelessWidget {
|
||||
const _SharePackDetailScreen({
|
||||
required this.item,
|
||||
required this.onImport,
|
||||
required this.onDecline,
|
||||
});
|
||||
|
||||
final ShareInboxItem item;
|
||||
final VoidCallback? onImport;
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final summary = _summaryFromPayload(item);
|
||||
final workouts = _packWorkoutNames(item);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Pack reçu')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
CourtBlazerAccentPanel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
summary.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Envoyé par ${item.senderUserId}'),
|
||||
const SizedBox(height: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_packBadge(summary.itemCount)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Séances', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
for (final name in workouts)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_note),
|
||||
title: Text(name),
|
||||
),
|
||||
if (item.status == ShareInboxStatus.pending) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: onImport == null
|
||||
? null
|
||||
: () {
|
||||
onImport!();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Importer'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: onDecline == null
|
||||
? null
|
||||
: () {
|
||||
onDecline!();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Refuser'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _InboxSummary {
|
||||
const _InboxSummary({required this.name, required this.detail});
|
||||
const _InboxSummary({
|
||||
required this.name,
|
||||
required this.detail,
|
||||
this.itemCount = 0,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String detail;
|
||||
final int itemCount;
|
||||
}
|
||||
|
||||
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
|
||||
@ -216,6 +328,11 @@ _InboxSummary _summaryFromPayload(ShareInboxItem item) {
|
||||
name: name,
|
||||
detail: _templateDetail(payload),
|
||||
),
|
||||
ShareResourceType.pack => _InboxSummary(
|
||||
name: name,
|
||||
detail: _packDetail(payload),
|
||||
itemCount: _packItemCount(payload),
|
||||
),
|
||||
};
|
||||
} on Object {
|
||||
return const _InboxSummary(name: 'Partage GameTime', detail: '');
|
||||
@ -247,10 +364,45 @@ String _templateDetail(Map<String, dynamic> payload) {
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
String _packDetail(Map<String, dynamic> payload) {
|
||||
final count = _packItemCount(payload);
|
||||
return '$count séance${count > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
int _packItemCount(Map<String, dynamic> payload) {
|
||||
return (payload['workouts'] as List<dynamic>? ?? const []).length;
|
||||
}
|
||||
|
||||
List<String> _packWorkoutNames(ShareInboxItem item) {
|
||||
try {
|
||||
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
|
||||
final workouts = payload['workouts'] as List<dynamic>? ?? const [];
|
||||
return workouts
|
||||
.whereType<Map>()
|
||||
.map((raw) => raw['name'] as String? ?? 'Séance partagée')
|
||||
.toList(growable: false);
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
String _packBadge(int count) {
|
||||
return 'Pack · $count séance${count > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
IconData _resourceIcon(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => Icons.list_alt,
|
||||
ShareResourceType.workoutTemplate => Icons.event_note,
|
||||
ShareResourceType.pack => Icons.inventory_2_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
String _resourceTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme',
|
||||
ShareResourceType.workoutTemplate => 'Séance',
|
||||
ShareResourceType.pack => 'Pack',
|
||||
};
|
||||
}
|
||||
|
||||
@ -267,5 +419,6 @@ String _acceptedMessage(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme ajouté.',
|
||||
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
|
||||
ShareResourceType.pack => 'Pack importé.',
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ final class ShareFormScreen extends StatefulWidget {
|
||||
required this.localResourceId,
|
||||
required this.resourceName,
|
||||
required this.resourceSummary,
|
||||
this.localResourceIds = const [],
|
||||
this.packName,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
@ -22,6 +24,8 @@ final class ShareFormScreen extends StatefulWidget {
|
||||
final SyncUseCases? syncUseCases;
|
||||
final ShareResourceType resourceType;
|
||||
final String localResourceId;
|
||||
final List<String> localResourceIds;
|
||||
final String? packName;
|
||||
final String resourceName;
|
||||
final String resourceSummary;
|
||||
|
||||
@ -106,6 +110,8 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
|
||||
final result = await widget.shareUseCases.sendShare(
|
||||
resourceType: widget.resourceType,
|
||||
localResourceId: widget.localResourceId,
|
||||
localResourceIds: widget.localResourceIds,
|
||||
packName: widget.packName,
|
||||
recipientEmails: _parseEmails(_emailsController.text),
|
||||
);
|
||||
if (!mounted) return;
|
||||
@ -154,6 +160,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
final targetLabel = switch (resourceType) {
|
||||
ShareResourceType.program => 'ce programme',
|
||||
ShareResourceType.workoutTemplate => 'cette séance',
|
||||
ShareResourceType.pack => 'ce pack',
|
||||
};
|
||||
final canOpenAuth = authUseCases != null && syncUseCases != null;
|
||||
return Scaffold(
|
||||
@ -254,6 +261,7 @@ String _shareTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Partager le programme',
|
||||
ShareResourceType.workoutTemplate => 'Partager la séance',
|
||||
ShareResourceType.pack => 'Partager le pack',
|
||||
};
|
||||
}
|
||||
|
||||
@ -261,5 +269,156 @@ String _resourceTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme',
|
||||
ShareResourceType.workoutTemplate => 'Séance',
|
||||
ShareResourceType.pack => 'Pack',
|
||||
};
|
||||
}
|
||||
|
||||
final class SharePackSelectionScreen extends StatefulWidget {
|
||||
const SharePackSelectionScreen({
|
||||
required this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ShareUseCases shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
|
||||
@override
|
||||
State<SharePackSelectionScreen> createState() =>
|
||||
_SharePackSelectionScreenState();
|
||||
}
|
||||
|
||||
final class _SharePackSelectionScreenState
|
||||
extends State<SharePackSelectionScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
late Future<List<WorkoutTemplate>> _templates;
|
||||
final _selectedTemplateIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_templates = widget.shareUseCases.listShareableWorkoutTemplates();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Composer un pack')),
|
||||
body: FutureBuilder<List<WorkoutTemplate>>(
|
||||
future: _templates,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final templates = snapshot.data ?? const <WorkoutTemplate>[];
|
||||
if (templates.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Aucune séance à partager.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
CourtBlazerAccentPanel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Pack à partager',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du pack',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Séances',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final template in templates)
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _selectedTemplateIds.contains(
|
||||
template.metadata.id,
|
||||
),
|
||||
title: Text(template.name),
|
||||
subtitle: Text(_templatePackSummary(template)),
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selectedTemplateIds.add(template.metadata.id);
|
||||
} else {
|
||||
_selectedTemplateIds.remove(
|
||||
template.metadata.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _canContinue ? _continueToRecipients : null,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('Continuer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canContinue =>
|
||||
_nameController.text.trim().isNotEmpty && _selectedTemplateIds.isNotEmpty;
|
||||
|
||||
Future<void> _continueToRecipients() async {
|
||||
final name = _nameController.text.trim();
|
||||
final ids = _selectedTemplateIds.toList(growable: false);
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareFormScreen(
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
resourceType: ShareResourceType.pack,
|
||||
localResourceId: '',
|
||||
localResourceIds: ids,
|
||||
packName: name,
|
||||
resourceName: name,
|
||||
resourceSummary: '${ids.length} séance${ids.length > 1 ? 's' : ''}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _templatePackSummary(WorkoutTemplate template) {
|
||||
return '${template.programs.length} programme'
|
||||
'${template.programs.length > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
@ -1634,7 +1634,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
final rest = await widget.activeUseCases.findActiveRest(
|
||||
sessionId: _session.metadata.id,
|
||||
);
|
||||
if (!mounted || rest == null) {
|
||||
if (!mounted) {
|
||||
return false;
|
||||
}
|
||||
if (rest == null) {
|
||||
_activeRestStateId = null;
|
||||
_restTicker?.cancel();
|
||||
_restTicker = null;
|
||||
return false;
|
||||
}
|
||||
final after = ExecutionPosition(
|
||||
@ -2706,6 +2712,96 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasRepsWithStopwatchScore =
|
||||
step.type == ExerciseStepType.reps &&
|
||||
step.hasScore &&
|
||||
step.scoreInputMode == ScoreInputMode.stopwatch;
|
||||
final hasTimedWithManualScore =
|
||||
step.type == ExerciseStepType.time &&
|
||||
step.hasScore &&
|
||||
step.scoreInputMode == ScoreInputMode.manual;
|
||||
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
step.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, bodyConstraints) {
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: bodyConstraints.maxWidth,
|
||||
height: hasRepsWithStopwatchScore ? 150 : 132,
|
||||
child: hasRepsWithStopwatchScore
|
||||
? _RepsWithStopwatchScoreStepBody(
|
||||
step: step,
|
||||
elapsedLabel: stepScoreElapsedLabel,
|
||||
running: stepScoreRunning,
|
||||
onStart: onStartStepScore,
|
||||
onStop: onStopStepScore,
|
||||
onReset: onResetStepScore,
|
||||
)
|
||||
: _TimedStepWithManualScoreBody(
|
||||
step: step,
|
||||
remainingLabel: remainingLabel,
|
||||
running:
|
||||
view.state.status ==
|
||||
ActiveExerciseStepProgressStatus
|
||||
.runningTimer,
|
||||
readyToStart: _isNextTimedStepReady(view),
|
||||
scoreController: stepScoreController,
|
||||
onStartTimer: onStartTimer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (hasRepsWithStopwatchScore)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onSkipStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Passer l’étape'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onCompleteStep,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Étape suivante'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
OutlinedButton(
|
||||
onPressed: onSkipStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Passer l’étape'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ConstrainedBox(
|
||||
@ -2860,6 +2956,274 @@ final class _TimedStepBody extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _RepsWithStopwatchScoreStepBody extends StatelessWidget {
|
||||
const _RepsWithStopwatchScoreStepBody({
|
||||
required this.step,
|
||||
required this.elapsedLabel,
|
||||
required this.running,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final ExerciseStep step;
|
||||
final String elapsedLabel;
|
||||
final bool running;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onReset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('RÉPÉTITIONS', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${step.defaultTargetValue}',
|
||||
style: AppTextStyles.timer(
|
||||
context,
|
||||
).copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('CHRONO SCORE', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
elapsedLabel,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CompactStepScoreStopwatchControls(
|
||||
running: running,
|
||||
onStart: onStart,
|
||||
onStop: onStop,
|
||||
onReset: onReset,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _TimedStepWithManualScoreBody extends StatelessWidget {
|
||||
const _TimedStepWithManualScoreBody({
|
||||
required this.step,
|
||||
required this.remainingLabel,
|
||||
required this.running,
|
||||
required this.readyToStart,
|
||||
required this.scoreController,
|
||||
required this.onStartTimer,
|
||||
});
|
||||
|
||||
final ExerciseStep step;
|
||||
final String remainingLabel;
|
||||
final bool running;
|
||||
final bool readyToStart;
|
||||
final TextEditingController scoreController;
|
||||
final VoidCallback onStartTimer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('CHRONO', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
remainingLabel,
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
fontFamily: 'Anton',
|
||||
fontFeatures: AppTextStyles.timer(context).fontFeatures,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!running) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (readyToStart) ...[
|
||||
Text(
|
||||
'Chrono prêt',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
IconButton.filled(
|
||||
tooltip: 'Lancer',
|
||||
onPressed: onStartTimer,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Center(
|
||||
child: _StepManualScoreStepper(
|
||||
controller: scoreController,
|
||||
initialValue: step.defaultTargetScore,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _CompactStepScoreStopwatchControls extends StatelessWidget {
|
||||
const _CompactStepScoreStopwatchControls({
|
||||
required this.running,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final bool running;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onReset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: running ? 'Arrêter' : 'Lancer',
|
||||
onPressed: running ? onStop : onStart,
|
||||
icon: Icon(running ? Icons.stop : Icons.play_arrow),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.outlined(
|
||||
tooltip: 'Réinitialiser',
|
||||
onPressed: onReset,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _StepManualScoreStepper extends StatefulWidget {
|
||||
const _StepManualScoreStepper({
|
||||
required this.controller,
|
||||
required this.initialValue,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final double? initialValue;
|
||||
|
||||
@override
|
||||
State<_StepManualScoreStepper> createState() =>
|
||||
_StepManualScoreStepperState();
|
||||
}
|
||||
|
||||
final class _StepManualScoreStepperState
|
||||
extends State<_StepManualScoreStepper> {
|
||||
double get _value {
|
||||
final parsed = double.tryParse(widget.controller.text.trim());
|
||||
return parsed ?? widget.initialValue ?? 0;
|
||||
}
|
||||
|
||||
void _changeBy(double delta) {
|
||||
final next = (_value + delta).clamp(0, 999).toDouble();
|
||||
widget.controller.text = _formatManualScoreInput(next);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('SCORE', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Diminuer',
|
||||
onPressed: () => _changeBy(-1),
|
||||
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
_formatManualScoreInput(_value),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Augmenter',
|
||||
onPressed: () => _changeBy(1),
|
||||
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatManualScoreInput(double value) {
|
||||
return value == value.roundToDouble()
|
||||
? value.toInt().toString()
|
||||
: value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
|
||||
final step = view.currentStep;
|
||||
if (step == null ||
|
||||
|
||||
Reference in New Issue
Block a user