660 lines
23 KiB
Dart
660 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../services/billing/billing_models.dart';
|
|
import '../../services/billing/billing_providers.dart';
|
|
import '../../services/billing/product_catalog.dart';
|
|
import '../../services/entitlements/entitlement_providers.dart';
|
|
import '../../services/entitlements/entitlement_state.dart';
|
|
import 'widgets/pro_lock_badge.dart';
|
|
|
|
enum ProPurchaseStep { idle, purchasing, verifying, restoring }
|
|
|
|
class ProScreen extends ConsumerStatefulWidget {
|
|
const ProScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<ProScreen> createState() => _ProScreenState();
|
|
}
|
|
|
|
class _ProScreenState extends ConsumerState<ProScreen> {
|
|
ProPurchaseStep _currentStep = ProPurchaseStep.idle;
|
|
String? _statusBanner;
|
|
bool _isBannerError = false;
|
|
List<StoreProduct> _products = [];
|
|
bool _isLoadingProducts = true;
|
|
bool _storeAvailable = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadStoreProducts();
|
|
}
|
|
|
|
Future<void> _loadStoreProducts() async {
|
|
setState(() => _isLoadingProducts = true);
|
|
final billing = ref.read(billingServiceProvider);
|
|
final isAvail = await billing.isAvailable();
|
|
if (!isAvail) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_storeAvailable = false;
|
|
_isLoadingProducts = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
final prods = await billing.queryProducts();
|
|
if (mounted) {
|
|
setState(() {
|
|
_storeAvailable = true;
|
|
_products = prods;
|
|
_isLoadingProducts = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBuy(String productId) async {
|
|
final billing = ref.read(billingServiceProvider);
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.purchasing;
|
|
_statusBanner = null;
|
|
});
|
|
|
|
final launched = await billing.launchPurchaseFlow(productId);
|
|
if (!launched) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.idle;
|
|
_statusBanner =
|
|
'Unable to launch Google Play purchase. Please try again.';
|
|
_isBannerError = true;
|
|
});
|
|
}
|
|
} else {
|
|
// Flow handed over to PlayBillingService purchaseStream -> EntitlementCoordinator
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.idle;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _handleRestore() async {
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.restoring;
|
|
_statusBanner = null;
|
|
});
|
|
|
|
try {
|
|
final billing = ref.read(billingServiceProvider);
|
|
await billing.restorePurchases();
|
|
// Allow brief moment for store emission
|
|
await Future<void>.delayed(const Duration(seconds: 1));
|
|
|
|
if (mounted) {
|
|
final entitlement = ref.read(entitlementProvider);
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.idle;
|
|
if (entitlement.isPro) {
|
|
_statusBanner = 'MotorSignal Pro restored successfully.';
|
|
_isBannerError = false;
|
|
} else if (entitlement.status ==
|
|
MotorSignalEntitlementStatus.syncPending) {
|
|
_statusBanner = 'Purchase found. Verifying entitlement...';
|
|
_isBannerError = false;
|
|
} else {
|
|
_statusBanner =
|
|
'No active purchases found for this Google Play account.';
|
|
_isBannerError = false;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_currentStep = ProPurchaseStep.idle;
|
|
_statusBanner =
|
|
'Restore request failed. Please check your network connection.';
|
|
_isBannerError = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final entitlement = ref.watch(entitlementProvider);
|
|
final monthlyProd = _products
|
|
.where((p) => p.id == ProductCatalog.proMonthly)
|
|
.firstOrNull;
|
|
final lifetimeProd = _products
|
|
.where((p) => p.id == ProductCatalog.proLifetime)
|
|
.firstOrNull;
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'MOTORSIGNAL',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: 1.5,
|
|
),
|
|
),
|
|
SizedBox(width: 6),
|
|
ProBadge(),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _currentStep != ProPurchaseStep.idle
|
|
? null
|
|
: _handleRestore,
|
|
child: const Text(
|
|
'RESTORE',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF00E5FF),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0),
|
|
children: [
|
|
// Status Banner if present
|
|
if (_statusBanner != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: _isBannerError
|
|
? Colors.redAccent.withValues(alpha: 0.15)
|
|
: Colors.greenAccent.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: _isBannerError
|
|
? Colors.redAccent
|
|
: Colors.greenAccent,
|
|
width: 0.8,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_isBannerError
|
|
? Icons.error_outline
|
|
: Icons.check_circle_outline,
|
|
color: _isBannerError
|
|
? Colors.redAccent
|
|
: Colors.greenAccent,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
_statusBanner!,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: _isBannerError
|
|
? Colors.redAccent
|
|
: Colors.greenAccent,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
// Active Pro Banner if user is already verified Pro
|
|
if (entitlement.isPro) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF00E5FF).withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: const Color(0xFF00E5FF)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.verified,
|
|
color: Color(0xFF00E5FF),
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entitlement.plan == MotorSignalPlan.proLifetime
|
|
? 'MOTORSIGNAL PRO LIFETIME ACTIVE'
|
|
: 'MOTORSIGNAL PRO ACTIVE',
|
|
style: const TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w900,
|
|
color: Color(0xFF00E5FF),
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
entitlement.plan == MotorSignalPlan.proLifetime
|
|
? 'Permanent access enabled. Thank you for supporting MotorSignal.'
|
|
: (entitlement.expiresAt != null
|
|
? 'Subscription active. Renews ${entitlement.expiresAt!.toLocal().toString().split(' ')[0]}.'
|
|
: 'Subscription active.'),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white70,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
// Header Pitch
|
|
const Text(
|
|
'PRECISION TELEMETRY.\nUNLIMITED RIDE DATA.',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.white,
|
|
letterSpacing: 0.2,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Unlock 20Hz deep telemetry analysis, multiple motorcycle profiles, unlimited cloud sync, and forensic data exports.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.white70,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Plan Cards
|
|
if (_isLoadingProducts) ...[
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32.0),
|
|
child: CircularProgressIndicator(color: Color(0xFF00E5FF)),
|
|
),
|
|
),
|
|
] else if (!_storeAvailable) ...[
|
|
_buildStoreUnavailableCard(),
|
|
] else ...[
|
|
// Lifetime Plan Card
|
|
_buildPlanCard(
|
|
title: 'PRO LIFETIME',
|
|
subtitle: 'One-time purchase • Permanent access',
|
|
priceDisplay: lifetimeProd?.price ?? 'Unavailable',
|
|
badgeText: 'BEST VALUE',
|
|
isHighlighted: true,
|
|
isLoading: _currentStep == ProPurchaseStep.purchasing,
|
|
isCurrentPlan:
|
|
entitlement.plan == MotorSignalPlan.proLifetime &&
|
|
entitlement.isPro,
|
|
onTap: lifetimeProd != null
|
|
? () => _handleBuy(ProductCatalog.proLifetime)
|
|
: null,
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Monthly Plan Card
|
|
_buildPlanCard(
|
|
title: 'PRO MONTHLY',
|
|
subtitle: 'Recurring subscription • Cancel anytime',
|
|
priceDisplay: monthlyProd != null
|
|
? '${monthlyProd.price}/month'
|
|
: 'Unavailable',
|
|
badgeText: null,
|
|
isHighlighted: false,
|
|
isLoading: _currentStep == ProPurchaseStep.purchasing,
|
|
isCurrentPlan:
|
|
entitlement.plan == MotorSignalPlan.proMonthly &&
|
|
entitlement.isPro,
|
|
onTap: monthlyProd != null
|
|
? () => _handleBuy(ProductCatalog.proMonthly)
|
|
: null,
|
|
),
|
|
],
|
|
|
|
const SizedBox(height: 28),
|
|
const Divider(color: Colors.white12),
|
|
const SizedBox(height: 16),
|
|
|
|
// Benefits Checklist
|
|
const Text(
|
|
'WHAT IS INCLUDED IN PRO',
|
|
style: TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
color: Color(0xFF00E5FF),
|
|
letterSpacing: 1.0,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
_buildBenefitRow(
|
|
icon: Icons.speed,
|
|
title: 'Advanced Acceleration & Braking',
|
|
desc:
|
|
'G-force timeline, sprint intervals, roll-on speed, and deceleration thresholds.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.screen_rotation,
|
|
title: 'Advanced Lean Angle Analysis',
|
|
desc:
|
|
'Left and right apex lean distributions, transition dynamics, and turn stability.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.show_chart,
|
|
title: '20Hz Sub-Second Telemetry',
|
|
desc:
|
|
'High-frequency forensic sensor fusion playback with interactive scrub control.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.history,
|
|
title: 'Unlimited Ride History',
|
|
desc:
|
|
'Complete persistent ledger of every ride without duration or count limitations.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.table_chart_outlined,
|
|
title: 'Forensic CSV & PDF Export',
|
|
desc:
|
|
'Export structured engineering data and printable performance run sheets.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.two_wheeler,
|
|
title: 'Multiple Motorcycle Garage',
|
|
desc:
|
|
'Log and compare telemetry across your entire personal motorcycle fleet.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.cloud_done_outlined,
|
|
title: 'Unlimited MotorCloud Sync',
|
|
desc:
|
|
'Automatic encrypted cloud backup with multi-device restore capability.',
|
|
),
|
|
_buildBenefitRow(
|
|
icon: Icons.alt_route,
|
|
title: 'Route Planning & Group Rides',
|
|
desc:
|
|
'Multi-waypoint scenic routing and group pack coordination features.',
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
const Divider(color: Colors.white12),
|
|
const SizedBox(height: 12),
|
|
|
|
// Terms & Clarity Note
|
|
const Text(
|
|
'Basic ride recording, cockpit instruments, offline history, local SQLite storage, and crash detection remain 100% free forever. MotorSignal Pro subscriptions can be managed or canceled anytime in Google Play Store settings.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white38,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStoreUnavailableCard() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF141414),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white12),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const Icon(Icons.cloud_off, color: Colors.amberAccent, size: 32),
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'Google Play Billing Unavailable',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
|
),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
'Cannot connect to Google Play. Please check your internet connection or Google Play Store service status.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12, color: Colors.white60),
|
|
),
|
|
const SizedBox(height: 14),
|
|
OutlinedButton(
|
|
onPressed: _loadStoreProducts,
|
|
child: const Text('RETRY CONNECTION'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlanCard({
|
|
required String title,
|
|
required String subtitle,
|
|
required String priceDisplay,
|
|
required String? badgeText,
|
|
required bool isHighlighted,
|
|
required bool isLoading,
|
|
required bool isCurrentPlan,
|
|
required VoidCallback? onTap,
|
|
}) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isHighlighted
|
|
? const Color(0xFF001A1E)
|
|
: const Color(0xFF0F0F0F),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: isCurrentPlan
|
|
? Colors.greenAccent
|
|
: (isHighlighted
|
|
? const Color(0xFF00E5FF)
|
|
: const Color(0xFF222222)),
|
|
width: isHighlighted || isCurrentPlan ? 1.6 : 1.0,
|
|
),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: isCurrentPlan ? null : onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontFamily: 'Sora',
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.white,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
if (badgeText != null) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF00E5FF),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
badgeText,
|
|
style: const TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.black,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (isCurrentPlan) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.greenAccent.withValues(
|
|
alpha: 0.2,
|
|
),
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: Colors.greenAccent),
|
|
),
|
|
child: const Text(
|
|
'CURRENT PLAN',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w900,
|
|
color: Colors.greenAccent,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white60,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
priceDisplay,
|
|
style: TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w900,
|
|
color: isHighlighted
|
|
? const Color(0xFF00E5FF)
|
|
: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
isCurrentPlan ? 'Active' : 'Select',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isHighlighted
|
|
? const Color(0xFF00E5FF)
|
|
: Colors.white54,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBenefitRow({
|
|
required IconData icon,
|
|
required String title,
|
|
required String desc,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16.0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF00E5FF).withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: const Color(0xFF00E5FF), size: 20),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
desc,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.white60,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|