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/settings/oauth_callback_screen.dart';
|
||||
import '../features/settings/account_screen.dart';
|
||||
import '../features/entitlements/pro_screen.dart';
|
||||
|
||||
// ignore: unused_element
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -40,7 +41,9 @@ final appRouter = GoRouter(
|
|||
redirect: (context, state) {
|
||||
final uri = state.uri;
|
||||
final uriStr = uri.toString();
|
||||
if (uri.scheme == 'motorsignal' || uri.host == 'oauth' || uriStr.contains('/oauth/callback')) {
|
||||
if (uri.scheme == 'motorsignal' ||
|
||||
uri.host == 'oauth' ||
|
||||
uriStr.contains('/oauth/callback')) {
|
||||
if (state.matchedLocation != '/oauth/callback') {
|
||||
return '/oauth/callback?${uri.query}';
|
||||
}
|
||||
|
|
@ -120,6 +123,10 @@ final appRouter = GoRouter(
|
|||
path: 'account',
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'pro',
|
||||
builder: (context, state) => const ProScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'diagnostics',
|
||||
builder: (context, state) => const DiagnosticsScreen(),
|
||||
|
|
@ -139,7 +146,8 @@ final appRouter = GoRouter(
|
|||
),
|
||||
GoRoute(
|
||||
path: 'field-test-pre-ride-check',
|
||||
builder: (context, state) => const FieldTestPreRideCheckScreen(),
|
||||
builder: (context, state) =>
|
||||
const FieldTestPreRideCheckScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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:go_router/go_router.dart';
|
||||
import '../../models/motorcycle_models.dart';
|
||||
import '../../services/entitlements/entitlement_providers.dart';
|
||||
import '../../services/entitlements/feature_access_service.dart';
|
||||
import '../entitlements/widgets/pro_lock_badge.dart';
|
||||
import 'garage_providers.dart';
|
||||
import 'widgets/motorcycle_photo.dart';
|
||||
|
||||
|
|
@ -9,7 +12,8 @@ class AddMotorcycleScreen extends ConsumerStatefulWidget {
|
|||
const AddMotorcycleScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AddMotorcycleScreen> createState() => _AddMotorcycleScreenState();
|
||||
ConsumerState<AddMotorcycleScreen> createState() =>
|
||||
_AddMotorcycleScreenState();
|
||||
}
|
||||
|
||||
class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||
|
|
@ -89,7 +93,11 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
_isLoading = true;
|
||||
});
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final variants = await repo.getVariants(_selectedYear!, _selectedMake!.id, model.id);
|
||||
final variants = await repo.getVariants(
|
||||
_selectedYear!,
|
||||
_selectedMake!.id,
|
||||
model.id,
|
||||
);
|
||||
|
||||
if (variants.length <= 1) {
|
||||
_selectedVariant = variants.isNotEmpty ? variants.first : null;
|
||||
|
|
@ -130,6 +138,21 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
}
|
||||
|
||||
Future<void> _saveMotorcycle() async {
|
||||
final bikes = ref.read(garageMotorcyclesStreamProvider).value ?? [];
|
||||
if (bikes.isNotEmpty) {
|
||||
final canAddMore = ref
|
||||
.read(featureAccessProvider)
|
||||
.canAccess(MotorSignalFeature.useMultipleMotorcycles);
|
||||
if (!canAddMore) {
|
||||
showProUpgradeSheet(
|
||||
context,
|
||||
ref,
|
||||
feature: MotorSignalFeature.useMultipleMotorcycles,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final selection = MotorcycleSelection(
|
||||
year: _selectedYear!,
|
||||
makeId: _selectedMake!.id,
|
||||
|
|
@ -143,12 +166,16 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
final controller = ref.read(garageControllerProvider);
|
||||
await controller.addMotorcycle(
|
||||
selection,
|
||||
nickname: _nicknameController.text.trim().isNotEmpty ? _nicknameController.text.trim() : null,
|
||||
nickname: _nicknameController.text.trim().isNotEmpty
|
||||
? _nicknameController.text.trim()
|
||||
: null,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added ${selection.displayName} to your Garage!')),
|
||||
SnackBar(
|
||||
content: Text('Added ${selection.displayName} to your Garage!'),
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
|
|
@ -175,7 +202,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_getStepTitle()),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: _goBack),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _goBack,
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
|
|
@ -195,23 +225,35 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
|
||||
String _getStepTitle() {
|
||||
switch (_step) {
|
||||
case 1: return 'Select Year';
|
||||
case 2: return 'Select Manufacturer';
|
||||
case 3: return 'Select Model (${_selectedMake?.name ?? ""})';
|
||||
case 4: return 'Select Variant';
|
||||
case 5: return 'Confirm Motorcycle';
|
||||
default: return 'Add Motorcycle';
|
||||
case 1:
|
||||
return 'Select Year';
|
||||
case 2:
|
||||
return 'Select Manufacturer';
|
||||
case 3:
|
||||
return 'Select Model (${_selectedMake?.name ?? ""})';
|
||||
case 4:
|
||||
return 'Select Variant';
|
||||
case 5:
|
||||
return 'Confirm Motorcycle';
|
||||
default:
|
||||
return 'Add Motorcycle';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
switch (_step) {
|
||||
case 1: return _buildYearStep();
|
||||
case 2: return _buildMakeStep();
|
||||
case 3: return _buildModelStep();
|
||||
case 4: return _buildVariantStep();
|
||||
case 5: return _buildConfirmStep();
|
||||
default: return const SizedBox();
|
||||
case 1:
|
||||
return _buildYearStep();
|
||||
case 2:
|
||||
return _buildMakeStep();
|
||||
case 3:
|
||||
return _buildModelStep();
|
||||
case 4:
|
||||
return _buildVariantStep();
|
||||
case 5:
|
||||
return _buildConfirmStep();
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,11 +271,22 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
final year = _years[index].year;
|
||||
return OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: _selectedYear == year ? Colors.cyanAccent : Colors.white24),
|
||||
backgroundColor: _selectedYear == year ? Colors.cyanAccent.withValues(alpha: 0.2) : null,
|
||||
side: BorderSide(
|
||||
color: _selectedYear == year ? Colors.cyanAccent : Colors.white24,
|
||||
),
|
||||
backgroundColor: _selectedYear == year
|
||||
? Colors.cyanAccent.withValues(alpha: 0.2)
|
||||
: null,
|
||||
),
|
||||
onPressed: () => _selectYear(year),
|
||||
child: Text('$year', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)),
|
||||
child: Text(
|
||||
'$year',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -241,7 +294,9 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
|
||||
Widget _buildMakeStep() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
final filtered = _makes.where((m) => m.name.toLowerCase().contains(query)).toList();
|
||||
final filtered = _makes
|
||||
.where((m) => m.name.toLowerCase().contains(query))
|
||||
.toList();
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
|
|
@ -253,7 +308,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
|
||||
filled: true,
|
||||
fillColor: Colors.white10,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
|
|
@ -262,12 +320,22 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10),
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, index) {
|
||||
final make = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(make.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||
title: Text(
|
||||
make.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white30,
|
||||
),
|
||||
onTap: () => _selectMake(make),
|
||||
);
|
||||
},
|
||||
|
|
@ -279,7 +347,9 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
|
||||
Widget _buildModelStep() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
final filtered = _models.where((m) => m.name.toLowerCase().contains(query)).toList();
|
||||
final filtered = _models
|
||||
.where((m) => m.name.toLowerCase().contains(query))
|
||||
.toList();
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
|
|
@ -291,7 +361,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
|
||||
filled: true,
|
||||
fillColor: Colors.white10,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
|
|
@ -300,13 +373,23 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10),
|
||||
separatorBuilder: (_, _) =>
|
||||
const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, index) {
|
||||
final model = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||
title: Text(
|
||||
model.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text('$_selectedYear ${_selectedMake?.name}'),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white30,
|
||||
),
|
||||
onTap: () => _selectModel(model),
|
||||
);
|
||||
},
|
||||
|
|
@ -325,8 +408,14 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
final variant = _variants[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(variant.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
trailing: const Icon(Icons.check_circle_outline, color: Colors.cyanAccent),
|
||||
title: Text(
|
||||
variant.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.cyanAccent,
|
||||
),
|
||||
onTap: () => _selectVariant(variant),
|
||||
),
|
||||
);
|
||||
|
|
@ -361,32 +450,74 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
Text(
|
||||
'$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (_selectedVariant?.name != null && _selectedVariant!.name != 'Standard') ...[
|
||||
if (_selectedVariant?.name != null &&
|
||||
_selectedVariant!.name != 'Standard') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(_selectedVariant!.name, style: const TextStyle(color: Colors.white70, fontSize: 16)),
|
||||
Text(
|
||||
_selectedVariant!.name,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 16),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (spec != null) ...[
|
||||
const Text('SPECIFICATION SUMMARY', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
const Text(
|
||||
'SPECIFICATION SUMMARY',
|
||||
style: TextStyle(
|
||||
color: Colors.cyanAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSpecChip('DISPLACEMENT', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : '—')),
|
||||
Expanded(
|
||||
child: _buildSpecChip(
|
||||
'DISPLACEMENT',
|
||||
spec.displacementCc != null
|
||||
? '${spec.displacementCc!.toStringAsFixed(0)} cc'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildSpecChip('HORSEPOWER', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP' : '—')),
|
||||
Expanded(
|
||||
child: _buildSpecChip(
|
||||
'HORSEPOWER',
|
||||
spec.horsepower != null
|
||||
? '${spec.horsepower!.toStringAsFixed(0)} HP'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSpecChip('TORQUE', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—')),
|
||||
Expanded(
|
||||
child: _buildSpecChip(
|
||||
'TORQUE',
|
||||
spec.torqueLbFt != null
|
||||
? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildSpecChip('WET WEIGHT', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : '—')),
|
||||
Expanded(
|
||||
child: _buildSpecChip(
|
||||
'WET WEIGHT',
|
||||
spec.wetWeightLbs != null
|
||||
? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs'
|
||||
: '—',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
|
@ -396,7 +527,9 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
decoration: InputDecoration(
|
||||
labelText: 'Nickname (Optional)',
|
||||
hintText: 'e.g., Blue Beast, The Track Bike',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
|
|
@ -406,10 +539,19 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
onPressed: _saveMotorcycle,
|
||||
child: const Text('ADD TO GARAGE', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)),
|
||||
child: const Text(
|
||||
'ADD TO GARAGE',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -427,9 +569,23 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white54,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import '../../models/motorcycle_models.dart';
|
|||
import '../../core/theme/evolution_theme_config.dart';
|
||||
import 'garage_providers.dart';
|
||||
import 'widgets/motorcycle_photo.dart';
|
||||
import '../../services/entitlements/entitlement_providers.dart';
|
||||
import '../../services/entitlements/feature_access_service.dart';
|
||||
import '../entitlements/widgets/pro_lock_badge.dart';
|
||||
|
||||
class GarageScreen extends ConsumerWidget {
|
||||
const GarageScreen({super.key});
|
||||
|
|
@ -17,51 +20,91 @@ class GarageScreen extends ConsumerWidget {
|
|||
final themeMode = ref.watch(dashboardThemeModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.black : null,
|
||||
backgroundColor: themeMode == DashboardThemeMode.evolution
|
||||
? Colors.black
|
||||
: null,
|
||||
appBar: AppBar(
|
||||
backgroundColor: themeMode == DashboardThemeMode.evolution ? Colors.transparent : null,
|
||||
backgroundColor: themeMode == DashboardThemeMode.evolution
|
||||
? Colors.transparent
|
||||
: null,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'My Garage',
|
||||
style: TextStyle(
|
||||
fontFamily: themeMode == DashboardThemeMode.evolution ? 'Sora' : null,
|
||||
fontFamily: themeMode == DashboardThemeMode.evolution
|
||||
? 'Sora'
|
||||
: null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline, color: Color(0xFF00E5FF)),
|
||||
icon: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: Color(0xFF00E5FF),
|
||||
),
|
||||
tooltip: 'Add Motorcycle',
|
||||
onPressed: () => context.push('/garage/add'),
|
||||
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: bikesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error loading garage: $err')),
|
||||
error: (err, stack) =>
|
||||
Center(child: Text('Error loading garage: $err')),
|
||||
data: (bikes) {
|
||||
if (bikes.isEmpty) return _buildEmptyState(context);
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: bikes.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildMotorcycleCard(context, ref, bikes[index], themeMode);
|
||||
return _buildMotorcycleCard(
|
||||
context,
|
||||
ref,
|
||||
bikes[index],
|
||||
themeMode,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty
|
||||
floatingActionButton:
|
||||
bikesAsync.value != null && bikesAsync.value!.isNotEmpty
|
||||
? FloatingActionButton.extended(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
icon: const Icon(Icons.two_wheeler),
|
||||
label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: () => context.push('/garage/add'),
|
||||
label: const Text(
|
||||
'ADD MOTORCYCLE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddMotorcycle(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<GarageMotorcycle>? bikes,
|
||||
) {
|
||||
if (bikes != null && bikes.isNotEmpty) {
|
||||
final canAddMore = ref
|
||||
.read(featureAccessProvider)
|
||||
.canAccess(MotorSignalFeature.useMultipleMotorcycles);
|
||||
if (!canAddMore) {
|
||||
showProUpgradeSheet(
|
||||
context,
|
||||
ref,
|
||||
feature: MotorSignalFeature.useMultipleMotorcycles,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.push('/garage/add');
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
|
|
@ -73,7 +116,11 @@ class GarageScreen extends ConsumerWidget {
|
|||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'YOUR GARAGE IS EMPTY',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 1.5),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
|
|
@ -86,11 +133,22 @@ class GarageScreen extends ConsumerWidget {
|
|||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 14,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
label: const Text(
|
||||
'ADD MOTORCYCLE',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
onPressed: () => context.push('/garage/add'),
|
||||
),
|
||||
],
|
||||
|
|
@ -99,11 +157,18 @@ class GarageScreen extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMotorcycleCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, DashboardThemeMode themeMode) {
|
||||
Widget _buildMotorcycleCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
GarageMotorcycle bike,
|
||||
DashboardThemeMode themeMode,
|
||||
) {
|
||||
MotorcycleSpecification? spec;
|
||||
if (bike.specificationJson != null) {
|
||||
try {
|
||||
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!));
|
||||
spec = MotorcycleSpecification.fromJson(
|
||||
json.decode(bike.specificationJson!),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
|
@ -164,11 +229,18 @@ class GarageScreen extends ConsumerWidget {
|
|||
),
|
||||
if (bike.isDefault)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
color: const Color(
|
||||
0xFF00E5FF,
|
||||
).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFF00E5FF)),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF00E5FF),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'CURRENT',
|
||||
|
|
@ -190,9 +262,13 @@ class GarageScreen extends ConsumerWidget {
|
|||
: '${bike.year} • ${bike.makeName}',
|
||||
style: TextStyle(
|
||||
fontFamily: isEvolution ? 'JetBrains Mono' : null,
|
||||
color: isEvolution ? const Color(0xFF00E5FF) : Colors.white70,
|
||||
color: isEvolution
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white70,
|
||||
fontSize: 13,
|
||||
fontWeight: isEvolution ? FontWeight.w700 : FontWeight.normal,
|
||||
fontWeight: isEvolution
|
||||
? FontWeight.w700
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -216,9 +292,15 @@ class GarageScreen extends ConsumerWidget {
|
|||
side: const BorderSide(color: Colors.white30),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
|
||||
ref
|
||||
.read(garageControllerProvider)
|
||||
.setDefaultMotorcycle(bike.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Set ${bike.makeName} ${bike.modelName} as current motorcycle.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Set as Current'),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../services/export_service.dart';
|
||||
import '../../services/entitlements/entitlement_providers.dart';
|
||||
import '../../services/entitlements/feature_access_service.dart';
|
||||
import '../entitlements/widgets/pro_lock_badge.dart';
|
||||
import '../replay/replay_screen.dart';
|
||||
import '../map/map_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -260,12 +263,38 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
|||
label: const Text('Export GPX'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(exportServiceProvider).exportRide(r.id, asGpx: false);
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final isProCsv = ref
|
||||
.watch(featureAccessProvider)
|
||||
.canAccess(MotorSignalFeature.csvExport);
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
if (!isProCsv) {
|
||||
showProUpgradeSheet(
|
||||
context,
|
||||
ref,
|
||||
feature: MotorSignalFeature.csvExport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(exportServiceProvider)
|
||||
.exportRide(r.id, asGpx: false);
|
||||
},
|
||||
icon: const Icon(Icons.table_chart),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Export CSV'),
|
||||
if (!isProCsv) ...[
|
||||
const SizedBox(width: 8),
|
||||
const ProBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.table_chart),
|
||||
label: const Text('Export CSV'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
import '../../../database/database.dart';
|
||||
import '../../../services/export_service.dart';
|
||||
import '../../../services/entitlements/entitlement_providers.dart';
|
||||
import '../../../services/entitlements/feature_access_service.dart';
|
||||
import '../../entitlements/widgets/pro_lock_badge.dart';
|
||||
import '../../../services/github/github_sync_service.dart';
|
||||
import '../../replay/replay_screen.dart';
|
||||
import '../../map/map_screen.dart';
|
||||
|
|
@ -19,8 +22,12 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null)
|
||||
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'.toUpperCase()
|
||||
final bikeName =
|
||||
(ride.motorcycleYear != null &&
|
||||
ride.motorcycleMake != null &&
|
||||
ride.motorcycleModel != null)
|
||||
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
|
||||
.toUpperCase()
|
||||
: 'YAMAHA MT-09 SP';
|
||||
|
||||
final durationSec = ride.endTime != null
|
||||
|
|
@ -29,7 +36,8 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
final durationMin = (durationSec / 60).toStringAsFixed(0);
|
||||
final movingMin = (ride.movingTime / 60).toStringAsFixed(0);
|
||||
|
||||
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
|
||||
final isLandscape =
|
||||
MediaQuery.of(context).orientation == Orientation.landscape;
|
||||
final syncService = ref.watch(githubSyncServiceProvider);
|
||||
final isUploaded = syncService.isRideUploaded(ride.id);
|
||||
final commitSha = syncService.getRideCommitSha(ride.id);
|
||||
|
|
@ -59,6 +67,17 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
icon: const Icon(Icons.share, color: Color(0xFF00E5FF)),
|
||||
tooltip: 'Export CSV',
|
||||
onPressed: () async {
|
||||
final isPro = ref
|
||||
.read(featureAccessProvider)
|
||||
.canAccess(MotorSignalFeature.csvExport);
|
||||
if (!isPro) {
|
||||
showProUpgradeSheet(
|
||||
context,
|
||||
ref,
|
||||
feature: MotorSignalFeature.csvExport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final exporter = ref.read(exportServiceProvider);
|
||||
await exporter.exportRide(ride.id, asGpx: false);
|
||||
},
|
||||
|
|
@ -71,7 +90,13 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// GitHub Sync Status Card
|
||||
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha),
|
||||
_buildGitHubSyncBanner(
|
||||
context,
|
||||
ref,
|
||||
syncService,
|
||||
isUploaded,
|
||||
commitSha,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Top Row: Motorcycle Blueprint & Route Preview Blueprint Cards
|
||||
|
|
@ -85,12 +110,19 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF1F1F1F),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler, color: Colors.white38, size: 40),
|
||||
const Icon(
|
||||
Icons.two_wheeler,
|
||||
color: Colors.white38,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
bikeName,
|
||||
|
|
@ -128,12 +160,19 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0D0D0D),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF00E5FF).withAlpha(100), width: 1.2),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF00E5FF).withAlpha(100),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.map_rounded, color: Color(0xFF00E5FF), size: 36),
|
||||
const Icon(
|
||||
Icons.map_rounded,
|
||||
color: Color(0xFF00E5FF),
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'ROUTE PREVIEW',
|
||||
|
|
@ -147,7 +186,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00E5FF).withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -185,11 +227,26 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMetricRow('DISTANCE:', '${ride.distanceMiles.toStringAsFixed(2)} mi', 'DURATION:', '$durationMin min'),
|
||||
_buildMetricRow(
|
||||
'DISTANCE:',
|
||||
'${ride.distanceMiles.toStringAsFixed(2)} mi',
|
||||
'DURATION:',
|
||||
'$durationMin min',
|
||||
),
|
||||
const Divider(color: Color(0xFF222222), height: 18),
|
||||
_buildMetricRow('MOVING TIME:', '$movingMin min', 'AVG SPEED:', '${ride.averageSpeed.toStringAsFixed(1)} mph'),
|
||||
_buildMetricRow(
|
||||
'MOVING TIME:',
|
||||
'$movingMin min',
|
||||
'AVG SPEED:',
|
||||
'${ride.averageSpeed.toStringAsFixed(1)} mph',
|
||||
),
|
||||
const Divider(color: Color(0xFF222222), height: 18),
|
||||
_buildMetricRow('TOP SPEED:', '${ride.topSpeedMph.toStringAsFixed(1)} mph', 'DATE:', DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal())),
|
||||
_buildMetricRow(
|
||||
'TOP SPEED:',
|
||||
'${ride.topSpeedMph.toStringAsFixed(1)} mph',
|
||||
'DATE:',
|
||||
DateFormat('MM/dd/yyyy').format(ride.startTime.toLocal()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -243,7 +300,13 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
)
|
||||
: Column(
|
||||
children: events.take(15).map((event) => _buildInteractiveEventTile(context, event)).toList(),
|
||||
children: events
|
||||
.take(15)
|
||||
.map(
|
||||
(event) =>
|
||||
_buildInteractiveEventTile(context, event),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -263,13 +326,22 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF00E5FF),
|
||||
side: const BorderSide(color: Color(0xFF00E5FF), width: 1.5),
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF00E5FF),
|
||||
width: 1.5,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'REPLAY RIDE',
|
||||
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w800, fontSize: 12),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -282,33 +354,81 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
side: const BorderSide(color: Color(0xFF333333), width: 1.2),
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF333333),
|
||||
width: 1.2,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'EXPORT GPX',
|
||||
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
final exporter = ref.read(exportServiceProvider);
|
||||
await exporter.exportRide(ride.id, asGpx: false);
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final isProCsv = ref
|
||||
.watch(featureAccessProvider)
|
||||
.canAccess(MotorSignalFeature.csvExport);
|
||||
return OutlinedButton(
|
||||
onPressed: () async {
|
||||
if (!isProCsv) {
|
||||
showProUpgradeSheet(
|
||||
context,
|
||||
ref,
|
||||
feature: MotorSignalFeature.csvExport,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final exporter = ref.read(exportServiceProvider);
|
||||
await exporter.exportRide(ride.id, asGpx: false);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF333333),
|
||||
width: 1.2,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'EXPORT CSV',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (!isProCsv) ...[
|
||||
const SizedBox(width: 6),
|
||||
const ProBadge(
|
||||
fontSize: 8,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
side: const BorderSide(color: Color(0xFF333333), width: 1.2),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text(
|
||||
'EXPORT CSV',
|
||||
style: TextStyle(fontFamily: 'Sora', fontWeight: FontWeight.w700, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -336,15 +456,21 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
color: const Color(0xFF0F141C),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isUploaded ? const Color(0xFF00E676).withValues(alpha: 0.5) : const Color(0xFF00E5FF).withValues(alpha: 0.4),
|
||||
color: isUploaded
|
||||
? const Color(0xFF00E676).withValues(alpha: 0.5)
|
||||
: const Color(0xFF00E5FF).withValues(alpha: 0.4),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isUploaded ? Icons.cloud_done : (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined),
|
||||
color: isUploaded ? const Color(0xFF00E676) : const Color(0xFF00E5FF),
|
||||
isUploaded
|
||||
? Icons.cloud_done
|
||||
: (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined),
|
||||
color: isUploaded
|
||||
? const Color(0xFF00E676)
|
||||
: const Color(0xFF00E5FF),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -353,7 +479,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isUploaded ? 'GITHUB LOGS SYNCHRONIZED' : (isSyncing ? 'UPLOADING TO GITHUB...' : 'GITHUB LOG SYNC'),
|
||||
isUploaded
|
||||
? 'GITHUB LOGS SYNCHRONIZED'
|
||||
: (isSyncing
|
||||
? 'UPLOADING TO GITHUB...'
|
||||
: 'GITHUB LOG SYNC'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Sora',
|
||||
fontSize: 11,
|
||||
|
|
@ -379,20 +509,32 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
onPressed: isSyncing
|
||||
? null
|
||||
: () async {
|
||||
final result = await syncService.uploadRide(ride.id, isManual: true);
|
||||
final result = await syncService.uploadRide(
|
||||
ride.id,
|
||||
isManual: true,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
backgroundColor: result.success ? const Color(0xFF00E676) : Colors.redAccent,
|
||||
backgroundColor: result.success
|
||||
? const Color(0xFF00E676)
|
||||
: Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
backgroundColor: const Color(
|
||||
0xFF00E5FF,
|
||||
).withValues(alpha: 0.15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isSyncing ? 'UPLOADING...' : 'UPLOAD NOW',
|
||||
|
|
@ -411,15 +553,44 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
|
||||
Widget _buildResponsiveHandlingCards(BuildContext context, bool isLandscape) {
|
||||
final cards = [
|
||||
_buildHandlingKpiCard('MAX LEAN (LEFT)', '${ride.maxLeanLeft.toStringAsFixed(1)}°', Icons.turn_left, const Color(0xFF00E5FF)),
|
||||
_buildHandlingKpiCard('MAX LEAN (RIGHT)', '${ride.maxLeanRight.toStringAsFixed(1)}°', Icons.turn_right, const Color(0xFF00E5FF)),
|
||||
_buildHandlingKpiCard('MAX ACCELERATION', '${ride.maxAccelG.toStringAsFixed(2)} G', Icons.speed, const Color(0xFF76FF03)),
|
||||
_buildHandlingKpiCard('MAX BRAKING', '${ride.maxBrakingG.toStringAsFixed(2)} G', Icons.pan_tool_alt, const Color(0xFFFF5252)),
|
||||
_buildHandlingKpiCard(
|
||||
'MAX LEAN (LEFT)',
|
||||
'${ride.maxLeanLeft.toStringAsFixed(1)}°',
|
||||
Icons.turn_left,
|
||||
const Color(0xFF00E5FF),
|
||||
),
|
||||
_buildHandlingKpiCard(
|
||||
'MAX LEAN (RIGHT)',
|
||||
'${ride.maxLeanRight.toStringAsFixed(1)}°',
|
||||
Icons.turn_right,
|
||||
const Color(0xFF00E5FF),
|
||||
),
|
||||
_buildHandlingKpiCard(
|
||||
'MAX ACCELERATION',
|
||||
'${ride.maxAccelG.toStringAsFixed(2)} G',
|
||||
Icons.speed,
|
||||
const Color(0xFF76FF03),
|
||||
),
|
||||
_buildHandlingKpiCard(
|
||||
'MAX BRAKING',
|
||||
'${ride.maxBrakingG.toStringAsFixed(2)} G',
|
||||
Icons.pan_tool_alt,
|
||||
const Color(0xFFFF5252),
|
||||
),
|
||||
];
|
||||
|
||||
if (isLandscape) {
|
||||
return Row(
|
||||
children: cards.map((c) => Expanded(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 4), child: c))).toList(),
|
||||
children: cards
|
||||
.map(
|
||||
(c) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: c,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -444,7 +615,12 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildHandlingKpiCard(String label, String value, IconData icon, Color accentColor) {
|
||||
Widget _buildHandlingKpiCard(
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color accentColor,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -499,22 +675,32 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
} else if (event.eventType.toLowerCase().contains('brak')) {
|
||||
badgeColor = const Color(0xFFFF5252);
|
||||
eventIcon = Icons.arrow_downward;
|
||||
} else if (event.eventType.toLowerCase().contains('corner') || event.eventType.toLowerCase().contains('lean')) {
|
||||
} else if (event.eventType.toLowerCase().contains('corner') ||
|
||||
event.eventType.toLowerCase().contains('lean')) {
|
||||
badgeColor = const Color(0xFF00E5FF);
|
||||
eventIcon = Icons.cached;
|
||||
}
|
||||
|
||||
String formattedValue = '${event.value.toStringAsFixed(1)}°';
|
||||
if (event.eventType.toLowerCase().contains('accel') || event.eventType.toLowerCase().contains('brak')) {
|
||||
if (event.eventType.toLowerCase().contains('accel') ||
|
||||
event.eventType.toLowerCase().contains('brak')) {
|
||||
formattedValue = '${event.value.toStringAsFixed(2)} G';
|
||||
}
|
||||
|
||||
final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal());
|
||||
final timeStr = DateFormat(
|
||||
'HH:mm:ss.SSS',
|
||||
).format(event.timestamp.toLocal());
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showEventDetailsDialog(context, event, formattedValue, timeStr, badgeColor),
|
||||
onTap: () => _showEventDetailsDialog(
|
||||
context,
|
||||
event,
|
||||
formattedValue,
|
||||
timeStr,
|
||||
badgeColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
|
|
@ -584,7 +770,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
backgroundColor: const Color(0xFF111111),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: badgeColor.withValues(alpha: 0.5), width: 1.2),
|
||||
side: BorderSide(
|
||||
color: badgeColor.withValues(alpha: 0.5),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
|
|
@ -614,7 +803,10 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
'GPS Coordinates:',
|
||||
'${event.latitude!.toStringAsFixed(6)}, ${event.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
_buildDialogRow('Event ID / Seq:', '#${event.id} (Ride #${event.rideId})'),
|
||||
_buildDialogRow(
|
||||
'Event ID / Seq:',
|
||||
'#${event.id} (Ride #${event.rideId})',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
|
|
@ -626,7 +818,11 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
child: const Text(
|
||||
'Authoritative telemetry sampled at 20 Hz. Moving metrics gated at speed >= 3.0 MPH.',
|
||||
style: TextStyle(fontFamily: 'Inter', fontSize: 10, color: Colors.white54),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -635,7 +831,14 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
if (event.latitude != null && event.longitude != null)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.map, color: Color(0xFF00E5FF), size: 16),
|
||||
label: const Text('VIEW ON MAP', style: TextStyle(color: Color(0xFF00E5FF), fontFamily: 'JetBrains Mono', fontWeight: FontWeight.bold)),
|
||||
label: const Text(
|
||||
'VIEW ON MAP',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.of(context).push(
|
||||
|
|
@ -647,7 +850,13 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('CLOSE', style: TextStyle(color: Colors.white70, fontFamily: 'JetBrains Mono')),
|
||||
child: const Text(
|
||||
'CLOSE',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontFamily: 'JetBrains Mono',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -660,12 +869,24 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, color: Colors.white54)),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -686,7 +907,12 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricRow(String label1, String value1, String label2, String value2) {
|
||||
Widget _buildMetricRow(
|
||||
String label1,
|
||||
String value1,
|
||||
String label2,
|
||||
String value2,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -695,11 +921,21 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
children: [
|
||||
Text(
|
||||
label1,
|
||||
style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value1,
|
||||
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -712,11 +948,21 @@ class EvolutionRideDetailsView extends ConsumerWidget {
|
|||
children: [
|
||||
Text(
|
||||
label2,
|
||||
style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white54),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value2,
|
||||
style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, fontWeight: FontWeight.w800, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'JetBrains Mono',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../../services/auth/auth_state.dart';
|
|||
import '../../services/auth/auth_user.dart';
|
||||
import '../../services/sync/cloud_restore_controller.dart';
|
||||
import '../../services/sync/sync_providers.dart';
|
||||
import 'widgets/current_plan_card.dart';
|
||||
|
||||
class AccountScreen extends ConsumerStatefulWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
|
@ -598,13 +599,27 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: Colors.amberAccent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.5)),
|
||||
border: Border.all(
|
||||
color: Colors.amberAccent.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))),
|
||||
TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')),
|
||||
IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Your email is not verified. Verify it to protect account recovery.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _sendVerification(),
|
||||
child: const Text('VERIFY'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh verification status',
|
||||
onPressed: () => _reloadUser(),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -637,7 +652,9 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
),
|
||||
icon: const Icon(Icons.delete_forever_outlined, size: 18),
|
||||
label: const Text('DELETE ACCOUNT'),
|
||||
onPressed: () => _handleDeleteAccount(user),
|
||||
|
|
@ -647,6 +664,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CurrentPlanCard(isEvolution: isEvolution),
|
||||
const SizedBox(height: 16),
|
||||
_buildCloudSyncSection(
|
||||
context: context,
|
||||
isEvolution: isEvolution,
|
||||
|
|
@ -762,6 +781,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CurrentPlanCard(isEvolution: isEvolution),
|
||||
const SizedBox(height: 16),
|
||||
_buildCloudSyncSection(
|
||||
context: context,
|
||||
isEvolution: isEvolution,
|
||||
|
|
@ -924,10 +945,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_buildInfoRow(
|
||||
'Cloud Provider',
|
||||
'MotorCloud backup',
|
||||
),
|
||||
_buildInfoRow('Cloud Provider', 'MotorCloud backup'),
|
||||
if (syncState.lastSuccessfulSync != null)
|
||||
_buildInfoRow(
|
||||
'Last Successful Sync',
|
||||
|
|
@ -1119,9 +1137,15 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
Future<void> _sendVerification() async {
|
||||
try {
|
||||
await ref.read(authNotifierProvider.notifier).sendEmailVerification();
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Verification email sent.')));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Verification email sent.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not send verification email: $e')));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not send verification email: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1129,7 +1153,10 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
try {
|
||||
await ref.read(authNotifierProvider.notifier).reloadCurrentUser();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not refresh account status: $e')));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not refresh account status: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1148,16 +1175,26 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Deleting your account will remove your MotorCloud backup data and account identity.'),
|
||||
const Text(
|
||||
'Deleting your account will remove your MotorCloud backup data and account identity.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Rides, telemetry/history, and garage data stored on this device will remain available.'),
|
||||
const Text(
|
||||
'Rides, telemetry/history, and garage data stored on this device will remain available.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Deleting your MotorSignal account does not cancel a Google Play subscription. Subscriptions must be managed in Google Play.',
|
||||
),
|
||||
if (user.providerId == 'password') ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Current password'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Current password',
|
||||
),
|
||||
validator: (value) => value == null || value.isEmpty
|
||||
? 'Enter your current password'
|
||||
: null,
|
||||
|
|
@ -1202,9 +1239,13 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
}
|
||||
|
||||
try {
|
||||
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: result.password);
|
||||
await ref
|
||||
.read(accountLifecycleServiceProvider)
|
||||
.deleteAccount(password: result.password);
|
||||
} catch (e) {
|
||||
debugPrint('[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted');
|
||||
debugPrint(
|
||||
'[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted',
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
const SnackBar(
|
||||
|
|
@ -1223,12 +1264,17 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
final user = ref.read(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) return;
|
||||
final rides = await db.select(db.rides).get();
|
||||
final owned = rides.where((ride) => ride.cloudOwnerUid == user.uid).toList();
|
||||
final unclaimed = rides.where((ride) => ride.cloudOwnerUid == null).toList();
|
||||
final owned = rides
|
||||
.where((ride) => ride.cloudOwnerUid == user.uid)
|
||||
.toList();
|
||||
final unclaimed = rides
|
||||
.where((ride) => ride.cloudOwnerUid == null)
|
||||
.toList();
|
||||
final otherOwned = rides.length - owned.length - unclaimed.length;
|
||||
var claimUnclaimed = false;
|
||||
if (unclaimed.isNotEmpty && mounted) {
|
||||
claimUnclaimed = await showDialog<bool>(
|
||||
claimUnclaimed =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Back Up Device Rides?'),
|
||||
|
|
@ -1236,8 +1282,14 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
'This will associate ${unclaimed.length} device-local ride(s) with your signed-in MotorCloud account. Rides already associated with another account will not be included.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('BACK UP')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('BACK UP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
|
|
@ -1477,7 +1529,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
}
|
||||
if (mounted && !ref.read(authNotifierProvider).hasError) {
|
||||
setState(() {
|
||||
_statusMessage = 'Password reset instructions sent. Check your email.';
|
||||
_statusMessage =
|
||||
'Password reset instructions sent. Check your email.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import '../../core/theme/evolution_theme_config.dart';
|
|||
import '../../services/osm_oauth_service.dart';
|
||||
import '../../services/auth/auth_providers.dart';
|
||||
import 'widgets/evolution_settings_view.dart';
|
||||
import 'widgets/current_plan_card.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -73,6 +74,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
_buildSectionHeader('Membership & Entitlement'),
|
||||
const SizedBox(height: 12),
|
||||
const CurrentPlanCard(),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('Account & Cloud Identity'),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
|
|
@ -90,7 +95,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isAuthed ? Icons.account_circle : Icons.account_circle_outlined,
|
||||
isAuthed
|
||||
? Icons.account_circle
|
||||
: Icons.account_circle_outlined,
|
||||
color: Colors.cyanAccent,
|
||||
size: 24,
|
||||
),
|
||||
|
|
@ -100,7 +107,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isAuthed ? (user?.displayLabel ?? 'Rider Account') : 'Sign In / Cloud Account',
|
||||
isAuthed
|
||||
? (user?.displayLabel ?? 'Rider Account')
|
||||
: 'Sign In / Cloud Account',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -109,7 +118,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isAuthed
|
||||
? (user?.email ?? 'Cloud Identity Active • Offline rides protected')
|
||||
? (user?.email ??
|
||||
'Cloud Identity Active • Offline rides protected')
|
||||
: 'Optional cloud identity • Local riding always active',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
|
|
@ -120,23 +130,38 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10,
|
||||
color: isAuthed
|
||||
? Colors.cyanAccent.withValues(alpha: 0.15)
|
||||
: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: isAuthed ? Colors.cyanAccent : Colors.white24),
|
||||
border: Border.all(
|
||||
color: isAuthed
|
||||
? Colors.cyanAccent
|
||||
: Colors.white24,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isAuthed ? 'SIGNED IN' : 'LOCAL',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isAuthed ? Colors.cyanAccent : Colors.white54,
|
||||
color: isAuthed
|
||||
? Colors.cyanAccent
|
||||
: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right, color: Colors.white54, size: 20),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white54,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -156,7 +181,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined, color: Colors.cyanAccent, size: 20),
|
||||
Icon(
|
||||
Icons.palette_outlined,
|
||||
color: Colors.cyanAccent,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
|
@ -190,12 +219,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
segments: const [
|
||||
ButtonSegment(
|
||||
value: DashboardThemeMode.classic,
|
||||
label: Text('Classic Cockpit', style: TextStyle(fontSize: 12)),
|
||||
icon: Icon(Icons.dashboard_customize_outlined, size: 18),
|
||||
label: Text(
|
||||
'Classic Cockpit',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.dashboard_customize_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: DashboardThemeMode.evolution,
|
||||
label: Text('MotorSignal Evolution', style: TextStyle(fontSize: 12)),
|
||||
label: Text(
|
||||
'MotorSignal Evolution',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: Icon(Icons.speed, size: 18),
|
||||
),
|
||||
],
|
||||
|
|
@ -257,12 +295,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
segments: const [
|
||||
ButtonSegment(
|
||||
value: AppMapType.openStreetMap,
|
||||
label: Text('OpenStreetMap', style: TextStyle(fontSize: 12)),
|
||||
label: Text(
|
||||
'OpenStreetMap',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: Icon(Icons.public, size: 18),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: AppMapType.googleMaps,
|
||||
label: Text('Google Maps', style: TextStyle(fontSize: 12)),
|
||||
label: Text(
|
||||
'Google Maps',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
icon: Icon(Icons.map_outlined, size: 18),
|
||||
),
|
||||
],
|
||||
|
|
@ -293,7 +337,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
child: Text(
|
||||
'OpenStreetMap Account',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -303,10 +350,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isAuthed ? Colors.cyanAccent : Colors.grey,
|
||||
color: isAuthed
|
||||
? Colors.cyanAccent
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
backgroundColor: isAuthed ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10,
|
||||
backgroundColor: isAuthed
|
||||
? Colors.cyanAccent.withValues(alpha: 0.15)
|
||||
: Colors.white10,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
|
|
@ -317,22 +368,44 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundImage: user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null,
|
||||
child: user.avatarUrl == null ? const Icon(Icons.person) : null,
|
||||
backgroundImage: user.avatarUrl != null
|
||||
? NetworkImage(user.avatarUrl!)
|
||||
: null,
|
||||
child: user.avatarUrl == null
|
||||
? const Icon(Icons.person)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
user.displayName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Changesets: ${user.changesetCount} • GPS Trace Sync Enabled',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
title: Text(user.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text('Changesets: ${user.changesetCount} • GPS Trace Sync Enabled', style: const TextStyle(fontSize: 11)),
|
||||
trailing: TextButton.icon(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
),
|
||||
icon: const Icon(Icons.link_off, size: 16),
|
||||
label: const Text('Disconnect', style: TextStyle(fontSize: 12)),
|
||||
onPressed: () => ref.read(osmOAuthServiceProvider.notifier).logout(),
|
||||
label: const Text(
|
||||
'Disconnect',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
onPressed: () => ref
|
||||
.read(osmOAuthServiceProvider.notifier)
|
||||
.logout(),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Text(
|
||||
'Sign in to OpenStreetMap via OAuth 2.0 PKCE to upload ride GPX tracks and contribute traces.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white60),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white60,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton.icon(
|
||||
|
|
@ -342,10 +415,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
minimumSize: const Size(double.infinity, 38),
|
||||
),
|
||||
icon: const Icon(Icons.login, size: 18),
|
||||
label: const Text('Connect OpenStreetMap', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
label: const Text(
|
||||
'Connect OpenStreetMap',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
onPressed: osmAuth.isLoading
|
||||
? null
|
||||
: () => ref.read(osmOAuthServiceProvider.notifier).startLoginFlow(),
|
||||
: () => ref
|
||||
.read(osmOAuthServiceProvider.notifier)
|
||||
.startLoginFlow(),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
@ -559,7 +640,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
controller: _communityKeyController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Community / RapidAPI Key',
|
||||
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
||||
hintText:
|
||||
'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
||||
helperText:
|
||||
'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
||||
helperMaxLines: 2,
|
||||
|
|
@ -576,7 +658,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
controller: _communityEndpointController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Custom Endpoint URL (Optional)',
|
||||
hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL',
|
||||
hintText:
|
||||
'e.g. https://google-map-places.p.rapidapi.com or custom URL',
|
||||
helperText:
|
||||
'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
|
||||
helperMaxLines: 2,
|
||||
|
|
@ -585,7 +668,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
onChanged: (val) {
|
||||
ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val);
|
||||
ref
|
||||
.read(communityEndpointUrlProvider.notifier)
|
||||
.setEndpoint(val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
|
@ -609,15 +694,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
if (mounted) {
|
||||
String msg = '';
|
||||
if (status == RoadAlertProviderStatus.connected) {
|
||||
msg = 'Community / OpenWebNinja API Connected successfully!';
|
||||
} else if (status == RoadAlertProviderStatus.authError) {
|
||||
msg = 'Authentication Error: Invalid or unauthorized key.';
|
||||
} else if (status == RoadAlertProviderStatus.notConfigured) {
|
||||
if (status ==
|
||||
RoadAlertProviderStatus.connected) {
|
||||
msg =
|
||||
'Community / OpenWebNinja API Connected successfully!';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.authError) {
|
||||
msg =
|
||||
'Authentication Error: Invalid or unauthorized key.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.notConfigured) {
|
||||
msg = 'API Key is empty.';
|
||||
} else if (status == RoadAlertProviderStatus.networkError) {
|
||||
msg = 'Network Error: Check internet connection or endpoint.';
|
||||
} else if (status == RoadAlertProviderStatus.rateLimited) {
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.networkError) {
|
||||
msg =
|
||||
'Network Error: Check internet connection or endpoint.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.rateLimited) {
|
||||
msg = 'Rate Limited: Plan limit reached.';
|
||||
} else {
|
||||
msg = 'Status: ${status.name}';
|
||||
|
|
@ -695,15 +788,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
if (mounted) {
|
||||
String msg = '';
|
||||
if (status == RoadAlertProviderStatus.connected) {
|
||||
if (status ==
|
||||
RoadAlertProviderStatus.connected) {
|
||||
msg = 'TomTom API Connected successfully!';
|
||||
} else if (status == RoadAlertProviderStatus.authError) {
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.authError) {
|
||||
msg = 'Authentication Error: Invalid Key.';
|
||||
} else if (status == RoadAlertProviderStatus.notConfigured) {
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.notConfigured) {
|
||||
msg = 'Key is empty.';
|
||||
} else if (status == RoadAlertProviderStatus.networkError) {
|
||||
msg = 'Network Error: Failed to connect to API.';
|
||||
} else if (status == RoadAlertProviderStatus.rateLimited) {
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.networkError) {
|
||||
msg =
|
||||
'Network Error: Failed to connect to API.';
|
||||
} else if (status ==
|
||||
RoadAlertProviderStatus.rateLimited) {
|
||||
msg = 'Rate Limited: Too many requests.';
|
||||
} else {
|
||||
msg = 'Status: ${status.name}';
|
||||
|
|
@ -799,9 +898,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
lastUpdated: DateTime.now(),
|
||||
source: 'Test',
|
||||
);
|
||||
ref.read(roadAlertServiceProvider).simulateAlert(alert);
|
||||
ref
|
||||
.read(roadAlertServiceProvider)
|
||||
.simulateAlert(alert);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Simulated Police Alert sent to Dashboard.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Simulated Police Alert sent to Dashboard.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -825,9 +930,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
lastUpdated: DateTime.now(),
|
||||
source: 'Test',
|
||||
);
|
||||
ref.read(roadAlertServiceProvider).simulateAlert(alert);
|
||||
ref
|
||||
.read(roadAlertServiceProvider)
|
||||
.simulateAlert(alert);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Simulated Hazard Alert sent to Dashboard.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Simulated Hazard Alert sent to Dashboard.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -891,7 +1002,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot calibrate while a ride is actively recording.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cannot calibrate while a ride is actively recording.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1011,7 +1126,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot clear history while a ride is actively recording.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cannot clear history while a ride is actively recording.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1020,7 +1139,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear All Ride History?'),
|
||||
content: const Text('This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.'),
|
||||
content: const Text(
|
||||
'This will permanently delete all saved rides, telemetry points, and events. This action cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
|
|
@ -1028,7 +1149,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('CLEAR ALL', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
|
||||
child: const Text(
|
||||
'CLEAR ALL',
|
||||
style: TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1044,7 +1171,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
});
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All ride history cleared.')),
|
||||
const SnackBar(
|
||||
content: Text('All ride history cleared.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1092,7 +1221,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
children: [
|
||||
Icon(
|
||||
Icons.biotech,
|
||||
color: isEnabled ? Colors.greenAccent : Colors.white54,
|
||||
color: isEnabled
|
||||
? Colors.greenAccent
|
||||
: Colors.white54,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -1102,11 +1233,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
children: [
|
||||
const Text(
|
||||
'Field Test Mode',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Silent black-box diagnostic recorder',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white60),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white60,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
|
@ -1120,7 +1257,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
value: isEnabled,
|
||||
activeThumbColor: Colors.greenAccent,
|
||||
onChanged: (val) {
|
||||
ref.read(fieldTestModeEnabledProvider.notifier).setEnabled(val);
|
||||
ref
|
||||
.read(fieldTestModeEnabledProvider.notifier)
|
||||
.setEnabled(val);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
@ -1129,30 +1268,44 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Logger Status:', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(
|
||||
'Logger Status:',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent.withValues(alpha: 0.15)
|
||||
: (isEnabled ? Colors.cyanAccent.withValues(alpha: 0.15) : Colors.white10),
|
||||
: (isEnabled
|
||||
? Colors.cyanAccent.withValues(alpha: 0.15)
|
||||
: Colors.white10),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent
|
||||
: (isEnabled ? Colors.cyanAccent : Colors.white30),
|
||||
: (isEnabled
|
||||
? Colors.cyanAccent
|
||||
: Colors.white30),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
activeSession != null
|
||||
? 'RECORDING (Ride #$activeSession)'
|
||||
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'),
|
||||
: (isEnabled
|
||||
? 'ARMED (Auto-starts on Ride)'
|
||||
: 'OFF'),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: activeSession != null
|
||||
? Colors.greenAccent
|
||||
: (isEnabled ? Colors.cyanAccent : Colors.white54),
|
||||
: (isEnabled
|
||||
? Colors.cyanAccent
|
||||
: Colors.white54),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1164,34 +1317,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent.withValues(alpha: 0.2),
|
||||
backgroundColor: Colors.greenAccent.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent),
|
||||
),
|
||||
icon: const Icon(Icons.fact_check, size: 18),
|
||||
label: const Text('Pre-Ride Check', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
onPressed: () => context.go('/settings/field-test-pre-ride-check'),
|
||||
label: const Text(
|
||||
'Pre-Ride Check',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
context.go('/settings/field-test-pre-ride-check'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||
backgroundColor: Colors.cyanAccent.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
foregroundColor: Colors.cyanAccent,
|
||||
side: const BorderSide(color: Colors.cyanAccent),
|
||||
),
|
||||
icon: const Icon(Icons.assessment, size: 18),
|
||||
label: const Text('View Reports', style: TextStyle(fontSize: 12)),
|
||||
onPressed: () => context.go('/settings/field-test-report'),
|
||||
label: const Text(
|
||||
'View Reports',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
onPressed: () =>
|
||||
context.go('/settings/field-test-report'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.receipt_long, size: 18),
|
||||
label: const Text('Live Log', style: TextStyle(fontSize: 12)),
|
||||
onPressed: () => context.go('/settings/field-test-live-log'),
|
||||
label: const Text(
|
||||
'Live Log',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
onPressed: () =>
|
||||
context.go('/settings/field-test-live-log'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -1200,14 +1372,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
),
|
||||
icon: const Icon(Icons.delete_sweep, size: 16),
|
||||
label: const Text('Clear Field Test Logs', style: TextStyle(fontSize: 11)),
|
||||
label: const Text(
|
||||
'Clear Field Test Logs',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
onPressed: () async {
|
||||
final session = ref.read(rideStateMachineProvider);
|
||||
if (session.state == RideState.riding) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cannot clear logs while a ride is recording.')),
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Cannot clear logs while a ride is recording.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1216,10 +1397,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear All Field Test Logs?'),
|
||||
content: const Text('This will delete all stored black-box diagnostic sessions. Standard ride history will NOT be affected.'),
|
||||
content: const Text(
|
||||
'This will delete all stored black-box diagnostic sessions. Standard ride history will NOT be affected.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('CANCEL')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('CLEAR', style: TextStyle(color: Colors.redAccent))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text(
|
||||
'CLEAR',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -1228,7 +1420,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
await logger.clearAllSessions();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Field test logs cleared.')),
|
||||
const SnackBar(
|
||||
content: Text('Field test logs cleared.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1380,7 +1574,10 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
|
|||
children: [
|
||||
Icon(Icons.fingerprint, color: Colors.cyanAccent),
|
||||
SizedBox(width: 8),
|
||||
Text('Software Identity', style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||
Text(
|
||||
'Software Identity',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
|
|
@ -1393,14 +1590,22 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
|
|||
_diagRow('Release Channel', identity.releaseChannel),
|
||||
_diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'),
|
||||
_diagRow('Export Schema', '${identity.exportSchemaVersion}'),
|
||||
_diagRow('Git Commit', identity.gitCommit.length > 12 ? identity.gitCommit.substring(0, 12) : identity.gitCommit),
|
||||
_diagRow(
|
||||
'Git Commit',
|
||||
identity.gitCommit.length > 12
|
||||
? identity.gitCommit.substring(0, 12)
|
||||
: identity.gitCommit,
|
||||
),
|
||||
_diagRow('Build Timestamp', identity.buildTimestamp),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('CLOSE', style: TextStyle(color: Colors.cyanAccent)),
|
||||
child: const Text(
|
||||
'CLOSE',
|
||||
style: TextStyle(color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1413,8 +1618,19 @@ class _AppVersionFooterState extends State<_AppVersionFooter> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text(value, style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, fontSize: 12, fontFamily: 'monospace')),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.cyanAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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