MotorSignal-App/lib/features/entitlements/pro_screen.dart

669 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 FittedBox(
fit: BoxFit.scaleDown,
child: 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: [
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 4,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.5,
),
),
if (badgeText != null)
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)
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),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 130),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: 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,
),
),
],
),
),
],
),
);
}
}