1792 lines
67 KiB
Dart
1792 lines
67 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/app_info.dart';
|
|
import '../../core/responsive/viewport_size.dart';
|
|
import '../../core/theme/theme_extensions.dart';
|
|
import '../../core/theme/theme_mode_provider.dart';
|
|
import '../../core/theme/app_typography.dart';
|
|
import '../../database/database.dart';
|
|
import '../../features/rides/ride_state_machine.dart';
|
|
import '../../services/alerts/road_alert_models.dart';
|
|
import '../../services/alerts/road_alert_providers.dart';
|
|
import '../../services/alerts/road_alert_service.dart';
|
|
import '../../services/auth/auth_providers.dart';
|
|
import '../../services/field_test/field_test_logger.dart';
|
|
import '../../services/map/map_provider_interface.dart';
|
|
import '../../services/map/map_providers.dart';
|
|
import '../../services/osm_oauth_service.dart';
|
|
import '../../services/sensor_fusion_service.dart';
|
|
import 'widgets/current_plan_card.dart';
|
|
|
|
class SettingsScreen extends ConsumerStatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|
late TextEditingController _tomtomKeyController;
|
|
late TextEditingController _communityKeyController;
|
|
late TextEditingController _communityEndpointController;
|
|
bool _isTestingTomTom = false;
|
|
bool _isTestingCommunity = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tomtomKeyController = TextEditingController(
|
|
text: ref.read(tomtomApiKeyProvider),
|
|
);
|
|
_communityKeyController = TextEditingController(
|
|
text: ref.read(communityApiKeyProvider),
|
|
);
|
|
_communityEndpointController = TextEditingController(
|
|
text: ref.read(communityEndpointUrlProvider),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tomtomKeyController.dispose();
|
|
_communityKeyController.dispose();
|
|
_communityEndpointController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final snapshot = context.responsive;
|
|
final isWide = snapshot.isExpanded && !snapshot.isShortViewport;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text(
|
|
'Settings',
|
|
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWideLayout(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 1200),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Left Column: Account, Plan, Appearance, Sensors
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildSectionHeader('Membership & Entitlement'),
|
|
const SizedBox(height: 10),
|
|
const CurrentPlanCard(),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Account & Cloud Identity'),
|
|
const SizedBox(height: 10),
|
|
_buildAccountCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Appearance & Display Theme'),
|
|
const SizedBox(height: 10),
|
|
_buildAppearanceCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Sensors & Calibration'),
|
|
const SizedBox(height: 10),
|
|
_buildSensorsCard(context),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 24),
|
|
// Right Column: Maps, Road Alerts, Data & Diagnostics
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildSectionHeader('Maps & Navigation Engine'),
|
|
const SizedBox(height: 10),
|
|
_buildMapsCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Road Alerts & Incidents'),
|
|
const SizedBox(height: 10),
|
|
_buildRoadAlertsCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildCommunityProviderCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildTomTomProviderCard(context),
|
|
if (kDebugMode) ...[
|
|
const SizedBox(height: 16),
|
|
_buildDeveloperAlertsCard(context),
|
|
],
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Data & Diagnostics'),
|
|
const SizedBox(height: 10),
|
|
_buildDiagnosticsLinkCard(context),
|
|
const SizedBox(height: 12),
|
|
_buildClearHistoryCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildFieldTestSection(context),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
const _AppVersionFooter(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCompactLayout(BuildContext context) {
|
|
return ListView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0),
|
|
children: [
|
|
_buildSectionHeader('Membership & Entitlement'),
|
|
const SizedBox(height: 10),
|
|
const CurrentPlanCard(),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Account & Cloud Identity'),
|
|
const SizedBox(height: 10),
|
|
_buildAccountCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Appearance & Display Theme'),
|
|
const SizedBox(height: 10),
|
|
_buildAppearanceCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Sensors & Calibration'),
|
|
const SizedBox(height: 10),
|
|
_buildSensorsCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Maps & Navigation Engine'),
|
|
const SizedBox(height: 10),
|
|
_buildMapsCard(context),
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Road Alerts & Incidents'),
|
|
const SizedBox(height: 10),
|
|
_buildRoadAlertsCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildCommunityProviderCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildTomTomProviderCard(context),
|
|
if (kDebugMode) ...[
|
|
const SizedBox(height: 16),
|
|
_buildDeveloperAlertsCard(context),
|
|
],
|
|
const SizedBox(height: 24),
|
|
_buildSectionHeader('Data & Diagnostics'),
|
|
const SizedBox(height: 10),
|
|
_buildDiagnosticsLinkCard(context),
|
|
const SizedBox(height: 12),
|
|
_buildClearHistoryCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildFieldTestSection(context),
|
|
const SizedBox(height: 28),
|
|
const _AppVersionFooter(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionHeader(String title) {
|
|
return Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontFamily: AppTypography.fontHeadline,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
color: context.motorColors.cyberCyan,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAccountCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
final authState = ref.watch(authNotifierProvider);
|
|
final isAuthed = authState.isAuthenticated;
|
|
final user = authState.user;
|
|
|
|
return InkWell(
|
|
key: const ValueKey('settings_account_card'),
|
|
onTap: () => context.go('/settings/account'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
isAuthed ? Icons.account_circle : Icons.account_circle_outlined,
|
|
color: isAuthed ? colors.cyberCyan : colors.outline,
|
|
size: 28,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
isAuthed
|
|
? (user?.displayLabel ?? 'Rider Account')
|
|
: 'Sign In / Cloud Account',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
isAuthed
|
|
? (user?.email ?? 'Cloud Identity Active • Offline rides protected')
|
|
: 'Optional cloud identity • Local riding always active',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: isAuthed
|
|
? colors.cyberCyan.withValues(alpha: 0.15)
|
|
: colors.outlineVariant.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(
|
|
color: isAuthed ? colors.cyberCyan : colors.outlineVariant,
|
|
),
|
|
),
|
|
child: Text(
|
|
isAuthed ? 'SIGNED IN' : 'LOCAL',
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: isAuthed ? colors.cyberCyan : colors.outline,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: colors.outline,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppearanceCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.brightness_medium_outlined,
|
|
color: colors.cyberCyan,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Display Appearance',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'System default, Daylight Light mode, or Cockpit OLED Dark mode.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final appThemeMode = ref.watch(appThemeModeProvider);
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: SegmentedButton<ThemeMode>(
|
|
showSelectedIcon: false,
|
|
style: const ButtonStyle(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: ThemeMode.system,
|
|
label: Text('System', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
|
icon: Icon(Icons.settings_brightness, size: 16),
|
|
),
|
|
ButtonSegment(
|
|
value: ThemeMode.light,
|
|
label: Text('Light', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
|
icon: Icon(Icons.light_mode, size: 16),
|
|
),
|
|
ButtonSegment(
|
|
value: ThemeMode.dark,
|
|
label: Text('Dark', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
|
icon: Icon(Icons.dark_mode, size: 16),
|
|
),
|
|
],
|
|
selected: {appThemeMode},
|
|
onSelectionChanged: (Set<ThemeMode> selection) {
|
|
ref
|
|
.read(appThemeModeProvider.notifier)
|
|
.setThemeMode(selection.first);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSensorsCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
Icons.screen_rotation,
|
|
color: colors.cyberCyan,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Calibrate Mount Angle',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Place the motorcycle upright and tap Calibrate to zero the lean angle sensor.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.cyberCyan,
|
|
foregroundColor: Colors.black,
|
|
minimumSize: const Size(120, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.tune, size: 18),
|
|
label: const Text(
|
|
'CALIBRATE',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
|
|
),
|
|
onPressed: () async {
|
|
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.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
ref.read(sensorFusionServiceProvider).calibrateUpright();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Sensors Calibrated upright!'),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMapsCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final mapType = ref.watch(mapProviderTypeNotifierProvider);
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.map_outlined, color: colors.cyberCyan, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Active Map Engine',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Select map rendering backend for live map, route preview, and replay.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: SegmentedButton<AppMapType>(
|
|
showSelectedIcon: false,
|
|
style: const ButtonStyle(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: AppMapType.openStreetMap,
|
|
label: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text('OpenStreetMap', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
|
),
|
|
icon: Icon(Icons.public, size: 16),
|
|
),
|
|
ButtonSegment(
|
|
value: AppMapType.googleMaps,
|
|
label: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text('Google Maps', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
|
|
),
|
|
icon: Icon(Icons.map_outlined, size: 16),
|
|
),
|
|
],
|
|
selected: {mapType},
|
|
onSelectionChanged: (Set<AppMapType> selection) {
|
|
ref
|
|
.read(mapProviderTypeNotifierProvider.notifier)
|
|
.setMapProviderType(selection.first);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final osmAuth = ref.watch(osmOAuthServiceProvider);
|
|
final isAuthed = osmAuth.isAuthenticated;
|
|
final user = osmAuth.user;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'OpenStreetMap Account',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Chip(
|
|
label: Text(
|
|
isAuthed ? 'Connected' : 'Disconnected',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: isAuthed ? colors.cyberCyan : colors.outline,
|
|
),
|
|
),
|
|
backgroundColor: isAuthed
|
|
? colors.cyberCyan.withValues(alpha: 0.15)
|
|
: colors.outlineVariant.withValues(alpha: 0.2),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
if (isAuthed && user != null) ...[
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
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),
|
|
),
|
|
trailing: TextButton.icon(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colors.raceRed,
|
|
),
|
|
icon: const Icon(Icons.link_off, size: 16),
|
|
label: const Text(
|
|
'Disconnect',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
onPressed: () => ref
|
|
.read(osmOAuthServiceProvider.notifier)
|
|
.logout(),
|
|
),
|
|
),
|
|
] else ...[
|
|
Text(
|
|
'Sign in to OpenStreetMap via OAuth 2.0 PKCE to upload ride GPX tracks and contribute traces.',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.cyberCyan,
|
|
foregroundColor: Colors.black,
|
|
minimumSize: const Size(double.infinity, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.login, size: 18),
|
|
label: const Text(
|
|
'Connect OpenStreetMap',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
onPressed: osmAuth.isLoading
|
|
? null
|
|
: () => ref
|
|
.read(osmOAuthServiceProvider.notifier)
|
|
.startLoginFlow(),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRoadAlertsCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
|
|
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
|
|
final alertRadiusMeters = ref.watch(alertRadiusMetersProvider);
|
|
final enabledCategories = ref.watch(enabledCategoriesProvider);
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SwitchListTile(
|
|
activeThumbColor: colors.cyberCyan,
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text(
|
|
'Road Alerts',
|
|
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
'Display real-time police, hazards, and traffic incidents on map.',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
value: roadAlertsEnabled,
|
|
onChanged: (val) {
|
|
ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val);
|
|
},
|
|
),
|
|
const Divider(),
|
|
SwitchListTile(
|
|
activeThumbColor: colors.cyberCyan,
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text(
|
|
'Audio Warnings (TTS)',
|
|
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
'Announce approaching hazards and enforcement over helmet headset.',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
value: audioAlertsEnabled,
|
|
onChanged: roadAlertsEnabled
|
|
? (val) {
|
|
ref
|
|
.read(audioAlertsEnabledProvider.notifier)
|
|
.setEnabled(val);
|
|
}
|
|
: null,
|
|
),
|
|
const Divider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Alert Radius',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Distance to detect upcoming incidents',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
DropdownButton<double>(
|
|
value: alertRadiusMeters,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 152.4,
|
|
child: Text('500 ft'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 304.8,
|
|
child: Text('1,000 ft'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 402.3,
|
|
child: Text('1/4 Mile'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 804.7,
|
|
child: Text('1/2 Mile'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 1609.3,
|
|
child: Text('1 Mile'),
|
|
),
|
|
],
|
|
onChanged: roadAlertsEnabled
|
|
? (val) {
|
|
if (val != null) {
|
|
ref
|
|
.read(alertRadiusMetersProvider.notifier)
|
|
.setRadius(val);
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
|
child: Text(
|
|
'Alert Categories',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
...RoadAlertCategory.values.map((cat) {
|
|
final isChecked = enabledCategories.contains(cat);
|
|
return CheckboxListTile(
|
|
activeColor: colors.cyberCyan,
|
|
checkColor: Colors.black,
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(
|
|
cat.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
secondary: Icon(cat.icon, color: cat.color, size: 20),
|
|
value: isChecked,
|
|
onChanged: roadAlertsEnabled
|
|
? (val) {
|
|
ref
|
|
.read(enabledCategoriesProvider.notifier)
|
|
.toggleCategory(cat, val ?? true);
|
|
}
|
|
: null,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCommunityProviderCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final communityStatus = ref.watch(communityStatusProvider);
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildProviderHeader(
|
|
icon: Icons.local_police,
|
|
iconColor: colors.velocityBlue,
|
|
title: 'Community Traffic Provider',
|
|
subtitle: 'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
|
status: communityStatus,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _communityKeyController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Community / RapidAPI Key',
|
|
hintText: 'Enter OpenWebNinja, RapidAPI, or Community API Key',
|
|
helperText: 'Supplies Police, Speed Cameras, and Crowd Hazards.',
|
|
helperMaxLines: 2,
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
style: const TextStyle(fontSize: 14),
|
|
onChanged: (val) {
|
|
ref.read(communityApiKeyProvider.notifier).setApiKey(val);
|
|
},
|
|
),
|
|
const SizedBox(height: 10),
|
|
TextField(
|
|
controller: _communityEndpointController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Custom Endpoint URL (Optional)',
|
|
hintText: 'e.g. https://google-map-places.p.rapidapi.com or custom URL',
|
|
helperText: 'Leave blank for automatic OpenWebNinja / RapidAPI detection.',
|
|
helperMaxLines: 2,
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
style: const TextStyle(fontSize: 13),
|
|
onChanged: (val) {
|
|
ref.read(communityEndpointUrlProvider.notifier).setEndpoint(val);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(140, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: _isTestingCommunity
|
|
? null
|
|
: () async {
|
|
setState(() => _isTestingCommunity = true);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final provider = ref.read(communityProviderInstance);
|
|
final status = await provider.testConnection(
|
|
_communityKeyController.text,
|
|
);
|
|
ref.read(communityStatusProvider.notifier).state = status;
|
|
setState(() => _isTestingCommunity = false);
|
|
|
|
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) {
|
|
msg = 'API Key is empty.';
|
|
} 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}';
|
|
}
|
|
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
},
|
|
icon: _isTestingCommunity
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.sync, size: 18),
|
|
label: const Text('Test Connection'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTomTomProviderCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildProviderHeader(
|
|
icon: Icons.security,
|
|
iconColor: colors.cyberCyan,
|
|
title: 'TomTom Traffic Provider',
|
|
subtitle: 'Supplies Accidents, Hazards, Closures & Construction data.',
|
|
status: tomtomStatus,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _tomtomKeyController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'TomTom API Key',
|
|
hintText: 'Enter your free TomTom API Key',
|
|
helperText: 'Supplies Accidents, Hazards, Closures & Construction data.',
|
|
helperMaxLines: 2,
|
|
border: OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
style: const TextStyle(fontSize: 14),
|
|
onChanged: (val) {
|
|
ref.read(tomtomApiKeyProvider.notifier).setApiKey(val);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(140, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: _isTestingTomTom
|
|
? null
|
|
: () async {
|
|
setState(() => _isTestingTomTom = true);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final provider = ref.read(tomtomProviderInstance);
|
|
final status = await provider.testConnection(
|
|
_tomtomKeyController.text,
|
|
);
|
|
ref.read(tomtomStatusProvider.notifier).state = status;
|
|
setState(() => _isTestingTomTom = false);
|
|
|
|
if (mounted) {
|
|
String msg = '';
|
|
if (status == RoadAlertProviderStatus.connected) {
|
|
msg = 'TomTom API Connected successfully!';
|
|
} else if (status == RoadAlertProviderStatus.authError) {
|
|
msg = 'Authentication Error: Invalid Key.';
|
|
} 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) {
|
|
msg = 'Rate Limited: Too many requests.';
|
|
} else {
|
|
msg = 'Status: ${status.name}';
|
|
}
|
|
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
|
}
|
|
},
|
|
icon: _isTestingTomTom
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.sync, size: 18),
|
|
label: const Text('Test Connection'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeveloperAlertsCard(BuildContext context) {
|
|
return Card(
|
|
color: Colors.orange.withValues(alpha: 0.08),
|
|
shape: RoundedRectangleBorder(
|
|
side: const BorderSide(color: Colors.orangeAccent),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.bug_report, color: Colors.orangeAccent, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
'Road Alert Test Mode (Debug Only)',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.orangeAccent,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Generate simulated test alerts for real-device testing.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
icon: const Icon(Icons.local_police, color: Colors.blueAccent, size: 18),
|
|
label: const Text('Test Police (100m)'),
|
|
onPressed: () {
|
|
final alert = RoadAlert(
|
|
id: 'sim_police',
|
|
providerId: 'mock',
|
|
category: RoadAlertCategory.police,
|
|
description: 'Simulated police alert',
|
|
latitude: 0,
|
|
longitude: 0,
|
|
distanceFromUser: 100.0,
|
|
reportedAt: DateTime.now(),
|
|
lastUpdated: DateTime.now(),
|
|
source: 'Test',
|
|
);
|
|
ref.read(roadAlertServiceProvider).simulateAlert(alert);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Simulated Police Alert sent.')),
|
|
);
|
|
},
|
|
),
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
icon: const Icon(Icons.warning, color: Colors.orangeAccent, size: 18),
|
|
label: const Text('Test Hazard (250m)'),
|
|
onPressed: () {
|
|
final alert = RoadAlert(
|
|
id: 'sim_hazard',
|
|
providerId: 'mock',
|
|
category: RoadAlertCategory.hazard,
|
|
description: 'Simulated hazard alert',
|
|
latitude: 0,
|
|
longitude: 0,
|
|
distanceFromUser: 250.0,
|
|
reportedAt: DateTime.now(),
|
|
lastUpdated: DateTime.now(),
|
|
source: 'Test',
|
|
);
|
|
ref.read(roadAlertServiceProvider).simulateAlert(alert);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Simulated Hazard Alert sent.')),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDiagnosticsLinkCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: InkWell(
|
|
onTap: () => context.go('/settings/diagnostics'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.monitor_heart, color: colors.cyberCyan, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Sensor Diagnostics',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'View raw IMU and GPS values in real time.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(Icons.chevron_right, color: colors.outline, size: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildClearHistoryCard(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.delete_forever, color: colors.raceRed, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Clear Ride History',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Permanently delete all recorded rides and performance runs on this device.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.raceRed.withValues(alpha: 0.15),
|
|
foregroundColor: colors.raceRed,
|
|
side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)),
|
|
minimumSize: const Size(120, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: () async {
|
|
final session = ref.read(rideStateMachineProvider);
|
|
if (session.state == RideState.riding) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Cannot clear history while a ride is actively recording.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
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.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(
|
|
'CLEAR ALL',
|
|
style: TextStyle(
|
|
color: colors.raceRed,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true && mounted) {
|
|
final db = ref.read(databaseProvider);
|
|
await db.transaction(() async {
|
|
await db.delete(db.telemetrySamples).go();
|
|
await db.delete(db.rideEvents).go();
|
|
await db.delete(db.performanceRuns).go();
|
|
await db.delete(db.rides).go();
|
|
});
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('All ride history cleared.'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: const Text('Clear History'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFieldTestSection(BuildContext context) {
|
|
final colors = context.motorColors;
|
|
final isEnabled = ref.watch(fieldTestModeEnabledProvider);
|
|
final activeSession = ref.watch(activeFieldTestSessionProvider);
|
|
final logger = ref.watch(fieldTestLoggerProvider);
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.biotech,
|
|
color: isEnabled ? colors.launchGreen : colors.outline,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Field Test Mode',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
Text(
|
|
'Silent black-box diagnostic recorder',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Switch(
|
|
value: isEnabled,
|
|
activeThumbColor: colors.launchGreen,
|
|
onChanged: (val) {
|
|
ref
|
|
.read(fieldTestModeEnabledProvider.notifier)
|
|
.setEnabled(val);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Logger Status:',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: activeSession != null
|
|
? colors.launchGreen.withValues(alpha: 0.15)
|
|
: (isEnabled
|
|
? colors.cyberCyan.withValues(alpha: 0.15)
|
|
: colors.outlineVariant.withValues(alpha: 0.15)),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(
|
|
color: activeSession != null
|
|
? colors.launchGreen
|
|
: (isEnabled ? colors.cyberCyan : colors.outlineVariant),
|
|
),
|
|
),
|
|
child: Text(
|
|
activeSession != null
|
|
? 'RECORDING (Ride #$activeSession)'
|
|
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'),
|
|
style: TextStyle(
|
|
fontFamily: 'Inter',
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: activeSession != null
|
|
? colors.launchGreen
|
|
: (isEnabled ? colors.cyberCyan : colors.outline),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.launchGreen.withValues(alpha: 0.2),
|
|
foregroundColor: colors.launchGreen,
|
|
side: BorderSide(color: colors.launchGreen),
|
|
minimumSize: const Size(130, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
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'),
|
|
),
|
|
ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
|
foregroundColor: colors.cyberCyan,
|
|
side: BorderSide(color: colors.cyberCyan),
|
|
minimumSize: const Size(120, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.assessment, size: 18),
|
|
label: const Text(
|
|
'Reports',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
),
|
|
onPressed: () => context.go('/settings/field-test-report'),
|
|
),
|
|
OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
|
side: BorderSide(color: colors.outlineVariant),
|
|
minimumSize: const Size(110, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: const Icon(Icons.receipt_long, size: 18),
|
|
label: const Text(
|
|
'Live Log',
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
|
),
|
|
onPressed: () => context.go('/settings/field-test-live-log'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton.icon(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colors.raceRed,
|
|
),
|
|
icon: const Icon(Icons.delete_sweep, size: 16),
|
|
label: const Text(
|
|
'Clear Field Test Logs',
|
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
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.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final confirm = await showDialog<bool>(
|
|
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.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(
|
|
'CLEAR',
|
|
style: TextStyle(color: colors.raceRed, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirm == true && context.mounted) {
|
|
await logger.clearAllSessions();
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Field test logs cleared.'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildProviderHeader({
|
|
required IconData icon,
|
|
required Color iconColor,
|
|
required String title,
|
|
required String subtitle,
|
|
required RoadAlertProviderStatus status,
|
|
}) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: iconColor, size: 20),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
softWrap: true,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
height: 1.3,
|
|
),
|
|
softWrap: true,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: _buildStatusBadge(status),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusBadge(RoadAlertProviderStatus status) {
|
|
final colors = context.motorColors;
|
|
String label;
|
|
Color color;
|
|
|
|
switch (status) {
|
|
case RoadAlertProviderStatus.connected:
|
|
label = 'Connected';
|
|
color = colors.launchGreen;
|
|
break;
|
|
case RoadAlertProviderStatus.configured:
|
|
label = 'Configured';
|
|
color = colors.cyberCyan;
|
|
break;
|
|
case RoadAlertProviderStatus.authError:
|
|
label = 'Auth Error';
|
|
color = colors.raceRed;
|
|
break;
|
|
case RoadAlertProviderStatus.rateLimited:
|
|
label = 'Rate Limited';
|
|
color = colors.warningAmber;
|
|
break;
|
|
case RoadAlertProviderStatus.networkError:
|
|
label = 'Network Error';
|
|
color = colors.warningAmber;
|
|
break;
|
|
case RoadAlertProviderStatus.notConfigured:
|
|
label = 'Not Set';
|
|
color = colors.outlineVariant;
|
|
break;
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.15),
|
|
border: Border.all(color: color),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Read-only footer that surfaces the app's build version inside Settings.
|
|
class _AppVersionFooter extends StatefulWidget {
|
|
const _AppVersionFooter();
|
|
|
|
@override
|
|
State<_AppVersionFooter> createState() => _AppVersionFooterState();
|
|
}
|
|
|
|
class _AppVersionFooterState extends State<_AppVersionFooter> {
|
|
late Future<String> _versionFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_versionFuture = AppInfo.versionString();
|
|
}
|
|
|
|
void _showIdentityDialog() {
|
|
final identity = AppInfo.identity;
|
|
final colors = context.motorColors;
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
Icon(Icons.fingerprint, color: colors.cyberCyan),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Software Identity',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_diagRow('Application', 'MotorSignal'),
|
|
_diagRow('Version', identity.semanticVersion),
|
|
_diagRow('Build Number', '${identity.buildNumber}'),
|
|
_diagRow('Release Channel', identity.releaseChannel),
|
|
_diagRow('Telemetry Schema', '${identity.telemetrySchemaVersion}'),
|
|
_diagRow('Export Schema', '${identity.exportSchemaVersion}'),
|
|
_diagRow(
|
|
'Git Commit',
|
|
identity.gitCommit.length > 12
|
|
? identity.gitCommit.substring(0, 12)
|
|
: identity.gitCommit,
|
|
),
|
|
_diagRow('Build Timestamp', identity.buildTimestamp),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(
|
|
'CLOSE',
|
|
style: TextStyle(color: colors.cyberCyan, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _diagRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
color: context.motorColors.cyberCyan,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: InkWell(
|
|
onTap: _showIdentityDialog,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: FutureBuilder<String>(
|
|
future: _versionFuture,
|
|
builder: (context, snapshot) {
|
|
final text = snapshot.data ?? AppInfo.fallbackVersion;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'Motorcycle Dash v$text',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontSize: 12,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'Tap for forensic build identity',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
|
fontSize: 9,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|