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(),
|
||||
|
||||
Reference in New Issue
Block a user