feat: add MotorSignal Pro experience and feature gating
This commit is contained in:
parent
88e0b48df3
commit
80f71698a0
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue