From 80f71698a00b483f155c36ba208378ecd62feb79 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 19:10:54 -0400 Subject: [PATCH] feat: add MotorSignal Pro experience and feature gating --- lib/core/router.dart | 12 +- lib/features/entitlements/pro_screen.dart | 659 +++++++++++++++ .../entitlements/widgets/pro_lock_badge.dart | 328 ++++++++ .../garage/add_motorcycle_screen.dart | 246 ++++-- lib/features/garage/garage_screen.dart | 128 ++- lib/features/history/ride_detail_screen.dart | 39 +- .../widgets/evolution_ride_details_view.dart | 374 +++++++-- lib/features/settings/account_screen.dart | 99 ++- lib/features/settings/settings_screen.dart | 382 +++++++-- .../settings/widgets/current_plan_card.dart | 240 ++++++ .../widgets/evolution_settings_view.dart | 759 ++++++++++++++---- test/feature_gating_test.dart | 233 ++++++ test/pro_screen_test.dart | 253 ++++++ test/rider_safety_entitlement_test.dart | 141 ++++ 14 files changed, 3511 insertions(+), 382 deletions(-) create mode 100644 lib/features/entitlements/pro_screen.dart create mode 100644 lib/features/entitlements/widgets/pro_lock_badge.dart create mode 100644 lib/features/settings/widgets/current_plan_card.dart create mode 100644 test/feature_gating_test.dart create mode 100644 test/pro_screen_test.dart create mode 100644 test/rider_safety_entitlement_test.dart diff --git a/lib/core/router.dart b/lib/core/router.dart index 4ee0b31..f6809b9 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -16,6 +16,7 @@ import '../features/settings/field_test_live_log_screen.dart'; import '../features/field_test/field_test_pre_ride_check_screen.dart'; import '../features/settings/oauth_callback_screen.dart'; import '../features/settings/account_screen.dart'; +import '../features/entitlements/pro_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -40,7 +41,9 @@ final appRouter = GoRouter( redirect: (context, state) { final uri = state.uri; final uriStr = uri.toString(); - if (uri.scheme == 'motorsignal' || uri.host == 'oauth' || uriStr.contains('/oauth/callback')) { + if (uri.scheme == 'motorsignal' || + uri.host == 'oauth' || + uriStr.contains('/oauth/callback')) { if (state.matchedLocation != '/oauth/callback') { return '/oauth/callback?${uri.query}'; } @@ -120,6 +123,10 @@ final appRouter = GoRouter( path: 'account', builder: (context, state) => const AccountScreen(), ), + GoRoute( + path: 'pro', + builder: (context, state) => const ProScreen(), + ), GoRoute( path: 'diagnostics', builder: (context, state) => const DiagnosticsScreen(), @@ -139,7 +146,8 @@ final appRouter = GoRouter( ), GoRoute( path: 'field-test-pre-ride-check', - builder: (context, state) => const FieldTestPreRideCheckScreen(), + builder: (context, state) => + const FieldTestPreRideCheckScreen(), ), ], ), diff --git a/lib/features/entitlements/pro_screen.dart b/lib/features/entitlements/pro_screen.dart new file mode 100644 index 0000000..74ececf --- /dev/null +++ b/lib/features/entitlements/pro_screen.dart @@ -0,0 +1,659 @@ +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 createState() => _ProScreenState(); +} + +class _ProScreenState extends ConsumerState { + ProPurchaseStep _currentStep = ProPurchaseStep.idle; + String? _statusBanner; + bool _isBannerError = false; + List _products = []; + bool _isLoadingProducts = true; + bool _storeAvailable = true; + + @override + void initState() { + super.initState(); + _loadStoreProducts(); + } + + Future _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 _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 _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.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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/entitlements/widgets/pro_lock_badge.dart b/lib/features/entitlements/widgets/pro_lock_badge.dart new file mode 100644 index 0000000..714f9e8 --- /dev/null +++ b/lib/features/entitlements/widgets/pro_lock_badge.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../services/entitlements/entitlement_providers.dart'; +import '../../../services/entitlements/feature_access_service.dart'; +import '../../rides/ride_state_machine.dart'; + +/// Small inline badge indicating a Pro-only feature. +/// Follows MotorSignal cockpit aesthetic (dark surface, subtle cyan accent, no neon glow). +class ProBadge extends StatelessWidget { + final double fontSize; + final EdgeInsetsGeometry padding; + + const ProBadge({ + super.key, + this.fontSize = 9, + this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFF00E5FF).withValues(alpha: 0.6), + width: 0.8, + ), + ), + child: Text( + 'PRO', + style: TextStyle( + fontFamily: 'Inter', + fontSize: fontSize, + fontWeight: FontWeight.w900, + color: const Color(0xFF00E5FF), + letterSpacing: 0.8, + ), + ), + ); + } +} + +/// Metadata description helper for MotorSignal features. +class FeatureBenefitInfo { + final String title; + final String description; + final IconData icon; + + const FeatureBenefitInfo({ + required this.title, + required this.description, + required this.icon, + }); + + static FeatureBenefitInfo forFeature(MotorSignalFeature feature) { + switch (feature) { + case MotorSignalFeature.cloudBackup: + case MotorSignalFeature.unlimitedCloudHistory: + return const FeatureBenefitInfo( + title: 'Unlimited MotorCloud Sync', + description: + 'Automatically preserve your entire ride catalog, telemetry, and motorcycle configurations to MotorCloud.', + icon: Icons.cloud_done_outlined, + ); + case MotorSignalFeature.multiDeviceRestore: + return const FeatureBenefitInfo( + title: 'Multi-Device Cloud Restore', + description: + 'Seamlessly sync your complete ride records and garage to new phones or secondary devices.', + icon: Icons.devices_outlined, + ); + case MotorSignalFeature.advancedTelemetry: + case MotorSignalFeature.advancedAnalytics: + return const FeatureBenefitInfo( + title: 'Advanced Telemetry & Analytics', + description: + 'Deep sensor analysis including 20Hz telemetry graphs, elevation profiles, and cornering metrics.', + icon: Icons.show_chart, + ); + case MotorSignalFeature.advancedAcceleration: + return const FeatureBenefitInfo( + title: 'Advanced Acceleration Analysis', + description: + 'Analyze longitudinal acceleration forces, sprint splits, and throttle roll-on dynamics.', + icon: Icons.speed, + ); + case MotorSignalFeature.advancedBraking: + return const FeatureBenefitInfo( + title: 'Advanced Braking Analysis', + description: + 'Detailed braking G-force thresholds, trail-braking telemetry, and stopping deceleration.', + icon: Icons.disc_full_outlined, + ); + case MotorSignalFeature.advancedLean: + return const FeatureBenefitInfo( + title: 'Advanced Lean Angle Analysis', + description: + 'Comprehensive left/right apex lean angle distribution, transition rate, and corner stability.', + icon: Icons.screen_rotation, + ); + case MotorSignalFeature.unlimitedHistory: + return const FeatureBenefitInfo( + title: 'Unlimited Ride History', + description: + 'Keep a permanent, searchable ledger of every ride you record without duration caps.', + icon: Icons.history, + ); + case MotorSignalFeature.csvExport: + case MotorSignalFeature.pdfExport: + return const FeatureBenefitInfo( + title: 'Forensic CSV & PDF Export', + description: + 'Export sub-second 20Hz forensic data tables and printable performance reports.', + icon: Icons.table_chart_outlined, + ); + case MotorSignalFeature.useMultipleMotorcycles: + return const FeatureBenefitInfo( + title: 'Multiple Motorcycle Garage', + description: + 'Manage your full fleet with motorcycle-specific geometry, specs, and ride logs.', + icon: Icons.two_wheeler, + ); + case MotorSignalFeature.planTrips: + return const FeatureBenefitInfo( + title: 'Advanced Route & Trip Planning', + description: + 'Plan multi-waypoint tours, fuel stops, and twisty scenic routing.', + icon: Icons.alt_route, + ); + case MotorSignalFeature.createGroupRides: + case MotorSignalFeature.useAdvancedGroupRideFeatures: + return const FeatureBenefitInfo( + title: 'Advanced Group Riding', + description: + 'Coordinate organized pack rides with real-time route progress and rendezvous points.', + icon: Icons.group_work_outlined, + ); + } + } +} + +/// Helper to trigger a contextual Pro upgrade sheet. +/// STRICT RIDER SAFETY GUARANTEE: +/// If the rider is currently riding (active ride recording or moving), this modal is SUPPRESSED +/// and replaced with an unobtrusive passive toast to prevent screen distraction. +Future showProUpgradeSheet( + BuildContext context, + WidgetRef ref, { + required MotorSignalFeature feature, +}) async { + final session = ref.read(rideStateMachineProvider); + final isRiding = session.state == RideState.riding; + + if (isRiding) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Pro feature locked. Upgrade available when stopped.'), + duration: Duration(seconds: 2), + ), + ); + return; + } + + final info = FeatureBenefitInfo.forFeature(feature); + + await showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF0F0F0F), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + info.icon, + color: const Color(0xFF00E5FF), + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + info.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + const ProBadge(), + ], + ), + const SizedBox(height: 2), + const Text( + 'MotorSignal Pro Feature', + style: TextStyle(fontSize: 11, color: Colors.white54), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + info.description, + style: const TextStyle( + fontSize: 13, + color: Colors.white70, + height: 1.4, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + Navigator.of(ctx).pop(); + context.push('/settings/pro'); + }, + child: const Text( + 'VIEW MOTORSIGNAL PRO', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + ), + const SizedBox(height: 10), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.white60), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('NOT NOW'), + ), + ], + ), + ), + ); + }, + ); +} + +/// Reusable inline locked feature row. +class ProLockedRow extends ConsumerWidget { + final MotorSignalFeature feature; + final Widget title; + final Widget? subtitle; + final Widget? leading; + + const ProLockedRow({ + super.key, + required this.feature, + required this.title, + this.subtitle, + this.leading, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final featureAccess = ref.watch(featureAccessProvider); + final hasAccess = featureAccess.canAccess(feature); + + if (hasAccess) { + return const SizedBox.shrink(); + } + + return InkWell( + onTap: () => showProUpgradeSheet(context, ref, feature: feature), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible(child: title), + const SizedBox(width: 6), + const ProBadge(), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + subtitle!, + ], + ], + ), + ), + const Icon(Icons.lock_outline, size: 18, color: Colors.white38), + ], + ), + ), + ); + } +} diff --git a/lib/features/garage/add_motorcycle_screen.dart b/lib/features/garage/add_motorcycle_screen.dart index cc4efd6..466613e 100644 --- a/lib/features/garage/add_motorcycle_screen.dart +++ b/lib/features/garage/add_motorcycle_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../models/motorcycle_models.dart'; +import '../../services/entitlements/entitlement_providers.dart'; +import '../../services/entitlements/feature_access_service.dart'; +import '../entitlements/widgets/pro_lock_badge.dart'; import 'garage_providers.dart'; import 'widgets/motorcycle_photo.dart'; @@ -9,7 +12,8 @@ class AddMotorcycleScreen extends ConsumerStatefulWidget { const AddMotorcycleScreen({super.key}); @override - ConsumerState createState() => _AddMotorcycleScreenState(); + ConsumerState createState() => + _AddMotorcycleScreenState(); } class _AddMotorcycleScreenState extends ConsumerState { @@ -89,7 +93,11 @@ class _AddMotorcycleScreenState extends ConsumerState { _isLoading = true; }); final repo = ref.read(motorcycleRepositoryProvider); - final variants = await repo.getVariants(_selectedYear!, _selectedMake!.id, model.id); + final variants = await repo.getVariants( + _selectedYear!, + _selectedMake!.id, + model.id, + ); if (variants.length <= 1) { _selectedVariant = variants.isNotEmpty ? variants.first : null; @@ -130,6 +138,21 @@ class _AddMotorcycleScreenState extends ConsumerState { } Future _saveMotorcycle() async { + final bikes = ref.read(garageMotorcyclesStreamProvider).value ?? []; + if (bikes.isNotEmpty) { + final canAddMore = ref + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.useMultipleMotorcycles); + if (!canAddMore) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.useMultipleMotorcycles, + ); + return; + } + } + final selection = MotorcycleSelection( year: _selectedYear!, makeId: _selectedMake!.id, @@ -143,12 +166,16 @@ class _AddMotorcycleScreenState extends ConsumerState { final controller = ref.read(garageControllerProvider); await controller.addMotorcycle( selection, - nickname: _nicknameController.text.trim().isNotEmpty ? _nicknameController.text.trim() : null, + nickname: _nicknameController.text.trim().isNotEmpty + ? _nicknameController.text.trim() + : null, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added ${selection.displayName} to your Garage!')), + SnackBar( + content: Text('Added ${selection.displayName} to your Garage!'), + ), ); context.pop(); } @@ -175,7 +202,10 @@ class _AddMotorcycleScreenState extends ConsumerState { child: Scaffold( appBar: AppBar( title: Text(_getStepTitle()), - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: _goBack), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _goBack, + ), ), body: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -195,23 +225,35 @@ class _AddMotorcycleScreenState extends ConsumerState { String _getStepTitle() { switch (_step) { - case 1: return 'Select Year'; - case 2: return 'Select Manufacturer'; - case 3: return 'Select Model (${_selectedMake?.name ?? ""})'; - case 4: return 'Select Variant'; - case 5: return 'Confirm Motorcycle'; - default: return 'Add Motorcycle'; + case 1: + return 'Select Year'; + case 2: + return 'Select Manufacturer'; + case 3: + return 'Select Model (${_selectedMake?.name ?? ""})'; + case 4: + return 'Select Variant'; + case 5: + return 'Confirm Motorcycle'; + default: + return 'Add Motorcycle'; } } Widget _buildStepContent() { switch (_step) { - case 1: return _buildYearStep(); - case 2: return _buildMakeStep(); - case 3: return _buildModelStep(); - case 4: return _buildVariantStep(); - case 5: return _buildConfirmStep(); - default: return const SizedBox(); + case 1: + return _buildYearStep(); + case 2: + return _buildMakeStep(); + case 3: + return _buildModelStep(); + case 4: + return _buildVariantStep(); + case 5: + return _buildConfirmStep(); + default: + return const SizedBox(); } } @@ -229,11 +271,22 @@ class _AddMotorcycleScreenState extends ConsumerState { final year = _years[index].year; return OutlinedButton( style: OutlinedButton.styleFrom( - side: BorderSide(color: _selectedYear == year ? Colors.cyanAccent : Colors.white24), - backgroundColor: _selectedYear == year ? Colors.cyanAccent.withValues(alpha: 0.2) : null, + side: BorderSide( + color: _selectedYear == year ? Colors.cyanAccent : Colors.white24, + ), + backgroundColor: _selectedYear == year + ? Colors.cyanAccent.withValues(alpha: 0.2) + : null, ), onPressed: () => _selectYear(year), - child: Text('$year', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)), + child: Text( + '$year', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.white, + ), + ), ); }, ); @@ -241,7 +294,9 @@ class _AddMotorcycleScreenState extends ConsumerState { Widget _buildMakeStep() { final query = _searchController.text.toLowerCase(); - final filtered = _makes.where((m) => m.name.toLowerCase().contains(query)).toList(); + final filtered = _makes + .where((m) => m.name.toLowerCase().contains(query)) + .toList(); return Column( children: [ Padding( @@ -253,7 +308,10 @@ class _AddMotorcycleScreenState extends ConsumerState { prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent), filled: true, fillColor: Colors.white10, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), ), onChanged: (_) => setState(() {}), ), @@ -262,12 +320,22 @@ class _AddMotorcycleScreenState extends ConsumerState { child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: filtered.length, - separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10), + separatorBuilder: (_, _) => + const Divider(height: 1, color: Colors.white10), itemBuilder: (context, index) { final make = filtered[index]; return ListTile( - title: Text(make.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)), - trailing: const Icon(Icons.chevron_right, color: Colors.white30), + title: Text( + make.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + trailing: const Icon( + Icons.chevron_right, + color: Colors.white30, + ), onTap: () => _selectMake(make), ); }, @@ -279,7 +347,9 @@ class _AddMotorcycleScreenState extends ConsumerState { Widget _buildModelStep() { final query = _searchController.text.toLowerCase(); - final filtered = _models.where((m) => m.name.toLowerCase().contains(query)).toList(); + final filtered = _models + .where((m) => m.name.toLowerCase().contains(query)) + .toList(); return Column( children: [ Padding( @@ -291,7 +361,10 @@ class _AddMotorcycleScreenState extends ConsumerState { prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent), filled: true, fillColor: Colors.white10, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), ), onChanged: (_) => setState(() {}), ), @@ -300,13 +373,23 @@ class _AddMotorcycleScreenState extends ConsumerState { child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: filtered.length, - separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10), + separatorBuilder: (_, _) => + const Divider(height: 1, color: Colors.white10), itemBuilder: (context, index) { final model = filtered[index]; return ListTile( - title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)), + title: Text( + model.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), subtitle: Text('$_selectedYear ${_selectedMake?.name}'), - trailing: const Icon(Icons.chevron_right, color: Colors.white30), + trailing: const Icon( + Icons.chevron_right, + color: Colors.white30, + ), onTap: () => _selectModel(model), ); }, @@ -325,8 +408,14 @@ class _AddMotorcycleScreenState extends ConsumerState { final variant = _variants[index]; return Card( child: ListTile( - title: Text(variant.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), - trailing: const Icon(Icons.check_circle_outline, color: Colors.cyanAccent), + title: Text( + variant.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + trailing: const Icon( + Icons.check_circle_outline, + color: Colors.cyanAccent, + ), onTap: () => _selectVariant(variant), ), ); @@ -361,32 +450,74 @@ class _AddMotorcycleScreenState extends ConsumerState { Text( '$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}', textAlign: TextAlign.center, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), - if (_selectedVariant?.name != null && _selectedVariant!.name != 'Standard') ...[ + if (_selectedVariant?.name != null && + _selectedVariant!.name != 'Standard') ...[ const SizedBox(height: 4), - Text(_selectedVariant!.name, style: const TextStyle(color: Colors.white70, fontSize: 16)), + Text( + _selectedVariant!.name, + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), ], ], ), ), const SizedBox(height: 24), if (spec != null) ...[ - const Text('SPECIFICATION SUMMARY', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2)), + const Text( + 'SPECIFICATION SUMMARY', + style: TextStyle( + color: Colors.cyanAccent, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), const SizedBox(height: 12), Row( children: [ - Expanded(child: _buildSpecChip('DISPLACEMENT', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : '—')), + Expanded( + child: _buildSpecChip( + 'DISPLACEMENT', + spec.displacementCc != null + ? '${spec.displacementCc!.toStringAsFixed(0)} cc' + : '—', + ), + ), const SizedBox(width: 12), - Expanded(child: _buildSpecChip('HORSEPOWER', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP' : '—')), + Expanded( + child: _buildSpecChip( + 'HORSEPOWER', + spec.horsepower != null + ? '${spec.horsepower!.toStringAsFixed(0)} HP' + : '—', + ), + ), ], ), const SizedBox(height: 12), Row( children: [ - Expanded(child: _buildSpecChip('TORQUE', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—')), + Expanded( + child: _buildSpecChip( + 'TORQUE', + spec.torqueLbFt != null + ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft' + : '—', + ), + ), const SizedBox(width: 12), - Expanded(child: _buildSpecChip('WET WEIGHT', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : '—')), + Expanded( + child: _buildSpecChip( + 'WET WEIGHT', + spec.wetWeightLbs != null + ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' + : '—', + ), + ), ], ), const SizedBox(height: 24), @@ -396,7 +527,9 @@ class _AddMotorcycleScreenState extends ConsumerState { decoration: InputDecoration( labelText: 'Nickname (Optional)', hintText: 'e.g., Blue Beast, The Track Bike', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent), ), ), @@ -406,10 +539,19 @@ class _AddMotorcycleScreenState extends ConsumerState { backgroundColor: Colors.cyanAccent, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), onPressed: _saveMotorcycle, - child: const Text('ADD TO GARAGE', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)), + child: const Text( + 'ADD TO GARAGE', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), ), ], ), @@ -427,9 +569,23 @@ class _AddMotorcycleScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)), + Text( + label, + style: const TextStyle( + fontSize: 10, + color: Colors.white54, + letterSpacing: 1.0, + ), + ), const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ], ), ); diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart index 74f23bc..857177d 100644 --- a/lib/features/garage/garage_screen.dart +++ b/lib/features/garage/garage_screen.dart @@ -7,6 +7,9 @@ import '../../models/motorcycle_models.dart'; import '../../core/theme/evolution_theme_config.dart'; import 'garage_providers.dart'; import 'widgets/motorcycle_photo.dart'; +import '../../services/entitlements/entitlement_providers.dart'; +import '../../services/entitlements/feature_access_service.dart'; +import '../entitlements/widgets/pro_lock_badge.dart'; class GarageScreen extends ConsumerWidget { const GarageScreen({super.key}); @@ -17,51 +20,91 @@ class GarageScreen extends ConsumerWidget { final themeMode = ref.watch(dashboardThemeModeProvider); return Scaffold( - backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.black : null, + backgroundColor: themeMode == DashboardThemeMode.evolution + ? Colors.black + : null, appBar: AppBar( - backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.transparent : null, + backgroundColor: themeMode == DashboardThemeMode.evolution + ? Colors.transparent + : null, elevation: 0, title: Text( 'My Garage', style: TextStyle( - fontFamily: themeMode == DashboardThemeMode.evolution ? 'Sora' : null, + fontFamily: themeMode == DashboardThemeMode.evolution + ? 'Sora' + : null, fontWeight: FontWeight.bold, ), ), actions: [ IconButton( - icon: const Icon(Icons.add_circle_outline, color: Color(0xFF00E5FF)), + icon: const Icon( + Icons.add_circle_outline, + color: Color(0xFF00E5FF), + ), tooltip: 'Add Motorcycle', - onPressed: () => context.push('/garage/add'), + onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value), ), ], ), body: bikesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('Error loading garage: $err')), + error: (err, stack) => + Center(child: Text('Error loading garage: $err')), data: (bikes) { if (bikes.isEmpty) return _buildEmptyState(context); return ListView.builder( padding: const EdgeInsets.all(16.0), itemCount: bikes.length, itemBuilder: (context, index) { - return _buildMotorcycleCard(context, ref, bikes[index], themeMode); + return _buildMotorcycleCard( + context, + ref, + bikes[index], + themeMode, + ); }, ); }, ), - floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty + floatingActionButton: + bikesAsync.value != null && bikesAsync.value!.isNotEmpty ? FloatingActionButton.extended( backgroundColor: Colors.cyanAccent, foregroundColor: Colors.black, icon: const Icon(Icons.two_wheeler), - label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold)), - onPressed: () => context.push('/garage/add'), + label: const Text( + 'ADD MOTORCYCLE', + style: TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value), ) : null, ); } + void _onAddMotorcycle( + BuildContext context, + WidgetRef ref, + List? bikes, + ) { + if (bikes != null && bikes.isNotEmpty) { + final canAddMore = ref + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.useMultipleMotorcycles); + if (!canAddMore) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.useMultipleMotorcycles, + ); + return; + } + } + context.push('/garage/add'); + } + Widget _buildEmptyState(BuildContext context) { return Center( child: Padding( @@ -73,7 +116,11 @@ class GarageScreen extends ConsumerWidget { const SizedBox(height: 24), const Text( 'YOUR GARAGE IS EMPTY', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 1.5), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), ), const SizedBox(height: 12), const Text( @@ -86,11 +133,22 @@ class GarageScreen extends ConsumerWidget { style: ElevatedButton.styleFrom( backgroundColor: Colors.cyanAccent, foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.add), - label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), + label: const Text( + 'ADD MOTORCYCLE', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), onPressed: () => context.push('/garage/add'), ), ], @@ -99,11 +157,18 @@ class GarageScreen extends ConsumerWidget { ); } - Widget _buildMotorcycleCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, DashboardThemeMode themeMode) { + Widget _buildMotorcycleCard( + BuildContext context, + WidgetRef ref, + GarageMotorcycle bike, + DashboardThemeMode themeMode, + ) { MotorcycleSpecification? spec; if (bike.specificationJson != null) { try { - spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!)); + spec = MotorcycleSpecification.fromJson( + json.decode(bike.specificationJson!), + ); } catch (_) {} } @@ -164,11 +229,18 @@ class GarageScreen extends ConsumerWidget { ), if (bike.isDefault) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( - color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + color: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), - border: Border.all(color: const Color(0xFF00E5FF)), + border: Border.all( + color: const Color(0xFF00E5FF), + ), ), child: const Text( 'CURRENT', @@ -190,9 +262,13 @@ class GarageScreen extends ConsumerWidget { : '${bike.year} • ${bike.makeName}', style: TextStyle( fontFamily: isEvolution ? 'JetBrains Mono' : null, - color: isEvolution ? const Color(0xFF00E5FF) : Colors.white70, + color: isEvolution + ? const Color(0xFF00E5FF) + : Colors.white70, fontSize: 13, - fontWeight: isEvolution ? FontWeight.w700 : FontWeight.normal, + fontWeight: isEvolution + ? FontWeight.w700 + : FontWeight.normal, ), ), ], @@ -216,9 +292,15 @@ class GarageScreen extends ConsumerWidget { side: const BorderSide(color: Colors.white30), ), onPressed: () { - ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id); + ref + .read(garageControllerProvider) + .setDefaultMotorcycle(bike.id); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')), + SnackBar( + content: Text( + 'Set ${bike.makeName} ${bike.modelName} as current motorcycle.', + ), + ), ); }, child: const Text('Set as Current'), diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart index d4bc995..d986916 100644 --- a/lib/features/history/ride_detail_screen.dart +++ b/lib/features/history/ride_detail_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../database/database.dart'; import '../../services/export_service.dart'; +import '../../services/entitlements/entitlement_providers.dart'; +import '../../services/entitlements/feature_access_service.dart'; +import '../entitlements/widgets/pro_lock_badge.dart'; import '../replay/replay_screen.dart'; import '../map/map_screen.dart'; import 'package:go_router/go_router.dart'; @@ -260,12 +263,38 @@ class _RideDetailScreenState extends ConsumerState { label: const Text('Export GPX'), ), const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () { - ref.read(exportServiceProvider).exportRide(r.id, asGpx: false); + Builder( + builder: (context) { + final isProCsv = ref + .watch(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport); + return OutlinedButton.icon( + onPressed: () { + if (!isProCsv) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ); + return; + } + ref + .read(exportServiceProvider) + .exportRide(r.id, asGpx: false); + }, + icon: const Icon(Icons.table_chart), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Export CSV'), + if (!isProCsv) ...[ + const SizedBox(width: 8), + const ProBadge(), + ], + ], + ), + ); }, - icon: const Icon(Icons.table_chart), - label: const Text('Export CSV'), ), const SizedBox(height: 8), TextButton.icon( diff --git a/lib/features/history/widgets/evolution_ride_details_view.dart b/lib/features/history/widgets/evolution_ride_details_view.dart index 23bfd10..5593963 100644 --- a/lib/features/history/widgets/evolution_ride_details_view.dart +++ b/lib/features/history/widgets/evolution_ride_details_view.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../../database/database.dart'; import '../../../services/export_service.dart'; +import '../../../services/entitlements/entitlement_providers.dart'; +import '../../../services/entitlements/feature_access_service.dart'; +import '../../entitlements/widgets/pro_lock_badge.dart'; import '../../../services/github/github_sync_service.dart'; import '../../replay/replay_screen.dart'; import '../../map/map_screen.dart'; @@ -19,8 +22,12 @@ class EvolutionRideDetailsView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null) - ? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'.toUpperCase() + final bikeName = + (ride.motorcycleYear != null && + ride.motorcycleMake != null && + ride.motorcycleModel != null) + ? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}' + .toUpperCase() : 'YAMAHA MT-09 SP'; final durationSec = ride.endTime != null @@ -29,7 +36,8 @@ class EvolutionRideDetailsView extends ConsumerWidget { final durationMin = (durationSec / 60).toStringAsFixed(0); final movingMin = (ride.movingTime / 60).toStringAsFixed(0); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final syncService = ref.watch(githubSyncServiceProvider); final isUploaded = syncService.isRideUploaded(ride.id); final commitSha = syncService.getRideCommitSha(ride.id); @@ -59,6 +67,17 @@ class EvolutionRideDetailsView extends ConsumerWidget { icon: const Icon(Icons.share, color: Color(0xFF00E5FF)), tooltip: 'Export CSV', onPressed: () async { + final isPro = ref + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport); + if (!isPro) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ); + return; + } final exporter = ref.read(exportServiceProvider); await exporter.exportRide(ride.id, asGpx: false); }, @@ -71,7 +90,13 @@ class EvolutionRideDetailsView extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // GitHub Sync Status Card - _buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha), + _buildGitHubSyncBanner( + context, + ref, + syncService, + isUploaded, + commitSha, + ), const SizedBox(height: 12), // Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards @@ -85,12 +110,19 @@ class EvolutionRideDetailsView extends ConsumerWidget { decoration: BoxDecoration( color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2), + border: Border.all( + color: const Color(0xFF1F1F1F), + width: 1.2, + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.two_wheeler, color: Colors.white38, size: 40), + const Icon( + Icons.two_wheeler, + color: Colors.white38, + size: 40, + ), const SizedBox(height: 8), Text( bikeName, @@ -128,12 +160,19 @@ class EvolutionRideDetailsView extends ConsumerWidget { decoration: BoxDecoration( color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF00E5FF).withAlpha(100), width: 1.2), + border: Border.all( + color: const Color(0xFF00E5FF).withAlpha(100), + width: 1.2, + ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 36), + const Icon( + Icons.map_rounded, + color: Color(0xFF00E5FF), + size: 36, + ), const SizedBox(height: 6), const Text( 'ROUTE PREVIEW', @@ -147,7 +186,10 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), const SizedBox(height: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: const Color(0xFF00E5FF).withAlpha(40), borderRadius: BorderRadius.circular(6), @@ -185,11 +227,26 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), child: Column( children: [ - _buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'), + _buildMetricRow( + 'DISTANCE:', + '${ride.distanceMiles.toStringAsFixed(2)} mi', + 'DURATION:', + '$durationMin min', + ), const Divider(color: Color(0xFF222222), height: 18), - _buildMetricRow('MOVING TIME:', '$movingMin min', 'AVG SPEED:', '${ride.averageSpeed.toStringAsFixed(1)} mph'), + _buildMetricRow( + 'MOVING TIME:', + '$movingMin min', + 'AVG SPEED:', + '${ride.averageSpeed.toStringAsFixed(1)} mph', + ), const Divider(color: Color(0xFF222222), height: 18), - _buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', 'DATE:', DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal())), + _buildMetricRow( + 'TOP SPEED:', + '${ride.topSpeedMph.toStringAsFixed(1)} mph', + 'DATE:', + DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal()), + ), ], ), ), @@ -243,7 +300,13 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), ) : Column( - children: events.take(15).map((event) => _buildInteractiveEventTile(context, event)).toList(), + children: events + .take(15) + .map( + (event) => + _buildInteractiveEventTile(context, event), + ) + .toList(), ), ), @@ -263,13 +326,22 @@ class EvolutionRideDetailsView extends ConsumerWidget { }, style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF00E5FF), - side: const BorderSide(color: Color(0xFF00E5FF), width: 1.5), + side: const BorderSide( + color: Color(0xFF00E5FF), + width: 1.5, + ), padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), child: const Text( 'REPLAY RIDE', - style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w800, fontSize: 12), + style: TextStyle( + fontFamily: 'Sora', + fontWeight: FontWeight.w800, + fontSize: 12, + ), ), ), ), @@ -282,33 +354,81 @@ class EvolutionRideDetailsView extends ConsumerWidget { }, style: OutlinedButton.styleFrom( foregroundColor: Colors.white70, - side: const BorderSide(color: Color(0xFF333333), width: 1.2), + side: const BorderSide( + color: Color(0xFF333333), + width: 1.2, + ), padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), child: const Text( 'EXPORT GPX', - style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12), + style: TextStyle( + fontFamily: 'Sora', + fontWeight: FontWeight.w700, + fontSize: 12, + ), ), ), ), const SizedBox(width: 8), Expanded( - child: OutlinedButton( - onPressed: () async { - final exporter = ref.read(exportServiceProvider); - await exporter.exportRide(ride.id, asGpx: false); + child: Builder( + builder: (context) { + final isProCsv = ref + .watch(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport); + return OutlinedButton( + onPressed: () async { + if (!isProCsv) { + showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ); + return; + } + final exporter = ref.read(exportServiceProvider); + await exporter.exportRide(ride.id, asGpx: false); + }, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide( + color: Color(0xFF333333), + width: 1.2, + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'EXPORT CSV', + style: TextStyle( + fontFamily: 'Sora', + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + if (!isProCsv) ...[ + const SizedBox(width: 6), + const ProBadge( + fontSize: 8, + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + ), + ], + ], + ), + ); }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Color(0xFF333333), width: 1.2), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: const Text( - 'EXPORT CSV', - style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12), - ), ), ), ], @@ -336,15 +456,21 @@ class EvolutionRideDetailsView extends ConsumerWidget { color: const Color(0xFF0F141C), borderRadius: BorderRadius.circular(12), border: Border.all( - color: isUploaded ? const Color(0xFF00E676).withValues(alpha: 0.5) : const Color(0xFF00E5FF).withValues(alpha: 0.4), + color: isUploaded + ? const Color(0xFF00E676).withValues(alpha: 0.5) + : const Color(0xFF00E5FF).withValues(alpha: 0.4), width: 1.2, ), ), child: Row( children: [ Icon( - isUploaded ? Icons.cloud_done : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined), - color: isUploaded ? const Color(0xFF00E676) : const Color(0xFF00E5FF), + isUploaded + ? Icons.cloud_done + : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined), + color: isUploaded + ? const Color(0xFF00E676) + : const Color(0xFF00E5FF), size: 24, ), const SizedBox(width: 12), @@ -353,7 +479,11 @@ class EvolutionRideDetailsView extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isUploaded ? 'GITHUB LOGS SYNCHRONIZED' : (isSyncing ? 'UPLOADING TO GITHUB...' : 'GITHUB LOG SYNC'), + isUploaded + ? 'GITHUB LOGS SYNCHRONIZED' + : (isSyncing + ? 'UPLOADING TO GITHUB...' + : 'GITHUB LOG SYNC'), style: TextStyle( fontFamily: 'Sora', fontSize: 11, @@ -379,20 +509,32 @@ class EvolutionRideDetailsView extends ConsumerWidget { onPressed: isSyncing ? null : () async { - final result = await syncService.uploadRide(ride.id, isManual: true); + final result = await syncService.uploadRide( + ride.id, + isManual: true, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(result.message), - backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent, + backgroundColor: result.success + ? const Color(0xFF00E676) + : Colors.redAccent, ), ); } }, style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: Text( isSyncing ? 'UPLOADING...' : 'UPLOAD NOW', @@ -411,15 +553,44 @@ class EvolutionRideDetailsView extends ConsumerWidget { Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) { final cards = [ - _buildHandlingKpiCard('MAX LEAN (LEFT)', '${ride.maxLeanLeft.toStringAsFixed(1)}°', Icons.turn_left, const Color(0xFF00E5FF)), - _buildHandlingKpiCard('MAX LEAN (RIGHT)', '${ride.maxLeanRight.toStringAsFixed(1)}°', Icons.turn_right, const Color(0xFF00E5FF)), - _buildHandlingKpiCard('MAX ACCELERATION', '${ride.maxAccelG.toStringAsFixed(2)} G', Icons.speed, const Color(0xFF76FF03)), - _buildHandlingKpiCard('MAX BRAKING', '${ride.maxBrakingG.toStringAsFixed(2)} G', Icons.pan_tool_alt, const Color(0xFFFF5252)), + _buildHandlingKpiCard( + 'MAX LEAN (LEFT)', + '${ride.maxLeanLeft.toStringAsFixed(1)}°', + Icons.turn_left, + const Color(0xFF00E5FF), + ), + _buildHandlingKpiCard( + 'MAX LEAN (RIGHT)', + '${ride.maxLeanRight.toStringAsFixed(1)}°', + Icons.turn_right, + const Color(0xFF00E5FF), + ), + _buildHandlingKpiCard( + 'MAX ACCELERATION', + '${ride.maxAccelG.toStringAsFixed(2)} G', + Icons.speed, + const Color(0xFF76FF03), + ), + _buildHandlingKpiCard( + 'MAX BRAKING', + '${ride.maxBrakingG.toStringAsFixed(2)} G', + Icons.pan_tool_alt, + const Color(0xFFFF5252), + ), ]; if (isLandscape) { return Row( - children: cards.map((c) => Expanded(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4), child: c))).toList(), + children: cards + .map( + (c) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: c, + ), + ), + ) + .toList(), ); } @@ -444,7 +615,12 @@ class EvolutionRideDetailsView extends ConsumerWidget { ); } - Widget _buildHandlingKpiCard(String label, String value, IconData icon, Color accentColor) { + Widget _buildHandlingKpiCard( + String label, + String value, + IconData icon, + Color accentColor, + ) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( @@ -499,22 +675,32 @@ class EvolutionRideDetailsView extends ConsumerWidget { } else if (event.eventType.toLowerCase().contains('brak')) { badgeColor = const Color(0xFFFF5252); eventIcon = Icons.arrow_downward; - } else if (event.eventType.toLowerCase().contains('corner') || event.eventType.toLowerCase().contains('lean')) { + } else if (event.eventType.toLowerCase().contains('corner') || + event.eventType.toLowerCase().contains('lean')) { badgeColor = const Color(0xFF00E5FF); eventIcon = Icons.cached; } String formattedValue = '${event.value.toStringAsFixed(1)}°'; - if (event.eventType.toLowerCase().contains('accel') || event.eventType.toLowerCase().contains('brak')) { + if (event.eventType.toLowerCase().contains('accel') || + event.eventType.toLowerCase().contains('brak')) { formattedValue = '${event.value.toStringAsFixed(2)} G'; } - final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal()); + final timeStr = DateFormat( + 'HH:mm:ss.SSS', + ).format(event.timestamp.toLocal()); return Material( color: Colors.transparent, child: InkWell( - onTap: () => _showEventDetailsDialog(context, event, formattedValue, timeStr, badgeColor), + onTap: () => _showEventDetailsDialog( + context, + event, + formattedValue, + timeStr, + badgeColor, + ), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), @@ -584,7 +770,10 @@ class EvolutionRideDetailsView extends ConsumerWidget { backgroundColor: const Color(0xFF111111), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: badgeColor.withValues(alpha: 0.5), width: 1.2), + side: BorderSide( + color: badgeColor.withValues(alpha: 0.5), + width: 1.2, + ), ), title: Row( children: [ @@ -614,7 +803,10 @@ class EvolutionRideDetailsView extends ConsumerWidget { 'GPS Coordinates:', '${event.latitude!.toStringAsFixed(6)}, ${event.longitude!.toStringAsFixed(6)}', ), - _buildDialogRow('Event ID / Seq:', '#${event.id} (Ride #${event.rideId})'), + _buildDialogRow( + 'Event ID / Seq:', + '#${event.id} (Ride #${event.rideId})', + ), const SizedBox(height: 12), Container( width: double.infinity, @@ -626,7 +818,11 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), child: const Text( 'Authoritative telemetry sampled at 20 Hz. Moving metrics gated at speed >= 3.0 MPH.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white54), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white54, + ), ), ), ], @@ -635,7 +831,14 @@ class EvolutionRideDetailsView extends ConsumerWidget { if (event.latitude != null && event.longitude != null) TextButton.icon( icon: const Icon(Icons.map, color: Color(0xFF00E5FF), size: 16), - label: const Text('VIEW ON MAP', style: TextStyle(color: Color(0xFF00E5FF), fontFamily: 'JetBrains Mono', fontWeight: FontWeight.bold)), + label: const Text( + 'VIEW ON MAP', + style: TextStyle( + color: Color(0xFF00E5FF), + fontFamily: 'JetBrains Mono', + fontWeight: FontWeight.bold, + ), + ), onPressed: () { Navigator.pop(ctx); Navigator.of(context).push( @@ -647,7 +850,13 @@ class EvolutionRideDetailsView extends ConsumerWidget { ), TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')), + child: const Text( + 'CLOSE', + style: TextStyle( + color: Colors.white70, + fontFamily: 'JetBrains Mono', + ), + ), ), ], ), @@ -660,12 +869,24 @@ class EvolutionRideDetailsView extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)), + Text( + label, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white54, + ), + ), Flexible( child: Text( value, textAlign: TextAlign.right, - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white), + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ], @@ -686,7 +907,12 @@ class EvolutionRideDetailsView extends ConsumerWidget { ); } - Widget _buildMetricRow(String label1, String value1, String label2, String value2) { + Widget _buildMetricRow( + String label1, + String value1, + String label2, + String value2, + ) { return Row( children: [ Expanded( @@ -695,11 +921,21 @@ class EvolutionRideDetailsView extends ConsumerWidget { children: [ Text( label1, - style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white54, + ), ), Text( value1, - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white), + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.white, + ), ), ], ), @@ -712,11 +948,21 @@ class EvolutionRideDetailsView extends ConsumerWidget { children: [ Text( label2, - style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white54, + ), ), Text( value2, - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white), + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + fontWeight: FontWeight.w800, + color: Colors.white, + ), ), ], ), diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 58d9623..686de6d 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -7,6 +7,7 @@ import '../../services/auth/auth_state.dart'; import '../../services/auth/auth_user.dart'; import '../../services/sync/cloud_restore_controller.dart'; import '../../services/sync/sync_providers.dart'; +import 'widgets/current_plan_card.dart'; class AccountScreen extends ConsumerStatefulWidget { const AccountScreen({super.key}); @@ -598,13 +599,27 @@ class _AccountScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.amberAccent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.5)), + border: Border.all( + color: Colors.amberAccent.withValues(alpha: 0.5), + ), ), child: Row( children: [ - const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))), - TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')), - IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)), + const Expanded( + child: Text( + 'Your email is not verified. Verify it to protect account recovery.', + style: TextStyle(fontSize: 11, color: Colors.white), + ), + ), + TextButton( + onPressed: () => _sendVerification(), + child: const Text('VERIFY'), + ), + IconButton( + tooltip: 'Refresh verification status', + onPressed: () => _reloadUser(), + icon: const Icon(Icons.refresh, size: 18), + ), ], ), ), @@ -637,7 +652,9 @@ class _AccountScreenState extends ConsumerState { SizedBox( width: double.infinity, child: TextButton.icon( - style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + ), icon: const Icon(Icons.delete_forever_outlined, size: 18), label: const Text('DELETE ACCOUNT'), onPressed: () => _handleDeleteAccount(user), @@ -647,6 +664,8 @@ class _AccountScreenState extends ConsumerState { ), ), const SizedBox(height: 16), + CurrentPlanCard(isEvolution: isEvolution), + const SizedBox(height: 16), _buildCloudSyncSection( context: context, isEvolution: isEvolution, @@ -762,6 +781,8 @@ class _AccountScreenState extends ConsumerState { ), ), const SizedBox(height: 16), + CurrentPlanCard(isEvolution: isEvolution), + const SizedBox(height: 16), _buildCloudSyncSection( context: context, isEvolution: isEvolution, @@ -924,10 +945,7 @@ class _AccountScreenState extends ConsumerState { ), const SizedBox(height: 12), ], - _buildInfoRow( - 'Cloud Provider', - 'MotorCloud backup', - ), + _buildInfoRow('Cloud Provider', 'MotorCloud backup'), if (syncState.lastSuccessfulSync != null) _buildInfoRow( 'Last Successful Sync', @@ -1119,9 +1137,15 @@ class _AccountScreenState extends ConsumerState { Future _sendVerification() async { try { await ref.read(authNotifierProvider.notifier).sendEmailVerification(); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Verification email sent.'))); + if (mounted) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Verification email sent.')), + ); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not send verification email: $e'))); + if (mounted) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not send verification email: $e')), + ); } } @@ -1129,7 +1153,10 @@ class _AccountScreenState extends ConsumerState { try { await ref.read(authNotifierProvider.notifier).reloadCurrentUser(); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not refresh account status: $e'))); + if (mounted) + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not refresh account status: $e')), + ); } } @@ -1148,16 +1175,26 @@ class _AccountScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Deleting your account will remove your MotorCloud backup data and account identity.'), + const Text( + 'Deleting your account will remove your MotorCloud backup data and account identity.', + ), const SizedBox(height: 12), - const Text('Rides, telemetry/history, and garage data stored on this device will remain available.'), + const Text( + 'Rides, telemetry/history, and garage data stored on this device will remain available.', + ), + const SizedBox(height: 12), + const Text( + 'Deleting your MotorSignal account does not cancel a Google Play subscription. Subscriptions must be managed in Google Play.', + ), if (user.providerId == 'password') ...[ const SizedBox(height: 16), TextFormField( controller: controller, obscureText: true, autofocus: true, - decoration: const InputDecoration(labelText: 'Current password'), + decoration: const InputDecoration( + labelText: 'Current password', + ), validator: (value) => value == null || value.isEmpty ? 'Enter your current password' : null, @@ -1202,9 +1239,13 @@ class _AccountScreenState extends ConsumerState { } try { - await ref.read(accountLifecycleServiceProvider).deleteAccount(password: result.password); + await ref + .read(accountLifecycleServiceProvider) + .deleteAccount(password: result.password); } catch (e) { - debugPrint('[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted'); + debugPrint( + '[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted', + ); if (mounted) { ScaffoldMessenger.maybeOf(context)?.showSnackBar( const SnackBar( @@ -1223,12 +1264,17 @@ class _AccountScreenState extends ConsumerState { final user = ref.read(currentUserProvider); if (user == null || user.isAnonymous) return; final rides = await db.select(db.rides).get(); - final owned = rides.where((ride) => ride.cloudOwnerUid == user.uid).toList(); - final unclaimed = rides.where((ride) => ride.cloudOwnerUid == null).toList(); + final owned = rides + .where((ride) => ride.cloudOwnerUid == user.uid) + .toList(); + final unclaimed = rides + .where((ride) => ride.cloudOwnerUid == null) + .toList(); final otherOwned = rides.length - owned.length - unclaimed.length; var claimUnclaimed = false; if (unclaimed.isNotEmpty && mounted) { - claimUnclaimed = await showDialog( + claimUnclaimed = + await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Back Up Device Rides?'), @@ -1236,8 +1282,14 @@ class _AccountScreenState extends ConsumerState { 'This will associate ${unclaimed.length} device-local ride(s) with your signed-in MotorCloud account. Rides already associated with another account will not be included.', ), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')), - FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('BACK UP')), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('CANCEL'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('BACK UP'), + ), ], ), ) ?? @@ -1477,7 +1529,8 @@ class _AccountScreenState extends ConsumerState { } if (mounted && !ref.read(authNotifierProvider).hasError) { setState(() { - _statusMessage = 'Password reset instructions sent. Check your email.'; + _statusMessage = + 'Password reset instructions sent. Check your email.'; }); } } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 4d0f514..62084f5 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -16,6 +16,7 @@ import '../../core/theme/evolution_theme_config.dart'; import '../../services/osm_oauth_service.dart'; import '../../services/auth/auth_providers.dart'; import 'widgets/evolution_settings_view.dart'; +import 'widgets/current_plan_card.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -73,6 +74,10 @@ class _SettingsScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.all(16.0), children: [ + _buildSectionHeader('Membership & Entitlement'), + const SizedBox(height: 12), + const CurrentPlanCard(), + const SizedBox(height: 24), _buildSectionHeader('Account & Cloud Identity'), const SizedBox(height: 12), Card( @@ -90,7 +95,9 @@ class _SettingsScreenState extends ConsumerState { child: Row( children: [ Icon( - isAuthed ? Icons.account_circle : Icons.account_circle_outlined, + isAuthed + ? Icons.account_circle + : Icons.account_circle_outlined, color: Colors.cyanAccent, size: 24, ), @@ -100,7 +107,9 @@ class _SettingsScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isAuthed ? (user?.displayLabel ?? 'Rider Account') : 'Sign In / Cloud Account', + isAuthed + ? (user?.displayLabel ?? 'Rider Account') + : 'Sign In / Cloud Account', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -109,7 +118,8 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 2), Text( isAuthed - ? (user?.email ?? 'Cloud Identity Active • Offline rides protected') + ? (user?.email ?? + 'Cloud Identity Active • Offline rides protected') : 'Optional cloud identity • Local riding always active', style: const TextStyle( fontSize: 12, @@ -120,23 +130,38 @@ class _SettingsScreenState extends ConsumerState { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( - color: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10, + color: isAuthed + ? Colors.cyanAccent.withValues(alpha: 0.15) + : Colors.white10, borderRadius: BorderRadius.circular(6), - border: Border.all(color: isAuthed ? Colors.cyanAccent : Colors.white24), + border: Border.all( + color: isAuthed + ? Colors.cyanAccent + : Colors.white24, + ), ), child: Text( isAuthed ? 'SIGNED IN' : 'LOCAL', style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, - color: isAuthed ? Colors.cyanAccent : Colors.white54, + color: isAuthed + ? Colors.cyanAccent + : Colors.white54, ), ), ), const SizedBox(width: 6), - const Icon(Icons.chevron_right, color: Colors.white54, size: 20), + const Icon( + Icons.chevron_right, + color: Colors.white54, + size: 20, + ), ], ), ), @@ -156,7 +181,11 @@ class _SettingsScreenState extends ConsumerState { children: [ const Row( children: [ - Icon(Icons.palette_outlined, color: Colors.cyanAccent, size: 20), + Icon( + Icons.palette_outlined, + color: Colors.cyanAccent, + size: 20, + ), SizedBox(width: 10), Expanded( child: Column( @@ -190,12 +219,21 @@ class _SettingsScreenState extends ConsumerState { segments: const [ ButtonSegment( value: DashboardThemeMode.classic, - label: Text('Classic Cockpit', style: TextStyle(fontSize: 12)), - icon: Icon(Icons.dashboard_customize_outlined, size: 18), + label: Text( + 'Classic Cockpit', + style: TextStyle(fontSize: 12), + ), + icon: Icon( + Icons.dashboard_customize_outlined, + size: 18, + ), ), ButtonSegment( value: DashboardThemeMode.evolution, - label: Text('MotorSignal Evolution', style: TextStyle(fontSize: 12)), + label: Text( + 'MotorSignal Evolution', + style: TextStyle(fontSize: 12), + ), icon: Icon(Icons.speed, size: 18), ), ], @@ -257,12 +295,18 @@ class _SettingsScreenState extends ConsumerState { segments: const [ ButtonSegment( value: AppMapType.openStreetMap, - label: Text('OpenStreetMap', style: TextStyle(fontSize: 12)), + label: Text( + 'OpenStreetMap', + style: TextStyle(fontSize: 12), + ), icon: Icon(Icons.public, size: 18), ), ButtonSegment( value: AppMapType.googleMaps, - label: Text('Google Maps', style: TextStyle(fontSize: 12)), + label: Text( + 'Google Maps', + style: TextStyle(fontSize: 12), + ), icon: Icon(Icons.map_outlined, size: 18), ), ], @@ -293,7 +337,10 @@ class _SettingsScreenState extends ConsumerState { child: Text( 'OpenStreetMap Account', overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), ), const SizedBox(width: 8), @@ -303,10 +350,14 @@ class _SettingsScreenState extends ConsumerState { style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, - color: isAuthed ? Colors.cyanAccent : Colors.grey, + color: isAuthed + ? Colors.cyanAccent + : Colors.grey, ), ), - backgroundColor: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10, + backgroundColor: isAuthed + ? Colors.cyanAccent.withValues(alpha: 0.15) + : Colors.white10, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, ), @@ -317,22 +368,44 @@ class _SettingsScreenState extends ConsumerState { ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( - backgroundImage: user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null, - child: user.avatarUrl == null ? const Icon(Icons.person) : null, + backgroundImage: user.avatarUrl != null + ? NetworkImage(user.avatarUrl!) + : null, + child: user.avatarUrl == null + ? const Icon(Icons.person) + : null, + ), + title: Text( + user.displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + 'Changesets: ${user.changesetCount} • GPS Trace Sync Enabled', + style: const TextStyle(fontSize: 11), ), - title: Text(user.displayName, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text('Changesets: ${user.changesetCount} • GPS Trace Sync Enabled', style: const TextStyle(fontSize: 11)), trailing: TextButton.icon( - style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + ), icon: const Icon(Icons.link_off, size: 16), - label: const Text('Disconnect', style: TextStyle(fontSize: 12)), - onPressed: () => ref.read(osmOAuthServiceProvider.notifier).logout(), + label: const Text( + 'Disconnect', + style: TextStyle(fontSize: 12), + ), + onPressed: () => ref + .read(osmOAuthServiceProvider.notifier) + .logout(), ), ), ] else ...[ const Text( 'Sign in to OpenStreetMap via OAuth 2.0 PKCE to upload ride GPX tracks and contribute traces.', - style: TextStyle(fontSize: 11, color: Colors.white60), + style: TextStyle( + fontSize: 11, + color: Colors.white60, + ), ), const SizedBox(height: 10), ElevatedButton.icon( @@ -342,10 +415,18 @@ class _SettingsScreenState extends ConsumerState { minimumSize: const Size(double.infinity, 38), ), icon: const Icon(Icons.login, size: 18), - label: const Text('Connect OpenStreetMap', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + label: const Text( + 'Connect OpenStreetMap', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), onPressed: osmAuth.isLoading ? null - : () => ref.read(osmOAuthServiceProvider.notifier).startLoginFlow(), + : () => ref + .read(osmOAuthServiceProvider.notifier) + .startLoginFlow(), ), ], ], @@ -559,7 +640,8 @@ class _SettingsScreenState extends ConsumerState { controller: _communityKeyController, decoration: const InputDecoration( labelText: 'Community / RapidAPI Key', - hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key', + hintText: + 'Enter OpenWebNinja, RapidAPI, or Community API Key', helperText: 'Supplies Police, Speed Cameras, and Crowd Hazards.', helperMaxLines: 2, @@ -576,7 +658,8 @@ class _SettingsScreenState extends ConsumerState { controller: _communityEndpointController, decoration: const InputDecoration( labelText: 'Custom Endpoint URL (Optional)', - hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL', + hintText: + 'e.g. https://google-map-places.p.rapidapi.com or custom URL', helperText: 'Leave blank for automatic OpenWebNinja / RapidAPI detection.', helperMaxLines: 2, @@ -585,7 +668,9 @@ class _SettingsScreenState extends ConsumerState { ), style: const TextStyle(fontSize: 13), onChanged: (val) { - ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val); + ref + .read(communityEndpointUrlProvider.notifier) + .setEndpoint(val); }, ), const SizedBox(height: 12), @@ -609,15 +694,23 @@ class _SettingsScreenState extends ConsumerState { if (mounted) { String msg = ''; - if (status == RoadAlertProviderStatus.connected) { - msg = 'Community / OpenWebNinja API Connected successfully!'; - } else if (status == RoadAlertProviderStatus.authError) { - msg = 'Authentication Error: Invalid or unauthorized key.'; - } else if (status == RoadAlertProviderStatus.notConfigured) { + if (status == + RoadAlertProviderStatus.connected) { + msg = + 'Community / OpenWebNinja API Connected successfully!'; + } else if (status == + RoadAlertProviderStatus.authError) { + msg = + 'Authentication Error: Invalid or unauthorized key.'; + } else if (status == + RoadAlertProviderStatus.notConfigured) { msg = 'API Key is empty.'; - } else if (status == RoadAlertProviderStatus.networkError) { - msg = 'Network Error: Check internet connection or endpoint.'; - } else if (status == RoadAlertProviderStatus.rateLimited) { + } else if (status == + RoadAlertProviderStatus.networkError) { + msg = + 'Network Error: Check internet connection or endpoint.'; + } else if (status == + RoadAlertProviderStatus.rateLimited) { msg = 'Rate Limited: Plan limit reached.'; } else { msg = 'Status: ${status.name}'; @@ -695,15 +788,21 @@ class _SettingsScreenState extends ConsumerState { if (mounted) { String msg = ''; - if (status == RoadAlertProviderStatus.connected) { + if (status == + RoadAlertProviderStatus.connected) { msg = 'TomTom API Connected successfully!'; - } else if (status == RoadAlertProviderStatus.authError) { + } else if (status == + RoadAlertProviderStatus.authError) { msg = 'Authentication Error: Invalid Key.'; - } else if (status == RoadAlertProviderStatus.notConfigured) { + } else if (status == + RoadAlertProviderStatus.notConfigured) { msg = 'Key is empty.'; - } else if (status == RoadAlertProviderStatus.networkError) { - msg = 'Network Error: Failed to connect to API.'; - } else if (status == RoadAlertProviderStatus.rateLimited) { + } else if (status == + RoadAlertProviderStatus.networkError) { + msg = + 'Network Error: Failed to connect to API.'; + } else if (status == + RoadAlertProviderStatus.rateLimited) { msg = 'Rate Limited: Too many requests.'; } else { msg = 'Status: ${status.name}'; @@ -799,9 +898,15 @@ class _SettingsScreenState extends ConsumerState { lastUpdated: DateTime.now(), source: 'Test', ); - ref.read(roadAlertServiceProvider).simulateAlert(alert); + ref + .read(roadAlertServiceProvider) + .simulateAlert(alert); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Simulated Police Alert sent to Dashboard.')), + const SnackBar( + content: Text( + 'Simulated Police Alert sent to Dashboard.', + ), + ), ); }, ), @@ -825,9 +930,15 @@ class _SettingsScreenState extends ConsumerState { lastUpdated: DateTime.now(), source: 'Test', ); - ref.read(roadAlertServiceProvider).simulateAlert(alert); + ref + .read(roadAlertServiceProvider) + .simulateAlert(alert); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Simulated Hazard Alert sent to Dashboard.')), + const SnackBar( + content: Text( + 'Simulated Hazard Alert sent to Dashboard.', + ), + ), ); }, ), @@ -891,7 +1002,11 @@ class _SettingsScreenState extends ConsumerState { final session = ref.read(rideStateMachineProvider); if (session.state == RideState.riding) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot calibrate while a ride is actively recording.')), + const SnackBar( + content: Text( + 'Cannot calibrate while a ride is actively recording.', + ), + ), ); return; } @@ -1011,7 +1126,11 @@ class _SettingsScreenState extends ConsumerState { final session = ref.read(rideStateMachineProvider); if (session.state == RideState.riding) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot clear history while a ride is actively recording.')), + const SnackBar( + content: Text( + 'Cannot clear history while a ride is actively recording.', + ), + ), ); return; } @@ -1020,7 +1139,9 @@ class _SettingsScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Clear All Ride History?'), - content: const Text('This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.'), + content: const Text( + 'This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -1028,7 +1149,13 @@ class _SettingsScreenState extends ConsumerState { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('CLEAR ALL', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)), + child: const Text( + 'CLEAR ALL', + style: TextStyle( + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + ), ), ], ), @@ -1044,7 +1171,9 @@ class _SettingsScreenState extends ConsumerState { }); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ride history cleared.')), + const SnackBar( + content: Text('All ride history cleared.'), + ), ); } } @@ -1092,7 +1221,9 @@ class _SettingsScreenState extends ConsumerState { children: [ Icon( Icons.biotech, - color: isEnabled ? Colors.greenAccent : Colors.white54, + color: isEnabled + ? Colors.greenAccent + : Colors.white54, size: 24, ), const SizedBox(width: 10), @@ -1102,11 +1233,17 @@ class _SettingsScreenState extends ConsumerState { children: [ const Text( 'Field Test Mode', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), ), Text( 'Silent black-box diagnostic recorder', - style: const TextStyle(fontSize: 11, color: Colors.white60), + style: const TextStyle( + fontSize: 11, + color: Colors.white60, + ), overflow: TextOverflow.ellipsis, ), ], @@ -1120,7 +1257,9 @@ class _SettingsScreenState extends ConsumerState { value: isEnabled, activeThumbColor: Colors.greenAccent, onChanged: (val) { - ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(val); + ref + .read(fieldTestModeEnabledProvider.notifier) + .setEnabled(val); }, ), ], @@ -1129,30 +1268,44 @@ class _SettingsScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Logger Status:', style: TextStyle(color: Colors.white70, fontSize: 13)), + Text( + 'Logger Status:', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: activeSession != null ? Colors.greenAccent.withValues(alpha: 0.15) - : (isEnabled ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10), + : (isEnabled + ? Colors.cyanAccent.withValues(alpha: 0.15) + : Colors.white10), borderRadius: BorderRadius.circular(6), border: Border.all( color: activeSession != null ? Colors.greenAccent - : (isEnabled ? Colors.cyanAccent : Colors.white30), + : (isEnabled + ? Colors.cyanAccent + : Colors.white30), ), ), child: Text( activeSession != null ? 'RECORDING (Ride #$activeSession)' - : (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'), + : (isEnabled + ? 'ARMED (Auto-starts on Ride)' + : 'OFF'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: activeSession != null ? Colors.greenAccent - : (isEnabled ? Colors.cyanAccent : Colors.white54), + : (isEnabled + ? Colors.cyanAccent + : Colors.white54), ), ), ), @@ -1164,34 +1317,53 @@ class _SettingsScreenState extends ConsumerState { Expanded( child: ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: Colors.greenAccent.withValues(alpha: 0.2), + backgroundColor: Colors.greenAccent.withValues( + alpha: 0.2, + ), foregroundColor: Colors.greenAccent, side: const BorderSide(color: Colors.greenAccent), ), icon: const Icon(Icons.fact_check, size: 18), - label: const Text('Pre-Ride Check', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), - onPressed: () => context.go('/settings/field-test-pre-ride-check'), + label: const Text( + 'Pre-Ride Check', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () => + context.go('/settings/field-test-pre-ride-check'), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: Colors.cyanAccent.withValues(alpha: 0.15), + backgroundColor: Colors.cyanAccent.withValues( + alpha: 0.15, + ), foregroundColor: Colors.cyanAccent, side: const BorderSide(color: Colors.cyanAccent), ), icon: const Icon(Icons.assessment, size: 18), - label: const Text('View Reports', style: TextStyle(fontSize: 12)), - onPressed: () => context.go('/settings/field-test-report'), + label: const Text( + 'View Reports', + style: TextStyle(fontSize: 12), + ), + onPressed: () => + context.go('/settings/field-test-report'), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.receipt_long, size: 18), - label: const Text('Live Log', style: TextStyle(fontSize: 12)), - onPressed: () => context.go('/settings/field-test-live-log'), + label: const Text( + 'Live Log', + style: TextStyle(fontSize: 12), + ), + onPressed: () => + context.go('/settings/field-test-live-log'), ), ), ], @@ -1200,14 +1372,23 @@ class _SettingsScreenState extends ConsumerState { Align( alignment: Alignment.centerRight, child: TextButton.icon( - style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + style: TextButton.styleFrom( + foregroundColor: Colors.redAccent, + ), icon: const Icon(Icons.delete_sweep, size: 16), - label: const Text('Clear Field Test Logs', style: TextStyle(fontSize: 11)), + label: const Text( + 'Clear Field Test Logs', + style: TextStyle(fontSize: 11), + ), onPressed: () async { final session = ref.read(rideStateMachineProvider); if (session.state == RideState.riding) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot clear logs while a ride is recording.')), + const SnackBar( + content: Text( + 'Cannot clear logs while a ride is recording.', + ), + ), ); return; } @@ -1216,10 +1397,21 @@ class _SettingsScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('Clear All Field Test Logs?'), - content: const Text('This will delete all stored black-box diagnostic sessions. Standard ride history will NOT be affected.'), + content: const Text( + 'This will delete all stored black-box diagnostic sessions. Standard ride history will NOT be affected.', + ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent))), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('CANCEL'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text( + 'CLEAR', + style: TextStyle(color: Colors.redAccent), + ), + ), ], ), ); @@ -1228,7 +1420,9 @@ class _SettingsScreenState extends ConsumerState { await logger.clearAllSessions(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Field test logs cleared.')), + const SnackBar( + content: Text('Field test logs cleared.'), + ), ); } } @@ -1380,7 +1574,10 @@ class _AppVersionFooterState extends State<_AppVersionFooter> { children: [ Icon(Icons.fingerprint, color: Colors.cyanAccent), SizedBox(width: 8), - Text('Software Identity', style: TextStyle(color: Colors.white, fontSize: 16)), + Text( + 'Software Identity', + style: TextStyle(color: Colors.white, fontSize: 16), + ), ], ), content: Column( @@ -1393,14 +1590,22 @@ class _AppVersionFooterState extends State<_AppVersionFooter> { _diagRow('Release Channel', identity.releaseChannel), _diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'), _diagRow('Export Schema', '${identity.exportSchemaVersion}'), - _diagRow('Git Commit', identity.gitCommit.length > 12 ? identity.gitCommit.substring(0, 12) : identity.gitCommit), + _diagRow( + 'Git Commit', + identity.gitCommit.length > 12 + ? identity.gitCommit.substring(0, 12) + : identity.gitCommit, + ), _diagRow('Build Timestamp', identity.buildTimestamp), ], ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), - child: const Text('CLOSE', style: TextStyle(color: Colors.cyanAccent)), + child: const Text( + 'CLOSE', + style: TextStyle(color: Colors.cyanAccent), + ), ), ], ), @@ -1413,8 +1618,19 @@ class _AppVersionFooterState extends State<_AppVersionFooter> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)), - Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + value, + style: const TextStyle( + color: Colors.cyanAccent, + fontWeight: FontWeight.bold, + fontSize: 12, + fontFamily: 'monospace', + ), + ), ], ), ); diff --git a/lib/features/settings/widgets/current_plan_card.dart b/lib/features/settings/widgets/current_plan_card.dart new file mode 100644 index 0000000..18ad24d --- /dev/null +++ b/lib/features/settings/widgets/current_plan_card.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../services/billing/product_catalog.dart'; +import '../../../services/entitlements/entitlement_providers.dart'; +import '../../../services/entitlements/entitlement_state.dart'; +import '../../entitlements/widgets/pro_lock_badge.dart'; + +/// Reusable card displaying the current plan and subscription management options. +/// Used in both Classic Settings and Evolution Settings. +class CurrentPlanCard extends ConsumerWidget { + final bool isEvolution; + + const CurrentPlanCard({super.key, this.isEvolution = false}); + + Future _openGooglePlaySubscriptions() async { + // Official Google Play subscription management deep link + final uri = Uri.parse( + 'https://play.google.com/store/account/subscriptions?package=app.motorsignal.motorcycle&sku=${ProductCatalog.proMonthly}', + ); + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final entitlement = ref.watch(entitlementProvider); + final isPro = entitlement.isPro; + final plan = entitlement.plan; + final status = entitlement.status; + + String planTitle = 'MotorSignal Free'; + String statusSubtitle = 'Basic telemetry & local recording active'; + Color badgeColor = Colors.white54; + String badgeText = 'FREE'; + + if (plan == MotorSignalPlan.proLifetime && isPro) { + planTitle = 'MotorSignal Pro'; + statusSubtitle = 'Lifetime access • Permanent ownership'; + badgeColor = const Color(0xFF00E5FF); + badgeText = 'LIFETIME'; + } else if (plan == MotorSignalPlan.proMonthly) { + planTitle = 'MotorSignal Pro'; + badgeColor = const Color(0xFF00E5FF); + badgeText = 'MONTHLY'; + + switch (status) { + case MotorSignalEntitlementStatus.proMonthlyActive: + statusSubtitle = entitlement.expiresAt != null + ? 'Active • Renews ${entitlement.expiresAt!.toLocal().toString().split(' ')[0]}' + : 'Active subscription'; + break; + case MotorSignalEntitlementStatus.proMonthlyGrace: + statusSubtitle = + 'Payment grace period • Action needed in Google Play'; + badgeColor = Colors.amberAccent; + badgeText = 'GRACE PERIOD'; + break; + case MotorSignalEntitlementStatus.proMonthlyAccountHold: + statusSubtitle = + 'Account on hold • Please update billing in Google Play'; + badgeColor = Colors.redAccent; + badgeText = 'ON HOLD'; + break; + case MotorSignalEntitlementStatus.proMonthlyPaused: + statusSubtitle = 'Subscription paused by rider in Google Play'; + badgeColor = Colors.amberAccent; + badgeText = 'PAUSED'; + break; + case MotorSignalEntitlementStatus.proMonthlyExpired: + statusSubtitle = 'Subscription expired'; + badgeColor = Colors.white38; + badgeText = 'EXPIRED'; + break; + case MotorSignalEntitlementStatus.syncPending: + statusSubtitle = 'Verification pending with Google Play...'; + badgeColor = Colors.amberAccent; + badgeText = 'VERIFYING'; + break; + default: + statusSubtitle = 'Status: ${status.name}'; + break; + } + } else if (status == MotorSignalEntitlementStatus.syncPending) { + statusSubtitle = 'Checking purchases with Google Play...'; + badgeColor = Colors.amberAccent; + badgeText = 'PENDING'; + } + + final isMonthlySub = plan == MotorSignalPlan.proMonthly; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isEvolution ? const Color(0xFF0D0D0D) : null, + borderRadius: BorderRadius.circular(isEvolution ? 16 : 12), + border: Border.all( + color: isPro + ? const Color(0xFF00E5FF).withValues(alpha: 0.6) + : (isEvolution ? const Color(0xFF1F1F1F) : Colors.white12), + width: 1.2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + isPro ? Icons.verified : Icons.shield_outlined, + color: isPro ? const Color(0xFF00E5FF) : Colors.white70, + size: 20, + ), + const SizedBox(width: 10), + Text( + planTitle.toUpperCase(), + style: TextStyle( + fontFamily: isEvolution ? 'Sora' : null, + fontSize: 13, + fontWeight: FontWeight.w800, + color: isPro ? const Color(0xFF00E5FF) : Colors.white, + letterSpacing: 0.5, + ), + ), + if (isPro) ...[const SizedBox(width: 8), const ProBadge()], + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: badgeColor.withValues(alpha: 0.5), + width: 0.8, + ), + ), + child: Text( + badgeText, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w900, + color: badgeColor, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + statusSubtitle, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + height: 1.3, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + if (!isPro) ...[ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => context.push('/settings/pro'), + child: const Text( + 'UPGRADE TO PRO', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 10), + ], + if (isMonthlySub) ...[ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Colors.white24), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: _openGooglePlaySubscriptions, + child: const Text( + 'MANAGE SUBSCRIPTION', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 10), + ], + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Colors.white24), + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => context.push('/settings/pro'), + child: Text( + isPro ? 'VIEW PLANS' : 'DETAILS', + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/widgets/evolution_settings_view.dart b/lib/features/settings/widgets/evolution_settings_view.dart index 7fde8f8..558724e 100644 --- a/lib/features/settings/widgets/evolution_settings_view.dart +++ b/lib/features/settings/widgets/evolution_settings_view.dart @@ -15,12 +15,14 @@ import '../../rides/ride_state_machine.dart'; import '../../../services/auth/auth_providers.dart'; import '../../../core/theme/evolution_theme_config.dart'; import '../../../core/app_info.dart'; +import 'current_plan_card.dart'; class EvolutionSettingsView extends ConsumerStatefulWidget { const EvolutionSettingsView({super.key}); @override - ConsumerState createState() => _EvolutionSettingsViewState(); + ConsumerState createState() => + _EvolutionSettingsViewState(); } class _EvolutionSettingsViewState extends ConsumerState { @@ -71,7 +73,8 @@ class _EvolutionSettingsViewState extends ConsumerState { final radiusMiles = (alertRadiusMeters / 1609.34).toStringAsFixed(1); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( backgroundColor: Colors.black, @@ -97,7 +100,10 @@ class _EvolutionSettingsViewState extends ConsumerState { final themeToggleBar = Container( margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: isLandscape ? 6 : 12), + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isLandscape ? 6 : 12, + ), decoration: BoxDecoration( color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(14), @@ -109,7 +115,11 @@ class _EvolutionSettingsViewState extends ConsumerState { const Expanded( child: Row( children: [ - Icon(Icons.palette_outlined, color: Color(0xFF00E5FF), size: 18), + Icon( + Icons.palette_outlined, + color: Color(0xFF00E5FF), + size: 18, + ), SizedBox(width: 8), Flexible( child: Text( @@ -145,13 +155,16 @@ class _EvolutionSettingsViewState extends ConsumerState { ], selected: {themeMode}, onSelectionChanged: (Set selection) { - ref.read(dashboardThemeModeProvider.notifier).setThemeMode(selection.first); + ref + .read(dashboardThemeModeProvider.notifier) + .setThemeMode(selection.first); }, ), ], ), ); + final currentPlanCard = const CurrentPlanCard(isEvolution: true); final accountCard = _buildAccountCard(context, ref); final osmAccountCard = _buildOsmAccountCard(context, ref, osmAuth); final mapEngineCard = _buildMapEngineCard(context, ref, mapType); @@ -169,7 +182,10 @@ class _EvolutionSettingsViewState extends ConsumerState { final communityCard = _buildCommunityCard(context, ref); final githubCard = _buildGitHubCard(context, ref); final dataCard = _buildDataManagementCard(context, ref); - final engineeringCard = _buildEngineeringDiagnosticsCard(context, ref); + final engineeringCard = _buildEngineeringDiagnosticsCard( + context, + ref, + ); final versionFooter = _buildVersionFooter(context); if (isLandscape) { @@ -184,6 +200,8 @@ class _EvolutionSettingsViewState extends ConsumerState { Expanded( child: Column( children: [ + currentPlanCard, + const SizedBox(height: 16), accountCard, const SizedBox(height: 16), sensorsCard, @@ -227,6 +245,8 @@ class _EvolutionSettingsViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ themeToggleBar, + currentPlanCard, + const SizedBox(height: 16), accountCard, const SizedBox(height: 16), sensorsCard, @@ -266,7 +286,9 @@ class _EvolutionSettingsViewState extends ConsumerState { color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(16), border: Border.all( - color: isAuthed ? const Color(0xFF00E5FF).withValues(alpha: 0.6) : const Color(0xFF1F1F1F), + color: isAuthed + ? const Color(0xFF00E5FF).withValues(alpha: 0.6) + : const Color(0xFF1F1F1F), width: 1.2, ), ), @@ -279,7 +301,11 @@ class _EvolutionSettingsViewState extends ConsumerState { const Expanded( child: Row( children: [ - Icon(Icons.account_circle, color: Color(0xFF00E5FF), size: 18), + Icon( + Icons.account_circle, + color: Color(0xFF00E5FF), + size: 18, + ), SizedBox(width: 8), Flexible( child: Text( @@ -325,9 +351,15 @@ class _EvolutionSettingsViewState extends ConsumerState { CircleAvatar( radius: 18, backgroundColor: Colors.white12, - backgroundImage: user.photoUrl != null ? NetworkImage(user.photoUrl!) : null, + backgroundImage: user.photoUrl != null + ? NetworkImage(user.photoUrl!) + : null, child: user.photoUrl == null - ? const Icon(Icons.person, color: Color(0xFF00E5FF), size: 20) + ? const Icon( + Icons.person, + color: Color(0xFF00E5FF), + size: 20, + ) : null, ), const SizedBox(width: 12), @@ -363,16 +395,26 @@ class _EvolutionSettingsViewState extends ConsumerState { foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF), width: 0.8), minimumSize: const Size(double.infinity, 38), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), icon: const Icon(Icons.manage_accounts, size: 16), - label: const Text('Manage Account & Cloud Sync', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + label: const Text( + 'Manage Account & Cloud Sync', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), onPressed: () => context.go('/settings/account'), ), ] else ...[ const Text( 'Sign in for optional multi-device account sync and cloud backup. All telemetry and ride logging work completely offline.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), ElevatedButton.icon( @@ -380,12 +422,18 @@ class _EvolutionSettingsViewState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, minimumSize: const Size(double.infinity, 38), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), icon: const Icon(Icons.login, size: 16), label: const Text( 'Sign In / Create Account', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w800), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w800, + ), ), onPressed: () => context.go('/settings/account'), ), @@ -395,7 +443,11 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } - Widget _buildOsmAccountCard(BuildContext context, WidgetRef ref, OsmAuthState osmAuth) { + Widget _buildOsmAccountCard( + BuildContext context, + WidgetRef ref, + OsmAuthState osmAuth, + ) { final isAuthed = osmAuth.isAuthenticated; final user = osmAuth.user; @@ -461,9 +513,15 @@ class _EvolutionSettingsViewState extends ConsumerState { CircleAvatar( radius: 20, backgroundColor: Colors.white12, - backgroundImage: user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null, + backgroundImage: user.avatarUrl != null + ? NetworkImage(user.avatarUrl!) + : null, child: user.avatarUrl == null - ? const Icon(Icons.person, color: Color(0xFF00E5FF), size: 22) + ? const Icon( + Icons.person, + color: Color(0xFF00E5FF), + size: 22, + ) : null, ), const SizedBox(width: 12), @@ -499,16 +557,27 @@ class _EvolutionSettingsViewState extends ConsumerState { foregroundColor: Colors.redAccent, side: const BorderSide(color: Colors.redAccent, width: 0.8), minimumSize: const Size(double.infinity, 38), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), icon: const Icon(Icons.link_off, size: 16), - label: const Text('Disconnect OpenStreetMap', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), - onPressed: () => ref.read(osmOAuthServiceProvider.notifier).logout(), + label: const Text( + 'Disconnect OpenStreetMap', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + onPressed: () => + ref.read(osmOAuthServiceProvider.notifier).logout(), ), ] else ...[ const Text( 'Link your OpenStreetMap account via OAuth 2.0 PKCE to upload GPS traces and sync ride telemetry.', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60, height: 1.4), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white60, + height: 1.4, + ), ), const SizedBox(height: 12), ElevatedButton.icon( @@ -516,16 +585,24 @@ class _EvolutionSettingsViewState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, minimumSize: const Size(double.infinity, 40), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), icon: const Icon(Icons.login, size: 18), label: const Text( 'Connect OpenStreetMap Account', - style: TextStyle(fontFamily: 'Inter', fontSize: 12, fontWeight: FontWeight.w800), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w800, + ), ), onPressed: osmAuth.isLoading ? null - : () => ref.read(osmOAuthServiceProvider.notifier).startLoginFlow(), + : () => ref + .read(osmOAuthServiceProvider.notifier) + .startLoginFlow(), ), ], if (osmAuth.errorMessage != null) ...[ @@ -540,7 +617,11 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } - Widget _buildMapEngineCard(BuildContext context, WidgetRef ref, AppMapType mapType) { + Widget _buildMapEngineCard( + BuildContext context, + WidgetRef ref, + AppMapType mapType, + ) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -564,30 +645,49 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 12), const Text( 'Active Map Engine', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white70), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), ), const SizedBox(height: 8), _buildEngineOption( title: 'OpenStreetMap', selected: mapType == AppMapType.openStreetMap, - onTap: () => ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(AppMapType.openStreetMap), + onTap: () => ref + .read(mapProviderTypeNotifierProvider.notifier) + .setMapProviderType(AppMapType.openStreetMap), ), const SizedBox(height: 6), _buildEngineOption( title: 'Google Maps', selected: mapType == AppMapType.googleMaps, - onTap: () => ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(AppMapType.googleMaps), + onTap: () => ref + .read(mapProviderTypeNotifierProvider.notifier) + .setMapProviderType(AppMapType.googleMaps), ), const SizedBox(height: 16), const Text( 'Rendering Preferences', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white70), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), ), const SizedBox(height: 8), _buildSwitchRow(Icons.apartment, '3D Buildings', true, (_) {}), _buildSwitchRow(Icons.terrain, 'Terrain Topo Data', true, (_) {}), _buildSwitchRow(Icons.traffic, 'Live Traffic Overlay', true, (_) {}), - _buildSwitchRow(Icons.play_circle_outline, 'Replay Preview', false, (_) {}), + _buildSwitchRow( + Icons.play_circle_outline, + 'Replay Preview', + false, + (_) {}, + ), ], ), ); @@ -625,37 +725,55 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 12), const Text( 'Alert Categories', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white70), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), ), const SizedBox(height: 8), _buildSwitchRow( Icons.local_police_outlined, 'Police Presence', enabledCategories.contains(RoadAlertCategory.police), - (v) => ref.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.police, v), + (v) => ref + .read(enabledCategoriesProvider.notifier) + .toggleCategory(RoadAlertCategory.police, v), ), _buildSwitchRow( Icons.camera_alt_outlined, 'Speed Cameras', enabledCategories.contains(RoadAlertCategory.speedCamera), - (v) => ref.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.speedCamera, v), + (v) => ref + .read(enabledCategoriesProvider.notifier) + .toggleCategory(RoadAlertCategory.speedCamera, v), ), _buildSwitchRow( Icons.warning_amber_rounded, 'Road Hazards', enabledCategories.contains(RoadAlertCategory.hazard), - (v) => ref.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.hazard, v), + (v) => ref + .read(enabledCategoriesProvider.notifier) + .toggleCategory(RoadAlertCategory.hazard, v), ), _buildSwitchRow( Icons.car_crash_outlined, 'Traffic Incidents', enabledCategories.contains(RoadAlertCategory.traffic), - (v) => ref.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.traffic, v), + (v) => ref + .read(enabledCategoriesProvider.notifier) + .toggleCategory(RoadAlertCategory.traffic, v), ), const SizedBox(height: 16), const Text( 'Radius Configuration', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white70), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), ), const SizedBox(height: 8), Center( @@ -673,24 +791,36 @@ class _EvolutionSettingsViewState extends ConsumerState { Row( children: [ IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Color(0xFF00E5FF)), + icon: const Icon( + Icons.remove_circle_outline, + color: Color(0xFF00E5FF), + ), onPressed: alertRadiusMeters > 500 - ? () => ref.read(alertRadiusMetersProvider.notifier).setRadius(alertRadiusMeters - 500.0) + ? () => ref + .read(alertRadiusMetersProvider.notifier) + .setRadius(alertRadiusMeters - 500.0) : null, ), Expanded( child: LinearProgressIndicator( value: (alertRadiusMeters / 5000.0).clamp(0.1, 1.0), backgroundColor: const Color(0xFF1F1F1F), - valueColor: const AlwaysStoppedAnimation(Color(0xFF00E5FF)), + valueColor: const AlwaysStoppedAnimation( + Color(0xFF00E5FF), + ), minHeight: 8, borderRadius: BorderRadius.circular(4), ), ), IconButton( - icon: const Icon(Icons.add_circle_outline, color: Color(0xFF00E5FF)), + icon: const Icon( + Icons.add_circle_outline, + color: Color(0xFF00E5FF), + ), onPressed: alertRadiusMeters < 5000 - ? () => ref.read(alertRadiusMetersProvider.notifier).setRadius(alertRadiusMeters + 500.0) + ? () => ref + .read(alertRadiusMetersProvider.notifier) + .setRadius(alertRadiusMeters + 500.0) : null, ), ], @@ -698,7 +828,11 @@ class _EvolutionSettingsViewState extends ConsumerState { const Center( child: Text( 'Distance to detect upcoming incidents', - style: TextStyle(fontFamily: 'Inter', fontSize: 9, color: Colors.white38), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + color: Colors.white38, + ), ), ), const SizedBox(height: 12), @@ -711,9 +845,12 @@ class _EvolutionSettingsViewState extends ConsumerState { foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF), width: 1), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), - onPressed: () => ref.read(roadAlertServiceProvider).triggerTestAlert(), + onPressed: () => + ref.read(roadAlertServiceProvider).triggerTestAlert(), ), ), ], @@ -721,7 +858,11 @@ class _EvolutionSettingsViewState extends ConsumerState { ); } - Widget _buildEngineOption({required String title, required bool selected, required VoidCallback onTap}) { + Widget _buildEngineOption({ + required String title, + required bool selected, + required VoidCallback onTap, + }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), @@ -747,14 +888,20 @@ class _EvolutionSettingsViewState extends ConsumerState { color: selected ? Colors.white : Colors.white60, ), ), - if (selected) const Icon(Icons.check, color: Color(0xFF00E5FF), size: 16), + if (selected) + const Icon(Icons.check, color: Color(0xFF00E5FF), size: 16), ], ), ), ); } - Widget _buildSwitchRow(IconData icon, String title, bool value, ValueChanged onChanged) { + Widget _buildSwitchRow( + IconData icon, + String title, + bool value, + ValueChanged onChanged, + ) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( @@ -764,7 +911,12 @@ class _EvolutionSettingsViewState extends ConsumerState { Expanded( child: Text( title, - style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), ), ), Switch( @@ -813,7 +965,12 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 8), const Text( 'Place motorcycle upright on level ground. Zeroes the roll and pitch offsets for your phone mount angle.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), Container( @@ -828,11 +985,21 @@ class _EvolutionSettingsViewState extends ConsumerState { children: [ const Text( 'CURRENT LEAN ANGLE:', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white54), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white54, + ), ), Text( '${currentLean.toStringAsFixed(1)}°', - style: const TextStyle(fontFamily: 'Inter', fontSize: 14, fontWeight: FontWeight.w900, color: Color(0xFF00E5FF)), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w900, + color: Color(0xFF00E5FF), + ), ), ], ), @@ -847,20 +1014,35 @@ class _EvolutionSettingsViewState extends ConsumerState { backgroundColor: const Color(0xFF00E5FF), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - textStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + ), ), onPressed: () { final session = ref.read(rideStateMachineProvider); if (session.state == RideState.riding) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot calibrate while a ride is actively recording.')), + const SnackBar( + content: Text( + 'Cannot calibrate while a ride is actively recording.', + ), + ), ); return; } ref.read(sensorFusionServiceProvider).calibrateUpright(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sensors Calibrated! Zero mount angle saved.')), + const SnackBar( + content: Text( + 'Sensors Calibrated! Zero mount angle saved.', + ), + ), ); }, ), @@ -913,11 +1095,20 @@ class _EvolutionSettingsViewState extends ConsumerState { children: [ Row( children: [ - Icon(Icons.monitor_heart, color: Color(0xFF00E5FF), size: 16), + Icon( + Icons.monitor_heart, + color: Color(0xFF00E5FF), + size: 16, + ), SizedBox(width: 8), Text( 'Sensor Diagnostics & Raw Telemetry', - style: TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), ), ], ), @@ -930,20 +1121,35 @@ class _EvolutionSettingsViewState extends ConsumerState { SizedBox( width: double.infinity, child: OutlinedButton.icon( - icon: const Icon(Icons.delete_forever, color: Colors.redAccent, size: 16), + icon: const Icon( + Icons.delete_forever, + color: Colors.redAccent, + size: 16, + ), label: const Text('CLEAR ALL RIDE HISTORY'), style: OutlinedButton.styleFrom( foregroundColor: Colors.redAccent, side: const BorderSide(color: Colors.redAccent, width: 1), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - textStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w800, letterSpacing: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), ), onPressed: () async { final session = ref.read(rideStateMachineProvider); if (session.state == RideState.riding) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Cannot clear history while a ride is actively recording.')), + const SnackBar( + content: Text( + 'Cannot clear history while a ride is actively recording.', + ), + ), ); return; } @@ -953,7 +1159,9 @@ class _EvolutionSettingsViewState extends ConsumerState { builder: (ctx) => AlertDialog( backgroundColor: const Color(0xFF141414), title: const Text('Clear All Ride History?'), - content: const Text('This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.'), + content: const Text( + 'This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.', + ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), @@ -961,7 +1169,13 @@ class _EvolutionSettingsViewState extends ConsumerState { ), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('CLEAR ALL', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)), + child: const Text( + 'CLEAR ALL', + style: TextStyle( + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + ), ), ], ), @@ -977,7 +1191,9 @@ class _EvolutionSettingsViewState extends ConsumerState { }); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All ride history cleared.')), + const SnackBar( + content: Text('All ride history cleared.'), + ), ); } } @@ -1009,7 +1225,13 @@ class _EvolutionSettingsViewState extends ConsumerState { Expanded( child: Row( children: [ - Icon(Icons.biotech, color: isEnabled ? Colors.greenAccent : const Color(0xFF00E5FF), size: 18), + Icon( + Icons.biotech, + color: isEnabled + ? Colors.greenAccent + : const Color(0xFF00E5FF), + size: 18, + ), const SizedBox(width: 8), const Flexible( child: Text( @@ -1032,7 +1254,9 @@ class _EvolutionSettingsViewState extends ConsumerState { value: isEnabled, activeThumbColor: Colors.greenAccent, onChanged: (val) { - ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(val); + ref + .read(fieldTestModeEnabledProvider.notifier) + .setEnabled(val); }, ), ], @@ -1040,24 +1264,41 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 6), const Text( 'High-resolution black-box recorder for sensor fusion, GPS, and Kalman filter diagnostics.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('RECORDER STATUS:', style: TextStyle(fontFamily: 'Inter', color: Colors.white54, fontSize: 10, fontWeight: FontWeight.bold)), + const Text( + 'RECORDER STATUS:', + style: TextStyle( + fontFamily: 'Inter', + color: Colors.white54, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: activeSession != null ? Colors.greenAccent.withValues(alpha: 0.15) - : (isEnabled ? const Color(0xFF00E5FF).withValues(alpha: 0.15) : Colors.white10), + : (isEnabled + ? const Color(0xFF00E5FF).withValues(alpha: 0.15) + : Colors.white10), borderRadius: BorderRadius.circular(6), border: Border.all( color: activeSession != null ? Colors.greenAccent - : (isEnabled ? const Color(0xFF00E5FF) : Colors.white24), + : (isEnabled + ? const Color(0xFF00E5FF) + : Colors.white24), ), ), child: Text( @@ -1070,7 +1311,9 @@ class _EvolutionSettingsViewState extends ConsumerState { fontWeight: FontWeight.bold, color: activeSession != null ? Colors.greenAccent - : (isEnabled ? const Color(0xFF00E5FF) : Colors.white54), + : (isEnabled + ? const Color(0xFF00E5FF) + : Colors.white54), ), ), ), @@ -1086,25 +1329,46 @@ class _EvolutionSettingsViewState extends ConsumerState { foregroundColor: Colors.greenAccent, side: const BorderSide(color: Colors.greenAccent), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.fact_check, size: 16), - label: const Text('PRE-RIDE CHECK', style: TextStyle(fontFamily: 'Inter', fontSize: 9, fontWeight: FontWeight.w800)), - onPressed: () => context.go('/settings/field-test-pre-ride-check'), + label: const Text( + 'PRE-RIDE CHECK', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + onPressed: () => + context.go('/settings/field-test-pre-ride-check'), ), ), const SizedBox(width: 6), Expanded( child: ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF)), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.assessment, size: 16), - label: const Text('REPORTS', style: TextStyle(fontFamily: 'Inter', fontSize: 9, fontWeight: FontWeight.w800)), + label: const Text( + 'REPORTS', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), onPressed: () => context.go('/settings/field-test-report'), ), ), @@ -1116,10 +1380,19 @@ class _EvolutionSettingsViewState extends ConsumerState { foregroundColor: Colors.white, side: const BorderSide(color: Colors.white24), padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), icon: const Icon(Icons.stream, size: 16), - label: const Text('LIVE LOG', style: TextStyle(fontFamily: 'Inter', fontSize: 9, fontWeight: FontWeight.w800)), + label: const Text( + 'LIVE LOG', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), onPressed: () => context.go('/settings/field-test-live-log'), ), ), @@ -1132,7 +1405,8 @@ class _EvolutionSettingsViewState extends ConsumerState { Widget _buildGitHubCard(BuildContext context, WidgetRef ref) { final authService = ref.watch(githubAuthServiceProvider); - final hasToken = authService.getToken() != null && authService.getToken()!.isNotEmpty; + final hasToken = + authService.getToken() != null && authService.getToken()!.isNotEmpty; return Container( padding: const EdgeInsets.all(16), @@ -1150,7 +1424,11 @@ class _EvolutionSettingsViewState extends ConsumerState { const Expanded( child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: Color(0xFF00E5FF), size: 18), + Icon( + Icons.cloud_upload_outlined, + color: Color(0xFF00E5FF), + size: 18, + ), SizedBox(width: 8), Flexible( child: Text( @@ -1192,7 +1470,12 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 8), const Text( 'Target Repository: bponders12-cell/ride_logs (branch: main). Offline-first background uploads with zero telemetry disruption.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), TextField( @@ -1200,14 +1483,31 @@ class _EvolutionSettingsViewState extends ConsumerState { obscureText: _obscureGitHubToken, decoration: InputDecoration( labelText: 'GitHub Personal Access Token (PAT)', - labelStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60), + labelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white60, + ), hintText: 'ghp_xxxxxxxxxxxxxxxxxxxx', - hintStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white30), + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white30, + ), filled: true, fillColor: const Color(0xFF141414), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF00E5FF))), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF00E5FF)), + ), isDense: true, suffixIcon: IconButton( icon: Icon( @@ -1220,7 +1520,11 @@ class _EvolutionSettingsViewState extends ConsumerState { }, ), ), - style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, color: Colors.white), + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: Colors.white, + ), onChanged: (val) { authService.saveToken(val); }, @@ -1236,16 +1540,30 @@ class _EvolutionSettingsViewState extends ConsumerState { _githubTokenController.clear(); setState(() {}); }, - child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent, fontFamily: 'Inter', fontSize: 10)), + child: const Text( + 'CLEAR', + style: TextStyle( + color: Colors.redAccent, + fontFamily: 'Inter', + fontSize: 10, + ), + ), ), const SizedBox(width: 8), ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF)), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), onPressed: _isTestingGitHub ? null @@ -1258,14 +1576,30 @@ class _EvolutionSettingsViewState extends ConsumerState { messenger.showSnackBar( SnackBar( content: Text(result['message'] as String), - backgroundColor: result['success'] == true ? const Color(0xFF00E676) : Colors.redAccent, + backgroundColor: result['success'] == true + ? const Color(0xFF00E676) + : Colors.redAccent, ), ); }, icon: _isTestingGitHub - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF))) + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), + ) : const Icon(Icons.sync, size: 16), - label: const Text('TEST CONNECTION', style: TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w800)), + label: const Text( + 'TEST CONNECTION', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), ), ], ), @@ -1318,24 +1652,50 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 8), const Text( 'Supplies real-time accidents, hazards, road closures, and construction feeds.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), TextField( controller: _tomtomKeyController, decoration: InputDecoration( labelText: 'TomTom API Key', - labelStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60), + labelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white60, + ), hintText: 'Enter your TomTom API Key', - hintStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white30), + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white30, + ), filled: true, fillColor: const Color(0xFF141414), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF00E5FF))), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF00E5FF)), + ), isDense: true, ), - style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.white), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Colors.white, + ), onChanged: (val) { ref.read(tomtomApiKeyProvider.notifier).setApiKey(val); }, @@ -1345,11 +1705,18 @@ class _EvolutionSettingsViewState extends ConsumerState { alignment: Alignment.centerRight, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF)), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), onPressed: _isTestingTomTom ? null @@ -1357,7 +1724,9 @@ class _EvolutionSettingsViewState extends ConsumerState { setState(() => _isTestingTomTom = true); final messenger = ScaffoldMessenger.of(context); final provider = ref.read(tomtomProviderInstance); - final status = await provider.testConnection(_tomtomKeyController.text); + final status = await provider.testConnection( + _tomtomKeyController.text, + ); ref.read(tomtomStatusProvider.notifier).state = status; setState(() => _isTestingTomTom = false); @@ -1367,9 +1736,23 @@ class _EvolutionSettingsViewState extends ConsumerState { messenger.showSnackBar(SnackBar(content: Text(msg))); }, icon: _isTestingTomTom - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF))) + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), + ) : const Icon(Icons.sync, size: 16), - label: const Text('TEST CONNECTION', style: TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w800)), + label: const Text( + 'TEST CONNECTION', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), ), ), ], @@ -1396,7 +1779,11 @@ class _EvolutionSettingsViewState extends ConsumerState { const Expanded( child: Row( children: [ - Icon(Icons.local_police, color: Color(0xFF00E5FF), size: 18), + Icon( + Icons.local_police, + color: Color(0xFF00E5FF), + size: 18, + ), SizedBox(width: 8), Flexible( child: Text( @@ -1421,24 +1808,50 @@ class _EvolutionSettingsViewState extends ConsumerState { const SizedBox(height: 8), const Text( 'Live crowdsourced speed traps, police radars, and hazard reports.', - style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white60, height: 1.3), + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + color: Colors.white60, + height: 1.3, + ), ), const SizedBox(height: 12), TextField( controller: _communityKeyController, decoration: InputDecoration( labelText: 'Community API Key', - labelStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60), + labelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white60, + ), hintText: 'Enter API Key', - hintStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white30), + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white30, + ), filled: true, fillColor: const Color(0xFF141414), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF00E5FF))), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF00E5FF)), + ), isDense: true, ), - style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.white), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Colors.white, + ), onChanged: (val) { ref.read(communityApiKeyProvider.notifier).setApiKey(val); }, @@ -1448,17 +1861,38 @@ class _EvolutionSettingsViewState extends ConsumerState { controller: _communityEndpointController, decoration: InputDecoration( labelText: 'Custom Endpoint URL (Optional)', - labelStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white60), + labelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white60, + ), hintText: 'e.g. https://google-map-places.p.rapidapi.com', - hintStyle: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white30), + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: Colors.white30, + ), filled: true, fillColor: const Color(0xFF141414), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF222222))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF00E5FF))), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF222222)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF00E5FF)), + ), isDense: true, ), - style: const TextStyle(fontFamily: 'Inter', fontSize: 12, color: Colors.white), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: Colors.white, + ), onChanged: (val) { ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val); }, @@ -1468,11 +1902,18 @@ class _EvolutionSettingsViewState extends ConsumerState { alignment: Alignment.centerRight, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + backgroundColor: const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), foregroundColor: const Color(0xFF00E5FF), side: const BorderSide(color: Color(0xFF00E5FF)), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), onPressed: _isTestingCommunity ? null @@ -1480,7 +1921,9 @@ class _EvolutionSettingsViewState extends ConsumerState { setState(() => _isTestingCommunity = true); final messenger = ScaffoldMessenger.of(context); final provider = ref.read(communityProviderInstance); - final status = await provider.testConnection(_communityKeyController.text); + final status = await provider.testConnection( + _communityKeyController.text, + ); ref.read(communityStatusProvider.notifier).state = status; setState(() => _isTestingCommunity = false); @@ -1490,9 +1933,23 @@ class _EvolutionSettingsViewState extends ConsumerState { messenger.showSnackBar(SnackBar(content: Text(msg))); }, icon: _isTestingCommunity - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF))) + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), + ) : const Icon(Icons.sync, size: 16), - label: const Text('TEST CONNECTION', style: TextStyle(fontFamily: 'Inter', fontSize: 10, fontWeight: FontWeight.w800)), + label: const Text( + 'TEST CONNECTION', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), ), ), ], @@ -1553,7 +2010,10 @@ class _EvolutionSettingsViewState extends ConsumerState { children: [ Icon(Icons.fingerprint, color: Colors.cyanAccent), SizedBox(width: 8), - Text('Software Identity', style: TextStyle(color: Colors.white, fontSize: 16)), + Text( + 'Software Identity', + style: TextStyle(color: Colors.white, fontSize: 16), + ), ], ), content: Column( @@ -1564,16 +2024,30 @@ class _EvolutionSettingsViewState extends ConsumerState { _diagRow('Version', identity.semanticVersion), _diagRow('Build Number', '${identity.buildNumber}'), _diagRow('Release Channel', identity.releaseChannel), - _diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'), - _diagRow('Export Schema', '${identity.exportSchemaVersion}'), - _diagRow('Git Commit', identity.gitCommit.length > 12 ? identity.gitCommit.substring(0, 12) : identity.gitCommit), + _diagRow( + 'Telemetry Schema', + '${identity.telemetrySchemaVersion}', + ), + _diagRow( + 'Export Schema', + '${identity.exportSchemaVersion}', + ), + _diagRow( + 'Git Commit', + identity.gitCommit.length > 12 + ? identity.gitCommit.substring(0, 12) + : identity.gitCommit, + ), _diagRow('Build Timestamp', identity.buildTimestamp), ], ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), - child: const Text('CLOSE', style: TextStyle(color: Colors.cyanAccent)), + child: const Text( + 'CLOSE', + style: TextStyle(color: Colors.cyanAccent), + ), ), ], ), @@ -1618,10 +2092,21 @@ class _EvolutionSettingsViewState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)), - Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + value, + style: const TextStyle( + color: Colors.cyanAccent, + fontWeight: FontWeight.bold, + fontSize: 12, + fontFamily: 'monospace', + ), + ), ], ), ); } -} \ No newline at end of file +} diff --git a/test/feature_gating_test.dart b/test/feature_gating_test.dart new file mode 100644 index 0000000..416d43d --- /dev/null +++ b/test/feature_gating_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart'; +import 'package:motorcycle_dash/services/billing/product_catalog.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; +import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FeatureAccessService Matrix Tests', () { + test( + 'Free tier allows basic cloud backup but denies all Pro-only features', + () { + final freeState = EntitlementState.free(); + final service = FeatureAccessService(freeState); + + // Cloud backup is permitted for free users + expect(service.canAccess(MotorSignalFeature.cloudBackup), isTrue); + + final proOnlyFeatures = MotorSignalFeature.values.where( + (f) => f != MotorSignalFeature.cloudBackup, + ); + for (final feature in proOnlyFeatures) { + expect( + service.canAccess(feature), + isFalse, + reason: 'Free tier must not access ${feature.name}', + ); + } + }, + ); + + test('Active Pro Monthly grants all features', () { + final proActiveState = EntitlementState.proMonthlyActive( + productId: ProductCatalog.proMonthly, + expiresAt: DateTime.now().add(const Duration(days: 30)), + ); + final service = FeatureAccessService(proActiveState); + + for (final feature in MotorSignalFeature.values) { + expect( + service.canAccess(feature), + isTrue, + reason: 'Active Pro Monthly must access ${feature.name}', + ); + } + }); + + test( + 'Pro Monthly in Grace Period grants all features for rider continuity', + () { + final graceState = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyGrace, + verificationStatus: EntitlementVerificationStatus.verified, + source: PurchaseSource.googlePlay, + productId: ProductCatalog.proMonthly, + expiresAt: DateTime.now().add(const Duration(days: 2)), + ); + final service = FeatureAccessService(graceState); + + for (final feature in MotorSignalFeature.values) { + expect( + service.canAccess(feature), + isTrue, + reason: 'Grace period must access ${feature.name}', + ); + } + }, + ); + + test('Pro Monthly on Account Hold denies all Pro-only features', () { + final holdState = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyAccountHold, + verificationStatus: EntitlementVerificationStatus.verified, + source: PurchaseSource.googlePlay, + productId: ProductCatalog.proMonthly, + ); + final service = FeatureAccessService(holdState); + + final proOnlyFeatures = MotorSignalFeature.values.where( + (f) => f != MotorSignalFeature.cloudBackup, + ); + for (final feature in proOnlyFeatures) { + expect( + service.canAccess(feature), + isFalse, + reason: 'Account hold must deny ${feature.name}', + ); + } + }); + + test('Pro Monthly Paused denies all Pro-only features', () { + final pausedState = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyPaused, + verificationStatus: EntitlementVerificationStatus.verified, + source: PurchaseSource.googlePlay, + productId: ProductCatalog.proMonthly, + ); + final service = FeatureAccessService(pausedState); + + final proOnlyFeatures = MotorSignalFeature.values.where( + (f) => f != MotorSignalFeature.cloudBackup, + ); + for (final feature in proOnlyFeatures) { + expect( + service.canAccess(feature), + isFalse, + reason: 'Paused subscription must deny ${feature.name}', + ); + } + }); + + test('Pro Monthly Expired denies all Pro-only features', () { + final expiredState = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyExpired, + verificationStatus: EntitlementVerificationStatus.expired, + source: PurchaseSource.googlePlay, + productId: ProductCatalog.proMonthly, + ); + final service = FeatureAccessService(expiredState); + + final proOnlyFeatures = MotorSignalFeature.values.where( + (f) => f != MotorSignalFeature.cloudBackup, + ); + for (final feature in proOnlyFeatures) { + expect( + service.canAccess(feature), + isFalse, + reason: 'Expired subscription must deny ${feature.name}', + ); + } + }); + + test('Pro Lifetime grants all features permanently', () { + final lifetimeState = EntitlementState.proLifetime( + productId: ProductCatalog.proLifetime, + ); + final service = FeatureAccessService(lifetimeState); + + for (final feature in MotorSignalFeature.values) { + expect( + service.canAccess(feature), + isTrue, + reason: 'Pro Lifetime must access ${feature.name}', + ); + } + }); + }); + + group('UI Lock & Badge Widgets Tests', () { + testWidgets('ProBadge renders with PRO label and styling', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: ProBadge())), + ); + + expect(find.text('PRO'), findsOneWidget); + }); + + testWidgets( + 'ProLockedRow renders when user is Free and hides when user is Pro', + (tester) async { + // 1. When user is Free, row is visible + await tester.pumpWidget( + ProviderScope( + overrides: [ + entitlementProvider.overrideWithValue( + const EntitlementState.free(), + ), + ], + child: const MaterialApp( + home: Scaffold( + body: ProLockedRow( + feature: MotorSignalFeature.csvExport, + title: Text('CSV Export'), + subtitle: Text('Export telemetry data as spreadsheet'), + ), + ), + ), + ), + ); + + expect(find.text('CSV Export'), findsOneWidget); + expect( + find.text('Export telemetry data as spreadsheet'), + findsOneWidget, + ); + expect(find.text('PRO'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsOneWidget); + + // 2. When user is Pro, row hides (SizedBox.shrink) + final proState = EntitlementState.proLifetime( + productId: ProductCatalog.proLifetime, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [entitlementProvider.overrideWithValue(proState)], + child: const MaterialApp( + home: Scaffold( + body: ProLockedRow( + feature: MotorSignalFeature.csvExport, + title: Text('CSV Export'), + subtitle: Text('Export telemetry data as spreadsheet'), + ), + ), + ), + ), + ); + + expect(find.text('CSV Export'), findsNothing); + expect(find.text('PRO'), findsNothing); + }, + ); + + test( + 'FeatureBenefitInfo provides title and description for all features', + () { + for (final feature in MotorSignalFeature.values) { + final info = FeatureBenefitInfo.forFeature(feature); + expect(info.title, isNotEmpty); + expect(info.description, isNotEmpty); + expect(info.icon, isNotNull); + } + }, + ); + }); +} diff --git a/test/pro_screen_test.dart b/test/pro_screen_test.dart new file mode 100644 index 0000000..5b91b12 --- /dev/null +++ b/test/pro_screen_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/features/entitlements/pro_screen.dart'; +import 'package:motorcycle_dash/features/settings/widgets/current_plan_card.dart'; +import 'package:motorcycle_dash/services/billing/billing_providers.dart'; +import 'package:motorcycle_dash/services/billing/fake_billing_service.dart'; +import 'package:motorcycle_dash/services/billing/product_catalog.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Widget buildTestableWidget({ + required Widget child, + List overrides = const [], + }) { + return ProviderScope( + overrides: overrides, + child: MaterialApp(home: child), + ); + } + + group('ProScreen UI and Flow Tests', () { + testWidgets( + 'renders dynamic products with prices from FakeBillingService', + (tester) async { + final fakeBilling = FakeBillingService(); + + await tester.pumpWidget( + buildTestableWidget( + child: const ProScreen(), + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ), + ); + + // Initial loading pump + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Check header and titles + expect(find.text('MOTORSIGNAL'), findsOneWidget); + expect( + find.text('PRECISION TELEMETRY.\nUNLIMITED RIDE DATA.'), + findsOneWidget, + ); + + // Check dynamic product prices and plan card titles + expect(find.text('\$4.99/month'), findsOneWidget); + expect(find.text('\$49.99'), findsOneWidget); + expect(find.text('PRO MONTHLY'), findsOneWidget); + expect(find.text('PRO LIFETIME'), findsOneWidget); + + // Check restore purchases button exists + expect(find.text('RESTORE'), findsOneWidget); + + // Scroll to benefits list + final benefitsHeader = find.text('WHAT IS INCLUDED IN PRO'); + await tester.scrollUntilVisible(benefitsHeader, 200); + expect(benefitsHeader, findsOneWidget); + + final garageBenefit = find.text('Multiple Motorcycle Garage'); + await tester.scrollUntilVisible(garageBenefit, 200); + expect(garageBenefit, findsOneWidget); + }, + ); + + testWidgets('triggers purchase flow on monthly plan card selection', ( + tester, + ) async { + final fakeBilling = FakeBillingService(); + + await tester.pumpWidget( + buildTestableWidget( + child: const ProScreen(), + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + // Tap on Pro Monthly card + final monthlyCard = find.text('PRO MONTHLY'); + expect(monthlyCard, findsOneWidget); + await tester.tap(monthlyCard); + await tester.pumpAndSettle(); + + expect(fakeBilling.launchCount, 1); + expect(fakeBilling.lastPurchasedProductId, ProductCatalog.proMonthly); + }); + + testWidgets('triggers purchase flow on lifetime plan card selection', ( + tester, + ) async { + final fakeBilling = FakeBillingService(); + + await tester.pumpWidget( + buildTestableWidget( + child: const ProScreen(), + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + // Tap on Pro Lifetime card + final lifetimeCard = find.text('PRO LIFETIME'); + expect(lifetimeCard, findsOneWidget); + await tester.tap(lifetimeCard); + await tester.pumpAndSettle(); + + expect(fakeBilling.launchCount, 1); + expect(fakeBilling.lastPurchasedProductId, ProductCatalog.proLifetime); + }); + + testWidgets('handles empty product catalog with retry option', ( + tester, + ) async { + final fakeBilling = FakeBillingService()..available = false; + + await tester.pumpWidget( + buildTestableWidget( + child: const ProScreen(), + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Google Play Billing Unavailable'), findsOneWidget); + expect(find.text('RETRY CONNECTION'), findsOneWidget); + }); + }); + + group('CurrentPlanCard Component Tests', () { + testWidgets('displays Free plan with Upgrade button', (tester) async { + final freeState = EntitlementState.free(); + + await tester.pumpWidget( + buildTestableWidget( + child: const Scaffold(body: CurrentPlanCard(isEvolution: true)), + overrides: [entitlementProvider.overrideWithValue(freeState)], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('MOTORSIGNAL FREE'), findsOneWidget); + expect(find.text('FREE'), findsOneWidget); + expect(find.text('UPGRADE TO PRO'), findsOneWidget); + expect( + find.text('Basic telemetry & local recording active'), + findsOneWidget, + ); + }); + + testWidgets('displays Active Pro Monthly with Manage Subscriptions link', ( + tester, + ) async { + final proState = EntitlementState.proMonthlyActive( + productId: ProductCatalog.proMonthly, + expiresAt: DateTime.now().add(const Duration(days: 20)), + ); + + await tester.pumpWidget( + buildTestableWidget( + child: const Scaffold(body: CurrentPlanCard(isEvolution: false)), + overrides: [entitlementProvider.overrideWithValue(proState)], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('MOTORSIGNAL PRO'), findsOneWidget); + expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget); + expect(find.text('MONTHLY'), findsOneWidget); + }); + + testWidgets( + 'displays Lifetime with Lifetime License label and no renewal', + (tester) async { + final lifetimeState = EntitlementState.proLifetime( + productId: ProductCatalog.proLifetime, + ); + + await tester.pumpWidget( + buildTestableWidget( + child: const Scaffold(body: CurrentPlanCard(isEvolution: true)), + overrides: [entitlementProvider.overrideWithValue(lifetimeState)], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('MOTORSIGNAL PRO'), findsOneWidget); + expect( + find.text('Lifetime access • Permanent ownership'), + findsOneWidget, + ); + expect(find.text('LIFETIME'), findsOneWidget); + }, + ); + + testWidgets('displays Grace Period with warning badge', (tester) async { + final graceState = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyGrace, + verificationStatus: EntitlementVerificationStatus.verified, + source: PurchaseSource.googlePlay, + productId: ProductCatalog.proMonthly, + expiresAt: DateTime.now().add(const Duration(days: 2)), + ); + + await tester.pumpWidget( + buildTestableWidget( + child: const Scaffold(body: CurrentPlanCard(isEvolution: true)), + overrides: [entitlementProvider.overrideWithValue(graceState)], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('GRACE PERIOD'), findsOneWidget); + expect( + find.text('Payment grace period • Action needed in Google Play'), + findsOneWidget, + ); + expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget); + }); + }); +} diff --git a/test/rider_safety_entitlement_test.dart b/test/rider_safety_entitlement_test.dart new file mode 100644 index 0000000..f6c0ba3 --- /dev/null +++ b/test/rider_safety_entitlement_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/features/entitlements/widgets/pro_lock_badge.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/services/billing/product_catalog.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; +import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Rider Safety & Entitlement Separation Tests', () { + testWidgets( + 'suppresses modal upgrade sheet and shows passive snackbar during active ride', + (tester) async { + final activeRidingSession = RideSession( + state: RideState.riding, + tripDistanceMiles: 5.2, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith( + (ref) => _FakeRideStateMachine(activeRidingSession), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + return ElevatedButton( + onPressed: () => showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ), + child: const Text('TRIGGER UPGRADE'), + ); + }, + ), + ), + ), + ), + ); + + // Tap the button that triggers upgrade sheet + await tester.tap(find.text('TRIGGER UPGRADE')); + await tester.pump(); + + // Ensure NO modal bottom sheet appeared + expect(find.byType(BottomSheet), findsNothing); + + // Ensure non-blocking passive snackbar appeared + expect( + find.text('Pro feature locked. Upgrade available when stopped.'), + findsOneWidget, + ); + }, + ); + + testWidgets('allows modal upgrade sheet when rider is idle / stopped', ( + tester, + ) async { + final idleSession = RideSession(state: RideState.idle); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + rideStateMachineProvider.overrideWith( + (ref) => _FakeRideStateMachine(idleSession), + ), + ], + child: MaterialApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + return ElevatedButton( + onPressed: () => showProUpgradeSheet( + context, + ref, + feature: MotorSignalFeature.csvExport, + ), + child: const Text('TRIGGER UPGRADE'), + ); + }, + ), + ), + ), + ), + ); + + // Tap the button that triggers upgrade sheet + await tester.tap(find.text('TRIGGER UPGRADE')); + await tester.pumpAndSettle(); + + // Ensure modal bottom sheet DID open + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text('Forensic CSV & PDF Export'), findsOneWidget); + expect(find.text('VIEW MOTORSIGNAL PRO'), findsOneWidget); + expect(find.text('NOT NOW'), findsOneWidget); + }); + + test('Entitlement state changes do not disrupt RideSession or RideState', () { + final activeSession = RideSession( + state: RideState.riding, + tripDistanceMiles: 12.4, + topSpeedMph: 64.0, + ); + + // Verify that changing EntitlementState from Free to Pro to Expired is independent of RideSession + var entitlement = const EntitlementState.free(); + expect(entitlement.isPro, isFalse); + expect(activeSession.state, RideState.riding); + + entitlement = EntitlementState.proMonthlyActive( + productId: ProductCatalog.proMonthly, + ); + expect(entitlement.isPro, isTrue); + expect(activeSession.state, RideState.riding); + + entitlement = EntitlementState.normalized( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyExpired, + verificationStatus: EntitlementVerificationStatus.expired, + ); + expect(entitlement.isPro, isFalse); + expect(activeSession.state, RideState.riding); + expect(activeSession.tripDistanceMiles, 12.4); + }); + }); +} + +class _FakeRideStateMachine extends StateNotifier + implements RideStateMachine { + _FakeRideStateMachine(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}