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:
2026-08-28 23:12:25 +02:00
parent d4df3822b7
commit b12a04ba30
10 changed files with 1096 additions and 26 deletions

View File

@ -1,3 +1,4 @@
import '../infrastructure/health_connect/health_connect.dart';
import '../infrastructure/local/local.dart'; import '../infrastructure/local/local.dart';
import '../infrastructure/remote/remote.dart'; import '../infrastructure/remote/remote.dart';
import '../infrastructure/security/security.dart'; import '../infrastructure/security/security.dart';
@ -14,7 +15,10 @@ abstract interface class AppDependencies {
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases; ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases; ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases; ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
ExecutionDebugUseCase get executionDebugUseCase;
SeedQaFunctionalContentUseCase get seedQaFunctionalContentUseCase;
WorkoutTelemetryUseCases get workoutTelemetryUseCases; WorkoutTelemetryUseCases get workoutTelemetryUseCases;
WatchCompanionProjectionUseCases get watchCompanionProjectionUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases; WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase; ProgressionStatsUseCase get progressionStatsUseCase;
@ -22,6 +26,8 @@ abstract interface class AppDependencies {
WatchAlertPublisher get watchAlertPublisher; WatchAlertPublisher get watchAlertPublisher;
DataExportUseCase get dataExportUseCase; DataExportUseCase get dataExportUseCase;
DataImportUseCase get dataImportUseCase; DataImportUseCase get dataImportUseCase;
HealthConnectUseCases get healthConnectUseCases;
EntitlementRevalidationUseCase get entitlementRevalidationUseCase;
SyncUseCases get syncUseCases; SyncUseCases get syncUseCases;
ShareUseCases get shareUseCases; ShareUseCases get shareUseCases;
} }
@ -37,6 +43,8 @@ final class AppBootstrap implements AppDependencies {
required this.activeWorkoutSessionUseCases, required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases, required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases, required this.activeWorkoutSensorUseCases,
required this.executionDebugUseCase,
required this.seedQaFunctionalContentUseCase,
required this.workoutTelemetryUseCases, required this.workoutTelemetryUseCases,
required this.watchCompanionProjectionUseCases, required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler, required this.watchCompanionCommandHandler,
@ -48,6 +56,8 @@ final class AppBootstrap implements AppDependencies {
required this.exercisePerformanceReferenceUseCase, required this.exercisePerformanceReferenceUseCase,
required this.dataExportUseCase, required this.dataExportUseCase,
required this.dataImportUseCase, required this.dataImportUseCase,
required this.healthConnectUseCases,
required this.entitlementRevalidationUseCase,
required this.syncUseCases, required this.syncUseCases,
required this.shareUseCases, required this.shareUseCases,
required this.syncGateway, required this.syncGateway,
@ -71,7 +81,12 @@ final class AppBootstrap implements AppDependencies {
@override @override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override @override
final ExecutionDebugUseCase executionDebugUseCase;
@override
final SeedQaFunctionalContentUseCase seedQaFunctionalContentUseCase;
@override
final WorkoutTelemetryUseCases workoutTelemetryUseCases; final WorkoutTelemetryUseCases workoutTelemetryUseCases;
@override
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter; final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
@ -91,6 +106,10 @@ final class AppBootstrap implements AppDependencies {
@override @override
final DataImportUseCase dataImportUseCase; final DataImportUseCase dataImportUseCase;
@override @override
final HealthConnectUseCases healthConnectUseCases;
@override
final EntitlementRevalidationUseCase entitlementRevalidationUseCase;
@override
final SyncUseCases syncUseCases; final SyncUseCases syncUseCases;
@override @override
final ShareUseCases shareUseCases; final ShareUseCases shareUseCases;
@ -110,6 +129,9 @@ final class AppBootstrap implements AppDependencies {
final pendingShareActionRepository = DriftPendingShareActionRepository( final pendingShareActionRepository = DriftPendingShareActionRepository(
database, database,
); );
final entitlementSnapshotRepository = DriftEntitlementSnapshotRepository(
database,
);
final programRepository = DriftProgramRepository(database); final programRepository = DriftProgramRepository(database);
final templateRepository = DriftWorkoutTemplateRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database);
@ -124,6 +146,7 @@ final class AppBootstrap implements AppDependencies {
const localBackupMediaStore = PathProviderLocalMediaStorage(); const localBackupMediaStore = PathProviderLocalMediaStorage();
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const authTokenStore = SecureStorageAuthTokenStore();
const originDeviceId = 'local-device'; const originDeviceId = 'local-device';
final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -142,6 +165,16 @@ final class AppBootstrap implements AppDependencies {
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock, clock: clock,
); );
final executionDebugUseCase = ExecutionDebugUseCase(
sessionRepository: activeSessionRepository,
clock: clock,
);
final seedQaFunctionalContentUseCase = SeedQaFunctionalContentUseCase(
seedStateRepository: starterSeedRepository,
contentRepository: starterSeedRepository,
clock: clock,
originDeviceId: originDeviceId,
);
final workoutTelemetryUseCases = WorkoutTelemetryUseCases( final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
repository: telemetryRepository, repository: telemetryRepository,
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -153,6 +186,7 @@ final class AppBootstrap implements AppDependencies {
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
); );
final watchCompanionCommandHandler = WatchCompanionCommandHandler( final watchCompanionCommandHandler = WatchCompanionCommandHandler(
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
@ -164,6 +198,11 @@ final class AppBootstrap implements AppDependencies {
repository: historyRepository, repository: historyRepository,
clock: clock, clock: clock,
); );
final healthConnectUseCases = HealthConnectUseCases(
gateway: const MethodChannelHealthConnectGateway(),
historyRepository: historyRepository,
telemetryUseCases: workoutTelemetryUseCases,
);
final watchWearDataLayerAdapter = WatchWearDataLayerAdapter( final watchWearDataLayerAdapter = WatchWearDataLayerAdapter(
nativeChannel: const MethodChannelWatchBridgeNativeChannel(), nativeChannel: const MethodChannelWatchBridgeNativeChannel(),
commandIngress: watchCompanionCommandHandler, commandIngress: watchCompanionCommandHandler,
@ -193,11 +232,15 @@ final class AppBootstrap implements AppDependencies {
final remoteShareApi = HttpRemoteShareApi( final remoteShareApi = HttpRemoteShareApi(
HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
); );
final remoteEntitlementSnapshotSource = HttpRemoteEntitlementSnapshotSource(
client: HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)),
tokenStore: authTokenStore,
);
return AppBootstrap._( return AppBootstrap._(
database: database, database: database,
authUseCases: AuthUseCases( authUseCases: AuthUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
accountRepository: onlineAccountRepository, accountRepository: onlineAccountRepository,
remoteAuthApi: remoteAuthApi, remoteAuthApi: remoteAuthApi,
clock: clock, clock: clock,
@ -236,6 +279,8 @@ final class AppBootstrap implements AppDependencies {
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases, activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
executionDebugUseCase: executionDebugUseCase,
seedQaFunctionalContentUseCase: seedQaFunctionalContentUseCase,
workoutTelemetryUseCases: workoutTelemetryUseCases, workoutTelemetryUseCases: workoutTelemetryUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler, watchCompanionCommandHandler: watchCompanionCommandHandler,
@ -245,6 +290,8 @@ final class AppBootstrap implements AppDependencies {
sessionRepository: activeSessionRepository, sessionRepository: activeSessionRepository,
historyRepository: historyRepository, historyRepository: historyRepository,
telemetryRepository: telemetryRepository, telemetryRepository: telemetryRepository,
healthConnectUseCases: healthConnectUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
@ -267,8 +314,13 @@ final class AppBootstrap implements AppDependencies {
mediaStore: localBackupMediaStore, mediaStore: localBackupMediaStore,
clock: clock, clock: clock,
), ),
healthConnectUseCases: healthConnectUseCases,
entitlementRevalidationUseCase: EntitlementRevalidationUseCase(
repository: entitlementSnapshotRepository,
remote: remoteEntitlementSnapshotSource,
),
syncUseCases: SyncUseCases( syncUseCases: SyncUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
remoteSyncApi: remoteSyncApi, remoteSyncApi: remoteSyncApi,
metadataRepository: syncMetadataRepository, metadataRepository: syncMetadataRepository,
mappingRepository: mappingRepository, mappingRepository: mappingRepository,
@ -277,7 +329,7 @@ final class AppBootstrap implements AppDependencies {
deviceId: originDeviceId, deviceId: originDeviceId,
), ),
shareUseCases: ShareUseCases( shareUseCases: ShareUseCases(
tokenStore: const SecureStorageAuthTokenStore(), tokenStore: authTokenStore,
remoteShareApi: remoteShareApi, remoteShareApi: remoteShareApi,
inboxRepository: shareInboxRepository, inboxRepository: shareInboxRepository,
pendingActionRepository: pendingShareActionRepository, pendingActionRepository: pendingShareActionRepository,

View File

@ -56,6 +56,98 @@ final class LocalBackupException implements Exception {
String toString() => error.name; String toString() => error.name;
} }
enum EntitlementTier { free, pro }
final class EntitlementSnapshot {
const EntitlementSnapshot._({
required this.entitlement,
required this.canSync,
required this.hasUnlimitedEditableLibrary,
required this.remainingEditableSlots,
required this.validatedAt,
});
factory EntitlementSnapshot.free({
required int remainingEditableSlots,
required DateTime validatedAt,
}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.free,
canSync: false,
hasUnlimitedEditableLibrary: false,
remainingEditableSlots: remainingEditableSlots,
validatedAt: validatedAt,
);
}
factory EntitlementSnapshot.pro({required DateTime validatedAt}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.pro,
canSync: true,
hasUnlimitedEditableLibrary: true,
remainingEditableSlots: null,
validatedAt: validatedAt,
);
}
final EntitlementTier entitlement;
final bool canSync;
final bool hasUnlimitedEditableLibrary;
final int? remainingEditableSlots;
final DateTime validatedAt;
}
abstract interface class EntitlementSnapshotRepository {
Future<EntitlementSnapshot?> read();
Future<void> save(EntitlementSnapshot snapshot);
}
abstract interface class RemoteEntitlementSnapshotSource {
Future<EntitlementSnapshot> read();
}
final class OfflineException implements Exception {
const OfflineException();
}
enum BillingAvailability { available, unavailable }
final class BillingProductRef {
const BillingProductRef({required this.id});
final String id;
}
enum BillingAttemptStatus { pendingVerification }
final class BillingAttemptResult {
const BillingAttemptResult._({required this.status, required this.product});
factory BillingAttemptResult.pendingVerification({
required BillingProductRef product,
}) {
return BillingAttemptResult._(
status: BillingAttemptStatus.pendingVerification,
product: product,
);
}
final BillingAttemptStatus status;
final BillingProductRef product;
}
abstract interface class BillingPort {
Future<BillingAvailability> availability();
Future<BillingAttemptResult> purchasePro({
required BillingProductRef product,
});
Future<BillingAttemptResult> restorePurchases();
Future<BillingAttemptResult> revalidate();
}
final class LocalBackupDocument { final class LocalBackupDocument {
const LocalBackupDocument({required this.fileName, required this.bytes}); const LocalBackupDocument({required this.fileName, required this.bytes});
@ -245,6 +337,136 @@ abstract interface class LocalBackupMediaStore {
); );
} }
enum HealthConnectAvailability {
available,
providerUpdateRequired,
accessBlocked,
unavailable,
}
enum HealthConnectConnectionStatus {
unavailable,
providerUpdateRequired,
accessBlocked,
permissionsRequired,
connected,
}
enum HealthConnectPermissionRequestOutcome {
none,
granted,
denied,
settingsFallback,
launchFailed,
}
enum HealthConnectExportStatus {
exported,
unavailable,
providerUpdateRequired,
accessBlocked,
permissionsRequired,
invalidWorkout,
failed,
}
final class HealthConnectConnectionState {
const HealthConnectConnectionState({
required this.availability,
required this.requiredPermissions,
required this.grantedPermissions,
this.permissionRequestOutcome = HealthConnectPermissionRequestOutcome.none,
});
final HealthConnectAvailability availability;
final Set<String> requiredPermissions;
final Set<String> grantedPermissions;
final HealthConnectPermissionRequestOutcome permissionRequestOutcome;
bool get hasRequiredPermissions =>
grantedPermissions.containsAll(requiredPermissions);
HealthConnectConnectionStatus get status {
switch (availability) {
case HealthConnectAvailability.unavailable:
return HealthConnectConnectionStatus.unavailable;
case HealthConnectAvailability.providerUpdateRequired:
return HealthConnectConnectionStatus.providerUpdateRequired;
case HealthConnectAvailability.accessBlocked:
return HealthConnectConnectionStatus.accessBlocked;
case HealthConnectAvailability.available:
return hasRequiredPermissions
? HealthConnectConnectionStatus.connected
: HealthConnectConnectionStatus.permissionsRequired;
}
}
}
final class HealthConnectHeartRateSample {
const HealthConnectHeartRateSample({
required this.time,
required this.beatsPerMinute,
});
final DateTime time;
final int beatsPerMinute;
}
final class HealthConnectWorkoutExport {
const HealthConnectWorkoutExport({
required this.historyId,
required this.title,
required this.startedAt,
required this.endedAt,
required this.durationMs,
this.totalDistanceMeters,
this.totalCaloriesKcal,
this.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
this.heartRateSamples = const [],
});
final String historyId;
final String title;
final DateTime startedAt;
final DateTime endedAt;
final int durationMs;
final double? totalDistanceMeters;
final double? totalCaloriesKcal;
final int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
final List<HealthConnectHeartRateSample> heartRateSamples;
bool get isValid =>
historyId.trim().isNotEmpty &&
title.trim().isNotEmpty &&
durationMs > 0 &&
endedAt.isAfter(startedAt);
}
final class HealthConnectWorkoutExportResult {
const HealthConnectWorkoutExportResult({
required this.status,
required this.workout,
this.errorMessage,
});
final HealthConnectExportStatus status;
final HealthConnectWorkoutExport? workout;
final String? errorMessage;
bool get exported => status == HealthConnectExportStatus.exported;
}
abstract interface class HealthConnectGateway {
Future<HealthConnectConnectionState> connectionState();
Future<HealthConnectConnectionState> requestPermissions();
Future<bool> openSettings();
Future<void> exportWorkout(HealthConnectWorkoutExport workout);
}
enum ProgressionPeriod { fourWeeks, threeMonths, all } enum ProgressionPeriod { fourWeeks, threeMonths, all }
enum ProgressionMeasure { manualScore, stopwatchScore, reps, time } enum ProgressionMeasure { manualScore, stopwatchScore, reps, time }

View File

@ -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 } enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
final class StarterSeedResult { final class StarterSeedResult {
@ -3461,12 +3518,14 @@ final class WatchCompanionProjectionUseCases implements WatchProjectionSource {
required Clock clock, required Clock clock,
required IdGenerator ids, required IdGenerator ids,
required String originDeviceId, required String originDeviceId,
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
WatchProjectionPublisher? publisher, WatchProjectionPublisher? publisher,
}) : _projector = WatchSessionProjectionProjector( }) : _projector = WatchSessionProjectionProjector(
sessionRepository: sessionRepository, sessionRepository: sessionRepository,
clock: clock, clock: clock,
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
), ),
_publisher = publisher; _publisher = publisher;
@ -3524,6 +3583,12 @@ bool _hasSameWatchCommandRevisionState(
left.stepIndex == right.stepIndex && left.stepIndex == right.stepIndex &&
left.stepTotal == right.stepTotal && left.stepTotal == right.stepTotal &&
left.stepName == right.stepName && 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( _hasSameWatchCommandRevisionTimerState(
left.dominantTimer, left.dominantTimer,
right.dominantTimer, right.dominantTimer,
@ -3543,6 +3608,7 @@ bool _hasSameWatchCommandRevisionState(
left.manualScoreTargetLabel == right.manualScoreTargetLabel && left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue && left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
left.manualScoreScope == right.manualScoreScope && left.manualScoreScope == right.manualScoreScope &&
left.totalDistanceM == right.totalDistanceM &&
_listEquals( _listEquals(
left.healthServicesExerciseTypeStrategy, left.healthServicesExerciseTypeStrategy,
right.healthServicesExerciseTypeStrategy, right.healthServicesExerciseTypeStrategy,
@ -3684,8 +3750,9 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
WatchCommandType.startPreparedTimedStep => WatchCommandType.startPreparedTimedStep =>
projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep, projection.primaryAction == WatchPrimaryAction.startPreparedTimedStep,
WatchCommandType.completeCurrentStep => WatchCommandType.completeCurrentStep =>
projection.stepType == WatchStepType.reps && (projection.stepType == WatchStepType.reps &&
projection.stepTargetValue != null, projection.stepTargetValue != null) ||
projection.manualScoreScope == WatchManualScoreScope.step,
WatchCommandType.skipCurrentStep => projection.secondaryActions.contains( WatchCommandType.skipCurrentStep => projection.secondaryActions.contains(
WatchSecondaryAction.skipCurrentStep, WatchSecondaryAction.skipCurrentStep,
), ),
@ -3713,7 +3780,10 @@ final class WatchCompanionCommandHandler implements WatchCommandIngress {
bool _allowsStaleRevision(WatchCommandType type) { bool _allowsStaleRevision(WatchCommandType type) {
return type == WatchCommandType.incrementScore || return type == WatchCommandType.incrementScore ||
type == WatchCommandType.decrementScore; type == WatchCommandType.decrementScore ||
type == WatchCommandType.startCurrentExercise ||
type == WatchCommandType.startPreparedTimedStep ||
type == WatchCommandType.completeCurrentStep;
} }
Future<WatchCommandAck> _route( Future<WatchCommandAck> _route(
@ -4050,16 +4120,18 @@ final class WatchSessionProjectionProjector {
required this.clock, required this.clock,
required this.ids, required this.ids,
required this.originDeviceId, required this.originDeviceId,
this.activeWorkoutSensorUseCases,
}); });
final ActiveSessionRepository sessionRepository; final ActiveSessionRepository sessionRepository;
final Clock clock; final Clock clock;
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
Future<WatchSessionProjection> project({required int revision}) async { Future<WatchSessionProjection> project({required int revision}) async {
final now = clock.now(); final now = clock.now();
final session = await sessionRepository.findOpen(); var session = await sessionRepository.findOpen();
if (session == null || if (session == null ||
session.status == ActiveWorkoutStatus.completed || session.status == ActiveWorkoutStatus.completed ||
session.status == ActiveWorkoutStatus.abandoned || session.status == ActiveWorkoutStatus.abandoned ||
@ -4078,6 +4150,24 @@ final class WatchSessionProjectionProjector {
statusLabel: 'Aucune séance en cours', 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( final snapshot = _findExerciseSnapshot(
resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson, resolvedTemplateSnapshotJson: session.resolvedTemplateSnapshotJson,
@ -4121,7 +4211,9 @@ final class WatchSessionProjectionProjector {
); );
final stepView = await _readStepViewIfStarted(session, snapshot); final stepView = await _readStepViewIfStarted(session, snapshot);
final stepState = stepView?.state; final stepState = stepView?.state;
final currentStep = stepView?.currentStep ?? _initialStep(snapshot); final currentStep = stepView == null
? _initialStep(snapshot)
: stepView.currentStep;
final manualScoreProjection = _watchManualScoreProjection( final manualScoreProjection = _watchManualScoreProjection(
snapshot: snapshot, snapshot: snapshot,
currentStep: currentStep, currentStep: currentStep,
@ -4132,22 +4224,25 @@ final class WatchSessionProjectionProjector {
final expectedPassages = _expectedPassages(snapshot); final expectedPassages = _expectedPassages(snapshot);
final projectedAtEpochMs = _epochMs(now); final projectedAtEpochMs = _epochMs(now);
final exerciseTargetTimeMs = snapshot.timeEnabled
? snapshot.targetTimeSeconds == null
? null
: snapshot.targetTimeSeconds! * 1000
: null;
final scoreStopwatchTimer = scoreStopwatch == null final scoreStopwatchTimer = scoreStopwatch == null
? null ? null
: _scoreStopwatchTimerProjection(scoreStopwatch, now); : _scoreStopwatchTimerProjection(scoreStopwatch, now);
final setTimerProjection = setTimer == null final setTimerProjection = setTimer == null
? null ? _pendingSetTimerProjection(snapshot, now)
: _setTimerProjection(setTimer, now); : _setTimerProjection(setTimer, now, targetMs: exerciseTargetTimeMs);
final allTimers = <WatchTimerProjection>[ final allTimers = <WatchTimerProjection>[
if (activeRest != null) _restTimerProjection(activeRest, now), if (activeRest != null) _restTimerProjection(activeRest, now),
if (currentStep != null && currentStep.type == ExerciseStepType.time) if (currentStep != null && currentStep.type == ExerciseStepType.time)
_stepTimerProjection(stepState, currentStep, now), _stepTimerProjection(stepState, currentStep, now),
?setTimerProjection,
?scoreStopwatchTimer, ?scoreStopwatchTimer,
?setTimerProjection,
]; ];
final displayTimers = allTimers final displayTimers = allTimers.toList(growable: false);
.where((timer) => timer.kind != WatchTimerKind.setTimer)
.toList(growable: false);
final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first; final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
final secondaryTimers = dominantTimer == null final secondaryTimers = dominantTimer == null
? const <WatchTimerProjection>[] ? const <WatchTimerProjection>[]
@ -4164,6 +4259,9 @@ final class WatchSessionProjectionProjector {
_sessionHealthServicesExerciseTypeStrategy( _sessionHealthServicesExerciseTypeStrategy(
session.resolvedTemplateSnapshotJson, session.resolvedTemplateSnapshotJson,
); );
final sensorState = activeWorkoutSensorUseCases?.current(
session.metadata.id,
);
return WatchSessionProjection( return WatchSessionProjection(
deviceSessionId: session.metadata.id, deviceSessionId: session.metadata.id,
@ -4193,6 +4291,12 @@ final class WatchSessionProjectionProjector {
null => null, null => null,
}, },
stepTargetValue: currentStep?.defaultTargetValue, 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, dominantTimer: dominantTimer,
secondaryTimers: secondaryTimers, secondaryTimers: secondaryTimers,
primaryAction: _primaryAction(phase), primaryAction: _primaryAction(phase),
@ -4225,6 +4329,7 @@ final class WatchSessionProjectionProjector {
manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue, manualScoreRepsTargetValue: manualScoreProjection?.repsTargetValue,
manualScoreScope: manualScoreProjection?.scope, manualScoreScope: manualScoreProjection?.scope,
healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy, healthServicesExerciseTypeStrategy: healthServicesExerciseTypeStrategy,
totalDistanceM: sensorState?.latestDistanceMeters,
); );
} }
@ -4288,6 +4393,50 @@ final class WatchSessionProjectionProjector {
..sort((left, right) => right.startedAt.compareTo(left.startedAt)); ..sort((left, right) => right.startedAt.compareTo(left.startedAt));
return active.isEmpty ? null : active.first; 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 { final class _WatchManualScoreProjectionData {
@ -4326,8 +4475,6 @@ _WatchManualScoreProjectionData? _watchManualScoreProjection({
return _WatchManualScoreProjectionData( return _WatchManualScoreProjectionData(
scope: WatchManualScoreScope.step, scope: WatchManualScoreScope.step,
value: result?.actualScore ?? 0, value: result?.actualScore ?? 0,
targetValue: step.defaultTargetScore,
targetLabel: step.defaultTargetScore == null ? null : 'Cible',
repsTargetValue: step.type == ExerciseStepType.reps repsTargetValue: step.type == ExerciseStepType.reps
? step.defaultTargetValue ? step.defaultTargetValue
: null, : null,
@ -4508,7 +4655,7 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
? WatchTimerRunState.running ? WatchTimerRunState.running
: WatchTimerRunState.paused, : WatchTimerRunState.paused,
referenceEpochMs: _epochMs(now), referenceEpochMs: _epochMs(now),
accumulatedMs: state.accumulatedMs, accumulatedMs: state.elapsedMillisecondsAt(now),
startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running startedAtEpochMs: state.status == ActiveScoreStopwatchStatus.running
? _epochMs(state.startedAt) ? _epochMs(state.startedAt)
: null, : null,
@ -4517,8 +4664,9 @@ WatchTimerProjection? _scoreStopwatchTimerProjection(
WatchTimerProjection? _setTimerProjection( WatchTimerProjection? _setTimerProjection(
ActiveSetTimerState state, ActiveSetTimerState state,
DateTime now, DateTime now, {
) { int? targetMs,
}) {
if (state.status == ActiveSetTimerStatus.stopped || if (state.status == ActiveSetTimerStatus.stopped ||
state.status == ActiveSetTimerStatus.skipped) { state.status == ActiveSetTimerStatus.skipped) {
return null; return null;
@ -4531,11 +4679,30 @@ WatchTimerProjection? _setTimerProjection(
? WatchTimerRunState.running ? WatchTimerRunState.running
: WatchTimerRunState.paused, : WatchTimerRunState.paused,
referenceEpochMs: _epochMs(now), referenceEpochMs: _epochMs(now),
accumulatedMs: state.accumulatedMs, accumulatedMs: state.elapsedMillisecondsAt(now),
startedAtEpochMs: startedAtEpochMs:
state.status == ActiveSetTimerStatus.running && state.startedAt != null state.status == ActiveSetTimerStatus.running && state.startedAt != null
? _epochMs(state.startedAt!) ? _epochMs(state.startedAt!)
: null, : 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.sessionRepository,
required this.historyRepository, required this.historyRepository,
this.telemetryRepository, this.telemetryRepository,
this.healthConnectUseCases,
this.activeWorkoutSensorUseCases,
required this.clock, required this.clock,
required this.ids, required this.ids,
required this.originDeviceId, required this.originDeviceId,
@ -5465,6 +5634,8 @@ final class CloseWorkoutSessionUseCase {
final ActiveSessionRepository sessionRepository; final ActiveSessionRepository sessionRepository;
final WorkoutHistoryRepository historyRepository; final WorkoutHistoryRepository historyRepository;
final WorkoutTelemetryRepository? telemetryRepository; final WorkoutTelemetryRepository? telemetryRepository;
final HealthConnectUseCases? healthConnectUseCases;
final ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases;
final Clock clock; final Clock clock;
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
@ -5510,6 +5681,11 @@ final class CloseWorkoutSessionUseCase {
final telemetrySamples = final telemetrySamples =
await telemetryRepository?.listSamples(session.metadata.id) ?? await telemetryRepository?.listSamples(session.metadata.id) ??
const <WorkoutTelemetrySample>[]; const <WorkoutTelemetrySample>[];
final totalSteps =
_totalStepsFromSamples(telemetrySamples) ??
activeWorkoutSensorUseCases
?.current(session.metadata.id)
?.latestStepCount;
final history = WorkoutHistory( final history = WorkoutHistory(
metadata: EntityMetadata( metadata: EntityMetadata(
id: historyId, id: historyId,
@ -5582,16 +5758,19 @@ final class CloseWorkoutSessionUseCase {
'telemetrySamples': telemetrySamples 'telemetrySamples': telemetrySamples
.map(_workoutTelemetrySampleSnapshotJson) .map(_workoutTelemetrySampleSnapshotJson)
.toList(), .toList(),
'totalSteps': totalSteps,
}), }),
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm, minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm, averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm, maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
totalDistanceMeters: telemetryAggregate?.totalDistanceMeters, totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal, totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
totalSteps: totalSteps,
results: historyResults, results: historyResults,
stepResults: historyStepResults, stepResults: historyStepResults,
); );
await historyRepository.save(history); await historyRepository.save(history);
await healthConnectUseCases?.exportAfterWorkout(history);
return history; return history;
} }
} }
@ -5822,6 +6001,7 @@ final class WorkoutTelemetryUseCases {
heartRateBpm: state.heartRateBpm, heartRateBpm: state.heartRateBpm,
distanceMeters: state.distanceMeters, distanceMeters: state.distanceMeters,
caloriesKcal: state.caloriesKcal, caloriesKcal: state.caloriesKcal,
stepCount: state.stepCount,
); );
} }
@ -5859,6 +6039,9 @@ final class WorkoutTelemetryUseCases {
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
distanceMeters: hasDistance ? sample.distanceMeters : null, distanceMeters: hasDistance ? sample.distanceMeters : null,
caloriesKcal: hasCalories ? sample.caloriesKcal : 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, 'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters, 'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal, 'caloriesKcal': sample.caloriesKcal,
'stepCount': sample.stepCount,
}; };
} }
@ -6177,12 +6361,26 @@ List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
heartRateBpm: json['heartRateBpm'] as int?, heartRateBpm: json['heartRateBpm'] as int?,
distanceMeters: (json['distanceMeters'] as num?)?.toDouble(), distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(), caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
stepCount: (json['stepCount'] as num?)?.toInt(),
), ),
); );
} }
return output; 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( bool _matchesTelemetryScope(
WorkoutTelemetrySample sample, { WorkoutTelemetrySample sample, {
required WorkoutTelemetryAggregateScope scope, 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 { final class ActiveWorkoutSensorState {
const ActiveWorkoutSensorState({ const ActiveWorkoutSensorState({
required this.sessionId, required this.sessionId,
@ -6403,6 +6726,7 @@ final class ActiveWorkoutSensorState {
this.latestDistanceMeters, this.latestDistanceMeters,
required this.latestDistanceAvailable, required this.latestDistanceAvailable,
this.latestCaloriesKcal, this.latestCaloriesKcal,
this.latestStepCount,
required this.estimatedCaloriesKcal, required this.estimatedCaloriesKcal,
}); });
@ -6417,6 +6741,7 @@ final class ActiveWorkoutSensorState {
final double? latestDistanceMeters; final double? latestDistanceMeters;
final bool latestDistanceAvailable; final bool latestDistanceAvailable;
final double? latestCaloriesKcal; final double? latestCaloriesKcal;
final int? latestStepCount;
final double estimatedCaloriesKcal; final double estimatedCaloriesKcal;
} }
@ -6445,7 +6770,9 @@ final class ActiveWorkoutSensorUseCases {
sample.distanceMeters != null && sample.distanceMeters! >= 0; sample.distanceMeters != null && sample.distanceMeters! >= 0;
final hasCalories = final hasCalories =
sample.caloriesKcal != null && sample.caloriesKcal! >= 0; 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; return null;
} }
final recordedAt = sample.capturedAtEpochMs > 0 final recordedAt = sample.capturedAtEpochMs > 0
@ -6463,6 +6790,7 @@ final class ActiveWorkoutSensorUseCases {
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null, heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
distanceMeters: hasDistance ? sample.distanceMeters : null, distanceMeters: hasDistance ? sample.distanceMeters : null,
caloriesKcal: hasCalories ? sample.caloriesKcal : null, caloriesKcal: hasCalories ? sample.caloriesKcal : null,
stepCount: hasSteps ? sample.stepCount : null,
recordedAt: recordedAt, recordedAt: recordedAt,
); );
if (snapshot == null) { if (snapshot == null) {
@ -6497,6 +6825,7 @@ final class _ActiveWorkoutSensorAccumulator {
double? _latestDistanceMeters; double? _latestDistanceMeters;
var _latestDistanceAvailable = false; var _latestDistanceAvailable = false;
double? _latestCaloriesKcal; double? _latestCaloriesKcal;
int? _latestStepCount;
ActiveWorkoutSensorState get snapshot { ActiveWorkoutSensorState get snapshot {
final averageHeartRateBpm = _heartRateSampleCount == 0 final averageHeartRateBpm = _heartRateSampleCount == 0
@ -6514,6 +6843,7 @@ final class _ActiveWorkoutSensorAccumulator {
latestDistanceMeters: _latestDistanceMeters, latestDistanceMeters: _latestDistanceMeters,
latestDistanceAvailable: _latestDistanceAvailable, latestDistanceAvailable: _latestDistanceAvailable,
latestCaloriesKcal: _latestCaloriesKcal, latestCaloriesKcal: _latestCaloriesKcal,
latestStepCount: _latestStepCount,
estimatedCaloriesKcal: _estimatedCaloriesKcal( estimatedCaloriesKcal: _estimatedCaloriesKcal(
averageHeartRateBpm: averageHeartRateBpm ?? 0, averageHeartRateBpm: averageHeartRateBpm ?? 0,
activeDuration: _latestSampleAt.difference(firstSampleAt), activeDuration: _latestSampleAt.difference(firstSampleAt),
@ -6526,6 +6856,7 @@ final class _ActiveWorkoutSensorAccumulator {
required int? heartRateBpm, required int? heartRateBpm,
required double? distanceMeters, required double? distanceMeters,
required double? caloriesKcal, required double? caloriesKcal,
required int? stepCount,
required DateTime recordedAt, required DateTime recordedAt,
}) { }) {
final stableSampleId = sampleId?.trim(); final stableSampleId = sampleId?.trim();
@ -6564,6 +6895,10 @@ final class _ActiveWorkoutSensorAccumulator {
(_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) { (_latestCaloriesKcal == null || caloriesKcal >= _latestCaloriesKcal!)) {
_latestCaloriesKcal = caloriesKcal; _latestCaloriesKcal = caloriesKcal;
} }
if (stepCount != null &&
(_latestStepCount == null || stepCount >= _latestStepCount!)) {
_latestStepCount = stepCount;
}
return snapshot; return snapshot;
} }
} }

View File

@ -52,7 +52,7 @@ final class AppDatabase extends _$AppDatabase {
} }
@override @override
int get schemaVersion => 26; int get schemaVersion => 27;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -62,6 +62,7 @@ final class AppDatabase extends _$AppDatabase {
await _migrateToSchema15(); await _migrateToSchema15();
await _migrateToSchema16(migrator); await _migrateToSchema16(migrator);
await _migrateToSchema25(); await _migrateToSchema25();
await _migrateToSchema27();
await _createIndexes(); await _createIndexes();
}, },
onUpgrade: (migrator, from, to) async { onUpgrade: (migrator, from, to) async {
@ -145,6 +146,9 @@ final class AppDatabase extends _$AppDatabase {
if (from < 26) { if (from < 26) {
await _migrateToSchema26(migrator); await _migrateToSchema26(migrator);
} }
if (from < 27) {
await _migrateToSchema27();
}
await _createIndexes(); await _createIndexes();
}, },
beforeOpen: (details) async { beforeOpen: (details) async {
@ -485,6 +489,23 @@ extension on AppDatabase {
await migrator.createTable(activeWorkoutTelemetryWindowStates); await migrator.createTable(activeWorkoutTelemetryWindowStates);
} }
Future<void> _migrateToSchema27() async {
await customStatement('''
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
id TEXT NOT NULL PRIMARY KEY CHECK (id = 'current'),
entitlement TEXT NOT NULL CHECK (entitlement IN ('free', 'pro')),
can_sync INTEGER NOT NULL CHECK (can_sync IN (0, 1)),
has_unlimited_editable_library INTEGER NOT NULL CHECK (
has_unlimited_editable_library IN (0, 1)
),
remaining_editable_slots INTEGER CHECK (
remaining_editable_slots IS NULL OR remaining_editable_slots >= 0
),
validated_at INTEGER NOT NULL
)
''');
}
Future<void> _migrateToSchema13() async { Future<void> _migrateToSchema13() async {
await customStatement('PRAGMA foreign_keys = OFF'); await customStatement('PRAGMA foreign_keys = OFF');
await customStatement(''' await customStatement('''

View File

@ -133,7 +133,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
} }
final class DriftStarterSeedRepository final class DriftStarterSeedRepository
implements StarterSeedStateRepository, StarterContentRepository { implements
StarterSeedStateRepository,
StarterContentRepository,
QaContentSeedStateRepository,
QaContentRepository {
const DriftStarterSeedRepository(this.database); const DriftStarterSeedRepository(this.database);
static const _starterSeedKey = 'starter'; static const _starterSeedKey = 'starter';
@ -165,6 +169,35 @@ final class DriftStarterSeedRepository
); );
} }
@override
Future<int> readAppliedQaContentSeedVersion() async {
final row = await database
.customSelect(
'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1',
variables: [Variable<String>(qaFunctionalContentSeedKey)],
)
.getSingleOrNull();
return row?.read<int>('version') ?? 0;
}
@override
Future<void> writeAppliedQaContentSeedVersion(
int version,
DateTime appliedAt,
) async {
await database.customStatement(
'INSERT INTO local_seed_metadata (key, version, applied_at) '
'VALUES (?, ?, ?) '
'ON CONFLICT(key) DO UPDATE SET '
'version = excluded.version, applied_at = excluded.applied_at',
[
qaFunctionalContentSeedKey,
version,
appliedAt.toUtc().millisecondsSinceEpoch,
],
);
}
@override @override
Future<bool> isLocalContentEmpty() async { Future<bool> isLocalContentEmpty() async {
final exerciseCount = await _tableCount(database.exercises.actualTableName); final exerciseCount = await _tableCount(database.exercises.actualTableName);
@ -215,6 +248,11 @@ final class DriftStarterSeedRepository
}); });
} }
@override
Future<void> insertQaContent(StarterContent content) {
return insertStarterContent(content);
}
Future<int> _tableCount(String tableName) async { Future<int> _tableCount(String tableName) async {
final row = await database final row = await database
.customSelect('SELECT COUNT(*) AS count FROM $tableName') .customSelect('SELECT COUNT(*) AS count FROM $tableName')
@ -223,6 +261,73 @@ final class DriftStarterSeedRepository
} }
} }
final class DriftEntitlementSnapshotRepository
implements EntitlementSnapshotRepository {
const DriftEntitlementSnapshotRepository(this.database);
final db.AppDatabase database;
@override
Future<EntitlementSnapshot?> read() async {
final row = await database.customSelect('''
SELECT
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
FROM entitlement_snapshots
WHERE id = 'current'
LIMIT 1
''').getSingleOrNull();
if (row == null) {
return null;
}
final tier = _entitlementTierFromDb(row.read<String>('entitlement'));
final validatedAt = DateTime.fromMillisecondsSinceEpoch(
row.read<int>('validated_at'),
isUtc: true,
);
return switch (tier) {
EntitlementTier.free => EntitlementSnapshot.free(
remainingEditableSlots:
row.readNullable<int>('remaining_editable_slots') ?? 0,
validatedAt: validatedAt,
),
EntitlementTier.pro => EntitlementSnapshot.pro(validatedAt: validatedAt),
};
}
@override
Future<void> save(EntitlementSnapshot snapshot) {
return database.customStatement(
'''
INSERT INTO entitlement_snapshots (
id,
entitlement,
can_sync,
has_unlimited_editable_library,
remaining_editable_slots,
validated_at
) VALUES ('current', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
entitlement = excluded.entitlement,
can_sync = excluded.can_sync,
has_unlimited_editable_library = excluded.has_unlimited_editable_library,
remaining_editable_slots = excluded.remaining_editable_slots,
validated_at = excluded.validated_at
''',
[
_entitlementTierToDb(snapshot.entitlement),
snapshot.canSync ? 1 : 0,
snapshot.hasUnlimitedEditableLibrary ? 1 : 0,
snapshot.remainingEditableSlots,
snapshot.validatedAt.toUtc().millisecondsSinceEpoch,
],
);
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository { final class DriftMediaAssetRepository implements MediaAssetRepository {
const DriftMediaAssetRepository(this.database); const DriftMediaAssetRepository(this.database);
@ -5549,6 +5654,7 @@ domain.WorkoutHistory _workoutHistoryFromRow(
maxHeartRateBpm: row.maxHeartRateBpm, maxHeartRateBpm: row.maxHeartRateBpm,
totalDistanceMeters: row.totalDistanceMeters, totalDistanceMeters: row.totalDistanceMeters,
totalCaloriesKcal: row.totalCaloriesKcal, totalCaloriesKcal: row.totalCaloriesKcal,
totalSteps: _totalStepsFromHistorySnapshotJson(row.historySnapshotJson),
results: results, results: results,
stepResults: stepResults, stepResults: stepResults,
); );
@ -5907,6 +6013,7 @@ Map<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
'maxHeartRateBpm': history.maxHeartRateBpm, 'maxHeartRateBpm': history.maxHeartRateBpm,
'totalDistanceMeters': history.totalDistanceMeters, 'totalDistanceMeters': history.totalDistanceMeters,
'totalCaloriesKcal': history.totalCaloriesKcal, 'totalCaloriesKcal': history.totalCaloriesKcal,
'totalSteps': history.totalSteps,
}; };
Map<String, Object?> _localWorkoutHistoryPayload( Map<String, Object?> _localWorkoutHistoryPayload(
@ -6006,6 +6113,7 @@ Map<String, Object?> _workoutTelemetrySamplePayload(
'heartRateBpm': sample.heartRateBpm, 'heartRateBpm': sample.heartRateBpm,
'distanceMeters': sample.distanceMeters, 'distanceMeters': sample.distanceMeters,
'caloriesKcal': sample.caloriesKcal, 'caloriesKcal': sample.caloriesKcal,
'stepCount': sample.stepCount,
}; };
List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload( List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromHistoryPayload(
@ -6056,12 +6164,45 @@ List<domain.WorkoutTelemetrySample> _workoutTelemetrySamplesFromPayload(
heartRateBpm: map['heartRateBpm'] as int?, heartRateBpm: map['heartRateBpm'] as int?,
distanceMeters: (map['distanceMeters'] as num?)?.toDouble(), distanceMeters: (map['distanceMeters'] as num?)?.toDouble(),
caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(), caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(),
stepCount: (map['stepCount'] as num?)?.toInt(),
), ),
); );
} }
return output; return output;
} }
int? _totalStepsFromHistorySnapshotJson(String snapshotJson) {
try {
final decoded = jsonDecode(snapshotJson);
if (decoded is! Map) {
return null;
}
final explicitTotal = decoded['totalSteps'];
if (explicitTotal is num && explicitTotal >= 0) {
return explicitTotal.toInt();
}
final rawSamples = decoded['telemetrySamples'];
if (rawSamples is! List) {
return null;
}
int? totalSteps;
for (final rawSample in rawSamples) {
if (rawSample is! Map) {
continue;
}
final stepCount = rawSample['stepCount'];
if (stepCount is num &&
stepCount >= 0 &&
(totalSteps == null || stepCount > totalSteps)) {
totalSteps = stepCount.toInt();
}
}
return totalSteps;
} on FormatException {
return null;
}
}
List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples( List<domain.WorkoutTelemetryAggregate> _workoutTelemetryAggregatesFromSamples(
List<domain.WorkoutTelemetrySample> samples, List<domain.WorkoutTelemetrySample> samples,
) { ) {
@ -6311,6 +6452,9 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(), totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(),
totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(), totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(),
totalSteps:
(payload['totalSteps'] as num?)?.toInt() ??
_totalStepsFromHistorySnapshotJson(historySnapshotJson),
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload( stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'], payload['stepResults'],
@ -6890,6 +7034,17 @@ String _syncResourceTypeToDb(SyncResourceType type) => switch (type) {
SyncResourceType.mediaAsset => 'mediaAsset', SyncResourceType.mediaAsset => 'mediaAsset',
}; };
String _entitlementTierToDb(EntitlementTier tier) => switch (tier) {
EntitlementTier.free => 'free',
EntitlementTier.pro => 'pro',
};
EntitlementTier _entitlementTierFromDb(String value) => switch (value) {
'free' => EntitlementTier.free,
'pro' => EntitlementTier.pro,
_ => throw domain.DomainException('Unknown entitlement tier: $value'),
};
SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) { SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) {
'exercise' => SyncResourceType.exercise, 'exercise' => SyncResourceType.exercise,
'program' => SyncResourceType.program, 'program' => SyncResourceType.program,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../application/application.dart'; import '../../application/application.dart';
@ -27,11 +28,20 @@ final class HttpApiClient {
static String defaultBaseUrlFor({ static String defaultBaseUrlFor({
required bool isAndroid, required bool isAndroid,
String configuredBaseUrl = _configuredBaseUrl, String configuredBaseUrl = _configuredBaseUrl,
bool isReleaseMode = kReleaseMode,
}) { }) {
final configured = configuredBaseUrl.trim(); final configured = configuredBaseUrl.trim();
if (configured.isNotEmpty) { if (configured.isNotEmpty) {
if (isReleaseMode && Uri.tryParse(configured)?.scheme != 'https') {
throw StateError(
'GAMETIME_API_BASE_URL must be an HTTPS URL in release builds.',
);
}
return configured; return configured;
} }
if (isReleaseMode) {
throw StateError('GAMETIME_API_BASE_URL is required in release builds.');
}
if (isAndroid) { if (isAndroid) {
return androidEmulatorDefaultBaseUrl; return androidEmulatorDefaultBaseUrl;
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'monetization_ui.dart';
import 'theme.dart'; import 'theme.dart';
final class ShareInboxScreen extends StatefulWidget { final class ShareInboxScreen extends StatefulWidget {
@ -176,6 +177,11 @@ final class _ShareInboxTile extends StatelessWidget {
], ],
), ),
if (item.status == ShareInboxStatus.pending) ...[ if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (item.resourceType == ShareResourceType.pack) if (item.resourceType == ShareResourceType.pack)
FilledButton(onPressed: onOpen, child: const Text('Voir le pack')) FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
@ -273,6 +279,11 @@ final class _SharePackDetailScreen extends StatelessWidget {
title: Text(name), title: Text(name),
), ),
if (item.status == ShareInboxStatus.pending) ...[ if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: onImport == null onPressed: onImport == null
@ -390,6 +401,22 @@ String _packBadge(int count) {
return 'Pack · $count séance${count > 1 ? 's' : ''}'; return 'Pack · $count séance${count > 1 ? 's' : ''}';
} }
int _quotaImpactCount(ShareInboxItem item, _InboxSummary summary) {
return switch (item.resourceType) {
ShareResourceType.program => 1,
ShareResourceType.workoutTemplate => 1,
ShareResourceType.pack => summary.itemCount,
};
}
String _quotaImpactLabel(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'programme',
ShareResourceType.workoutTemplate => 'séance',
ShareResourceType.pack => 'séance',
};
}
IconData _resourceIcon(ShareResourceType type) { IconData _resourceIcon(ShareResourceType type) {
return switch (type) { return switch (type) {
ShareResourceType.program => Icons.list_alt, ShareResourceType.program => Icons.list_alt,

View File

@ -1216,10 +1216,19 @@ void main() {
totalCaloriesKcal: 56, totalCaloriesKcal: 56,
), ),
); );
final activeWorkoutSensorUseCases =
ActiveWorkoutSensorUseCases(clock: clock)..recordTelemetrySample(
WatchSensorSample(
sessionId: session.metadata.id,
capturedAtEpochMs: clock.now().millisecondsSinceEpoch,
stepCount: 1320,
),
);
final useCase = CloseWorkoutSessionUseCase( final useCase = CloseWorkoutSessionUseCase(
sessionRepository: sessionRepository, sessionRepository: sessionRepository,
historyRepository: historyRepository, historyRepository: historyRepository,
telemetryRepository: telemetryRepository, telemetryRepository: telemetryRepository,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
clock: clock, clock: clock,
ids: _FakeIds(), ids: _FakeIds(),
originDeviceId: 'device-1', originDeviceId: 'device-1',
@ -1236,7 +1245,13 @@ void main() {
expect(history.maxHeartRateBpm, 151); expect(history.maxHeartRateBpm, 151);
expect(history.totalDistanceMeters, 720); expect(history.totalDistanceMeters, 720);
expect(history.totalCaloriesKcal, 56); expect(history.totalCaloriesKcal, 56);
expect(history.totalSteps, 1320);
expect(
jsonDecode(history.historySnapshotJson),
containsPair('totalSteps', 1320),
);
expect(historyRepository.histories.single, history); expect(historyRepository.histories.single, history);
await activeWorkoutSensorUseCases.dispose();
}); });
test('adjustRestSeconds persists adjusted rest duration', () async { test('adjustRestSeconds persists adjusted rest duration', () async {
@ -3240,6 +3255,7 @@ void main() {
heartRateBpm: 120, heartRateBpm: 120,
distanceMeters: 500, distanceMeters: 500,
caloriesKcal: 42, caloriesKcal: 42,
stepCount: 900,
), ),
); );
final duplicate = useCase.recordTelemetrySample( final duplicate = useCase.recordTelemetrySample(
@ -3271,18 +3287,35 @@ void main() {
).millisecondsSinceEpoch, ).millisecondsSinceEpoch,
distanceMeters: 620, distanceMeters: 620,
caloriesKcal: 48, caloriesKcal: 48,
stepCount: 1320,
),
);
final stepsOnly = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-3',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
3,
).millisecondsSinceEpoch,
stepCount: 1480,
), ),
); );
expect(first?.latestHeartRateBpm, 120); expect(first?.latestHeartRateBpm, 120);
expect(duplicate, isNull); expect(duplicate, isNull);
expect(distanceOnly?.sampleCount, 2); expect(distanceOnly?.sampleCount, 2);
expect(stepsOnly?.sampleCount, 3);
expect(distanceOnly?.latestHeartRateBpm, 120); expect(distanceOnly?.latestHeartRateBpm, 120);
expect(distanceOnly?.minHeartRateBpm, 120); expect(distanceOnly?.minHeartRateBpm, 120);
expect(distanceOnly?.averageHeartRateBpm, 120); expect(distanceOnly?.averageHeartRateBpm, 120);
expect(distanceOnly?.maxHeartRateBpm, 120); expect(distanceOnly?.maxHeartRateBpm, 120);
expect(distanceOnly?.latestDistanceMeters, 620); expect(distanceOnly?.latestDistanceMeters, 620);
expect(distanceOnly?.latestCaloriesKcal, 48); expect(distanceOnly?.latestCaloriesKcal, 48);
expect(stepsOnly?.latestStepCount, 1480);
await useCase.dispose(); await useCase.dispose();
}, },
); );

View File

@ -22,6 +22,7 @@ void main() {
late local.DriftWorkoutTelemetryRepository telemetryRepository; late local.DriftWorkoutTelemetryRepository telemetryRepository;
late local.DriftExercisePerformanceReferenceRepository late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository; performanceReferenceRepository;
late local.DriftEntitlementSnapshotRepository entitlementSnapshotRepository;
setUp(() { setUp(() {
database = local.AppDatabase(NativeDatabase.memory()); database = local.AppDatabase(NativeDatabase.memory());
@ -39,6 +40,9 @@ void main() {
telemetryRepository = local.DriftWorkoutTelemetryRepository(database); telemetryRepository = local.DriftWorkoutTelemetryRepository(database);
performanceReferenceRepository = performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database); local.DriftExercisePerformanceReferenceRepository(database);
entitlementSnapshotRepository = local.DriftEntitlementSnapshotRepository(
database,
);
}); });
tearDown(() async { tearDown(() async {
@ -148,7 +152,33 @@ void main() {
await columnNames('workout_telemetry_aggregates'), await columnNames('workout_telemetry_aggregates'),
contains('sample_count'), contains('sample_count'),
); );
expect(database.schemaVersion, 26); expect(await columnNames('entitlement_snapshots'), contains('entitlement'));
expect(database.schemaVersion, 27);
});
test('entitlement snapshot is persisted locally for offline reads', () async {
final free = EntitlementSnapshot.free(
remainingEditableSlots: 2,
validatedAt: DateTime.utc(2026, 8, 22, 8),
);
await entitlementSnapshotRepository.save(free);
final restoredFree = await entitlementSnapshotRepository.read();
expect(restoredFree?.entitlement, EntitlementTier.free);
expect(restoredFree?.canSync, isFalse);
expect(restoredFree?.remainingEditableSlots, 2);
expect(restoredFree?.validatedAt, DateTime.utc(2026, 8, 22, 8));
final pro = EntitlementSnapshot.pro(
validatedAt: DateTime.utc(2026, 8, 22, 9),
);
await entitlementSnapshotRepository.save(pro);
final restoredPro = await entitlementSnapshotRepository.read();
expect(restoredPro?.entitlement, EntitlementTier.pro);
expect(restoredPro?.canSync, isTrue);
expect(restoredPro?.remainingEditableSlots, isNull);
expect(restoredPro?.validatedAt, DateTime.utc(2026, 8, 22, 9));
}); });
test('exercise business types persist with category fallback', () async { test('exercise business types persist with category fallback', () async {
@ -2759,6 +2789,161 @@ CREATE TABLE pending_share_actions (
}, },
); );
test(
'QA core offline journey persists exercise to progression through local repositories',
() async {
final now = DateTime.utc(2026, 8, 21, 9);
final clock = _FakeClock(now);
final ids = _FakeIds();
final exerciseUseCases = ExerciseUseCases(
repository: exerciseRepository,
programRepository: programRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final programUseCases = ProgramUseCases(
programRepository: programRepository,
exerciseRepository: exerciseRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final templateUseCases = WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final sessionUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final closeUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: ids,
originDeviceId: 'qa-core-offline',
);
final exercise = await exerciseUseCases.create(
name: 'QA drift offline squat',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
tags: const ['qa', 'offline'],
);
final program = await programUseCases.saveConfigured(
name: 'QA drift offline program',
defaultRestSeconds: 30,
tags: const ['qa', 'offline'],
exercises: [
ProgramExerciseConfig(
sourceExerciseId: exercise.metadata.id,
exerciseNameSnapshot: exercise.name,
availableTimeSnapshot: exercise.hasTimeMeasure,
availableRepsSnapshot: exercise.hasRepsMeasure,
availableScoreSnapshot: exercise.hasScoreMeasure,
setsCount: 1,
enabledMeasures: const {WorkoutMeasure.reps},
targetReps: 8,
),
],
);
final template = await templateUseCases.saveConfigured(
name: 'QA drift offline template',
tags: const ['qa', 'offline'],
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
sourceProgramId: program.metadata.id,
programNameSnapshot: program.name,
defaultRestSecondsSnapshot: program.defaultRestSeconds,
programSnapshotJson: _programSnapshotJson(program),
),
],
overrides: const [],
);
final session = await sessionUseCases.startFromTemplate(
template.metadata.id,
);
await sessionUseCases.recordCurrentSetResult(
sessionId: session.metadata.id,
programSnapshotId: template.programs.single.metadata.id,
exerciseSnapshotId: program.exercises.single.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualReps: 9,
scoreInputModeSnapshot: ScoreInputMode.manual,
);
clock.value = now.add(const Duration(minutes: 6));
await sessionUseCases.complete(session.metadata.id);
final history = await closeUseCase.close(
sessionId: session.metadata.id,
nameSnapshot: template.name,
completed: true,
);
final restoredExercise = await exerciseRepository.findById(
exercise.metadata.id,
);
final restoredProgram = await programRepository.findById(
program.metadata.id,
);
final restoredTemplate = await templateRepository.findById(
template.metadata.id,
);
final restoredHistory = await historyRepository.findById(
history.metadata.id,
);
final progressionStats = await progressionStatsRepository.readGlobalStats(
ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now.add(const Duration(days: 1)),
),
);
final progressionSeries = await progressionStatsRepository
.readExerciseSeries(
range: ProgressionDateRange(
startedAt: now.subtract(const Duration(days: 1)),
endedAt: now.add(const Duration(days: 1)),
),
exerciseKey: exercise.metadata.id,
measure: ProgressionMeasure.reps,
);
expect(restoredExercise, isNotNull);
expect(restoredProgram, isNotNull);
expect(restoredTemplate, isNotNull);
expect(restoredHistory, isNotNull);
expect(restoredHistory!.completed, isTrue);
expect(restoredHistory.results, hasLength(1));
expect(restoredHistory.results.single.actualReps, 9);
expect(
restoredHistory.results.single.sourceExerciseIdSnapshot,
exercise.metadata.id,
);
expect(progressionStats.completedSessionCount, 1);
expect(progressionStats.hasAnyCompletedHistory, isTrue);
expect(progressionSeries.hasAnyCompletedExerciseResult, isTrue);
expect(progressionSeries.points.single.rawValue, 9);
expect(
progressionSeries.points.single.workoutHistoryId,
history.metadata.id,
);
},
);
test( test(
'closing a session stores autonomous history rows with set snapshots', 'closing a session stores autonomous history rows with set snapshots',
() async { () async {
@ -3733,14 +3918,26 @@ final class _ExerciseRoundTripCase {
} }
final class _FakeClock implements Clock { final class _FakeClock implements Clock {
const _FakeClock(this.value); _FakeClock(this.value);
final DateTime value; DateTime value;
@override @override
DateTime now() => value; DateTime now() => value;
} }
String _programSnapshotJson(Program program) {
return WorkoutTemplateProgram.snapshotFromProgram(
metadata: _metadata(
'snapshot-${program.metadata.id}',
program.metadata.updatedAt,
),
workoutTemplateId: 'template-preview',
program: program,
position: 0,
).programSnapshotJson;
}
final class _FakeIds implements IdGenerator { final class _FakeIds implements IdGenerator {
var _next = 0; var _next = 0;

View File

@ -43,6 +43,18 @@ void main() {
expect(find.text('Programme tirs'), findsOneWidget); expect(find.text('Programme tirs'), findsOneWidget);
expect(find.text('Séance match'), findsOneWidget); expect(find.text('Séance match'), findsOneWidget);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 1 programme. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsOneWidget,
);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 1 séance. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsOneWidget,
);
expect(find.text('Accepter'), findsNWidgets(2)); expect(find.text('Accepter'), findsNWidgets(2));
expect(find.text('Refuser'), findsNWidgets(2)); expect(find.text('Refuser'), findsNWidgets(2));
@ -111,6 +123,12 @@ void main() {
expect(find.text('Pack reçu'), findsOneWidget); expect(find.text('Pack reçu'), findsOneWidget);
expect(find.text('Séance tirs'), findsOneWidget); expect(find.text('Séance tirs'), findsOneWidget);
expect(find.text('Séance jambes'), findsOneWidget); expect(find.text('Séance jambes'), findsOneWidget);
expect(
find.text(
'À lacceptation, votre bibliothèque éditable recevra 2 séances. Le quota receveur sera vérifié au moment de lacceptation.',
),
findsWidgets,
);
await tester.tap(find.text('Importer')); await tester.tap(find.text('Importer'));
await tester.pump(); await tester.pump();