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

@ -56,6 +56,98 @@ final class LocalBackupException implements Exception {
String toString() => error.name;
}
enum EntitlementTier { free, pro }
final class EntitlementSnapshot {
const EntitlementSnapshot._({
required this.entitlement,
required this.canSync,
required this.hasUnlimitedEditableLibrary,
required this.remainingEditableSlots,
required this.validatedAt,
});
factory EntitlementSnapshot.free({
required int remainingEditableSlots,
required DateTime validatedAt,
}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.free,
canSync: false,
hasUnlimitedEditableLibrary: false,
remainingEditableSlots: remainingEditableSlots,
validatedAt: validatedAt,
);
}
factory EntitlementSnapshot.pro({required DateTime validatedAt}) {
return EntitlementSnapshot._(
entitlement: EntitlementTier.pro,
canSync: true,
hasUnlimitedEditableLibrary: true,
remainingEditableSlots: null,
validatedAt: validatedAt,
);
}
final EntitlementTier entitlement;
final bool canSync;
final bool hasUnlimitedEditableLibrary;
final int? remainingEditableSlots;
final DateTime validatedAt;
}
abstract interface class EntitlementSnapshotRepository {
Future<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 {
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 ProgressionMeasure { manualScore, stopwatchScore, reps, time }