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:
@ -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,
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('''
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
'À 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('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(
|
||||||
|
'À 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.tap(find.text('Importer'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|||||||
Reference in New Issue
Block a user