feat: add MotorSignal Pro experience and feature gating

This commit is contained in:
Developer 2026-09-03 19:10:54 -04:00
parent 88e0b48df3
commit 80f71698a0
14 changed files with 3511 additions and 382 deletions

View File

@ -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<NavigatorState>();
@ -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(),
),
],
),

View File

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

View File

@ -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<void> 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<void>(
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),
],
),
),
);
}
}

View File

@ -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<AddMotorcycleScreen> createState() => _AddMotorcycleScreenState();
ConsumerState<AddMotorcycleScreen> createState() =>
_AddMotorcycleScreenState();
}
class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
@ -89,7 +93,11 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
_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<AddMotorcycleScreen> {
}
Future<void> _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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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<AddMotorcycleScreen> {
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,
),
),
],
),
);

View File

@ -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<GarageMotorcycle>? 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'),

View File

@ -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<RideDetailScreen> {
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(

View File

@ -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,
),
),
],
),

View File

@ -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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
),
),
const SizedBox(height: 16),
CurrentPlanCard(isEvolution: isEvolution),
const SizedBox(height: 16),
_buildCloudSyncSection(
context: context,
isEvolution: isEvolution,
@ -762,6 +781,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
),
),
const SizedBox(height: 16),
CurrentPlanCard(isEvolution: isEvolution),
const SizedBox(height: 16),
_buildCloudSyncSection(
context: context,
isEvolution: isEvolution,
@ -924,10 +945,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
),
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<AccountScreen> {
Future<void> _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<AccountScreen> {
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<AccountScreen> {
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<AccountScreen> {
}
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<AccountScreen> {
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<bool>(
claimUnclaimed =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Back Up Device Rides?'),
@ -1236,8 +1282,14 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
'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<AccountScreen> {
}
if (mounted && !ref.read(authNotifierProvider).hasError) {
setState(() {
_statusMessage = 'Password reset instructions sent. Check your email.';
_statusMessage =
'Password reset instructions sent. Check your email.';
});
}
}

View File

@ -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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
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<SettingsScreen> {
});
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
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',
),
),
],
),
);

View File

@ -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<void> _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),
),
),
],
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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);
}
},
);
});
}

253
test/pro_screen_test.dart Normal file
View File

@ -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<Override> 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);
});
});
}

View File

@ -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<RideSession>
implements RideStateMachine {
_FakeRideStateMachine(super.initialState);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}