335 lines
11 KiB
Dart
335 lines
11 KiB
Dart
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,
|
|
proMonthlyPaused,
|
|
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.proMonthlyPaused) 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<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;
|
|
}
|