feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release
- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases - Add DriftEntitlementSnapshotRepository for offline entitlement caching - Add HealthConnect gateway and integration - Add quota enforcement in share acceptance (server and UI) - Add entitlement API endpoints in server - Add privacy policy v1 and release compliance checklist - Add QA gates and device runbooks for release validation - Add demo content screen and settings screen Ticket #261
This commit is contained in:
@ -230,6 +230,63 @@ final class DataImportUseCase {
|
||||
}
|
||||
}
|
||||
|
||||
final class EntitlementRevalidationUseCase {
|
||||
const EntitlementRevalidationUseCase({
|
||||
required this.repository,
|
||||
required this.remote,
|
||||
});
|
||||
|
||||
final EntitlementSnapshotRepository repository;
|
||||
final RemoteEntitlementSnapshotSource remote;
|
||||
|
||||
Future<EntitlementSnapshot> readCached() => _readRequiredCached();
|
||||
|
||||
Future<EntitlementSnapshot> revalidateIfPossible() async {
|
||||
try {
|
||||
final snapshot = await remote.read();
|
||||
await repository.save(snapshot);
|
||||
return snapshot;
|
||||
} on OfflineException {
|
||||
return _readRequiredCached();
|
||||
}
|
||||
}
|
||||
|
||||
Future<EntitlementSnapshot> _readRequiredCached() async {
|
||||
final snapshot = await repository.read();
|
||||
if (snapshot == null) {
|
||||
throw const OfflineException();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
final class BillingUseCases {
|
||||
const BillingUseCases({
|
||||
required BillingPort billingPort,
|
||||
required BillingProductRef proProduct,
|
||||
}) : _billingPort = billingPort,
|
||||
_proProduct = proProduct;
|
||||
|
||||
final BillingPort _billingPort;
|
||||
final BillingProductRef _proProduct;
|
||||
|
||||
Future<BillingAvailability> availability() {
|
||||
return _billingPort.availability();
|
||||
}
|
||||
|
||||
Future<BillingAttemptResult> purchasePro() {
|
||||
return _billingPort.purchasePro(product: _proProduct);
|
||||
}
|
||||
|
||||
Future<BillingAttemptResult> restorePurchases() {
|
||||
return _billingPort.restorePurchases();
|
||||
}
|
||||
|
||||
Future<BillingAttemptResult> revalidate() {
|
||||
return _billingPort.revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
|
||||
|
||||
final class StarterSeedResult {
|
||||
@ -3461,12 +3518,14 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource {
|
||||
required Clock clock,
|
||||
required IdGenerator ids,
|
||||
required String originDeviceId,
|
||||
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
||||
WatchProjectionPublisher? publisher,
|
||||
}) : _projector = WatchSessionProjectionProjector(
|
||||
sessionRepository: sessionRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
originDeviceId: originDeviceId,
|
||||
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
|
||||
),
|
||||
_publisher = publisher;
|
||||
|
||||
@ -3524,6 +3583,12 @@ bool _hasSameWatchCommandRevisionState(
|
||||
left.stepIndex == right.stepIndex &&
|
||||
left.stepTotal == right.stepTotal &&
|
||||
left.stepName == right.stepName &&
|
||||
left.stepType == right.stepType &&
|
||||
left.stepTargetValue == right.stepTargetValue &&
|
||||
left.exerciseTargetReps == right.exerciseTargetReps &&
|
||||
left.exerciseTargetTimeMs == right.exerciseTargetTimeMs &&
|
||||
left.exerciseTargetScore == right.exerciseTargetScore &&
|
||||
left.exerciseTargetScoreTimeMs == right.exerciseTargetScoreTimeMs &&
|
||||
_hasSameWatchCommandRevisionTimerState(
|
||||
left.dominantTimer,
|
||||
right.dominantTimer,
|
||||
@ -3543,6 +3608,7 @@ bool _hasSameWatchCommandRevisionState(
|
||||
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
|
||||
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
|
||||
left.manualScoreScope == right.manualScoreScope &&
|
||||
left.totalDistanceM == right.totalDistanceM &&
|
||||
_listEquals(
|
||||
left.healthServicesExerciseTypeStrategy,
|
||||
right.healthServicesExerciseTypeStrategy,
|
||||
@ -3684,8 +3750,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
|
||||
WatchCommandType.startPreparedTimedStep =>
|
||||
projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep,
|
||||
WatchCommandType.completeCurrentStep =>
|
||||
projection.stepType == WatchStepType.reps &&
|
||||
projection.stepTargetValue != null,
|
||||
(projection.stepType == WatchStepType.reps &&
|
||||
projection.stepTargetValue != null) ||
|
||||
projection.manualScoreScope == WatchManualScoreScope.step,
|
||||
WatchCommandType.skipCurrentStep => projection.secondaryActions.contains(
|
||||
WatchSecondaryAction.skipCurrentStep,
|
||||
),
|
||||
@ -3713,7 +3780,10 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
|
||||
|
||||
bool _allowsStaleRevision(WatchCommandType type) {
|
||||
return type == WatchCommandType.incrementScore ||
|
||||
type == WatchCommandType.decrementScore;
|
||||
type == WatchCommandType.decrementScore ||
|
||||
type == WatchCommandType.startCurrentExercise ||
|
||||
type == WatchCommandType.startPreparedTimedStep ||
|
||||
type == WatchCommandType.completeCurrentStep;
|
||||
}
|
||||
|
||||
Future<WatchCommandAck> _route(
|
||||
@ -4050,16 +4120,18 @@ final class WatchSessionProjectionProjector {
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
this.activeWorkoutSensorUseCases,
|
||||
});
|
||||
|
||||
final ActiveSessionRepository sessionRepository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
|
||||
|
||||
Future<WatchSessionProjection> project({required int revision}) async {
|
||||
final now = clock.now();
|
||||
final session = await sessionRepository.findOpen();
|
||||
var session = await sessionRepository.findOpen();
|
||||
if (session == null ||
|
||||
session.status == ActiveWorkoutStatus.completed ||
|
||||
session.status == ActiveWorkoutStatus.abandoned ||
|
||||
@ -4078,6 +4150,24 @@ final class WatchSessionProjectionProjector {
|
||||
statusLabel: 'Aucune séance en cours',
|
||||
);
|
||||
}
|
||||
session = await _advanceExpiredRestBeforeProjection(session, now);
|
||||
if (session.status == ActiveWorkoutStatus.completed ||
|
||||
session.status == ActiveWorkoutStatus.abandoned ||
|
||||
session.status == ActiveWorkoutStatus.savedExit) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: '',
|
||||
revision: revision,
|
||||
projectedAtEpochMs: _epochMs(now),
|
||||
expiresAtEpochMs: _watchProjectionExpiresAtEpochMs(now),
|
||||
phase: WatchSessionPhase.noActiveSession,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 0,
|
||||
seriesTotal: 0,
|
||||
exerciseName: '',
|
||||
primaryAction: WatchPrimaryAction.none,
|
||||
statusLabel: 'Aucune séance en cours',
|
||||
);
|
||||
}
|
||||
|
||||
final snapshot = _findExerciseSnapshot(
|
||||
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
|
||||
@ -4121,7 +4211,9 @@ final class WatchSessionProjectionProjector {
|
||||
);
|
||||
final stepView = await _readStepViewIfStarted(session, snapshot);
|
||||
final stepState = stepView?.state;
|
||||
final currentStep = stepView?.currentStep ?? _initialStep(snapshot);
|
||||
final currentStep = stepView == null
|
||||
? _initialStep(snapshot)
|
||||
: stepView.currentStep;
|
||||
final manualScoreProjection = _watchManualScoreProjection(
|
||||
snapshot: snapshot,
|
||||
currentStep: currentStep,
|
||||
@ -4132,22 +4224,25 @@ final class WatchSessionProjectionProjector {
|
||||
final expectedPassages = _expectedPassages(snapshot);
|
||||
final projectedAtEpochMs = _epochMs(now);
|
||||
|
||||
final exerciseTargetTimeMs = snapshot.timeEnabled
|
||||
? snapshot.targetTimeSeconds == null
|
||||
? null
|
||||
: snapshot.targetTimeSeconds! * 1000
|
||||
: null;
|
||||
final scoreStopwatchTimer = scoreStopwatch == null
|
||||
? null
|
||||
: _scoreStopwatchTimerProjection(scoreStopwatch, now);
|
||||
final setTimerProjection = setTimer == null
|
||||
? null
|
||||
: _setTimerProjection(setTimer, now);
|
||||
? _pendingSetTimerProjection(snapshot, now)
|
||||
: _setTimerProjection(setTimer, now, targetMs: exerciseTargetTimeMs);
|
||||
final allTimers = <WatchTimerProjection>[
|
||||
if (activeRest != null) _restTimerProjection(activeRest, now),
|
||||
if (currentStep != null && currentStep.type == ExerciseStepType.time)
|
||||
_stepTimerProjection(stepState, currentStep, now),
|
||||
?setTimerProjection,
|
||||
?scoreStopwatchTimer,
|
||||
?setTimerProjection,
|
||||
];
|
||||
final displayTimers = allTimers
|
||||
.where((timer) => timer.kind != WatchTimerKind.setTimer)
|
||||
.toList(growable: false);
|
||||
final displayTimers = allTimers.toList(growable: false);
|
||||
final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
|
||||
final secondaryTimers = dominantTimer == null
|
||||
? const <WatchTimerProjection>[]
|
||||
@ -4164,6 +4259,9 @@ final class WatchSessionProjectionProjector {
|
||||
_sessionHealthServicesExerciseTypeStrategy(
|
||||
session.resolvedTemplateSnapshotJson,
|
||||
);
|
||||
final sensorState = activeWorkoutSensorUseCases?.current(
|
||||
session.metadata.id,
|
||||
);
|
||||
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: session.metadata.id,
|
||||
@ -4193,6 +4291,12 @@ final class WatchSessionProjectionProjector {
|
||||
null => null,
|
||||
},
|
||||
stepTargetValue: currentStep?.defaultTargetValue,
|
||||
exerciseTargetReps: snapshot.repsEnabled ? snapshot.targetReps : null,
|
||||
exerciseTargetTimeMs: exerciseTargetTimeMs,
|
||||
exerciseTargetScore: snapshot.scoreEnabled ? snapshot.targetScore : null,
|
||||
exerciseTargetScoreTimeMs: snapshot.scoreEnabled
|
||||
? snapshot.targetScoreTimeMs
|
||||
: null,
|
||||
dominantTimer: dominantTimer,
|
||||
secondaryTimers: secondaryTimers,
|
||||
primaryAction: _primaryAction(phase),
|
||||
@ -4225,6 +4329,7 @@ final class WatchSessionProjectionProjector {
|
||||
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
|
||||
manualScoreScope: manualScoreProjection?.scope,
|
||||
healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy,
|
||||
totalDistanceM: sensorState?.latestDistanceMeters,
|
||||
);
|
||||
}
|
||||
|
||||
@ -4288,6 +4393,50 @@ final class WatchSessionProjectionProjector {
|
||||
..sort((left, right) => right.startedAt.compareTo(left.startedAt));
|
||||
return active.isEmpty ? null : active.first;
|
||||
}
|
||||
|
||||
Future<ActiveWorkoutSession> _advanceExpiredRestBeforeProjection(
|
||||
ActiveWorkoutSession session,
|
||||
DateTime now,
|
||||
) async {
|
||||
final activeRest = await _findActiveRest(session.metadata.id);
|
||||
if (activeRest == null ||
|
||||
activeRest.pausedAt != null ||
|
||||
activeRest.remainingMillisecondsAt(now) > 0) {
|
||||
return session;
|
||||
}
|
||||
|
||||
await sessionRepository.saveRestState(
|
||||
activeRest.copyWith(
|
||||
metadata: activeRest.metadata.touch(now),
|
||||
endedAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
final next = _nextPositionAfter(
|
||||
session.resolvedTemplateSnapshotJson,
|
||||
programIndex: activeRest.afterProgramIndex,
|
||||
exerciseIndex: activeRest.afterExerciseIndex,
|
||||
setIndex: activeRest.afterSetIndex,
|
||||
);
|
||||
final updated = next == null
|
||||
? session.copyWith(
|
||||
status: ActiveWorkoutStatus.completed,
|
||||
endedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
|
||||
metadata: session.metadata.touch(now),
|
||||
)
|
||||
: session.copyWith(
|
||||
status: ActiveWorkoutStatus.running,
|
||||
currentProgramIndex: next.programIndex,
|
||||
currentExerciseIndex: next.exerciseIndex,
|
||||
currentSetIndex: next.setIndex,
|
||||
lastPersistedAt: now,
|
||||
metadata: session.metadata.touch(now),
|
||||
);
|
||||
await sessionRepository.save(updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
final class _WatchManualScoreProjectionData {
|
||||
@ -4326,8 +4475,6 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({
|
||||
return _WatchManualScoreProjectionData(
|
||||
scope: WatchManualScoreScope.step,
|
||||
value: result?.actualScore ?? 0,
|
||||
targetValue: step.defaultTargetScore,
|
||||
targetLabel: step.defaultTargetScore == null ? null : 'Cible',
|
||||
repsTargetValue: step.type == ExerciseStepType.reps
|
||||
? step.defaultTargetValue
|
||||
: null,
|
||||
@ -4508,7 +4655,7 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
|
||||
? WatchTimerRunState.running
|
||||
: WatchTimerRunState.paused,
|
||||
referenceEpochMs: _epochMs(now),
|
||||
accumulatedMs: state.accumulatedMs,
|
||||
accumulatedMs: state.elapsedMillisecondsAt(now),
|
||||
startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running
|
||||
? _epochMs(state.startedAt)
|
||||
: null,
|
||||
@ -4517,8 +4664,9 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
|
||||
|
||||
WatchTimerProjection? _setTimerProjection(
|
||||
ActiveSetTimerState state,
|
||||
DateTime now,
|
||||
) {
|
||||
DateTime now, {
|
||||
int? targetMs,
|
||||
}) {
|
||||
if (state.status == ActiveSetTimerStatus.stopped ||
|
||||
state.status == ActiveSetTimerStatus.skipped) {
|
||||
return null;
|
||||
@ -4531,11 +4679,30 @@ WatchTimerProjection? _setTimerProjection(
|
||||
? WatchTimerRunState.running
|
||||
: WatchTimerRunState.paused,
|
||||
referenceEpochMs: _epochMs(now),
|
||||
accumulatedMs: state.accumulatedMs,
|
||||
accumulatedMs: state.elapsedMillisecondsAt(now),
|
||||
startedAtEpochMs:
|
||||
state.status == ActiveSetTimerStatus.running && state.startedAt != null
|
||||
? _epochMs(state.startedAt!)
|
||||
: null,
|
||||
targetMs: targetMs,
|
||||
);
|
||||
}
|
||||
|
||||
WatchTimerProjection? _pendingSetTimerProjection(
|
||||
_ResolvedExerciseSnapshot snapshot,
|
||||
DateTime now,
|
||||
) {
|
||||
if (!snapshot.timeEnabled || snapshot.targetTimeSeconds == null) {
|
||||
return null;
|
||||
}
|
||||
return WatchTimerProjection(
|
||||
kind: WatchTimerKind.setTimer,
|
||||
label: 'Temps de série',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.stopped,
|
||||
referenceEpochMs: _epochMs(now),
|
||||
accumulatedMs: 0,
|
||||
targetMs: snapshot.targetTimeSeconds! * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
@ -5457,6 +5624,8 @@ final class CloseWorkoutSessionUseCase {
|
||||
required this.sessionRepository,
|
||||
required this.historyRepository,
|
||||
this.telemetryRepository,
|
||||
this.healthConnectUseCases,
|
||||
this.activeWorkoutSensorUseCases,
|
||||
required this.clock,
|
||||
required this.ids,
|
||||
required this.originDeviceId,
|
||||
@ -5465,6 +5634,8 @@ final class CloseWorkoutSessionUseCase {
|
||||
final ActiveSessionRepository sessionRepository;
|
||||
final WorkoutHistoryRepository historyRepository;
|
||||
final WorkoutTelemetryRepository? telemetryRepository;
|
||||
final HealthConnectUseCases? healthConnectUseCases;
|
||||
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
final String originDeviceId;
|
||||
@ -5510,6 +5681,11 @@ final class CloseWorkoutSessionUseCase {
|
||||
final telemetrySamples =
|
||||
await telemetryRepository?.listSamples(session.metadata.id) ??
|
||||
const <WorkoutTelemetrySample>[];
|
||||
final totalSteps =
|
||||
_totalStepsFromSamples(telemetrySamples) ??
|
||||
activeWorkoutSensorUseCases
|
||||
?.current(session.metadata.id)
|
||||
?.latestStepCount;
|
||||
final history = WorkoutHistory(
|
||||
metadata: EntityMetadata(
|
||||
id: historyId,
|
||||
@ -5582,16 +5758,19 @@ final class CloseWorkoutSessionUseCase {
|
||||
'telemetrySamples': telemetrySamples
|
||||
.map(_workoutTelemetrySampleSnapshotJson)
|
||||
.toList(),
|
||||
'totalSteps': totalSteps,
|
||||
}),
|
||||
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
|
||||
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
|
||||
maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
|
||||
totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
|
||||
totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
|
||||
totalSteps: totalSteps,
|
||||
results: historyResults,
|
||||
stepResults: historyStepResults,
|
||||
);
|
||||
await historyRepository.save(history);
|
||||
await healthConnectUseCases?.exportAfterWorkout(history);
|
||||
return history;
|
||||
}
|
||||
}
|
||||
@ -5822,6 +6001,7 @@ final class WorkoutTelemetryUseCases {
|
||||
heartRateBpm: state.heartRateBpm,
|
||||
distanceMeters: state.distanceMeters,
|
||||
caloriesKcal: state.caloriesKcal,
|
||||
stepCount: state.stepCount,
|
||||
);
|
||||
}
|
||||
|
||||
@ -5859,6 +6039,9 @@ final class WorkoutTelemetryUseCases {
|
||||
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
|
||||
distanceMeters: hasDistance ? sample.distanceMeters : null,
|
||||
caloriesKcal: hasCalories ? sample.caloriesKcal : null,
|
||||
stepCount: sample.stepCount != null && sample.stepCount! >= 0
|
||||
? sample.stepCount
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -6135,6 +6318,7 @@ Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
|
||||
'heartRateBpm': sample.heartRateBpm,
|
||||
'distanceMeters': sample.distanceMeters,
|
||||
'caloriesKcal': sample.caloriesKcal,
|
||||
'stepCount': sample.stepCount,
|
||||
};
|
||||
}
|
||||
|
||||
@ -6177,12 +6361,26 @@ List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
|
||||
heartRateBpm: json['heartRateBpm'] as int?,
|
||||
distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
|
||||
caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
|
||||
stepCount: (json['stepCount'] as num?)?.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
int? _totalStepsFromSamples(List<WorkoutTelemetrySample> samples) {
|
||||
int? totalSteps;
|
||||
for (final sample in samples) {
|
||||
final stepCount = sample.stepCount;
|
||||
if (stepCount != null &&
|
||||
stepCount >= 0 &&
|
||||
(totalSteps == null || stepCount > totalSteps)) {
|
||||
totalSteps = stepCount;
|
||||
}
|
||||
}
|
||||
return totalSteps;
|
||||
}
|
||||
|
||||
bool _matchesTelemetryScope(
|
||||
WorkoutTelemetrySample sample, {
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
@ -6390,6 +6588,131 @@ final class _TelemetryAggregateBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
final class HealthConnectUseCases {
|
||||
const HealthConnectUseCases({
|
||||
required this.gateway,
|
||||
required this.historyRepository,
|
||||
this.telemetryUseCases,
|
||||
});
|
||||
|
||||
final HealthConnectGateway gateway;
|
||||
final WorkoutHistoryRepository historyRepository;
|
||||
final WorkoutTelemetryUseCases? telemetryUseCases;
|
||||
|
||||
Future<HealthConnectConnectionState> connectionState() {
|
||||
return gateway.connectionState();
|
||||
}
|
||||
|
||||
Future<HealthConnectConnectionState> requestPermissions() {
|
||||
return gateway.requestPermissions();
|
||||
}
|
||||
|
||||
Future<bool> openSettings() {
|
||||
return gateway.openSettings();
|
||||
}
|
||||
|
||||
Future<HealthConnectWorkoutExportResult> exportHistory(
|
||||
String historyId,
|
||||
) async {
|
||||
final history = await historyRepository.findById(historyId);
|
||||
if (history == null) {
|
||||
return const HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.invalidWorkout,
|
||||
workout: null,
|
||||
errorMessage: 'Workout history not found.',
|
||||
);
|
||||
}
|
||||
return exportAfterWorkout(history);
|
||||
}
|
||||
|
||||
Future<HealthConnectWorkoutExportResult> exportAfterWorkout(
|
||||
WorkoutHistory history,
|
||||
) async {
|
||||
final workout = await _workoutExportFromHistory(history);
|
||||
if (!workout.isValid) {
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.invalidWorkout,
|
||||
workout: workout,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final state = await gateway.connectionState();
|
||||
switch (state.status) {
|
||||
case HealthConnectConnectionStatus.unavailable:
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.unavailable,
|
||||
workout: workout,
|
||||
);
|
||||
case HealthConnectConnectionStatus.providerUpdateRequired:
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.providerUpdateRequired,
|
||||
workout: workout,
|
||||
);
|
||||
case HealthConnectConnectionStatus.accessBlocked:
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.accessBlocked,
|
||||
workout: workout,
|
||||
);
|
||||
case HealthConnectConnectionStatus.permissionsRequired:
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.permissionsRequired,
|
||||
workout: workout,
|
||||
);
|
||||
case HealthConnectConnectionStatus.connected:
|
||||
await gateway.exportWorkout(workout);
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.exported,
|
||||
workout: workout,
|
||||
);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
return HealthConnectWorkoutExportResult(
|
||||
status: HealthConnectExportStatus.failed,
|
||||
workout: workout,
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<HealthConnectWorkoutExport> _workoutExportFromHistory(
|
||||
WorkoutHistory history,
|
||||
) async {
|
||||
final samples =
|
||||
await telemetryUseCases?.listSamplesForHistory(history) ??
|
||||
const <WorkoutTelemetrySample>[];
|
||||
return HealthConnectWorkoutExport(
|
||||
historyId: history.metadata.id,
|
||||
title: history.nameSnapshot,
|
||||
startedAt: history.startedAt,
|
||||
endedAt: history.endedAt,
|
||||
durationMs: history.totalActiveMs,
|
||||
totalDistanceMeters: _positiveDoubleOrNull(history.totalDistanceMeters),
|
||||
totalCaloriesKcal: _positiveDoubleOrNull(history.totalCaloriesKcal),
|
||||
minHeartRateBpm: history.minHeartRateBpm,
|
||||
averageHeartRateBpm: history.averageHeartRateBpm,
|
||||
maxHeartRateBpm: history.maxHeartRateBpm,
|
||||
heartRateSamples: [
|
||||
for (final sample in samples)
|
||||
if (sample.heartRateBpm != null &&
|
||||
!sample.capturedAt.isBefore(history.startedAt) &&
|
||||
!sample.capturedAt.isAfter(history.endedAt))
|
||||
HealthConnectHeartRateSample(
|
||||
time: sample.capturedAt,
|
||||
beatsPerMinute: sample.heartRateBpm!,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double? _positiveDoubleOrNull(double? value) {
|
||||
if (value == null || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
final class ActiveWorkoutSensorState {
|
||||
const ActiveWorkoutSensorState({
|
||||
required this.sessionId,
|
||||
@ -6403,6 +6726,7 @@ final class ActiveWorkoutSensorState {
|
||||
this.latestDistanceMeters,
|
||||
required this.latestDistanceAvailable,
|
||||
this.latestCaloriesKcal,
|
||||
this.latestStepCount,
|
||||
required this.estimatedCaloriesKcal,
|
||||
});
|
||||
|
||||
@ -6417,6 +6741,7 @@ final class ActiveWorkoutSensorState {
|
||||
final double? latestDistanceMeters;
|
||||
final bool latestDistanceAvailable;
|
||||
final double? latestCaloriesKcal;
|
||||
final int? latestStepCount;
|
||||
final double estimatedCaloriesKcal;
|
||||
}
|
||||
|
||||
@ -6445,7 +6770,9 @@ final class ActiveWorkoutSensorUseCases {
|
||||
sample.distanceMeters != null && sample.distanceMeters! >= 0;
|
||||
final hasCalories =
|
||||
sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
|
||||
if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) {
|
||||
final hasSteps = sample.stepCount != null && sample.stepCount! >= 0;
|
||||
if (sessionId.isEmpty ||
|
||||
(!hasHeartRate && !hasDistance && !hasCalories && !hasSteps)) {
|
||||
return null;
|
||||
}
|
||||
final recordedAt = sample.capturedAtEpochMs > 0
|
||||
@ -6463,6 +6790,7 @@ final class ActiveWorkoutSensorUseCases {
|
||||
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
|
||||
distanceMeters: hasDistance ? sample.distanceMeters : null,
|
||||
caloriesKcal: hasCalories ? sample.caloriesKcal : null,
|
||||
stepCount: hasSteps ? sample.stepCount : null,
|
||||
recordedAt: recordedAt,
|
||||
);
|
||||
if (snapshot == null) {
|
||||
@ -6497,6 +6825,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
||||
double? _latestDistanceMeters;
|
||||
var _latestDistanceAvailable = false;
|
||||
double? _latestCaloriesKcal;
|
||||
int? _latestStepCount;
|
||||
|
||||
ActiveWorkoutSensorState get snapshot {
|
||||
final averageHeartRateBpm = _heartRateSampleCount == 0
|
||||
@ -6514,6 +6843,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
||||
latestDistanceMeters: _latestDistanceMeters,
|
||||
latestDistanceAvailable: _latestDistanceAvailable,
|
||||
latestCaloriesKcal: _latestCaloriesKcal,
|
||||
latestStepCount: _latestStepCount,
|
||||
estimatedCaloriesKcal: _estimatedCaloriesKcal(
|
||||
averageHeartRateBpm: averageHeartRateBpm ?? 0,
|
||||
activeDuration: _latestSampleAt.difference(firstSampleAt),
|
||||
@ -6526,6 +6856,7 @@ final class _ActiveWorkoutSensorAccumulator {
|
||||
required int? heartRateBpm,
|
||||
required double? distanceMeters,
|
||||
required double? caloriesKcal,
|
||||
required int? stepCount,
|
||||
required DateTime recordedAt,
|
||||
}) {
|
||||
final stableSampleId = sampleId?.trim();
|
||||
@ -6564,6 +6895,10 @@ final class _ActiveWorkoutSensorAccumulator {
|
||||
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
|
||||
_latestCaloriesKcal = caloriesKcal;
|
||||
}
|
||||
if (stepCount != null &&
|
||||
(_latestStepCount == null || stepCount >= _latestStepCount!)) {
|
||||
_latestStepCount = stepCount;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user