feat: establish entitlement domain and feature access foundation

This commit is contained in:
Developer 2026-09-03 18:22:21 -04:00
parent 885d562690
commit ee5fc42b37
6 changed files with 1027 additions and 78 deletions

280
AGENTS.md
View File

@ -1,78 +1,152 @@
# MotorSignal — Agent Development Rules
# MotorSignal — Agent Development Rules
## Project Identity
MotorSignal is a Flutter motorcycle telemetry and ride-analysis application.
MotorSignal is a Flutter motorcycle telemetry, ride-recording, performance-analysis, and cloud-synchronization application.
Before modifying code, agents MUST inspect the relevant existing architecture and
tests and understand the current implementation.
Before modifying code, agents MUST inspect the relevant existing architecture, providers, services, persistence paths, and tests and understand the current implementation.
Do not infer architecture from filenames or documentation alone when the production code can be inspected directly.
## Required Development Workflow
Before making changes:
1. Inspect the relevant source files.
2. Identify existing services, providers, models, repositories, and tests.
3. Do not duplicate existing functionality.
4. Make the smallest safe change necessary.
5. Preserve existing public interfaces unless the task explicitly requires change.
6. Add or update tests for behavioral changes.
7. Run static analysis.
8. Run the relevant test suite.
9. Run the complete test suite before declaring the task complete.
10. Report exactly what changed and what verification passed.
1. Confirm the current Git branch and working-tree state.
2. Identify any pre-existing modified/untracked files and do not alter them unless they are explicitly part of the task.
3. Inspect the relevant source files.
4. Identify existing services, providers, models, repositories, database helpers, and tests.
5. Search for all production callers before replacing or removing an implementation.
6. Do not duplicate existing functionality.
7. Make the smallest safe change necessary.
8. Preserve existing public interfaces unless the task explicitly requires a change.
9. Add or update tests for behavioral changes.
10. Run the relevant focused tests first when appropriate.
11. Run the complete test suite.
12. Run static analysis.
13. Build the debug APK for changes that can affect production compilation/runtime wiring.
14. Report exactly what changed and what verification passed.
15. Do not commit unless explicitly authorized.
If the requested change conflicts with an existing architectural contract, stop and report the conflict before broadening scope.
## Telemetry Freeze Boundary
The following subsystem is FROZEN unless the task explicitly requires
modification and the user approves the architectural change:
The following subsystem is FROZEN unless the task explicitly requires modification and the user authorizes that change:
- TelemetryService
- SensorFusionService
- RideStateMachine
* TelemetryService
* SensorFusionService
* RideStateMachine telemetry/motion semantics
Changes to RideStateMachine that are unrelated to telemetry semantics may be permitted when explicitly required by the task, but existing ride lifecycle behavior must be preserved unless the task specifically authorizes otherwise.
### TelemetryService
Authoritative telemetry acquisition runs at 20 Hz / 50 ms.
Do not:
- change acquisition cadence
- introduce competing telemetry clocks
- duplicate sensor acquisition
- bypass the authoritative telemetry pipeline
- introduce UI-driven polling of sensors
* change acquisition cadence
* introduce competing telemetry clocks
* duplicate sensor acquisition
* bypass the authoritative telemetry pipeline
* introduce UI-driven polling of sensors
All consumers must use the existing telemetry architecture.
### SensorFusionService
Sensor fusion is authoritative for derived orientation/lean data.
Sensor fusion is authoritative for derived orientation and lean data.
Do not introduce an independent lean-angle calculation without explicit
architectural justification and regression tests.
Do not introduce an independent lean-angle calculation without explicit architectural justification and regression tests.
### RideStateMachine
Preserve existing movement/crash/recovery semantics.
Preserve existing movement, crash, lifecycle, interrupted-ride recovery, and state-transition semantics.
The 3.0 MPH moving gate and kickstand isolation behavior are architectural
contracts unless explicitly changed by the user.
The 3.0 MPH moving gate and kickstand isolation behavior are architectural contracts unless explicitly changed by the user.
Ride lifecycle changes must include regression coverage.
## Database Freeze Boundary
AppDatabase is currently Schema 5.
AppDatabase is currently Schema 7.
Do not modify database schema, migrations, event semantics, or persistence
behavior casually.
Do not modify database schema, migrations, event semantics, or persistence behavior casually.
Any schema change MUST include:
- migration
- migration tests
- backward compatibility consideration
- verification against existing ride datasets
* an explicit schema-version decision
* migration logic where required
* migration tests
* backward-compatibility consideration
* verification against existing ride datasets
* consideration for legacy/null data
Never delete or rewrite historical ride data during feature development.
Never delete, destructively migrate, or rewrite historical ride data during normal feature development.
Use existing database helpers where available instead of duplicating raw insert/update behavior.
Stable ride identity (`cloudRideId`) is part of the persistence contract. Do not regenerate or replace an existing ride identity.
## Cloud / Offline Sync Architecture
MotorSignal uses an offline-first cloud synchronization architecture.
The authoritative automatic completed-ride synchronization path is:
RideStateMachine
→ syncObserverProvider
→ SyncManager
→ SyncOutbox
→ FirebaseSyncProvider
→ Cloudflare R2
→ Firestore
Do not introduce another automatic ride-upload queue or bypass SyncManager/SyncOutbox for normal completed-ride synchronization.
### SyncManager / SyncOutbox
SyncManager and SyncOutbox are authoritative for durable automatic cloud synchronization.
Preserve:
* durable queued work
* retry behavior
* authentication-required state
* restart recovery
* duplicate suppression
* account scoping
* offline operation
Do not replace durable queueing with an in-memory-only queue.
### FirebaseSyncProvider
FirebaseSyncProvider is the authoritative MotorCloud upload implementation.
Normal cloud synchronization must preserve:
* stable cloudRideId
* R2 telemetry-bundle identity
* Firestore ride identity
* retry/idempotency behavior
Legacy compatibility or explicit developer-export services must not become competing automatic synchronization paths.
### GitHub Sync / Export
GitHub-related upload/export functionality may remain for explicit developer or compatibility workflows.
Do not route normal automatic completed-ride synchronization through a legacy GitHubSyncService queue.
## Performance Data
Performance timing data such as 0–60 and 0–100 results must remain associated with the ride that produced it when recorded during an active ride.
Do not silently discard or detach ride-specific performance data from cloud/export payloads.
Legacy standalone performance runs with no ride association must remain valid where supported by the existing design.
## Forensic / Export Freeze Boundary
@ -80,14 +154,30 @@ FieldTestExportService is authoritative for forensic exports.
Preserve:
- CSV schema
- JSON session schema 1
- GPX export
- timestamp semantics
- event ordering
- sub-second telemetry fidelity
* CSV schema
* JSON session schema 1
* GPX export
* timestamp semantics
* event ordering
* sub-second telemetry fidelity
Feature/UI work must not silently alter exported historical data.
Feature or UI work must not silently alter exported historical data.
Any intentional export-schema change must be explicitly authorized and versioned appropriately.
## Authentication
Authentication must not block local ride recording.
Preserve offline/local functionality when:
* signed out
* Firebase is unavailable
* network is unavailable
Do not make cloud authentication a prerequisite for collecting or storing local ride data unless explicitly required.
Anonymous/authentication transition behavior must not cause ride loss.
## State Management
@ -95,37 +185,102 @@ Follow the project's existing Riverpod architecture.
Do not introduce another state-management framework.
Prefer existing providers/services over creating parallel state containers.
Prefer existing providers and services over creating parallel state containers.
Be careful with provider lifecycle and `autoDispose` behavior for long-lived services, observers, synchronization, and ride-state listeners.
## UI Development
UI features must consume existing domain/application services.
Do not place telemetry acquisition, persistence, sensor fusion, or business
logic directly inside widgets.
Do not place telemetry acquisition, persistence, sensor fusion, synchronization, or substantial business logic directly inside widgets.
Support both portrait and landscape layouts where applicable.
Do not make UI refresh frequency drive telemetry or persistence frequency.
## Testing
Every behavioral change requires appropriate automated coverage.
Before completion:
Prefer behavioral tests over source-text assertions when practical.
dart analyze lib test
flutter test
Before completion, run:
A task is NOT considered complete if tests fail, analysis reports new issues,
or existing regression datasets change unexpectedly.
```text
flutter test
flutter analyze
flutter build apk --debug
```
Focused tests may be run first during development.
A task is NOT considered complete if:
* tests fail
* analysis introduces new errors
* new warnings are introduced without justification
* the debug APK no longer builds when the task can affect compilation/runtime wiring
* existing regression datasets change unexpectedly
Pre-existing known warnings/info may remain if they are outside task scope, but they must be reported accurately.
Never claim physical motorcycle/device validation was performed unless it actually occurred.
## Git Safety
Never reset, force-push, rebase, delete branches, delete tags, or rewrite
history unless explicitly requested.
Never:
Create a checkpoint commit before significant architectural changes.
* reset unrelated work
* force-push
* rewrite history
* rebase unless explicitly requested
* delete branches
* delete tags
* discard pre-existing user changes
* stage unrelated files
* commit unless explicitly authorized
Do not modify unrelated files.
Create or verify a checkpoint before significant architectural changes.
If the working tree is dirty before a task:
1. identify the pre-existing files
2. preserve them
3. do not include them in task changes
4. clearly separate them in the final report
Use per-command Git safe-directory overrides when necessary rather than changing global Git configuration unless explicitly requested.
## Dependency / Toolchain Safety
Do not perform broad dependency or toolchain upgrades to solve a narrow problem.
Prefer the smallest compatible dependency change.
Do not run broad package upgrades such as `flutter pub upgrade` unless explicitly required.
When changing dependencies:
* identify the exact reason
* inspect lockfile impact
* report exact version changes
* verify tests, analysis, and APK build
Do not raise Android compileSdk, targetSdk, AGP, Gradle, Kotlin, or Java versions merely to satisfy one package without first investigating a narrower compatible solution.
## Documentation Accuracy
Treat documentation as guidance, not proof of current production behavior.
When documentation conflicts with production code:
1. verify behavior from source/tests
2. preserve the correct production behavior
3. report stale documentation
4. update documentation only if it is within task scope
Do not silently rely on stale schema versions, timing values, provider wiring, or architecture descriptions.
## Agent Behavior
@ -135,5 +290,12 @@ Prefer surgical patches over broad rewrites.
Do not "fix" unrelated problems discovered during a task.
If an architectural conflict is discovered, stop and report it rather than
silently changing a frozen subsystem.
Do not silently expand scope.
Do not claim validation that was not actually performed.
Separate confirmed defects from suspected risks.
If an architectural conflict is discovered, stop and report it rather than silently changing a frozen subsystem.
When a task explicitly says STOP after reporting, do not proceed into the next stage.

View File

@ -1,7 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'entitlement_state.dart';
import 'entitlement_storage.dart';
import 'feature_access_service.dart';
/// Temporary launch-safe entitlement provider. It defaults to Free until the
/// Google Play Billing implementation is wired in. Keeping this boundary now
/// prevents future feature code from depending directly on billing SDK types.
final entitlementProvider = Provider<EntitlementState>((ref) => const EntitlementState());
/// Provider for the physical entitlement storage contract.
/// Can be overridden in tests with [InMemoryEntitlementStorage].
final entitlementStorageProvider = Provider<EntitlementStorage>((ref) {
return const SecureEntitlementStorage();
});
/// Authoritative entitlement state provider.
/// Defaults to [EntitlementState.free] until the billing coordinator or cache loader runs.
/// In Stage 4B-2, this will be wired to the billing lifecycle coordinator.
final entitlementProvider = StateProvider<EntitlementState>((ref) {
return const EntitlementState.free();
});
/// Centralized provider for checking feature accessibility.
/// Consumers watch this provider and call [FeatureAccessService.canAccess].
final featureAccessProvider = Provider<FeatureAccessService>((ref) {
final state = ref.watch(entitlementProvider);
return FeatureAccessService(state);
});

View File

@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
enum MotorSignalPlan {
free,
proMonthly,
proLifetime,
}
/// Supported commercial product plans for MotorSignal.
enum MotorSignalPlan { free, proMonthly, proLifetime }
/// Lifecycle and billing entitlement states.
enum MotorSignalEntitlementStatus {
free,
proMonthlyActive,
@ -17,23 +15,198 @@ enum MotorSignalEntitlementStatus {
syncPending,
}
/// Central capability model. UI and services should depend on capabilities,
/// not Google Play product IDs or scattered `isPro` checks.
/// Verification / freshness state of store-asserted entitlement.
enum EntitlementVerificationStatus {
/// Unverified or default state before store inquiry.
unverified,
/// Confirmed valid by app store or server verification.
verified,
/// Validated locally under defined offline grace rules.
offlineGrace,
/// Explicitly expired subscription.
expired,
/// Revoked or refunded by store/issuer.
revoked,
}
/// Origin of purchase assertion.
enum PurchaseSource { none, googlePlay, offlineCache }
/// Central capability model and immutable entitlement state.
/// UI and services depend on capabilities, not store product IDs or scattered checks.
@immutable
class EntitlementState {
final MotorSignalPlan plan;
final MotorSignalEntitlementStatus status;
final EntitlementVerificationStatus verificationStatus;
final PurchaseSource source;
final String? productId;
final String? purchaseTokenHash;
final DateTime? expiresAt;
final DateTime? lastVerifiedAt;
const EntitlementState({
this.plan = MotorSignalPlan.free,
this.status = MotorSignalEntitlementStatus.free,
this.verificationStatus = EntitlementVerificationStatus.unverified,
this.source = PurchaseSource.none,
this.productId,
this.purchaseTokenHash,
this.expiresAt,
this.lastVerifiedAt,
});
bool get isPro => plan != MotorSignalPlan.free &&
status != MotorSignalEntitlementStatus.proMonthlyExpired;
/// Factory creating normalized default Free tier.
const EntitlementState.free()
: plan = MotorSignalPlan.free,
status = MotorSignalEntitlementStatus.free,
verificationStatus = EntitlementVerificationStatus.verified,
source = PurchaseSource.none,
productId = null,
purchaseTokenHash = null,
expiresAt = null,
lastVerifiedAt = null;
/// Factory creating normalized active Pro Monthly tier.
EntitlementState.proMonthlyActive({
this.productId,
this.purchaseTokenHash,
this.expiresAt,
DateTime? lastVerifiedAt,
this.source = PurchaseSource.googlePlay,
}) : plan = MotorSignalPlan.proMonthly,
status = MotorSignalEntitlementStatus.proMonthlyActive,
verificationStatus = EntitlementVerificationStatus.verified,
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
/// Factory creating normalized active Pro Lifetime tier.
EntitlementState.proLifetime({
this.productId,
this.purchaseTokenHash,
DateTime? lastVerifiedAt,
this.source = PurchaseSource.googlePlay,
}) : plan = MotorSignalPlan.proLifetime,
status = MotorSignalEntitlementStatus.proLifetime,
verificationStatus = EntitlementVerificationStatus.verified,
expiresAt = null,
lastVerifiedAt = lastVerifiedAt ?? DateTime.now();
/// Factory normalizing raw inputs to prevent invalid or contradictory combinations.
factory EntitlementState.normalized({
required MotorSignalPlan plan,
required MotorSignalEntitlementStatus status,
EntitlementVerificationStatus verificationStatus =
EntitlementVerificationStatus.unverified,
PurchaseSource source = PurchaseSource.none,
String? productId,
String? purchaseTokenHash,
DateTime? expiresAt,
DateTime? lastVerifiedAt,
}) {
// Normalization rules:
// 1. Free plan must not possess Pro status, active expiration, or Pro product IDs.
if (plan == MotorSignalPlan.free) {
return EntitlementState(
plan: MotorSignalPlan.free,
status: MotorSignalEntitlementStatus.free,
verificationStatus:
verificationStatus == EntitlementVerificationStatus.revoked
? EntitlementVerificationStatus.revoked
: EntitlementVerificationStatus.verified,
source: PurchaseSource.none,
productId: null,
purchaseTokenHash: null,
expiresAt: null,
lastVerifiedAt: lastVerifiedAt,
);
}
// 2. Revoked verification forces expired/revoked standing regardless of plan.
if (verificationStatus == EntitlementVerificationStatus.revoked) {
return EntitlementState(
plan: plan,
status: plan == MotorSignalPlan.proLifetime
? MotorSignalEntitlementStatus.unknown
: MotorSignalEntitlementStatus.proMonthlyExpired,
verificationStatus: EntitlementVerificationStatus.revoked,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
// 3. Lifetime plan normalization: status must be proLifetime, expiresAt is null.
if (plan == MotorSignalPlan.proLifetime) {
final isSyncPending = status == MotorSignalEntitlementStatus.syncPending;
return EntitlementState(
plan: MotorSignalPlan.proLifetime,
status: isSyncPending
? MotorSignalEntitlementStatus.syncPending
: MotorSignalEntitlementStatus.proLifetime,
verificationStatus: verificationStatus,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: null,
lastVerifiedAt: lastVerifiedAt,
);
}
// 4. Pro Monthly: check explicit expiration if past expiresAt.
final now = DateTime.now();
var resolvedStatus = status;
var resolvedVerification = verificationStatus;
if (expiresAt != null && now.isAfter(expiresAt)) {
if (status == MotorSignalEntitlementStatus.proMonthlyActive) {
// Expired active subscription
resolvedStatus = MotorSignalEntitlementStatus.proMonthlyExpired;
resolvedVerification = EntitlementVerificationStatus.expired;
}
}
return EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: resolvedStatus,
verificationStatus: resolvedVerification,
source: source,
productId: productId,
purchaseTokenHash: purchaseTokenHash,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
/// Whether user currently holds effective Pro entitlement.
/// Free plan, expired subscription, revoked verification, or account hold do NOT grant Pro access.
bool get isPro {
if (plan == MotorSignalPlan.free) return false;
if (verificationStatus == EntitlementVerificationStatus.revoked) {
return false;
}
if (verificationStatus == EntitlementVerificationStatus.expired) {
return false;
}
if (status == MotorSignalEntitlementStatus.proMonthlyExpired) return false;
if (status == MotorSignalEntitlementStatus.proMonthlyAccountHold) {
return false;
}
if (status == MotorSignalEntitlementStatus.free) return false;
if (status == MotorSignalEntitlementStatus.unknown) return false;
// Active or grace period allows effective access
return status == MotorSignalEntitlementStatus.proMonthlyActive ||
status == MotorSignalEntitlementStatus.proMonthlyGrace ||
status == MotorSignalEntitlementStatus.proLifetime;
}
// Capability mapping conforming to MOTORSIGNAL_FREE_PRO_SPEC.md
bool get canCloudBackup => true;
bool get canUnlimitedCloudHistory => isPro;
bool get canMultiDeviceRestore => isPro;
@ -50,17 +223,110 @@ class EntitlementState {
bool get canCreateGroupRides => isPro;
bool get canUseAdvancedGroupRideFeatures => isPro;
/// The Free cloud allowance is intentionally not hard-coded here. Product
/// economics will define the exact quota before launch.
/// Free cloud allowance quota. null indicates unconstrained (Pro) or to be determined by backend.
int? get cloudRideAllowance => isPro ? null : null;
EntitlementState copyWith({
MotorSignalPlan? plan,
MotorSignalEntitlementStatus? status,
EntitlementVerificationStatus? verificationStatus,
PurchaseSource? source,
String? productId,
String? purchaseTokenHash,
DateTime? expiresAt,
DateTime? lastVerifiedAt,
}) => EntitlementState(
plan: plan ?? this.plan,
status: status ?? this.status,
expiresAt: expiresAt ?? this.expiresAt,
);
plan: plan ?? this.plan,
status: status ?? this.status,
verificationStatus: verificationStatus ?? this.verificationStatus,
source: source ?? this.source,
productId: productId ?? this.productId,
purchaseTokenHash: purchaseTokenHash ?? this.purchaseTokenHash,
expiresAt: expiresAt ?? this.expiresAt,
lastVerifiedAt: lastVerifiedAt ?? this.lastVerifiedAt,
);
Map<String, dynamic> toJson() => {
'version': 1,
'plan': plan.name,
'status': status.name,
'verificationStatus': verificationStatus.name,
'source': source.name,
'productId': productId,
'purchaseTokenHash': purchaseTokenHash,
'expiresAt': expiresAt?.toIso8601String(),
'lastVerifiedAt': lastVerifiedAt?.toIso8601String(),
};
factory EntitlementState.fromJson(Map<String, dynamic> json) {
final version = json['version'] as int? ?? 1;
if (version > 1) {
// Forward-compatibility safeguard for future cache schema versions
throw const FormatException('Unsupported entitlement cache version');
}
final plan = MotorSignalPlan.values.firstWhere(
(e) => e.name == json['plan'],
orElse: () => MotorSignalPlan.free,
);
final status = MotorSignalEntitlementStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => MotorSignalEntitlementStatus.free,
);
final verificationStatus = EntitlementVerificationStatus.values.firstWhere(
(e) => e.name == json['verificationStatus'],
orElse: () => EntitlementVerificationStatus.unverified,
);
final source = PurchaseSource.values.firstWhere(
(e) => e.name == json['source'],
orElse: () => PurchaseSource.none,
);
final expiresAt = json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String)
: null;
final lastVerifiedAt = json['lastVerifiedAt'] != null
? DateTime.tryParse(json['lastVerifiedAt'] as String)
: null;
return EntitlementState.normalized(
plan: plan,
status: status,
verificationStatus: verificationStatus,
source: source,
productId: json['productId'] as String?,
purchaseTokenHash: json['purchaseTokenHash'] as String?,
expiresAt: expiresAt,
lastVerifiedAt: lastVerifiedAt,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EntitlementState &&
runtimeType == other.runtimeType &&
plan == other.plan &&
status == other.status &&
verificationStatus == other.verificationStatus &&
source == other.source &&
productId == other.productId &&
purchaseTokenHash == other.purchaseTokenHash &&
expiresAt == other.expiresAt &&
lastVerifiedAt == other.lastVerifiedAt;
@override
int get hashCode =>
plan.hashCode ^
status.hashCode ^
verificationStatus.hashCode ^
source.hashCode ^
productId.hashCode ^
purchaseTokenHash.hashCode ^
expiresAt.hashCode ^
lastVerifiedAt.hashCode;
}

View File

@ -0,0 +1,94 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'entitlement_state.dart';
/// Contract defining persistence operations for cached entitlement state.
/// This abstraction isolates the billing coordinator and domain from physical storage.
abstract class EntitlementStorage {
/// Reads cached entitlement state, returning [EntitlementState.free] if none exists or invalid.
Future<EntitlementState> read();
/// Persists [state] to cache.
Future<void> write(EntitlementState state);
/// Clears cached entitlement, reverting cache to default.
Future<void> clear();
}
/// In-memory implementation of [EntitlementStorage] for unit testing.
class InMemoryEntitlementStorage implements EntitlementStorage {
EntitlementState _state;
InMemoryEntitlementStorage([this._state = const EntitlementState.free()]);
@override
Future<EntitlementState> read() async => _state;
@override
Future<void> write(EntitlementState state) async {
_state = state;
}
@override
Future<void> clear() async {
_state = const EntitlementState.free();
}
}
/// Production implementation backed by [FlutterSecureStorage].
/// Stores normalized JSON with versioning. Never stores raw Google Play purchase tokens.
class SecureEntitlementStorage implements EntitlementStorage {
static const String storageKey = 'motorsignal_entitlement_v1';
final FlutterSecureStorage _storage;
const SecureEntitlementStorage({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
@override
Future<EntitlementState> read() async {
try {
final raw = await _storage.read(key: storageKey);
if (raw == null || raw.trim().isEmpty) {
return const EntitlementState.free();
}
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
debugPrint('[EntitlementStorage] Cache payload not a valid JSON map');
return const EntitlementState.free();
}
return EntitlementState.fromJson(decoded);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error reading cached entitlement: $e\n$st',
);
return const EntitlementState.free();
}
}
@override
Future<void> write(EntitlementState state) async {
try {
final payload = jsonEncode(state.toJson());
await _storage.write(key: storageKey, value: payload);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error writing cached entitlement: $e\n$st',
);
}
}
@override
Future<void> clear() async {
try {
await _storage.delete(key: storageKey);
} catch (e, st) {
debugPrint(
'[EntitlementStorage] Error clearing cached entitlement: $e\n$st',
);
}
}
}

View File

@ -0,0 +1,67 @@
import 'entitlement_state.dart';
/// Strongly typed identifiers for all capabilities that can be gated or regulated.
enum MotorSignalFeature {
cloudBackup,
unlimitedCloudHistory,
multiDeviceRestore,
advancedTelemetry,
advancedAcceleration,
advancedBraking,
advancedLean,
advancedAnalytics,
unlimitedHistory,
csvExport,
pdfExport,
useMultipleMotorcycles,
planTrips,
createGroupRides,
useAdvancedGroupRideFeatures,
}
/// Centralized domain service determining access to MotorSignal features.
/// UI and background operations check [canAccess] rather than inspecting raw plan or store fields.
class FeatureAccessService {
final EntitlementState _state;
const FeatureAccessService(this._state);
/// Current underlying entitlement state.
EntitlementState get state => _state;
/// Returns true if the requested [feature] is permitted under current entitlement.
bool canAccess(MotorSignalFeature feature) {
switch (feature) {
case MotorSignalFeature.cloudBackup:
return _state.canCloudBackup;
case MotorSignalFeature.unlimitedCloudHistory:
return _state.canUnlimitedCloudHistory;
case MotorSignalFeature.multiDeviceRestore:
return _state.canMultiDeviceRestore;
case MotorSignalFeature.advancedTelemetry:
return _state.canAdvancedTelemetry;
case MotorSignalFeature.advancedAcceleration:
return _state.canAdvancedAcceleration;
case MotorSignalFeature.advancedBraking:
return _state.canAdvancedBraking;
case MotorSignalFeature.advancedLean:
return _state.canAdvancedLean;
case MotorSignalFeature.advancedAnalytics:
return _state.canAdvancedAnalytics;
case MotorSignalFeature.unlimitedHistory:
return _state.canUnlimitedHistory;
case MotorSignalFeature.csvExport:
return _state.canExportCsv;
case MotorSignalFeature.pdfExport:
return _state.canExportPdf;
case MotorSignalFeature.useMultipleMotorcycles:
return _state.canUseMultipleMotorcycles;
case MotorSignalFeature.planTrips:
return _state.canPlanTrips;
case MotorSignalFeature.createGroupRides:
return _state.canCreateGroupRides;
case MotorSignalFeature.useAdvancedGroupRideFeatures:
return _state.canUseAdvancedGroupRideFeatures;
}
}
}

View File

@ -0,0 +1,343 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart';
import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('EntitlementState domain rules', () {
test('default constructor creates free tier', () {
const state = EntitlementState();
expect(state.plan, MotorSignalPlan.free);
expect(state.status, MotorSignalEntitlementStatus.free);
expect(state.isPro, isFalse);
expect(state.canCloudBackup, isTrue);
expect(state.canUnlimitedCloudHistory, isFalse);
expect(state.canAdvancedTelemetry, isFalse);
expect(state.canExportCsv, isFalse);
});
test('EntitlementState.free factory normalizes to valid free tier', () {
const state = EntitlementState.free();
expect(state.plan, MotorSignalPlan.free);
expect(state.status, MotorSignalEntitlementStatus.free);
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
expect(state.isPro, isFalse);
expect(state.productId, isNull);
expect(state.expiresAt, isNull);
});
test('Pro Monthly active exposes complete premium capability surface', () {
final now = DateTime.now();
final expiry = now.add(const Duration(days: 30));
final state = EntitlementState.proMonthlyActive(
productId: 'motorsignal_pro_monthly',
purchaseTokenHash: 'abc123hash',
expiresAt: expiry,
lastVerifiedAt: now,
);
expect(state.isPro, isTrue);
expect(state.plan, MotorSignalPlan.proMonthly);
expect(state.status, MotorSignalEntitlementStatus.proMonthlyActive);
expect(state.verificationStatus, EntitlementVerificationStatus.verified);
expect(state.source, PurchaseSource.googlePlay);
expect(state.productId, 'motorsignal_pro_monthly');
expect(state.purchaseTokenHash, 'abc123hash');
expect(state.expiresAt, expiry);
// Verify all Pro capabilities
expect(state.canCloudBackup, isTrue);
expect(state.canUnlimitedCloudHistory, isTrue);
expect(state.canMultiDeviceRestore, isTrue);
expect(state.canAdvancedTelemetry, isTrue);
expect(state.canAdvancedAcceleration, isTrue);
expect(state.canAdvancedBraking, isTrue);
expect(state.canAdvancedLean, isTrue);
expect(state.canAdvancedAnalytics, isTrue);
expect(state.canUnlimitedHistory, isTrue);
expect(state.canExportCsv, isTrue);
expect(state.canExportPdf, isTrue);
expect(state.canUseMultipleMotorcycles, isTrue);
expect(state.canPlanTrips, isTrue);
expect(state.canCreateGroupRides, isTrue);
expect(state.canUseAdvancedGroupRideFeatures, isTrue);
});
test('Pro Monthly Grace provides effective premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyGrace,
verificationStatus: EntitlementVerificationStatus.offlineGrace,
source: PurchaseSource.offlineCache,
productId: 'motorsignal_pro_monthly',
);
expect(state.isPro, isTrue);
expect(state.canAdvancedTelemetry, isTrue);
expect(state.canExportCsv, isTrue);
});
test('Pro Monthly Expired denies premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyExpired,
verificationStatus: EntitlementVerificationStatus.expired,
);
expect(state.isPro, isFalse);
expect(state.canCloudBackup, isTrue);
expect(state.canAdvancedTelemetry, isFalse);
expect(state.canExportCsv, isFalse);
expect(state.canUnlimitedHistory, isFalse);
});
test('Pro Monthly Account Hold denies premium access', () {
final state = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyAccountHold,
);
expect(state.isPro, isFalse);
});
test('Pro Lifetime exposes full capabilities without expiration', () {
final state = EntitlementState.proLifetime(
productId: 'motorsignal_pro_lifetime',
purchaseTokenHash: 'lifetime_hash',
);
expect(state.isPro, isTrue);
expect(state.plan, MotorSignalPlan.proLifetime);
expect(state.status, MotorSignalEntitlementStatus.proLifetime);
expect(state.expiresAt, isNull);
expect(state.canUnlimitedCloudHistory, isTrue);
expect(state.canAdvancedLean, isTrue);
expect(state.canPlanTrips, isTrue);
});
test('Unknown or sync pending statuses do not grant Pro access', () {
const unknownState = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.unknown,
);
expect(unknownState.isPro, isFalse);
const pendingState = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.syncPending,
);
expect(pendingState.isPro, isFalse);
});
});
group('EntitlementState normalization & invalid state prevention', () {
test('normalizes contradictory free plan with pro status to pure free', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.free,
status: MotorSignalEntitlementStatus.proLifetime,
productId: 'some_product',
expiresAt: DateTime.now().add(const Duration(days: 10)),
);
expect(normalized.plan, MotorSignalPlan.free);
expect(normalized.status, MotorSignalEntitlementStatus.free);
expect(normalized.isPro, isFalse);
expect(normalized.productId, isNull);
expect(normalized.expiresAt, isNull);
});
test('normalizes revoked verification status to non-pro status', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proLifetime,
status: MotorSignalEntitlementStatus.proLifetime,
verificationStatus: EntitlementVerificationStatus.revoked,
);
expect(
normalized.verificationStatus,
EntitlementVerificationStatus.revoked,
);
expect(normalized.isPro, isFalse);
});
test('normalizes expired subscription with past expiresAt date', () {
final pastDate = DateTime.now().subtract(const Duration(days: 2));
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyActive,
expiresAt: pastDate,
);
expect(normalized.status, MotorSignalEntitlementStatus.proMonthlyExpired);
expect(
normalized.verificationStatus,
EntitlementVerificationStatus.expired,
);
expect(normalized.isPro, isFalse);
});
test('normalizes Lifetime plan ensuring null expiration', () {
final normalized = EntitlementState.normalized(
plan: MotorSignalPlan.proLifetime,
status: MotorSignalEntitlementStatus.proMonthlyActive,
expiresAt: DateTime.now().add(const Duration(days: 365)),
);
expect(normalized.plan, MotorSignalPlan.proLifetime);
expect(normalized.status, MotorSignalEntitlementStatus.proLifetime);
expect(normalized.expiresAt, isNull);
expect(normalized.isPro, isTrue);
});
});
group('FeatureAccessService exhaustive mapping', () {
test('every MotorSignalFeature enum entry maps to explicit capability', () {
const freeService = FeatureAccessService(EntitlementState.free());
final proService = FeatureAccessService(EntitlementState.proLifetime());
for (final feature in MotorSignalFeature.values) {
// Must execute cleanly without exception
final freeResult = freeService.canAccess(feature);
final proResult = proService.canAccess(feature);
if (feature == MotorSignalFeature.cloudBackup) {
expect(
freeResult,
isTrue,
reason: 'Cloud backup included in free tier',
);
expect(proResult, isTrue);
} else {
expect(
freeResult,
isFalse,
reason: '$feature should be denied on Free tier',
);
expect(
proResult,
isTrue,
reason: '$feature should be granted on Pro tier',
);
}
}
});
});
group('Entitlement serialization & storage contract', () {
test('toJson and fromJson perform lossless round trip', () {
final original = EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyActive,
verificationStatus: EntitlementVerificationStatus.verified,
source: PurchaseSource.googlePlay,
productId: 'motorsignal_pro_monthly',
purchaseTokenHash: 'token_sha256_hash',
expiresAt: DateTime.utc(2026, 10, 1),
lastVerifiedAt: DateTime.utc(2026, 9, 1),
);
final json = original.toJson();
final restored = EntitlementState.fromJson(json);
expect(restored.plan, original.plan);
expect(restored.status, original.status);
expect(restored.verificationStatus, original.verificationStatus);
expect(restored.source, original.source);
expect(restored.productId, original.productId);
expect(restored.purchaseTokenHash, original.purchaseTokenHash);
expect(restored.expiresAt, original.expiresAt);
expect(restored.lastVerifiedAt, original.lastVerifiedAt);
expect(restored.isPro, isTrue);
});
test('fromJson throws on unsupported future version', () {
final invalidVersionJson = {'version': 99, 'plan': 'proMonthly'};
expect(
() => EntitlementState.fromJson(invalidVersionJson),
throwsA(isA<FormatException>()),
);
});
test('InMemoryEntitlementStorage behaves according to contract', () async {
final storage = InMemoryEntitlementStorage();
// Missing cache defaults to Free
final initial = await storage.read();
expect(initial.plan, MotorSignalPlan.free);
expect(initial.isPro, isFalse);
// Write Pro Lifetime
final proLifetime = EntitlementState.proLifetime(productId: 'lifetime');
await storage.write(proLifetime);
final cached = await storage.read();
expect(cached.plan, MotorSignalPlan.proLifetime);
expect(cached.isPro, isTrue);
// Clear reverts to Free
await storage.clear();
final cleared = await storage.read();
expect(cleared.plan, MotorSignalPlan.free);
expect(cleared.isPro, isFalse);
});
});
group('Riverpod Provider integration', () {
test('featureAccessProvider reacts to entitlementProvider updates', () {
final container = ProviderContainer(
overrides: [
entitlementStorageProvider.overrideWithValue(
InMemoryEntitlementStorage(),
),
],
);
addTearDown(container.dispose);
// Default is Free
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
// Update to Pro Lifetime
container.read(entitlementProvider.notifier).state =
EntitlementState.proLifetime();
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isTrue,
);
// Downgrade to Expired
container
.read(entitlementProvider.notifier)
.state = const EntitlementState(
plan: MotorSignalPlan.proMonthly,
status: MotorSignalEntitlementStatus.proMonthlyExpired,
);
expect(
container
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport),
isFalse,
);
});
});
group('Architectural boundaries & Rider safety invariant', () {
test('entitlement domain requires zero Firebase or auth dependencies', () {
// Constructs, normalizes, serializes, queries access with no Firebase context
const state = EntitlementState.free();
const service = FeatureAccessService(state);
expect(service.canAccess(MotorSignalFeature.cloudBackup), isTrue);
});
});
}