feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release

- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases
- Add DriftEntitlementSnapshotRepository for offline entitlement caching
- Add HealthConnect gateway and integration
- Add quota enforcement in share acceptance (server and UI)
- Add entitlement API endpoints in server
- Add privacy policy v1 and release compliance checklist
- Add QA gates and device runbooks for release validation
- Add demo content screen and settings screen

Ticket #261
This commit is contained in:
2026-08-28 23:12:25 +02:00
parent d4df3822b7
commit b12a04ba30
10 changed files with 1096 additions and 26 deletions

View File

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

View File

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

View File

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