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:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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(),

View File

@ -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,

View File

@ -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

View File

@ -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'),
};

View File

@ -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)',
];
}

View File

@ -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',

View File

@ -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));
}),
);
}

View File

@ -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();

View File

@ -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,
),
),
],
),
),
],

View File

@ -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é.',
};
}

View File

@ -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' : ''}';
}

View File

@ -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 ||