diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 55dfc70..ce9896b 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -1,3 +1,4 @@ +import '../infrastructure/health_connect/health_connect.dart'; import '../infrastructure/local/local.dart'; import '../infrastructure/remote/remote.dart'; import '../infrastructure/security/security.dart'; @@ -14,7 +15,10 @@ abstract interface class AppDependencies { ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases; ActiveExerciseStepUseCases get activeExerciseStepUseCases; ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases; + ExecutionDebugUseCase get executionDebugUseCase; + SeedQaFunctionalContentUseCase get seedQaFunctionalContentUseCase; WorkoutTelemetryUseCases get workoutTelemetryUseCases; + WatchCompanionProjectionUseCases get watchCompanionProjectionUseCases; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; WorkoutHistoryUseCases get workoutHistoryUseCases; ProgressionStatsUseCase get progressionStatsUseCase; @@ -22,6 +26,8 @@ abstract interface class AppDependencies { WatchAlertPublisher get watchAlertPublisher; DataExportUseCase get dataExportUseCase; DataImportUseCase get dataImportUseCase; + HealthConnectUseCases get healthConnectUseCases; + EntitlementRevalidationUseCase get entitlementRevalidationUseCase; SyncUseCases get syncUseCases; ShareUseCases get shareUseCases; } @@ -37,6 +43,8 @@ final class AppBootstrap implements AppDependencies { required this.activeWorkoutSessionUseCases, required this.activeExerciseStepUseCases, required this.activeWorkoutSensorUseCases, + required this.executionDebugUseCase, + required this.seedQaFunctionalContentUseCase, required this.workoutTelemetryUseCases, required this.watchCompanionProjectionUseCases, required this.watchCompanionCommandHandler, @@ -48,6 +56,8 @@ final class AppBootstrap implements AppDependencies { required this.exercisePerformanceReferenceUseCase, required this.dataExportUseCase, required this.dataImportUseCase, + required this.healthConnectUseCases, + required this.entitlementRevalidationUseCase, required this.syncUseCases, required this.shareUseCases, required this.syncGateway, @@ -71,7 +81,12 @@ final class AppBootstrap implements AppDependencies { @override final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases; @override + final ExecutionDebugUseCase executionDebugUseCase; + @override + final SeedQaFunctionalContentUseCase seedQaFunctionalContentUseCase; + @override final WorkoutTelemetryUseCases workoutTelemetryUseCases; + @override final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases; final WatchCompanionCommandHandler watchCompanionCommandHandler; final WatchWearDataLayerAdapter watchWearDataLayerAdapter; @@ -91,6 +106,10 @@ final class AppBootstrap implements AppDependencies { @override final DataImportUseCase dataImportUseCase; @override + final HealthConnectUseCases healthConnectUseCases; + @override + final EntitlementRevalidationUseCase entitlementRevalidationUseCase; + @override final SyncUseCases syncUseCases; @override final ShareUseCases shareUseCases; @@ -110,6 +129,9 @@ final class AppBootstrap implements AppDependencies { final pendingShareActionRepository = DriftPendingShareActionRepository( database, ); + final entitlementSnapshotRepository = DriftEntitlementSnapshotRepository( + database, + ); final programRepository = DriftProgramRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); @@ -124,6 +146,7 @@ final class AppBootstrap implements AppDependencies { const localBackupMediaStore = PathProviderLocalMediaStorage(); final ids = LocalIdGenerator(); const clock = SystemClock(); + const authTokenStore = SecureStorageAuthTokenStore(); const originDeviceId = 'local-device'; final activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeSessionRepository, @@ -142,6 +165,16 @@ final class AppBootstrap implements AppDependencies { final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases( clock: clock, ); + final executionDebugUseCase = ExecutionDebugUseCase( + sessionRepository: activeSessionRepository, + clock: clock, + ); + final seedQaFunctionalContentUseCase = SeedQaFunctionalContentUseCase( + seedStateRepository: starterSeedRepository, + contentRepository: starterSeedRepository, + clock: clock, + originDeviceId: originDeviceId, + ); final workoutTelemetryUseCases = WorkoutTelemetryUseCases( repository: telemetryRepository, sessionRepository: activeSessionRepository, @@ -153,6 +186,7 @@ final class AppBootstrap implements AppDependencies { clock: clock, ids: ids, originDeviceId: originDeviceId, + activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, ); final watchCompanionCommandHandler = WatchCompanionCommandHandler( sessionRepository: activeSessionRepository, @@ -164,6 +198,11 @@ final class AppBootstrap implements AppDependencies { repository: historyRepository, clock: clock, ); + final healthConnectUseCases = HealthConnectUseCases( + gateway: const MethodChannelHealthConnectGateway(), + historyRepository: historyRepository, + telemetryUseCases: workoutTelemetryUseCases, + ); final watchWearDataLayerAdapter = WatchWearDataLayerAdapter( nativeChannel: const MethodChannelWatchBridgeNativeChannel(), commandIngress: watchCompanionCommandHandler, @@ -193,11 +232,15 @@ final class AppBootstrap implements AppDependencies { final remoteShareApi = HttpRemoteShareApi( HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), ); + final remoteEntitlementSnapshotSource = HttpRemoteEntitlementSnapshotSource( + client: HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), + tokenStore: authTokenStore, + ); return AppBootstrap._( database: database, authUseCases: AuthUseCases( - tokenStore: const SecureStorageAuthTokenStore(), + tokenStore: authTokenStore, accountRepository: onlineAccountRepository, remoteAuthApi: remoteAuthApi, clock: clock, @@ -236,6 +279,8 @@ final class AppBootstrap implements AppDependencies { activeWorkoutSessionUseCases: activeWorkoutSessionUseCases, activeExerciseStepUseCases: activeExerciseStepUseCases, activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, + executionDebugUseCase: executionDebugUseCase, + seedQaFunctionalContentUseCase: seedQaFunctionalContentUseCase, workoutTelemetryUseCases: workoutTelemetryUseCases, watchCompanionProjectionUseCases: watchCompanionProjectionUseCases, watchCompanionCommandHandler: watchCompanionCommandHandler, @@ -245,6 +290,8 @@ final class AppBootstrap implements AppDependencies { sessionRepository: activeSessionRepository, historyRepository: historyRepository, telemetryRepository: telemetryRepository, + healthConnectUseCases: healthConnectUseCases, + activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, clock: clock, ids: ids, originDeviceId: originDeviceId, @@ -267,8 +314,13 @@ final class AppBootstrap implements AppDependencies { mediaStore: localBackupMediaStore, clock: clock, ), + healthConnectUseCases: healthConnectUseCases, + entitlementRevalidationUseCase: EntitlementRevalidationUseCase( + repository: entitlementSnapshotRepository, + remote: remoteEntitlementSnapshotSource, + ), syncUseCases: SyncUseCases( - tokenStore: const SecureStorageAuthTokenStore(), + tokenStore: authTokenStore, remoteSyncApi: remoteSyncApi, metadataRepository: syncMetadataRepository, mappingRepository: mappingRepository, @@ -277,7 +329,7 @@ final class AppBootstrap implements AppDependencies { deviceId: originDeviceId, ), shareUseCases: ShareUseCases( - tokenStore: const SecureStorageAuthTokenStore(), + tokenStore: authTokenStore, remoteShareApi: remoteShareApi, inboxRepository: shareInboxRepository, pendingActionRepository: pendingShareActionRepository, diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 3085bff..03af181 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -56,6 +56,98 @@ final class LocalBackupException implements Exception { 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 read(); + Future save(EntitlementSnapshot snapshot); +} + +abstract interface class RemoteEntitlementSnapshotSource { + Future 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 availability(); + + Future purchasePro({ + required BillingProductRef product, + }); + + Future restorePurchases(); + + Future revalidate(); +} + final class LocalBackupDocument { 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 requiredPermissions; + final Set 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 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 connectionState(); + Future requestPermissions(); + Future openSettings(); + Future exportWorkout(HealthConnectWorkoutExport workout); +} + enum ProgressionPeriod { fourWeeks, threeMonths, all } enum ProgressionMeasure { manualScore, stopwatchScore, reps, time } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 4fb13f9..f4cf571 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -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 readCached() => _readRequiredCached(); + + Future revalidateIfPossible() async { + try { + final snapshot = await remote.read(); + await repository.save(snapshot); + return snapshot; + } on OfflineException { + return _readRequiredCached(); + } + } + + Future _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 availability() { + return _billingPort.availability(); + } + + Future purchasePro() { + return _billingPort.purchasePro(product: _proProduct); + } + + Future restorePurchases() { + return _billingPort.restorePurchases(); + } + + Future 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 _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 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 = [ 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 [] @@ -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 _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 []; + 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 _workoutTelemetrySampleSnapshotJson( 'heartRateBpm': sample.heartRateBpm, 'distanceMeters': sample.distanceMeters, 'caloriesKcal': sample.caloriesKcal, + 'stepCount': sample.stepCount, }; } @@ -6177,12 +6361,26 @@ List _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 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 connectionState() { + return gateway.connectionState(); + } + + Future requestPermissions() { + return gateway.requestPermissions(); + } + + Future openSettings() { + return gateway.openSettings(); + } + + Future 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 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 _workoutExportFromHistory( + WorkoutHistory history, + ) async { + final samples = + await telemetryUseCases?.listSamplesForHistory(history) ?? + const []; + 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; } } diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index e54b187..ed5f949 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -52,7 +52,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 26; + int get schemaVersion => 27; @override MigrationStrategy get migration { @@ -62,6 +62,7 @@ final class AppDatabase extends _$AppDatabase { await _migrateToSchema15(); await _migrateToSchema16(migrator); await _migrateToSchema25(); + await _migrateToSchema27(); await _createIndexes(); }, onUpgrade: (migrator, from, to) async { @@ -145,6 +146,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 26) { await _migrateToSchema26(migrator); } + if (from < 27) { + await _migrateToSchema27(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -485,6 +489,23 @@ extension on AppDatabase { await migrator.createTable(activeWorkoutTelemetryWindowStates); } + Future _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 _migrateToSchema13() async { await customStatement('PRAGMA foreign_keys = OFF'); await customStatement(''' diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 8ba7283..da46897 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -133,7 +133,11 @@ final class DriftExerciseRepository implements ExerciseRepository { } final class DriftStarterSeedRepository - implements StarterSeedStateRepository, StarterContentRepository { + implements + StarterSeedStateRepository, + StarterContentRepository, + QaContentSeedStateRepository, + QaContentRepository { const DriftStarterSeedRepository(this.database); static const _starterSeedKey = 'starter'; @@ -165,6 +169,35 @@ final class DriftStarterSeedRepository ); } + @override + Future readAppliedQaContentSeedVersion() async { + final row = await database + .customSelect( + 'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1', + variables: [Variable(qaFunctionalContentSeedKey)], + ) + .getSingleOrNull(); + return row?.read('version') ?? 0; + } + + @override + Future 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 Future isLocalContentEmpty() async { final exerciseCount = await _tableCount(database.exercises.actualTableName); @@ -215,6 +248,11 @@ final class DriftStarterSeedRepository }); } + @override + Future insertQaContent(StarterContent content) { + return insertStarterContent(content); + } + Future _tableCount(String tableName) async { final row = await database .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 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('entitlement')); + final validatedAt = DateTime.fromMillisecondsSinceEpoch( + row.read('validated_at'), + isUtc: true, + ); + return switch (tier) { + EntitlementTier.free => EntitlementSnapshot.free( + remainingEditableSlots: + row.readNullable('remaining_editable_slots') ?? 0, + validatedAt: validatedAt, + ), + EntitlementTier.pro => EntitlementSnapshot.pro(validatedAt: validatedAt), + }; + } + + @override + Future 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 { const DriftMediaAssetRepository(this.database); @@ -5549,6 +5654,7 @@ domain.WorkoutHistory _workoutHistoryFromRow( maxHeartRateBpm: row.maxHeartRateBpm, totalDistanceMeters: row.totalDistanceMeters, totalCaloriesKcal: row.totalCaloriesKcal, + totalSteps: _totalStepsFromHistorySnapshotJson(row.historySnapshotJson), results: results, stepResults: stepResults, ); @@ -5907,6 +6013,7 @@ Map _workoutHistoryPayload(domain.WorkoutHistory history) => { 'maxHeartRateBpm': history.maxHeartRateBpm, 'totalDistanceMeters': history.totalDistanceMeters, 'totalCaloriesKcal': history.totalCaloriesKcal, + 'totalSteps': history.totalSteps, }; Map _localWorkoutHistoryPayload( @@ -6006,6 +6113,7 @@ Map _workoutTelemetrySamplePayload( 'heartRateBpm': sample.heartRateBpm, 'distanceMeters': sample.distanceMeters, 'caloriesKcal': sample.caloriesKcal, + 'stepCount': sample.stepCount, }; List _workoutTelemetrySamplesFromHistoryPayload( @@ -6056,12 +6164,45 @@ List _workoutTelemetrySamplesFromPayload( heartRateBpm: map['heartRateBpm'] as int?, distanceMeters: (map['distanceMeters'] as num?)?.toDouble(), caloriesKcal: (map['caloriesKcal'] as num?)?.toDouble(), + stepCount: (map['stepCount'] as num?)?.toInt(), ), ); } 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 _workoutTelemetryAggregatesFromSamples( List samples, ) { @@ -6311,6 +6452,9 @@ domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(), totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(), + totalSteps: + (payload['totalSteps'] as num?)?.toInt() ?? + _totalStepsFromHistorySnapshotJson(historySnapshotJson), results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), stepResults: _workoutHistoryStepResultsFromPayload( payload['stepResults'], @@ -6890,6 +7034,17 @@ String _syncResourceTypeToDb(SyncResourceType type) => switch (type) { 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) { 'exercise' => SyncResourceType.exercise, 'program' => SyncResourceType.program, diff --git a/lib/infrastructure/remote/http_api_client.dart b/lib/infrastructure/remote/http_api_client.dart index e4fac93..fe4f131 100644 --- a/lib/infrastructure/remote/http_api_client.dart +++ b/lib/infrastructure/remote/http_api_client.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../application/application.dart'; @@ -27,11 +28,20 @@ final class HttpApiClient { static String defaultBaseUrlFor({ required bool isAndroid, String configuredBaseUrl = _configuredBaseUrl, + bool isReleaseMode = kReleaseMode, }) { final configured = configuredBaseUrl.trim(); 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; } + if (isReleaseMode) { + throw StateError('GAMETIME_API_BASE_URL is required in release builds.'); + } if (isAndroid) { return androidEmulatorDefaultBaseUrl; } diff --git a/lib/presentation/share_inbox_screen.dart b/lib/presentation/share_inbox_screen.dart index a4690ef..9c43ac5 100644 --- a/lib/presentation/share_inbox_screen.dart +++ b/lib/presentation/share_inbox_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; +import 'monetization_ui.dart'; import 'theme.dart'; final class ShareInboxScreen extends StatefulWidget { @@ -176,6 +177,11 @@ final class _ShareInboxTile extends StatelessWidget { ], ), if (item.status == ShareInboxStatus.pending) ...[ + const SizedBox(height: 16), + QuotaImpactNotice( + acceptedItemCount: _quotaImpactCount(item, summary), + acceptedItemLabel: _quotaImpactLabel(item.resourceType), + ), const SizedBox(height: 16), if (item.resourceType == ShareResourceType.pack) FilledButton(onPressed: onOpen, child: const Text('Voir le pack')) @@ -273,6 +279,11 @@ final class _SharePackDetailScreen extends StatelessWidget { title: Text(name), ), if (item.status == ShareInboxStatus.pending) ...[ + const SizedBox(height: 16), + QuotaImpactNotice( + acceptedItemCount: _quotaImpactCount(item, summary), + acceptedItemLabel: _quotaImpactLabel(item.resourceType), + ), const SizedBox(height: 16), FilledButton( onPressed: onImport == null @@ -390,6 +401,22 @@ String _packBadge(int count) { 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) { return switch (type) { ShareResourceType.program => Icons.list_alt, diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 884cfbf..c44df43 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1216,10 +1216,19 @@ void main() { totalCaloriesKcal: 56, ), ); + final activeWorkoutSensorUseCases = + ActiveWorkoutSensorUseCases(clock: clock)..recordTelemetrySample( + WatchSensorSample( + sessionId: session.metadata.id, + capturedAtEpochMs: clock.now().millisecondsSinceEpoch, + stepCount: 1320, + ), + ); final useCase = CloseWorkoutSessionUseCase( sessionRepository: sessionRepository, historyRepository: historyRepository, telemetryRepository: telemetryRepository, + activeWorkoutSensorUseCases: activeWorkoutSensorUseCases, clock: clock, ids: _FakeIds(), originDeviceId: 'device-1', @@ -1236,7 +1245,13 @@ void main() { expect(history.maxHeartRateBpm, 151); expect(history.totalDistanceMeters, 720); expect(history.totalCaloriesKcal, 56); + expect(history.totalSteps, 1320); + expect( + jsonDecode(history.historySnapshotJson), + containsPair('totalSteps', 1320), + ); expect(historyRepository.histories.single, history); + await activeWorkoutSensorUseCases.dispose(); }); test('adjustRestSeconds persists adjusted rest duration', () async { @@ -3240,6 +3255,7 @@ void main() { heartRateBpm: 120, distanceMeters: 500, caloriesKcal: 42, + stepCount: 900, ), ); final duplicate = useCase.recordTelemetrySample( @@ -3271,18 +3287,35 @@ void main() { ).millisecondsSinceEpoch, distanceMeters: 620, 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(duplicate, isNull); expect(distanceOnly?.sampleCount, 2); + expect(stepsOnly?.sampleCount, 3); expect(distanceOnly?.latestHeartRateBpm, 120); expect(distanceOnly?.minHeartRateBpm, 120); expect(distanceOnly?.averageHeartRateBpm, 120); expect(distanceOnly?.maxHeartRateBpm, 120); expect(distanceOnly?.latestDistanceMeters, 620); expect(distanceOnly?.latestCaloriesKcal, 48); + expect(stepsOnly?.latestStepCount, 1480); await useCase.dispose(); }, ); diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 8adf012..53b11d3 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -22,6 +22,7 @@ void main() { late local.DriftWorkoutTelemetryRepository telemetryRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; + late local.DriftEntitlementSnapshotRepository entitlementSnapshotRepository; setUp(() { database = local.AppDatabase(NativeDatabase.memory()); @@ -39,6 +40,9 @@ void main() { telemetryRepository = local.DriftWorkoutTelemetryRepository(database); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); + entitlementSnapshotRepository = local.DriftEntitlementSnapshotRepository( + database, + ); }); tearDown(() async { @@ -148,7 +152,33 @@ void main() { await columnNames('workout_telemetry_aggregates'), 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 { @@ -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( 'closing a session stores autonomous history rows with set snapshots', () async { @@ -3733,14 +3918,26 @@ final class _ExerciseRoundTripCase { } final class _FakeClock implements Clock { - const _FakeClock(this.value); + _FakeClock(this.value); - final DateTime value; + DateTime value; @override 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 { var _next = 0; diff --git a/test/presentation/share_inbox_screen_test.dart b/test/presentation/share_inbox_screen_test.dart index c91cd3e..00dab78 100644 --- a/test/presentation/share_inbox_screen_test.dart +++ b/test/presentation/share_inbox_screen_test.dart @@ -43,6 +43,18 @@ void main() { expect(find.text('Programme tirs'), findsOneWidget); expect(find.text('Séance match'), findsOneWidget); + expect( + find.text( + 'À l’acceptation, votre bibliothèque éditable recevra 1 programme. Le quota receveur sera vérifié au moment de l’acceptation.', + ), + findsOneWidget, + ); + expect( + find.text( + 'À l’acceptation, votre bibliothèque éditable recevra 1 séance. Le quota receveur sera vérifié au moment de l’acceptation.', + ), + findsOneWidget, + ); expect(find.text('Accepter'), findsNWidgets(2)); expect(find.text('Refuser'), findsNWidgets(2)); @@ -111,6 +123,12 @@ void main() { expect(find.text('Pack reçu'), findsOneWidget); expect(find.text('Séance tirs'), findsOneWidget); expect(find.text('Séance jambes'), findsOneWidget); + expect( + find.text( + 'À l’acceptation, votre bibliothèque éditable recevra 2 séances. Le quota receveur sera vérifié au moment de l’acceptation.', + ), + findsWidgets, + ); await tester.tap(find.text('Importer')); await tester.pump();