import 'package:flutter/foundation.dart'; /// Supported commercial product plans for MotorSignal. enum MotorSignalPlan { free, proMonthly, proLifetime } /// Lifecycle and billing entitlement states. enum MotorSignalEntitlementStatus { free, proMonthlyActive, proMonthlyGrace, proMonthlyAccountHold, proMonthlyExpired, proLifetime, unknown, syncPending, } /// 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, }); /// 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; bool get canAdvancedTelemetry => isPro; bool get canAdvancedAcceleration => isPro; bool get canAdvancedBraking => isPro; bool get canAdvancedLean => isPro; bool get canAdvancedAnalytics => isPro; bool get canUnlimitedHistory => isPro; bool get canExportCsv => isPro; bool get canExportPdf => isPro; bool get canUseMultipleMotorcycles => isPro; bool get canPlanTrips => isPro; bool get canCreateGroupRides => isPro; bool get canUseAdvancedGroupRideFeatures => isPro; /// 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, 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 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 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; }