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

783 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/responsive/viewport_size.dart';
import '../../core/theme/theme_extensions.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 {
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();
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 colors = context.motorColors;
final snapshot = context.responsive;
final isWide = snapshot.isMedium || snapshot.isExpanded;
final monthlyProd = _products
.where((p) => p.id == ProductCatalog.proMonthly)
.firstOrNull;
final lifetimeProd = _products
.where((p) => p.id == ProductCatalog.proLifetime)
.firstOrNull;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF0B0D0F) : Theme.of(context).colorScheme.surface,
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: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: TextButton(
style: TextButton.styleFrom(
minimumSize: const Size(48, 48),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: _currentStep != ProPurchaseStep.idle
? null
: _handleRestore,
child: Text(
'RESTORE',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isWide ? 24.0 : 20.0,
vertical: 12.0,
),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isWide ? 1100 : 640,
),
child: isWide
? _buildWideLayout(entitlement, monthlyProd, lifetimeProd)
: _buildCompactLayout(entitlement, monthlyProd, lifetimeProd),
),
),
),
),
);
}
Widget _buildWideLayout(
EntitlementState entitlement,
StoreProduct? monthlyProd,
StoreProduct? lifetimeProd,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_statusBanner != null) ...[
_buildStatusBanner(),
const SizedBox(height: 16),
],
if (entitlement.isPro) ...[
_buildActiveProBanner(entitlement),
const SizedBox(height: 20),
],
_buildPitchHeader(),
const SizedBox(height: 24),
_buildPlanSection(entitlement, monthlyProd, lifetimeProd),
],
),
),
const SizedBox(width: 32),
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBenefitsSection(),
const SizedBox(height: 20),
_buildDisclaimer(),
],
),
),
],
);
}
Widget _buildCompactLayout(
EntitlementState entitlement,
StoreProduct? monthlyProd,
StoreProduct? lifetimeProd,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_statusBanner != null) ...[
_buildStatusBanner(),
const SizedBox(height: 16),
],
if (entitlement.isPro) ...[
_buildActiveProBanner(entitlement),
const SizedBox(height: 20),
],
_buildPitchHeader(),
const SizedBox(height: 24),
_buildPlanSection(entitlement, monthlyProd, lifetimeProd),
const SizedBox(height: 28),
const Divider(color: Colors.white12),
const SizedBox(height: 16),
_buildBenefitsSection(),
const SizedBox(height: 24),
const Divider(color: Colors.white12),
const SizedBox(height: 12),
_buildDisclaimer(),
const SizedBox(height: 24),
],
);
}
Widget _buildStatusBanner() {
final colors = context.motorColors;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isBannerError
? colors.raceRed.withValues(alpha: 0.15)
: colors.launchGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _isBannerError ? colors.raceRed : colors.launchGreen,
width: 0.8,
),
),
child: Row(
children: [
Icon(
_isBannerError
? Icons.error_outline
: Icons.check_circle_outline,
color: _isBannerError ? colors.raceRed : colors.launchGreen,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
_statusBanner!,
style: TextStyle(
fontSize: 12,
color: _isBannerError ? colors.raceRed : colors.launchGreen,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Widget _buildActiveProBanner(EntitlementState entitlement) {
final colors = context.motorColors;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.cyberCyan),
),
child: Row(
children: [
Icon(
Icons.verified,
color: colors.cyberCyan,
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: TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w900,
color: colors.cyberCyan,
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: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildPitchHeader() {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'PRECISION TELEMETRY.\nUNLIMITED RIDE DATA.',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 22,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
letterSpacing: 0.2,
height: 1.2,
),
),
const SizedBox(height: 8),
Text(
'Unlock 20Hz deep telemetry analysis, multiple motorcycle profiles, unlimited cloud sync, and forensic data exports.',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
);
}
Widget _buildPlanSection(
EntitlementState entitlement,
StoreProduct? monthlyProd,
StoreProduct? lifetimeProd,
) {
if (_isLoadingProducts) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: CircularProgressIndicator(color: context.motorColors.cyberCyan),
),
);
}
if (!_storeAvailable) {
return _buildStoreUnavailableCard();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Lifetime Plan Card
_buildPlanCard(
title: 'PRO LIFETIME',
subtitle: 'One-time purchase • Permanent access',
priceDisplay: lifetimeProd?.price ?? 'Unavailable',
badgeText: lifetimeProd != null ? 'BEST VALUE' : null,
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,
),
],
);
}
Widget _buildBenefitsSection() {
final colors = context.motorColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WHAT IS INCLUDED IN PRO',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
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.',
),
],
);
}
Widget _buildDisclaimer() {
return 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: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
height: 1.4,
),
);
}
Widget _buildStoreUnavailableCard() {
final colors = context.motorColors;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Column(
children: [
Icon(Icons.cloud_off, color: colors.warningAmber, size: 32),
const SizedBox(height: 12),
const Text(
'Google Play Billing Unavailable',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 6),
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: Theme.of(context).colorScheme.onSurfaceVariant),
),
const SizedBox(height: 14),
OutlinedButton(
style: OutlinedButton.styleFrom(minimumSize: const Size(120, 48)),
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,
}) {
final colors = context.motorColors;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: isCurrentPlan ? null : onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 64),
decoration: BoxDecoration(
color: isHighlighted
? colors.cyberCyan.withValues(alpha: 0.08)
: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isCurrentPlan
? colors.launchGreen
: (isHighlighted
? colors.cyberCyan
: colors.outlineVariant),
width: isHighlighted || isCurrentPlan ? 1.6 : 1.0,
),
),
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: TextStyle(
fontFamily: 'Sora',
fontSize: 15,
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
letterSpacing: 0.5,
),
),
if (badgeText != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'BEST VALUE',
style: 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.launchGreen.withValues(
alpha: 0.2,
),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: colors.launchGreen),
),
child: Text(
'CURRENT PLAN',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w900,
color: colors.launchGreen,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
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
? colors.cyberCyan
: Theme.of(context).colorScheme.onSurface,
),
),
),
const SizedBox(height: 2),
Text(
isCurrentPlan
? 'Active'
: (onTap == null ? 'Unavailable' : 'Select'),
style: TextStyle(
fontSize: 11,
color: isHighlighted
? colors.cyberCyan
: (onTap == null
? Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: Theme.of(context).colorScheme.onSurfaceVariant),
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
),
);
}
Widget _buildBenefitRow({
required IconData icon,
required String title,
required String desc,
}) {
final colors = context.motorColors;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: colors.cyberCyan, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
desc,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
],
),
);
}
}