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/field_test/field_test_pre_ride_check_screen.dart';
import '../features/settings/oauth_callback_screen.dart'; import '../features/settings/oauth_callback_screen.dart';
import '../features/settings/account_screen.dart'; import '../features/settings/account_screen.dart';
import '../features/entitlements/pro_screen.dart';
// ignore: unused_element // ignore: unused_element
final _rootNavigatorKey = GlobalKey<NavigatorState>(); final _rootNavigatorKey = GlobalKey<NavigatorState>();
@ -40,7 +41,9 @@ final appRouter = GoRouter(
redirect: (context, state) { redirect: (context, state) {
final uri = state.uri; final uri = state.uri;
final uriStr = uri.toString(); 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') { if (state.matchedLocation != '/oauth/callback') {
return '/oauth/callback?${uri.query}'; return '/oauth/callback?${uri.query}';
} }
@ -120,6 +123,10 @@ final appRouter = GoRouter(
path: 'account', path: 'account',
builder: (context, state) => const AccountScreen(), builder: (context, state) => const AccountScreen(),
), ),
GoRoute(
path: 'pro',
builder: (context, state) => const ProScreen(),
),
GoRoute( GoRoute(
path: 'diagnostics', path: 'diagnostics',
builder: (context, state) => const DiagnosticsScreen(), builder: (context, state) => const DiagnosticsScreen(),
@ -139,7 +146,8 @@ final appRouter = GoRouter(
), ),
GoRoute( GoRoute(
path: 'field-test-pre-ride-check', 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/motorcycle_models.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 'garage_providers.dart';
import 'widgets/motorcycle_photo.dart'; import 'widgets/motorcycle_photo.dart';
@ -9,7 +12,8 @@ class AddMotorcycleScreen extends ConsumerStatefulWidget {
const AddMotorcycleScreen({super.key}); const AddMotorcycleScreen({super.key});
@override @override
ConsumerState<AddMotorcycleScreen> createState() => _AddMotorcycleScreenState(); ConsumerState<AddMotorcycleScreen> createState() =>
_AddMotorcycleScreenState();
} }
class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> { class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
@ -89,7 +93,11 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
_isLoading = true; _isLoading = true;
}); });
final repo = ref.read(motorcycleRepositoryProvider); 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) { if (variants.length <= 1) {
_selectedVariant = variants.isNotEmpty ? variants.first : null; _selectedVariant = variants.isNotEmpty ? variants.first : null;
@ -130,6 +138,21 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
} }
Future<void> _saveMotorcycle() async { 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( final selection = MotorcycleSelection(
year: _selectedYear!, year: _selectedYear!,
makeId: _selectedMake!.id, makeId: _selectedMake!.id,
@ -143,12 +166,16 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
final controller = ref.read(garageControllerProvider); final controller = ref.read(garageControllerProvider);
await controller.addMotorcycle( await controller.addMotorcycle(
selection, selection,
nickname: _nicknameController.text.trim().isNotEmpty ? _nicknameController.text.trim() : null, nickname: _nicknameController.text.trim().isNotEmpty
? _nicknameController.text.trim()
: null,
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${selection.displayName} to your Garage!')), SnackBar(
content: Text('Added ${selection.displayName} to your Garage!'),
),
); );
context.pop(); context.pop();
} }
@ -175,7 +202,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_getStepTitle()), 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 body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@ -195,23 +225,35 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
String _getStepTitle() { String _getStepTitle() {
switch (_step) { switch (_step) {
case 1: return 'Select Year'; case 1:
case 2: return 'Select Manufacturer'; return 'Select Year';
case 3: return 'Select Model (${_selectedMake?.name ?? ""})'; case 2:
case 4: return 'Select Variant'; return 'Select Manufacturer';
case 5: return 'Confirm Motorcycle'; case 3:
default: return 'Add Motorcycle'; return 'Select Model (${_selectedMake?.name ?? ""})';
case 4:
return 'Select Variant';
case 5:
return 'Confirm Motorcycle';
default:
return 'Add Motorcycle';
} }
} }
Widget _buildStepContent() { Widget _buildStepContent() {
switch (_step) { switch (_step) {
case 1: return _buildYearStep(); case 1:
case 2: return _buildMakeStep(); return _buildYearStep();
case 3: return _buildModelStep(); case 2:
case 4: return _buildVariantStep(); return _buildMakeStep();
case 5: return _buildConfirmStep(); case 3:
default: return const SizedBox(); 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; final year = _years[index].year;
return OutlinedButton( return OutlinedButton(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: BorderSide(color: _selectedYear == year ? Colors.cyanAccent : Colors.white24), side: BorderSide(
backgroundColor: _selectedYear == year ? Colors.cyanAccent.withValues(alpha: 0.2) : null, color: _selectedYear == year ? Colors.cyanAccent : Colors.white24,
),
backgroundColor: _selectedYear == year
? Colors.cyanAccent.withValues(alpha: 0.2)
: null,
), ),
onPressed: () => _selectYear(year), 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() { Widget _buildMakeStep() {
final query = _searchController.text.toLowerCase(); 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( return Column(
children: [ children: [
Padding( Padding(
@ -253,7 +308,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent), prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
filled: true, filled: true,
fillColor: Colors.white10, fillColor: Colors.white10,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
), ),
@ -262,12 +320,22 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length, itemCount: filtered.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10), separatorBuilder: (_, _) =>
const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final make = filtered[index]; final make = filtered[index];
return ListTile( return ListTile(
title: Text(make.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)), title: Text(
trailing: const Icon(Icons.chevron_right, color: Colors.white30), make.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
trailing: const Icon(
Icons.chevron_right,
color: Colors.white30,
),
onTap: () => _selectMake(make), onTap: () => _selectMake(make),
); );
}, },
@ -279,7 +347,9 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
Widget _buildModelStep() { Widget _buildModelStep() {
final query = _searchController.text.toLowerCase(); 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( return Column(
children: [ children: [
Padding( Padding(
@ -291,7 +361,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent), prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
filled: true, filled: true,
fillColor: Colors.white10, fillColor: Colors.white10,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
), ),
@ -300,13 +373,23 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length, itemCount: filtered.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10), separatorBuilder: (_, _) =>
const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final model = filtered[index]; final model = filtered[index];
return ListTile( 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}'), 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), onTap: () => _selectModel(model),
); );
}, },
@ -325,8 +408,14 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
final variant = _variants[index]; final variant = _variants[index];
return Card( return Card(
child: ListTile( child: ListTile(
title: Text(variant.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), title: Text(
trailing: const Icon(Icons.check_circle_outline, color: Colors.cyanAccent), variant.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
trailing: const Icon(
Icons.check_circle_outline,
color: Colors.cyanAccent,
),
onTap: () => _selectVariant(variant), onTap: () => _selectVariant(variant),
), ),
); );
@ -361,32 +450,74 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
Text( Text(
'$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}', '$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}',
textAlign: TextAlign.center, 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), 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), const SizedBox(height: 24),
if (spec != null) ...[ 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), const SizedBox(height: 12),
Row( Row(
children: [ 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), 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), const SizedBox(height: 12),
Row( Row(
children: [ 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), 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), const SizedBox(height: 24),
@ -396,7 +527,9 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Nickname (Optional)', labelText: 'Nickname (Optional)',
hintText: 'e.g., Blue Beast, The Track Bike', 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), prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent),
), ),
), ),
@ -406,10 +539,19 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
backgroundColor: Colors.cyanAccent, backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
onPressed: _saveMotorcycle, 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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 '../../core/theme/evolution_theme_config.dart';
import 'garage_providers.dart'; import 'garage_providers.dart';
import 'widgets/motorcycle_photo.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 { class GarageScreen extends ConsumerWidget {
const GarageScreen({super.key}); const GarageScreen({super.key});
@ -17,51 +20,91 @@ class GarageScreen extends ConsumerWidget {
final themeMode = ref.watch(dashboardThemeModeProvider); final themeMode = ref.watch(dashboardThemeModeProvider);
return Scaffold( return Scaffold(
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.black : null, backgroundColor: themeMode == DashboardThemeMode.evolution
? Colors.black
: null,
appBar: AppBar( appBar: AppBar(
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.transparent : null, backgroundColor: themeMode == DashboardThemeMode.evolution
? Colors.transparent
: null,
elevation: 0, elevation: 0,
title: Text( title: Text(
'My Garage', 'My Garage',
style: TextStyle( style: TextStyle(
fontFamily: themeMode == DashboardThemeMode.evolution ? 'Sora' : null, fontFamily: themeMode == DashboardThemeMode.evolution
? 'Sora'
: null,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add_circle_outline, color: Color(0xFF00E5FF)), icon: const Icon(
Icons.add_circle_outline,
color: Color(0xFF00E5FF),
),
tooltip: 'Add Motorcycle', tooltip: 'Add Motorcycle',
onPressed: () => context.push('/garage/add'), onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
), ),
], ],
), ),
body: bikesAsync.when( body: bikesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), 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) { data: (bikes) {
if (bikes.isEmpty) return _buildEmptyState(context); if (bikes.isEmpty) return _buildEmptyState(context);
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: bikes.length, itemCount: bikes.length,
itemBuilder: (context, index) { 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( ? FloatingActionButton.extended(
backgroundColor: Colors.cyanAccent, backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black, foregroundColor: Colors.black,
icon: const Icon(Icons.two_wheeler), icon: const Icon(Icons.two_wheeler),
label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold)), label: const Text(
onPressed: () => context.push('/garage/add'), 'ADD MOTORCYCLE',
style: TextStyle(fontWeight: FontWeight.bold),
),
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
) )
: null, : 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) { Widget _buildEmptyState(BuildContext context) {
return Center( return Center(
child: Padding( child: Padding(
@ -73,7 +116,11 @@ class GarageScreen extends ConsumerWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( const Text(
'YOUR GARAGE IS EMPTY', '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 SizedBox(height: 12),
const Text( const Text(
@ -86,11 +133,22 @@ class GarageScreen extends ConsumerWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent, backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), padding: const EdgeInsets.symmetric(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), horizontal: 24,
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
), ),
icon: const Icon(Icons.add), 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'), 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; MotorcycleSpecification? spec;
if (bike.specificationJson != null) { if (bike.specificationJson != null) {
try { try {
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!)); spec = MotorcycleSpecification.fromJson(
json.decode(bike.specificationJson!),
);
} catch (_) {} } catch (_) {}
} }
@ -164,11 +229,18 @@ class GarageScreen extends ConsumerWidget {
), ),
if (bike.isDefault) if (bike.isDefault)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF00E5FF).withValues(alpha: 0.15), color: const Color(
0xFF00E5FF,
).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFF00E5FF)), border: Border.all(
color: const Color(0xFF00E5FF),
),
), ),
child: const Text( child: const Text(
'CURRENT', 'CURRENT',
@ -190,9 +262,13 @@ class GarageScreen extends ConsumerWidget {
: '${bike.year} • ${bike.makeName}', : '${bike.year} • ${bike.makeName}',
style: TextStyle( style: TextStyle(
fontFamily: isEvolution ? 'JetBrains Mono' : null, fontFamily: isEvolution ? 'JetBrains Mono' : null,
color: isEvolution ? const Color(0xFF00E5FF) : Colors.white70, color: isEvolution
? const Color(0xFF00E5FF)
: Colors.white70,
fontSize: 13, 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), side: const BorderSide(color: Colors.white30),
), ),
onPressed: () { onPressed: () {
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id); ref
.read(garageControllerProvider)
.setDefaultMotorcycle(bike.id);
ScaffoldMessenger.of(context).showSnackBar( 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'), 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../../services/export_service.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 '../replay/replay_screen.dart';
import '../map/map_screen.dart'; import '../map/map_screen.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -260,12 +263,38 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
label: const Text('Export GPX'), label: const Text('Export GPX'),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
OutlinedButton.icon( Builder(
onPressed: () { builder: (context) {
ref.read(exportServiceProvider).exportRide(r.id, asGpx: false); 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), const SizedBox(height: 8),
TextButton.icon( TextButton.icon(

View File

@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../database/database.dart'; import '../../../database/database.dart';
import '../../../services/export_service.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 '../../../services/github/github_sync_service.dart';
import '../../replay/replay_screen.dart'; import '../../replay/replay_screen.dart';
import '../../map/map_screen.dart'; import '../../map/map_screen.dart';
@ -19,8 +22,12 @@ class EvolutionRideDetailsView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null) final bikeName =
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'.toUpperCase() (ride.motorcycleYear != null &&
ride.motorcycleMake != null &&
ride.motorcycleModel != null)
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
.toUpperCase()
: 'YAMAHA MT-09 SP'; : 'YAMAHA MT-09 SP';
final durationSec = ride.endTime != null final durationSec = ride.endTime != null
@ -29,7 +36,8 @@ class EvolutionRideDetailsView extends ConsumerWidget {
final durationMin = (durationSec / 60).toStringAsFixed(0); final durationMin = (durationSec / 60).toStringAsFixed(0);
final movingMin = (ride.movingTime / 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 syncService = ref.watch(githubSyncServiceProvider);
final isUploaded = syncService.isRideUploaded(ride.id); final isUploaded = syncService.isRideUploaded(ride.id);
final commitSha = syncService.getRideCommitSha(ride.id); final commitSha = syncService.getRideCommitSha(ride.id);
@ -59,6 +67,17 @@ class EvolutionRideDetailsView extends ConsumerWidget {
icon: const Icon(Icons.share, color: Color(0xFF00E5FF)), icon: const Icon(Icons.share, color: Color(0xFF00E5FF)),
tooltip: 'Export CSV', tooltip: 'Export CSV',
onPressed: () async { onPressed: () async {
final isPro = ref
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport);
if (!isPro) {
showProUpgradeSheet(
context,
ref,
feature: MotorSignalFeature.csvExport,
);
return;
}
final exporter = ref.read(exportServiceProvider); final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: false); await exporter.exportRide(ride.id, asGpx: false);
}, },
@ -71,7 +90,13 @@ class EvolutionRideDetailsView extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// GitHub Sync Status Card // GitHub Sync Status Card
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha), _buildGitHubSyncBanner(
context,
ref,
syncService,
isUploaded,
commitSha,
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards // Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards
@ -85,12 +110,19 @@ class EvolutionRideDetailsView extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0D0D0D), color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14), 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 8),
Text( Text(
bikeName, bikeName,
@ -128,12 +160,19 @@ class EvolutionRideDetailsView extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0D0D0D), color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14), 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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 SizedBox(height: 6),
const Text( const Text(
'ROUTE PREVIEW', 'ROUTE PREVIEW',
@ -147,7 +186,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF00E5FF).withAlpha(40), color: const Color(0xFF00E5FF).withAlpha(40),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@ -185,11 +227,26 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
child: Column( child: Column(
children: [ 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), 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), 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( : 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( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF00E5FF), 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), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
child: const Text( child: const Text(
'REPLAY RIDE', '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( style: OutlinedButton.styleFrom(
foregroundColor: Colors.white70, 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), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
child: const Text( child: const Text(
'EXPORT GPX', 'EXPORT GPX',
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12), style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.w700,
fontSize: 12,
),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: OutlinedButton( child: Builder(
onPressed: () async { builder: (context) {
final exporter = ref.read(exportServiceProvider); final isProCsv = ref
await exporter.exportRide(ride.id, asGpx: false); .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), color: const Color(0xFF0F141C),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( 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, width: 1.2,
), ),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
isUploaded ? Icons.cloud_done : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined), isUploaded
color: isUploaded ? const Color(0xFF00E676) : const Color(0xFF00E5FF), ? Icons.cloud_done
: (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined),
color: isUploaded
? const Color(0xFF00E676)
: const Color(0xFF00E5FF),
size: 24, size: 24,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -353,7 +479,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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( style: TextStyle(
fontFamily: 'Sora', fontFamily: 'Sora',
fontSize: 11, fontSize: 11,
@ -379,20 +509,32 @@ class EvolutionRideDetailsView extends ConsumerWidget {
onPressed: isSyncing onPressed: isSyncing
? null ? null
: () async { : () async {
final result = await syncService.uploadRide(ride.id, isManual: true); final result = await syncService.uploadRide(
ride.id,
isManual: true,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(result.message), content: Text(result.message),
backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent, backgroundColor: result.success
? const Color(0xFF00E676)
: Colors.redAccent,
), ),
); );
} }
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), horizontal: 10,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), vertical: 4,
),
backgroundColor: const Color(
0xFF00E5FF,
).withValues(alpha: 0.15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
), ),
child: Text( child: Text(
isSyncing ? 'UPLOADING...' : 'UPLOAD NOW', isSyncing ? 'UPLOADING...' : 'UPLOAD NOW',
@ -411,15 +553,44 @@ class EvolutionRideDetailsView extends ConsumerWidget {
Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) { Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) {
final cards = [ final cards = [
_buildHandlingKpiCard('MAX LEAN (LEFT)', '${ride.maxLeanLeft.toStringAsFixed(1)}°', Icons.turn_left, const Color(0xFF00E5FF)), _buildHandlingKpiCard(
_buildHandlingKpiCard('MAX LEAN (RIGHT)', '${ride.maxLeanRight.toStringAsFixed(1)}°', Icons.turn_right, const Color(0xFF00E5FF)), 'MAX LEAN (LEFT)',
_buildHandlingKpiCard('MAX ACCELERATION', '${ride.maxAccelG.toStringAsFixed(2)} G', Icons.speed, const Color(0xFF76FF03)), '${ride.maxLeanLeft.toStringAsFixed(1)}°',
_buildHandlingKpiCard('MAX BRAKING', '${ride.maxBrakingG.toStringAsFixed(2)} G', Icons.pan_tool_alt, const Color(0xFFFF5252)), 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) { if (isLandscape) {
return Row( 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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -499,22 +675,32 @@ class EvolutionRideDetailsView extends ConsumerWidget {
} else if (event.eventType.toLowerCase().contains('brak')) { } else if (event.eventType.toLowerCase().contains('brak')) {
badgeColor = const Color(0xFFFF5252); badgeColor = const Color(0xFFFF5252);
eventIcon = Icons.arrow_downward; 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); badgeColor = const Color(0xFF00E5FF);
eventIcon = Icons.cached; eventIcon = Icons.cached;
} }
String formattedValue = '${event.value.toStringAsFixed(1)}°'; 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'; 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( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () => _showEventDetailsDialog(context, event, formattedValue, timeStr, badgeColor), onTap: () => _showEventDetailsDialog(
context,
event,
formattedValue,
timeStr,
badgeColor,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
@ -584,7 +770,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
backgroundColor: const Color(0xFF111111), backgroundColor: const Color(0xFF111111),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), 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( title: Row(
children: [ children: [
@ -614,7 +803,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
'GPS Coordinates:', 'GPS Coordinates:',
'${event.latitude!.toStringAsFixed(6)}, ${event.longitude!.toStringAsFixed(6)}', '${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), const SizedBox(height: 12),
Container( Container(
width: double.infinity, width: double.infinity,
@ -626,7 +818,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
child: const Text( child: const Text(
'Authoritative telemetry sampled at 20 Hz. Moving metrics gated at speed >= 3.0 MPH.', '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) if (event.latitude != null && event.longitude != null)
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.map, color: Color(0xFF00E5FF), size: 16), 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: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
Navigator.of(context).push( Navigator.of(context).push(
@ -647,7 +850,13 @@ class EvolutionRideDetailsView extends ConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), 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( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( Flexible(
child: Text( child: Text(
value, value,
textAlign: TextAlign.right, 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( return Row(
children: [ children: [
Expanded( Expanded(
@ -695,11 +921,21 @@ class EvolutionRideDetailsView extends ConsumerWidget {
children: [ children: [
Text( Text(
label1, 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( Text(
value1, 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: [ children: [
Text( Text(
label2, 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( Text(
value2, 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/auth/auth_user.dart';
import '../../services/sync/cloud_restore_controller.dart'; import '../../services/sync/cloud_restore_controller.dart';
import '../../services/sync/sync_providers.dart'; import '../../services/sync/sync_providers.dart';
import 'widgets/current_plan_card.dart';
class AccountScreen extends ConsumerStatefulWidget { class AccountScreen extends ConsumerStatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -598,13 +599,27 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amberAccent.withValues(alpha: 0.12), color: Colors.amberAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8), 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( child: Row(
children: [ children: [
const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))), const Expanded(
TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')), child: Text(
IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)), '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( SizedBox(
width: double.infinity, width: double.infinity,
child: TextButton.icon( child: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.redAccent), style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
),
icon: const Icon(Icons.delete_forever_outlined, size: 18), icon: const Icon(Icons.delete_forever_outlined, size: 18),
label: const Text('DELETE ACCOUNT'), label: const Text('DELETE ACCOUNT'),
onPressed: () => _handleDeleteAccount(user), onPressed: () => _handleDeleteAccount(user),
@ -647,6 +664,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
CurrentPlanCard(isEvolution: isEvolution),
const SizedBox(height: 16),
_buildCloudSyncSection( _buildCloudSyncSection(
context: context, context: context,
isEvolution: isEvolution, isEvolution: isEvolution,
@ -762,6 +781,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
CurrentPlanCard(isEvolution: isEvolution),
const SizedBox(height: 16),
_buildCloudSyncSection( _buildCloudSyncSection(
context: context, context: context,
isEvolution: isEvolution, isEvolution: isEvolution,
@ -924,10 +945,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
_buildInfoRow( _buildInfoRow('Cloud Provider', 'MotorCloud backup'),
'Cloud Provider',
'MotorCloud backup',
),
if (syncState.lastSuccessfulSync != null) if (syncState.lastSuccessfulSync != null)
_buildInfoRow( _buildInfoRow(
'Last Successful Sync', 'Last Successful Sync',
@ -1119,9 +1137,15 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
Future<void> _sendVerification() async { Future<void> _sendVerification() async {
try { try {
await ref.read(authNotifierProvider.notifier).sendEmailVerification(); 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) { } 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 { try {
await ref.read(authNotifierProvider.notifier).reloadCurrentUser(); await ref.read(authNotifierProvider.notifier).reloadCurrentUser();
} catch (e) { } 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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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 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') ...[ if (user.providerId == 'password') ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: controller, controller: controller,
obscureText: true, obscureText: true,
autofocus: true, autofocus: true,
decoration: const InputDecoration(labelText: 'Current password'), decoration: const InputDecoration(
labelText: 'Current password',
),
validator: (value) => value == null || value.isEmpty validator: (value) => value == null || value.isEmpty
? 'Enter your current password' ? 'Enter your current password'
: null, : null,
@ -1202,9 +1239,13 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
} }
try { try {
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: result.password); await ref
.read(accountLifecycleServiceProvider)
.deleteAccount(password: result.password);
} catch (e) { } catch (e) {
debugPrint('[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted'); debugPrint(
'[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted',
);
if (mounted) { if (mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar( ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar( const SnackBar(
@ -1223,12 +1264,17 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
final user = ref.read(currentUserProvider); final user = ref.read(currentUserProvider);
if (user == null || user.isAnonymous) return; if (user == null || user.isAnonymous) return;
final rides = await db.select(db.rides).get(); final rides = await db.select(db.rides).get();
final owned = rides.where((ride) => ride.cloudOwnerUid == user.uid).toList(); final owned = rides
final unclaimed = rides.where((ride) => ride.cloudOwnerUid == null).toList(); .where((ride) => ride.cloudOwnerUid == user.uid)
.toList();
final unclaimed = rides
.where((ride) => ride.cloudOwnerUid == null)
.toList();
final otherOwned = rides.length - owned.length - unclaimed.length; final otherOwned = rides.length - owned.length - unclaimed.length;
var claimUnclaimed = false; var claimUnclaimed = false;
if (unclaimed.isNotEmpty && mounted) { if (unclaimed.isNotEmpty && mounted) {
claimUnclaimed = await showDialog<bool>( claimUnclaimed =
await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Back Up Device Rides?'), 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.', '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: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')), TextButton(
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('BACK UP')), 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) { if (mounted && !ref.read(authNotifierProvider).hasError) {
setState(() { 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/osm_oauth_service.dart';
import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_providers.dart';
import 'widgets/evolution_settings_view.dart'; import 'widgets/evolution_settings_view.dart';
import 'widgets/current_plan_card.dart';
class SettingsScreen extends ConsumerStatefulWidget { class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -73,6 +74,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ children: [
_buildSectionHeader('Membership & Entitlement'),
const SizedBox(height: 12),
const CurrentPlanCard(),
const SizedBox(height: 24),
_buildSectionHeader('Account & Cloud Identity'), _buildSectionHeader('Account & Cloud Identity'),
const SizedBox(height: 12), const SizedBox(height: 12),
Card( Card(
@ -90,7 +95,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: Row( child: Row(
children: [ children: [
Icon( Icon(
isAuthed ? Icons.account_circle : Icons.account_circle_outlined, isAuthed
? Icons.account_circle
: Icons.account_circle_outlined,
color: Colors.cyanAccent, color: Colors.cyanAccent,
size: 24, size: 24,
), ),
@ -100,7 +107,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
isAuthed ? (user?.displayLabel ?? 'Rider Account') : 'Sign In / Cloud Account', isAuthed
? (user?.displayLabel ?? 'Rider Account')
: 'Sign In / Cloud Account',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -109,7 +118,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
isAuthed isAuthed
? (user?.email ?? 'Cloud Identity Active • Offline rides protected') ? (user?.email ??
'Cloud Identity Active • Offline rides protected')
: 'Optional cloud identity • Local riding always active', : 'Optional cloud identity • Local riding always active',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
@ -120,23 +130,38 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(6),
border: Border.all(color: isAuthed ? Colors.cyanAccent : Colors.white24), border: Border.all(
color: isAuthed
? Colors.cyanAccent
: Colors.white24,
),
), ),
child: Text( child: Text(
isAuthed ? 'SIGNED IN' : 'LOCAL', isAuthed ? 'SIGNED IN' : 'LOCAL',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isAuthed ? Colors.cyanAccent : Colors.white54, color: isAuthed
? Colors.cyanAccent
: Colors.white54,
), ),
), ),
), ),
const SizedBox(width: 6), 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: [ children: [
const Row( const Row(
children: [ children: [
Icon(Icons.palette_outlined, color: Colors.cyanAccent, size: 20), Icon(
Icons.palette_outlined,
color: Colors.cyanAccent,
size: 20,
),
SizedBox(width: 10), SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
@ -190,12 +219,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: DashboardThemeMode.classic, value: DashboardThemeMode.classic,
label: Text('Classic Cockpit', style: TextStyle(fontSize: 12)), label: Text(
icon: Icon(Icons.dashboard_customize_outlined, size: 18), 'Classic Cockpit',
style: TextStyle(fontSize: 12),
),
icon: Icon(
Icons.dashboard_customize_outlined,
size: 18,
),
), ),
ButtonSegment( ButtonSegment(
value: DashboardThemeMode.evolution, 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), icon: Icon(Icons.speed, size: 18),
), ),
], ],
@ -257,12 +295,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: AppMapType.openStreetMap, value: AppMapType.openStreetMap,
label: Text('OpenStreetMap', style: TextStyle(fontSize: 12)), label: Text(
'OpenStreetMap',
style: TextStyle(fontSize: 12),
),
icon: Icon(Icons.public, size: 18), icon: Icon(Icons.public, size: 18),
), ),
ButtonSegment( ButtonSegment(
value: AppMapType.googleMaps, 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), icon: Icon(Icons.map_outlined, size: 18),
), ),
], ],
@ -293,7 +337,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: Text( child: Text(
'OpenStreetMap Account', 'OpenStreetMap Account',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13), style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -303,10 +350,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, 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, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
@ -317,22 +368,44 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: CircleAvatar( leading: CircleAvatar(
backgroundImage: user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null, backgroundImage: user.avatarUrl != null
child: user.avatarUrl == null ? const Icon(Icons.person) : 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( trailing: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.redAccent), style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
),
icon: const Icon(Icons.link_off, size: 16), icon: const Icon(Icons.link_off, size: 16),
label: const Text('Disconnect', style: TextStyle(fontSize: 12)), label: const Text(
onPressed: () => ref.read(osmOAuthServiceProvider.notifier).logout(), 'Disconnect',
style: TextStyle(fontSize: 12),
),
onPressed: () => ref
.read(osmOAuthServiceProvider.notifier)
.logout(),
), ),
), ),
] else ...[ ] else ...[
const Text( const Text(
'Sign in to OpenStreetMap via OAuth 2.0 PKCE to upload ride GPX tracks and contribute traces.', '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), const SizedBox(height: 10),
ElevatedButton.icon( ElevatedButton.icon(
@ -342,10 +415,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
minimumSize: const Size(double.infinity, 38), minimumSize: const Size(double.infinity, 38),
), ),
icon: const Icon(Icons.login, size: 18), 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 onPressed: osmAuth.isLoading
? null ? null
: () => ref.read(osmOAuthServiceProvider.notifier).startLoginFlow(), : () => ref
.read(osmOAuthServiceProvider.notifier)
.startLoginFlow(),
), ),
], ],
], ],
@ -559,7 +640,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
controller: _communityKeyController, controller: _communityKeyController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Community / RapidAPI Key', labelText: 'Community / RapidAPI Key',
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key', hintText:
'Enter OpenWebNinja, RapidAPI, or Community API Key',
helperText: helperText:
'Supplies Police, Speed Cameras, and Crowd Hazards.', 'Supplies Police, Speed Cameras, and Crowd Hazards.',
helperMaxLines: 2, helperMaxLines: 2,
@ -576,7 +658,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
controller: _communityEndpointController, controller: _communityEndpointController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Custom Endpoint URL (Optional)', 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: helperText:
'Leave blank for automatic OpenWebNinja / RapidAPI detection.', 'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
helperMaxLines: 2, helperMaxLines: 2,
@ -585,7 +668,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
onChanged: (val) { onChanged: (val) {
ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val); ref
.read(communityEndpointUrlProvider.notifier)
.setEndpoint(val);
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -609,15 +694,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) { if (mounted) {
String msg = ''; String msg = '';
if (status == RoadAlertProviderStatus.connected) { if (status ==
msg = 'Community / OpenWebNinja API Connected successfully!'; RoadAlertProviderStatus.connected) {
} else if (status == RoadAlertProviderStatus.authError) { msg =
msg = 'Authentication Error: Invalid or unauthorized key.'; 'Community / OpenWebNinja API Connected successfully!';
} else if (status == RoadAlertProviderStatus.notConfigured) { } else if (status ==
RoadAlertProviderStatus.authError) {
msg =
'Authentication Error: Invalid or unauthorized key.';
} else if (status ==
RoadAlertProviderStatus.notConfigured) {
msg = 'API Key is empty.'; msg = 'API Key is empty.';
} else if (status == RoadAlertProviderStatus.networkError) { } else if (status ==
msg = 'Network Error: Check internet connection or endpoint.'; RoadAlertProviderStatus.networkError) {
} else if (status == RoadAlertProviderStatus.rateLimited) { msg =
'Network Error: Check internet connection or endpoint.';
} else if (status ==
RoadAlertProviderStatus.rateLimited) {
msg = 'Rate Limited: Plan limit reached.'; msg = 'Rate Limited: Plan limit reached.';
} else { } else {
msg = 'Status: ${status.name}'; msg = 'Status: ${status.name}';
@ -695,15 +788,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) { if (mounted) {
String msg = ''; String msg = '';
if (status == RoadAlertProviderStatus.connected) { if (status ==
RoadAlertProviderStatus.connected) {
msg = 'TomTom API Connected successfully!'; msg = 'TomTom API Connected successfully!';
} else if (status == RoadAlertProviderStatus.authError) { } else if (status ==
RoadAlertProviderStatus.authError) {
msg = 'Authentication Error: Invalid Key.'; msg = 'Authentication Error: Invalid Key.';
} else if (status == RoadAlertProviderStatus.notConfigured) { } else if (status ==
RoadAlertProviderStatus.notConfigured) {
msg = 'Key is empty.'; msg = 'Key is empty.';
} else if (status == RoadAlertProviderStatus.networkError) { } else if (status ==
msg = 'Network Error: Failed to connect to API.'; RoadAlertProviderStatus.networkError) {
} else if (status == RoadAlertProviderStatus.rateLimited) { msg =
'Network Error: Failed to connect to API.';
} else if (status ==
RoadAlertProviderStatus.rateLimited) {
msg = 'Rate Limited: Too many requests.'; msg = 'Rate Limited: Too many requests.';
} else { } else {
msg = 'Status: ${status.name}'; msg = 'Status: ${status.name}';
@ -799,9 +898,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
lastUpdated: DateTime.now(), lastUpdated: DateTime.now(),
source: 'Test', source: 'Test',
); );
ref.read(roadAlertServiceProvider).simulateAlert(alert); ref
.read(roadAlertServiceProvider)
.simulateAlert(alert);
ScaffoldMessenger.of(context).showSnackBar( 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(), lastUpdated: DateTime.now(),
source: 'Test', source: 'Test',
); );
ref.read(roadAlertServiceProvider).simulateAlert(alert); ref
.read(roadAlertServiceProvider)
.simulateAlert(alert);
ScaffoldMessenger.of(context).showSnackBar( 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); final session = ref.read(rideStateMachineProvider);
if (session.state == RideState.riding) { if (session.state == RideState.riding) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -1011,7 +1126,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final session = ref.read(rideStateMachineProvider); final session = ref.read(rideStateMachineProvider);
if (session.state == RideState.riding) { if (session.state == RideState.riding) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -1020,7 +1139,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Clear All Ride History?'), 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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
@ -1028,7 +1149,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), 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) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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: [ children: [
Icon( Icon(
Icons.biotech, Icons.biotech,
color: isEnabled ? Colors.greenAccent : Colors.white54, color: isEnabled
? Colors.greenAccent
: Colors.white54,
size: 24, size: 24,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -1102,11 +1233,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
children: [ children: [
const Text( const Text(
'Field Test Mode', 'Field Test Mode',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
'Silent black-box diagnostic recorder', 'Silent black-box diagnostic recorder',
style: const TextStyle(fontSize: 11, color: Colors.white60), style: const TextStyle(
fontSize: 11,
color: Colors.white60,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
@ -1120,7 +1257,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
value: isEnabled, value: isEnabled,
activeThumbColor: Colors.greenAccent, activeThumbColor: Colors.greenAccent,
onChanged: (val) { onChanged: (val) {
ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(val); ref
.read(fieldTestModeEnabledProvider.notifier)
.setEnabled(val);
}, },
), ),
], ],
@ -1129,30 +1268,44 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Logger Status:', style: TextStyle(color: Colors.white70, fontSize: 13)), Text(
'Logger Status:',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: activeSession != null color: activeSession != null
? Colors.greenAccent.withValues(alpha: 0.15) ? 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), borderRadius: BorderRadius.circular(6),
border: Border.all( border: Border.all(
color: activeSession != null color: activeSession != null
? Colors.greenAccent ? Colors.greenAccent
: (isEnabled ? Colors.cyanAccent : Colors.white30), : (isEnabled
? Colors.cyanAccent
: Colors.white30),
), ),
), ),
child: Text( child: Text(
activeSession != null activeSession != null
? 'RECORDING (Ride #$activeSession)' ? 'RECORDING (Ride #$activeSession)'
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'), : (isEnabled
? 'ARMED (Auto-starts on Ride)'
: 'OFF'),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: activeSession != null color: activeSession != null
? Colors.greenAccent ? Colors.greenAccent
: (isEnabled ? Colors.cyanAccent : Colors.white54), : (isEnabled
? Colors.cyanAccent
: Colors.white54),
), ),
), ),
), ),
@ -1164,34 +1317,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.greenAccent.withValues(alpha: 0.2), backgroundColor: Colors.greenAccent.withValues(
alpha: 0.2,
),
foregroundColor: Colors.greenAccent, foregroundColor: Colors.greenAccent,
side: const BorderSide(color: Colors.greenAccent), side: const BorderSide(color: Colors.greenAccent),
), ),
icon: const Icon(Icons.fact_check, size: 18), icon: const Icon(Icons.fact_check, size: 18),
label: const Text('Pre-Ride Check', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), label: const Text(
onPressed: () => context.go('/settings/field-test-pre-ride-check'), 'Pre-Ride Check',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
onPressed: () =>
context.go('/settings/field-test-pre-ride-check'),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent.withValues(alpha: 0.15), backgroundColor: Colors.cyanAccent.withValues(
alpha: 0.15,
),
foregroundColor: Colors.cyanAccent, foregroundColor: Colors.cyanAccent,
side: const BorderSide(color: Colors.cyanAccent), side: const BorderSide(color: Colors.cyanAccent),
), ),
icon: const Icon(Icons.assessment, size: 18), icon: const Icon(Icons.assessment, size: 18),
label: const Text('View Reports', style: TextStyle(fontSize: 12)), label: const Text(
onPressed: () => context.go('/settings/field-test-report'), 'View Reports',
style: TextStyle(fontSize: 12),
),
onPressed: () =>
context.go('/settings/field-test-report'),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(Icons.receipt_long, size: 18), icon: const Icon(Icons.receipt_long, size: 18),
label: const Text('Live Log', style: TextStyle(fontSize: 12)), label: const Text(
onPressed: () => context.go('/settings/field-test-live-log'), 'Live Log',
style: TextStyle(fontSize: 12),
),
onPressed: () =>
context.go('/settings/field-test-live-log'),
), ),
), ),
], ],
@ -1200,14 +1372,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.redAccent), style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
),
icon: const Icon(Icons.delete_sweep, size: 16), 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 { onPressed: () async {
final session = ref.read(rideStateMachineProvider); final session = ref.read(rideStateMachineProvider);
if (session.state == RideState.riding) { if (session.state == RideState.riding) {
ScaffoldMessenger.of(context).showSnackBar( 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; return;
} }
@ -1216,10 +1397,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Clear All Field Test Logs?'), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')), TextButton(
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent))), 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(); await logger.clearAllSessions();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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: [ children: [
Icon(Icons.fingerprint, color: Colors.cyanAccent), Icon(Icons.fingerprint, color: Colors.cyanAccent),
SizedBox(width: 8), 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( content: Column(
@ -1393,14 +1590,22 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
_diagRow('Release Channel', identity.releaseChannel), _diagRow('Release Channel', identity.releaseChannel),
_diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'), _diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'),
_diagRow('Export Schema', '${identity.exportSchemaVersion}'), _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), _diagRow('Build Timestamp', identity.buildTimestamp),
], ],
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(), 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( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)), Text(
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')), 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);
}