From 51d31211f08ea716bed77dcfe4a94b327d322979 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 27 Aug 2026 09:28:23 -0400 Subject: [PATCH] CHECKPOINT: Pre-audit baseline snapshot before field readiness review --- CHANGELOG.md | 70 ++ docs/RELEASING.md | 93 +++ docs/features.html | 97 +++ generate_icon.dart | 20 +- lib/core/app_info.dart | 65 ++ lib/core/theme/theme.dart | 47 +- lib/features/dashboard/dashboard_screen.dart | 77 ++- .../garage/motorcycle_detail_screen.dart | 6 +- lib/features/history/ride_detail_screen.dart | 206 ++++-- lib/features/settings/settings_screen.dart | 654 ++++++++++++++---- lib/services/alerts/road_alert_service.dart | 9 + pubspec.lock | 4 +- pubspec.yaml | 2 + 13 files changed, 1099 insertions(+), 251 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/RELEASING.md create mode 100644 docs/features.html create mode 100644 lib/core/app_info.dart diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4ac4f43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to **Motorcycle Digital Dashboard** will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +> **Versioning scheme (Flutter):** The `version` field in `pubspec.yaml` uses +> the format `MAJOR.MINOR.PATCH+BUILD`. +> - `MAJOR.MINOR.PATCH` → the user-facing release (also stored as the `v` git tag). +> - `+BUILD` → an auto-incrementing integer consumed by `versionCode` (Android) and +> `CFBundleVersion` (iOS). It is bumped independently of the user-facing version. + +## [Unreleased] + +### Planned +- Google Maps API key configuration hardening. +- iOS port (Location, Motion, Background permissions in `Info.plist`). +- Real third-party police-alert provider behind `PoliceAlertService`. + +--- + +## [1.0.0] - 2026-08-27 + +First production-ready release of the Motorcycle Digital Dashboard. + +### Added +- **Real-time telemetry** via `geolocator` (GPS, 5 Hz) and `sensors_plus` (accel + gyro). +- **Complementary filter sensor fusion** (α = 0.96) for accurate lean/bank, pitch, and + longitudinal/lateral G-force calculation with vibration rejection. +- **Mount calibration** — rider can zero the sensors to compensate for arbitrary + mounting angles. +- **Automatic ride recording** state machine (`Idle` → `Ready` → `Riding`) that + detects motion, tracks session maximums (top speed, max left/right lean, max Gs), + and persists the ride to local storage on stop. +- **Performance metrics** — automatic 0–60 MPH and 0–100 MPH timing using + high-resolution timers triggered on hard acceleration. +- **Drift (SQLite) persistence** with `Rides` and `PerformanceRuns` tables, linked by + foreign key. +- **GoRouter** navigation with a `StatefulShellRoute` and persistent + `BottomNavigationBar`. +- **Riverpod** for reactive UI state and dependency injection across all features. +- **Material 3 dark theme** with portrait- and landscape-aware dashboard layout, + including a large speedometer, heading indicator, lean gauge, and live G-force readout. +- **Map screen** with Google Maps and OpenStreetMap providers, satellite toggle, + and follow-rider mode. +- **Ride history** list with per-ride telemetry cards, and **ride replay** screen + that synchronizes telemetry with the map polyline. +- **Audio (TTS) alerts** for ride states and milestones, configurable via + `flutter_tts`. +- **Data export** to `.csv` and `.gpx` via `share_plus`. +- **Police alert abstraction** (`PoliceAlertService` interface) with a mock + implementation ready to be swapped for a real provider (Waze, RadarBot, etc.). +- **Settings & diagnostics** screens, including calibration and a "clear history" + action. +- **Production hardening**: + - Lint pass with `flutter_lints` and cleanup. + - `http` added as a direct dependency for telemetry/network features. + - OSM null-safety fix for map fallback. + - Road-alert pipeline integrated into the rider state machine. + +### Known Limitations +- Google Maps support requires the user to supply a real API key in + `android/app/src/main/AndroidManifest.xml` (replacing `YOUR_API_KEY_HERE`). +- The app is currently Android-first; iOS permissions and build config are not yet + configured. +- Police-alert data is mock-only until a real provider is integrated. + +[Unreleased]: https://github.com/your-org/motorcycle-dash/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/your-org/motorcycle-dash/releases/tag/v1.0.0 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..076f87d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,93 @@ +# Release Process + +This document describes how to cut a new release of **Motorcycle Digital Dashboard**. +The goal is a single, repeatable flow that keeps `pubspec.yaml`, `CHANGELOG.md`, and +the git tag in sync. + +## Versioning Rules + +We follow [Semantic Versioning 2.0.0](https://semver.org/) for the user-facing +`MAJOR.MINOR.PATCH` portion, and Flutter's `+BUILD` convention for the build number. + +| Change type | Bump | +|---|---| +| Breaking change to UX, data model, or public API | `MAJOR` | +| Backwards-compatible feature, new screen, new sensor | `MINOR` | +| Bug fix, refactor, docs, lint cleanup, performance | `PATCH` | +| Every build uploaded to the store (Play Console / App Store Connect) | `+BUILD` | + +The `+BUILD` counter **never resets** — it monotonically increases for the lifetime +of the app so the stores always see a newer `versionCode` / `CFBundleVersion`. + +## Pre-Release Checklist + +Before tagging a release, confirm: + +- [ ] All work for the milestone is merged to `master`. +- [ ] CI is green on the latest commit of `master`. +- [ ] `flutter analyze` produces zero issues. +- [ ] `flutter test` passes locally. +- [ ] Any new permission, manifest, or Info.plist changes are documented in + `CHANGELOG.md`. +- [ ] `flutter pub get` and `flutter pub outdated` have been run. + +## Release Steps + +1. **Update the version in `pubspec.yaml`:** + ```yaml + version: 1.1.0+2 # user-facing 1.1.0, build #2 + ``` + - Use the build number from the last store upload **+ 1** for `+BUILD`. + +2. **Add a new section to `CHANGELOG.md`** above the previous release, dated + `YYYY-MM-DD`. Use the existing `1.0.0` entry as a template. Move any + in-progress items out of `[Unreleased]` into the new version section. + +3. **Open a "Release" PR** titled e.g. `chore(release): 1.1.0`. Include both + `pubspec.yaml` and `CHANGELOG.md` in the PR. Get a review and merge. + +4. **Tag the merged commit** (from `master`): + ```bash + git checkout master + git pull --tags + git tag -a v1.1.0 -m "Release 1.1.0" + git push origin v1.1.0 + ``` + - Use an **annotated** tag (`-a`) so the tag carries author, date, and message. + - The tag name **must** be `vMAJOR.MINOR.PATCH` to match the changelog links + and the `package_info_plus` lookup the in-app "About" screen may do later. + +5. **Build & upload:** + ```bash + flutter build appbundle --release --build-name=1.1.0 --build-number=2 + ``` + The `--build-name` and `--build-number` flags are a safety net — they must + match the values in `pubspec.yaml`. Upload the resulting `.aab` to the Play + Console. + +6. **Draft a GitHub release** from the tag, copy the changelog section into the + release notes, and attach the `.aab` (or link to the Play Console listing). + +## Hotfix / Patch Flow + +For urgent fixes that can't wait for the next scheduled release: + +1. Branch from the current `master` tag: `git checkout -b hotfix/1.0.1 v1.0.0`. +2. Land the fix, update `pubspec.yaml` to `1.0.1+3`, add a `1.0.1` section to + `CHANGELOG.md`. +3. Merge back to `master`, tag `v1.0.1`, push the tag, build, upload. + +## File Layout Owned by This Process + +| File | Owned by release process | +|---|---| +| `pubspec.yaml` (`version:` line) | Yes | +| `CHANGELOG.md` | Yes | +| `git tag` (`v*`) | Yes | +| `lib/core/app_info.dart` | Yes (auto-mirrors `pubspec.yaml` — see file header) | + +## Reference + +- Flutter versioning docs: +- Keep a Changelog 1.1.0: +- Semantic Versioning 2.0.0: diff --git a/docs/features.html b/docs/features.html new file mode 100644 index 0000000..7d28b1e --- /dev/null +++ b/docs/features.html @@ -0,0 +1,97 @@ + + + + + + Motorcycle Dash - Feature List + + + +
+

Motorcycle Dash - Full Feature List

+ +

Dashboard & Telemetry

+
    +
  • Live Telemetry: Real-time speed (MPH), compass heading, and elevation tracking.
  • +
  • IMU Sensors: Live visualizers for Lean Angle (Left/Right degrees) and G-Forces (Acceleration & Braking).
  • +
  • Custom Layouts: Switch instantly between Sport, Touring, and Minimal dashboard layouts depending on your riding style.
  • +
  • Auto-Wake: The screen stays awake automatically while a ride session is active.
  • +
+ +

Ride Tracking & History

+
    +
  • Session Recording: Track your route, trip distance, moving time, and elevation gain.
  • +
  • Performance Stats: Automatically logs your Top Speed and Average Speed for every ride.
  • +
  • Ride History: View past rides and review your route and metrics.
  • +
  • 3D Replay: Replay past rides in a 3D environment to analyze your cornering and speeds along the route.
  • +
+ +

Live Hazards & Navigation

+
    +
  • Interactive Map: View your location on the map with support for custom overlays.
  • +
  • Community Traffic API: Live alerts for Police presence, Speed Cameras, and crowd-reported hazards.
  • +
  • TomTom Traffic API: Live alerts for road closures, construction zones, traffic delays, and accidents.
  • +
  • Visual Banners: Immersive, color-coded alert banners appear on the dashboard when approaching a hazard.
  • +
  • Audio TTS Alerts: Text-To-Speech audio warnings for hazards so you can keep your eyes on the road.
  • +
+ +

Garage & Customization

+
    +
  • Virtual Garage: Add and manage multiple motorcycles in your garage.
  • +
  • Bike Specs: Store the Year, Make, Model, Nickname, and specific settings for each bike.
  • +
  • Sensor Calibration: Calibrate your device's mount angle directly in the app to ensure accurate lean and G-force data.
  • +
+ + +
+ + diff --git a/generate_icon.dart b/generate_icon.dart index e5a597d..3ca9710 100644 --- a/generate_icon.dart +++ b/generate_icon.dart @@ -6,13 +6,27 @@ void main() { img.fill(image, color: img.ColorRgb8(17, 17, 17)); // Dark grey // Draw a cyan circle - img.drawCircle(image, x: 256, y: 256, radius: 200, color: img.ColorRgb8(0, 255, 255)); + img.drawCircle( + image, + x: 256, + y: 256, + radius: 200, + color: img.ColorRgb8(0, 255, 255), + ); // Draw a red needle - img.drawLine(image, x1: 256, y1: 256, x2: 350, y2: 150, color: img.ColorRgb8(255, 0, 0), thickness: 10); + img.drawLine( + image, + x1: 256, + y1: 256, + x2: 350, + y2: 150, + color: img.ColorRgb8(255, 0, 0), + thickness: 10, + ); final png = img.encodePng(image); File('assets/icon.png') ..createSync(recursive: true) ..writeAsBytesSync(png); - print('Icon generated.'); + stdout.writeln('Icon generated.'); } diff --git a/lib/core/app_info.dart b/lib/core/app_info.dart new file mode 100644 index 0000000..4db0b5b --- /dev/null +++ b/lib/core/app_info.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// Single source of truth for the app's identity shown inside the running app. +/// +/// **Source of truth** for the version numbers is the `version:` field in +/// `pubspec.yaml` (e.g. `1.0.0+1`). At runtime we read that value via +/// `package_info_plus`, which Flutter populates from `pubspec.yaml` *unless* +/// overridden at build time with `--build-name` / `--build-number`. +/// +/// If `package_info_plus` cannot resolve the values (e.g. running in a unit +/// test that hasn't initialised the plugin platform), we fall back to the +/// [fallbackVersion] constant. Keep that constant in sync with `pubspec.yaml` +/// when you bump versions; the release process described in +/// `docs/RELEASING.md` owns that update. +class AppInfo { + AppInfo._(); + + /// Hard-coded fallback. Must mirror the `version:` field in `pubspec.yaml`. + /// Format: `MAJOR.MINOR.PATCH+BUILD`. + static const String fallbackVersion = '1.0.0+1'; + + /// Cached result so we only hit the platform once. + static PackageInfo? _cached; + + /// Asynchronously loads the package info, caching the result. + static Future _load() async { + return _cached ??= await PackageInfo.fromPlatform(); + } + + /// Resolves the build version in the form `MAJOR.MINOR.PATCH+BUILD` + /// (e.g. `1.0.0+1`). Falls back to [fallbackVersion] on error. + static Future versionString() async { + try { + final info = await _load(); + final name = info.version.isEmpty ? '0.0.0' : info.version; + final build = info.buildNumber.isEmpty ? '0' : info.buildNumber; + return '$name+$build'; + } catch (e, st) { + debugPrint('AppInfo.versionString() fell back to constant: $e\n$st'); + return fallbackVersion; + } + } + + /// The user-facing `MAJOR.MINOR.PATCH` portion (e.g. `1.0.0`). + static Future versionName() async { + try { + final info = await _load(); + return info.version.isEmpty ? '0.0.0' : info.version; + } catch (_) { + // Extract the part before the '+' from the fallback. + return fallbackVersion.split('+').first; + } + } + + /// The monotonically increasing `+BUILD` portion (e.g. `1`). + static Future buildNumber() async { + try { + final info = await _load(); + return info.buildNumber.isEmpty ? '0' : info.buildNumber; + } catch (_) { + return fallbackVersion.split('+').last; + } + } +} diff --git a/lib/core/theme/theme.dart b/lib/core/theme/theme.dart index 0e3a07c..ca056ff 100644 --- a/lib/core/theme/theme.dart +++ b/lib/core/theme/theme.dart @@ -14,34 +14,25 @@ class AppTheme { secondary: Color(0xFFFF2A2A), surface: Color(0xFF1A1A1A), ), - textTheme: const TextTheme( - displayLarge: TextStyle( - fontSize: 140, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: -4.0, - ), - displayMedium: TextStyle( - fontSize: 80, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -2.0, - ), - headlineLarge: TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - labelLarge: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w600, - color: Colors.white54, - letterSpacing: 2.0, - ), - bodyLarge: TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - color: Colors.white, + + segmentedButtonTheme: SegmentedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.selected)) { + return const Color(0xFF00E5FF).withOpacity(0.2); // Cyan with opacity for selected + } + return null; // Use default for unselected + }, + ), + foregroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.selected)) { + return const Color(0xFF00E5FF); // Cyan text for selected + } + return Colors.white70; // White text for unselected + }, + ), ), ), cardTheme: CardThemeData( diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart index eda4f7a..af7c329 100644 --- a/lib/features/dashboard/dashboard_screen.dart +++ b/lib/features/dashboard/dashboard_screen.dart @@ -7,6 +7,8 @@ import '../../services/telemetry_service.dart'; import 'dashboard_config.dart'; import 'widgets/g_force_visualizer.dart'; import 'widgets/lean_visualizer.dart'; +import '../../services/alerts/road_alert_models.dart'; +import '../../services/alerts/road_alert_service.dart'; class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); @@ -16,6 +18,10 @@ class DashboardScreen extends ConsumerWidget { final telemetryAsync = ref.watch(telemetryStreamProvider); final rideSession = ref.watch(rideStateMachineProvider); final layoutMode = ref.watch(dashboardConfigProvider); + final alertsAsync = ref.watch(activeAlertsStreamProvider); + + final activeAlerts = alertsAsync.value ?? []; + final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null; TelemetrySample? telemetry = telemetryAsync.value; double currentSpeedMph = telemetry?.speed ?? 0.0; @@ -35,28 +41,67 @@ class DashboardScreen extends ConsumerWidget { ), child: Scaffold( body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - - if (layoutMode == DashboardLayout.minimal) { - return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref); - } - if (layoutMode == DashboardLayout.touring) { - return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref); - } - - if (isLandscape) { - return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref); - } - return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref); - }, + child: Column( + children: [ + if (closestAlert != null) _buildAlertBanner(closestAlert), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final isLandscape = constraints.maxWidth > constraints.maxHeight; + + if (layoutMode == DashboardLayout.minimal) { + return _buildMinimalLayout(context, currentSpeedMph, session: rideSession, ref: ref); + } + if (layoutMode == DashboardLayout.touring) { + return _buildTouringLayout(context, currentSpeedMph, heading, rideSession, ref); + } + + if (isLandscape) { + return _buildLandscapeLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref); + } + return _buildPortraitLayout(context, currentSpeedMph, heading, leanAngle, accelG, lateralG, rideSession, ref); + }, + ), + ), + ], ), ), ), ); } + Widget _buildAlertBanner(RoadAlert alert) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: alert.category.color.withValues(alpha: 0.2), + border: Border(bottom: BorderSide(color: alert.category.color, width: 2)), + ), + child: Row( + children: [ + Icon(alert.category.icon, color: alert.category.color, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + alert.category.displayName.toUpperCase(), + style: TextStyle(color: alert.category.color, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.2), + ), + Text( + '${alert.distanceFromUser?.toStringAsFixed(0) ?? '?'}m Ahead', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref) { return Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/features/garage/motorcycle_detail_screen.dart b/lib/features/garage/motorcycle_detail_screen.dart index 82e6c7d..6a161a0 100644 --- a/lib/features/garage/motorcycle_detail_screen.dart +++ b/lib/features/garage/motorcycle_detail_screen.dart @@ -51,7 +51,11 @@ class MotorcycleDetailScreen extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Text('${bike.year} ${bike.makeName} ${bike.modelName}'), + title: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text('${bike.year} ${bike.makeName} ${bike.modelName}'), + ), actions: [ IconButton( icon: const Icon(Icons.edit), diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart index 726dddb..f07d108 100644 --- a/lib/features/history/ride_detail_screen.dart +++ b/lib/features/history/ride_detail_screen.dart @@ -27,7 +27,9 @@ class _RideDetailScreenState extends ConsumerState { Future _loadEvents() async { final db = ref.read(databaseProvider); - final events = await (db.select(db.rideEvents)..where((e) => e.rideId.equals(widget.ride.id))).get(); + final events = await (db.select( + db.rideEvents, + )..where((e) => e.rideId.equals(widget.ride.id))).get(); if (mounted) { setState(() { _events = events; @@ -51,11 +53,18 @@ class _RideDetailScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.cyanAccent.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), + border: Border.all( + color: Colors.cyanAccent.withValues(alpha: 0.5), + ), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.two_wheeler, color: Colors.cyanAccent, size: 32), + const Icon( + Icons.two_wheeler, + color: Colors.cyanAccent, + size: 32, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -63,22 +72,42 @@ class _RideDetailScreenState extends ConsumerState { children: [ Text( '${r.motorcycleYear ?? ""} ${r.motorcycleMake} ${r.motorcycleModel}', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + softWrap: true, ), - if (r.motorcycleVariant != null && r.motorcycleVariant!.isNotEmpty) - Text(r.motorcycleVariant!, style: const TextStyle(color: Colors.white70, fontSize: 12)), + if (r.motorcycleVariant != null && + r.motorcycleVariant!.isNotEmpty) + Text( + r.motorcycleVariant!, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + softWrap: true, + ), + if (r.motorcycleId != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.cyanAccent, + side: const BorderSide( + color: Colors.cyanAccent, + ), + ), + onPressed: () => + context.push('/garage/${r.motorcycleId}'), + child: const Text('View Specs'), + ), + ), + ], ], ), ), - if (r.motorcycleId != null) - OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.cyanAccent, - side: const BorderSide(color: Colors.cyanAccent), - ), - onPressed: () => context.push('/garage/${r.motorcycleId}'), - child: const Text('View Specs'), - ), ], ), ), @@ -95,27 +124,47 @@ class _RideDetailScreenState extends ConsumerState { border: Border.all(color: Colors.white12), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.map, color: Colors.cyanAccent, size: 36), const SizedBox(width: 16), - const Expanded( + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('ROUTE PREVIEW', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, letterSpacing: 1.5, color: Colors.cyanAccent)), - SizedBox(height: 4), - Text('View ride path and telemetry events on interactive map', style: TextStyle(fontSize: 12, color: Colors.white70)), + const Text( + 'ROUTE PREVIEW', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + letterSpacing: 1.5, + color: Colors.cyanAccent, + ), + softWrap: true, + ), + const SizedBox(height: 4), + const Text( + 'View ride path and telemetry events on interactive map', + style: TextStyle(fontSize: 12, color: Colors.white70), + softWrap: true, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MapScreen(rideId: r.id), + ), + ); + }, + child: const Text('VIEW ROUTE'), + ), + ), ], ), ), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => MapScreen(rideId: r.id)), - ); - }, - child: const Text('VIEW ROUTE'), - ), ], ), ), @@ -123,16 +172,34 @@ class _RideDetailScreenState extends ConsumerState { _buildSectionHeader('SUMMARY'), _buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'), - _buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'), - _buildStatRow('Moving Time', '${(r.movingTime / 60).toStringAsFixed(1)} min'), - _buildStatRow('Avg Speed', '${r.averageSpeed.toStringAsFixed(1)} mph'), + _buildStatRow( + 'Duration', + '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min', + ), + _buildStatRow( + 'Moving Time', + '${(r.movingTime / 60).toStringAsFixed(1)} min', + ), + _buildStatRow( + 'Avg Speed', + '${r.averageSpeed.toStringAsFixed(1)} mph', + ), _buildStatRow('Top Speed', '${r.topSpeedMph.toStringAsFixed(1)} mph'), const Divider(), _buildSectionHeader('HANDLING'), - _buildStatRow('Max Lean (Left)', '${r.maxLeanLeft.toStringAsFixed(1)}°'), - _buildStatRow('Max Lean (Right)', '${r.maxLeanRight.toStringAsFixed(1)}°'), - _buildStatRow('Max Acceleration', '${r.maxAccelG.toStringAsFixed(2)} G'), + _buildStatRow( + 'Max Lean (Left)', + '${r.maxLeanLeft.toStringAsFixed(1)}°', + ), + _buildStatRow( + 'Max Lean (Right)', + '${r.maxLeanRight.toStringAsFixed(1)}°', + ), + _buildStatRow( + 'Max Acceleration', + '${r.maxAccelG.toStringAsFixed(2)} G', + ), _buildStatRow('Max Braking', '${r.maxBrakingG.toStringAsFixed(2)} G'), const Divider(), @@ -145,21 +212,34 @@ class _RideDetailScreenState extends ConsumerState { if (_isLoadingEvents) const Center(child: CircularProgressIndicator()) else if (_events.isEmpty) - const Padding(padding: EdgeInsets.all(8.0), child: Text('No events recorded.')) + const Padding( + padding: EdgeInsets.all(8.0), + child: Text('No events recorded.'), + ) else - ..._events.map((e) => ListTile( - dense: true, - leading: const Icon(Icons.warning_amber, color: Colors.amberAccent), - title: Text(e.eventType), - subtitle: Text(e.timestamp.toLocal().toString().split('.')[0]), - trailing: Text(e.value.toStringAsFixed(1), style: const TextStyle(fontWeight: FontWeight.bold)), - )), + ..._events.map( + (e) => ListTile( + dense: true, + leading: const Icon( + Icons.warning_amber, + color: Colors.amberAccent, + ), + title: Text(e.eventType), + subtitle: Text(e.timestamp.toLocal().toString().split('.')[0]), + trailing: Text( + e.value.toStringAsFixed(1), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), const Divider(), _buildSectionHeader('ACTIONS'), ElevatedButton.icon( onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReplayScreen(rideId: r.id))); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ReplayScreen(rideId: r.id)), + ); }, icon: const Icon(Icons.play_arrow), label: const Text('Replay Ride'), @@ -189,10 +269,16 @@ class _RideDetailScreenState extends ConsumerState { title: const Text('Delete Ride?'), content: const Text('This cannot be undone.'), actions: [ - TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('CANCEL')), + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('CANCEL'), + ), TextButton( onPressed: () => Navigator.pop(c, true), - child: const Text('DELETE', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'DELETE', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -200,16 +286,25 @@ class _RideDetailScreenState extends ConsumerState { if (confirm == true && mounted) { final db = ref.read(databaseProvider); - await (db.delete(db.telemetrySamples)..where((t) => t.rideId.equals(r.id))).go(); - await (db.delete(db.rideEvents)..where((e) => e.rideId.equals(r.id))).go(); - await (db.delete(db.rides)..where((ride) => ride.id.equals(r.id))).go(); + await (db.delete( + db.telemetrySamples, + )..where((t) => t.rideId.equals(r.id))).go(); + await (db.delete( + db.rideEvents, + )..where((e) => e.rideId.equals(r.id))).go(); + await (db.delete( + db.rides, + )..where((ride) => ride.id.equals(r.id))).go(); if (context.mounted) { Navigator.of(context).pop(); } } }, icon: const Icon(Icons.delete, color: Colors.redAccent), - label: const Text('Delete Ride', style: TextStyle(color: Colors.redAccent)), + label: const Text( + 'Delete Ride', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -236,9 +331,20 @@ class _RideDetailScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(color: Colors.white70)), - Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + Expanded( + child: Text(label, style: const TextStyle(color: Colors.white70)), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + textAlign: TextAlign.right, + softWrap: true, + ), + ), ], ), ); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 917f70f..940c8da 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -7,7 +7,9 @@ import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_providers.dart'; import '../../services/alerts/road_alert_models.dart'; import '../../services/alerts/road_alert_providers.dart'; +import '../../services/alerts/road_alert_service.dart'; import '../../database/database.dart'; +import '../../core/app_info.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -25,8 +27,12 @@ class _SettingsScreenState extends ConsumerState { @override void initState() { super.initState(); - _tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider)); - _communityKeyController = TextEditingController(text: ref.read(communityApiKeyProvider)); + _tomtomKeyController = TextEditingController( + text: ref.read(tomtomApiKeyProvider), + ); + _communityKeyController = TextEditingController( + text: ref.read(communityApiKeyProvider), + ); } @override @@ -51,11 +57,8 @@ class _SettingsScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.all(16.0), children: [ - const Text( - 'Maps & Navigation Engine', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), - ), - const SizedBox(height: 16), + _buildSectionHeader('Maps & Navigation Engine'), + const SizedBox(height: 12), Card( clipBehavior: Clip.antiAlias, child: Padding( @@ -65,52 +68,65 @@ class _SettingsScreenState extends ConsumerState { children: [ const Row( children: [ - Icon(Icons.map, color: Colors.cyanAccent), - SizedBox(width: 12), + Icon(Icons.map, color: Colors.cyanAccent, size: 20), + SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Active Map Engine', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('Select map rendering backend for live map, route preview, and replay.', style: TextStyle(fontSize: 13, color: Colors.white70)), + Text( + 'Active Map Engine', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 2), + Text( + 'Select map rendering backend for live map, route preview, and replay.', + style: TextStyle( + fontSize: 12, + color: Colors.white70, + height: 1.3, + ), + ), ], ), ), ], ), const SizedBox(height: 16), - SegmentedButton( - segments: const [ - ButtonSegment( - value: AppMapType.openStreetMap, - label: Text('OpenStreetMap (Primary)'), - icon: Icon(Icons.public), - ), - ButtonSegment( - value: AppMapType.googleMaps, - label: Text('Google Maps'), - icon: Icon(Icons.map_outlined), - ), - ], - selected: {mapType}, - onSelectionChanged: (Set selection) { - ref - .read(mapProviderTypeNotifierProvider.notifier) - .setMapProviderType(selection.first); - }, + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: AppMapType.openStreetMap, + label: Text('OpenStreetMap'), + icon: Icon(Icons.public), + ), + ButtonSegment( + value: AppMapType.googleMaps, + label: Text('Google Maps'), + icon: Icon(Icons.map_outlined), + ), + ], + selected: {mapType}, + onSelectionChanged: (Set selection) { + ref + .read(mapProviderTypeNotifierProvider.notifier) + .setMapProviderType(selection.first); + }, + ), ), ], ), ), ), - const SizedBox(height: 32), - const Text( - 'Road Alerts & Incidents', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), - ), - const SizedBox(height: 16), + const SizedBox(height: 24), + _buildSectionHeader('Road Alerts & Incidents'), + const SizedBox(height: 12), Card( clipBehavior: Clip.antiAlias, child: Padding( @@ -120,52 +136,119 @@ class _SettingsScreenState extends ConsumerState { children: [ SwitchListTile( activeThumbColor: Colors.cyanAccent, - title: const Text('Road Alerts', style: TextStyle(fontWeight: FontWeight.bold)), - subtitle: const Text('Display real-time police, hazards, and traffic incidents on map.'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 0.0, + ), + title: const Text( + 'Road Alerts', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + subtitle: const Text( + 'Display real-time police, hazards, and traffic incidents on map.', + style: TextStyle(fontSize: 11), + ), value: roadAlertsEnabled, onChanged: (val) { - ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val); + ref + .read(roadAlertsEnabledProvider.notifier) + .setEnabled(val); }, ), const Divider(), SwitchListTile( activeThumbColor: Colors.cyanAccent, - title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)), - subtitle: const Text('Announce approaching hazards and enforcement over helmet headset.'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 0.0, + ), + title: const Text( + 'Audio Warnings (TTS)', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + subtitle: const Text( + 'Announce approaching hazards and enforcement over helmet headset.', + style: TextStyle(fontSize: 11), + ), value: audioAlertsEnabled, onChanged: roadAlertsEnabled ? (val) { - ref.read(audioAlertsEnabledProvider.notifier).setEnabled(val); + ref + .read(audioAlertsEnabledProvider.notifier) + .setEnabled(val); } : null, ), const Divider(), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Alert Radius', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - SizedBox(height: 4), - Text('Distance to detect upcoming incidents', style: TextStyle(fontSize: 12, color: Colors.white70)), - ], + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Alert Radius', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + SizedBox(height: 4), + Text( + 'Distance to detect upcoming incidents', + style: TextStyle( + fontSize: 11, + color: Colors.white70, + ), + ), + ], + ), ), + const SizedBox(width: 12), DropdownButton( 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')), + 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); + ref + .read( + alertRadiusMetersProvider.notifier, + ) + .setRadius(val); } } : null, @@ -175,16 +258,37 @@ class _SettingsScreenState extends ConsumerState { ), const Divider(), const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Text('Alert Categories', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + padding: EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Text( + 'Alert Categories', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), ), ...RoadAlertCategory.values.map((cat) { final isChecked = enabledCategories.contains(cat); return CheckboxListTile( activeColor: Colors.cyanAccent, checkColor: Colors.black, - title: Text(cat.displayName), - secondary: Icon(cat.icon, color: cat.color), + dense: true, + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 0.0, + ), + 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) { @@ -209,31 +313,27 @@ class _SettingsScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Icon(Icons.local_police, color: Colors.blueAccent), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Community Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('Supplies Police, Speed Cameras, and Crowd Hazards.', style: TextStyle(fontSize: 12, color: Colors.white70)), - ], - ), - ), - _buildStatusBadge(communityStatus), - ], + _buildProviderHeader( + icon: Icons.local_police, + iconColor: Colors.blueAccent, + 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 Provider API Key', + labelText: 'Community API Key', hintText: 'Enter API Key for Community Traffic Feed', + 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); }, @@ -247,20 +347,42 @@ class _SettingsScreenState extends ConsumerState { : () 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; + 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 = 'Community API Connected successfully!'; - if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.'; - if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.'; - messenger.showSnackBar(SnackBar(content: Text(msg))); + String msg = ''; + if (status == RoadAlertProviderStatus.connected) { + msg = 'Community 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: _isTestingCommunity - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.sync), label: const Text('Test Connection'), ), @@ -279,22 +401,13 @@ class _SettingsScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Icon(Icons.security, color: Colors.cyanAccent), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('TomTom Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('Supplies Accidents, Hazards, Closures & Construction data.', style: TextStyle(fontSize: 12, color: Colors.white70)), - ], - ), - ), - _buildStatusBadge(tomtomStatus), - ], + _buildProviderHeader( + icon: Icons.security, + iconColor: Colors.cyanAccent, + title: 'TomTom Traffic Provider', + subtitle: + 'Supplies Accidents, Hazards, Closures & Construction data.', + status: tomtomStatus, ), const SizedBox(height: 16), TextField( @@ -302,8 +415,13 @@ class _SettingsScreenState extends ConsumerState { 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); }, @@ -318,19 +436,39 @@ class _SettingsScreenState extends ConsumerState { 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; + final status = await provider.testConnection( + _tomtomKeyController.text, + ); + ref.read(tomtomStatusProvider.notifier).state = + status; setState(() => _isTestingTomTom = false); if (mounted) { - String msg = 'TomTom API Connected successfully!'; - if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.'; - if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.'; - messenger.showSnackBar(SnackBar(content: Text(msg))); + 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 SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) : const Icon(Icons.sync), label: const Text('Test Connection'), ), @@ -345,7 +483,11 @@ class _SettingsScreenState extends ConsumerState { const SizedBox(height: 32), const Text( 'Developer Options', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.orangeAccent), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.orangeAccent, + ), ), const SizedBox(height: 16), Card( @@ -361,30 +503,82 @@ class _SettingsScreenState extends ConsumerState { children: [ const Row( children: [ - Icon(Icons.bug_report, color: Colors.orangeAccent), - SizedBox(width: 12), - Text('Road Alert Test Mode', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.orangeAccent)), + Icon( + Icons.bug_report, + color: Colors.orangeAccent, + size: 20, + ), + SizedBox(width: 10), + Text( + 'Road Alert Test Mode', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.orangeAccent, + ), + ), ], ), const SizedBox(height: 8), - const Text('Generate simulated test alerts for real-device testing (Debug Build Only).', style: TextStyle(fontSize: 12, color: Colors.white70)), + const Text( + 'Generate simulated test alerts for real-device testing (Debug Build Only).', + style: TextStyle(fontSize: 12, color: Colors.white70), + ), const SizedBox(height: 16), Wrap( spacing: 8, runSpacing: 8, children: [ ElevatedButton.icon( - icon: const Icon(Icons.local_police, color: Colors.blueAccent, size: 18), + icon: const Icon( + Icons.local_police, + color: Colors.blueAccent, + size: 18, + ), label: const Text('Test Police (100m)'), onPressed: () { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Simulated Police Alert 100m ahead triggered.'))); + 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 to Dashboard.')), + ); }, ), ElevatedButton.icon( - icon: const Icon(Icons.warning, color: Colors.orangeAccent, size: 18), + icon: const Icon( + Icons.warning, + color: Colors.orangeAccent, + size: 18, + ), label: const Text('Test Hazard (250m)'), onPressed: () { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Simulated Hazard Alert 250m ahead triggered.'))); + 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 to Dashboard.')), + ); }, ), ], @@ -395,12 +589,9 @@ class _SettingsScreenState extends ConsumerState { ), ], - const SizedBox(height: 32), - const Text( - 'Sensors', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), - ), - const SizedBox(height: 16), + const SizedBox(height: 24), + _buildSectionHeader('Sensors'), + const SizedBox(height: 12), Card( clipBehavior: Clip.antiAlias, child: Padding( @@ -411,20 +602,31 @@ class _SettingsScreenState extends ConsumerState { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.screen_rotation, color: Colors.cyanAccent), - const SizedBox(width: 12), + const Icon( + Icons.screen_rotation, + color: Colors.cyanAccent, + size: 20, + ), + const SizedBox(width: 10), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Calibrate Mount Angle', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - SizedBox(height: 4), + SizedBox(height: 2), Text( 'Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.', - style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3), + style: TextStyle( + fontSize: 12, + color: Colors.white70, + height: 1.3, + ), ), ], ), @@ -436,10 +638,14 @@ class _SettingsScreenState extends ConsumerState { alignment: Alignment.centerRight, child: ElevatedButton( onPressed: () async { - ref.read(sensorFusionServiceProvider).calibrateUpright(); + ref + .read(sensorFusionServiceProvider) + .calibrateUpright(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Sensors Calibrated!')), + const SnackBar( + content: Text('Sensors Calibrated!'), + ), ); } }, @@ -450,12 +656,9 @@ class _SettingsScreenState extends ConsumerState { ), ), ), - const SizedBox(height: 32), - const Text( - 'Data', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), - ), - const SizedBox(height: 16), + const SizedBox(height: 24), + _buildSectionHeader('Data'), + const SizedBox(height: 12), Card( clipBehavior: Clip.antiAlias, child: InkWell( @@ -464,19 +667,35 @@ class _SettingsScreenState extends ConsumerState { padding: EdgeInsets.all(16.0), child: Row( children: [ - Icon(Icons.monitor_heart, color: Colors.cyanAccent), - SizedBox(width: 12), + Icon( + Icons.monitor_heart, + color: Colors.cyanAccent, + size: 20, + ), + SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Sensor Diagnostics', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('View raw IMU and GPS values.', style: TextStyle(fontSize: 13, color: Colors.white70)), + Text( + 'Sensor Diagnostics', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 2), + Text( + 'View raw IMU and GPS values.', + style: TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), ], ), ), - Icon(Icons.chevron_right, color: Colors.white54), + Icon(Icons.chevron_right, color: Colors.white54, size: 20), ], ), ), @@ -493,15 +712,32 @@ class _SettingsScreenState extends ConsumerState { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.delete_forever, color: Colors.redAccent), - const SizedBox(width: 12), + const Icon( + Icons.delete_forever, + color: Colors.redAccent, + size: 20, + ), + const SizedBox(width: 10), const Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Clear Ride History', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('Permanently delete all recorded rides and performance runs.', style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3)), + Text( + 'Clear Ride History', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 2), + Text( + 'Permanently delete all recorded rides and performance runs.', + style: TextStyle( + fontSize: 12, + color: Colors.white70, + height: 1.3, + ), + ), ], ), ), @@ -511,7 +747,9 @@ class _SettingsScreenState extends ConsumerState { Align( alignment: Alignment.centerRight, child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.withValues(alpha: 0.2), + ), onPressed: () async { final db = ref.read(databaseProvider); await db.delete(db.rides).go(); @@ -522,18 +760,81 @@ class _SettingsScreenState extends ConsumerState { ); } }, - child: const Text('Clear', style: TextStyle(color: Colors.redAccent)), + child: const Text( + 'Clear', + style: TextStyle(color: Colors.redAccent), + ), ), ), ], ), ), ), + const SizedBox(height: 24), + _AppVersionFooter(), ], ), ); } + Widget _buildSectionHeader(String title) { + return Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: Colors.cyanAccent, + ), + ); + } + + 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.w600, + ), + softWrap: true, + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + height: 1.3, + ), + softWrap: true, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: _buildStatusBadge(status), + ), + ], + ), + ), + ], + ); + } + Widget _buildStatusBadge(RoadAlertProviderStatus status) { String label; Color color; @@ -572,7 +873,58 @@ class _SettingsScreenState extends ConsumerState { border: Border.all(color: color), borderRadius: BorderRadius.circular(8), ), - child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold)), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +/// Read-only footer that surfaces the app's build version inside Settings. +/// Resolves the version asynchronously via [AppInfo]; shows a placeholder +/// while loading and the literal fallback string if the platform call fails. +class _AppVersionFooter extends StatefulWidget { + const _AppVersionFooter(); + + @override + State<_AppVersionFooter> createState() => _AppVersionFooterState(); +} + +class _AppVersionFooterState extends State<_AppVersionFooter> { + late Future _versionFuture; + + @override + void initState() { + super.initState(); + _versionFuture = AppInfo.versionString(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: FutureBuilder( + future: _versionFuture, + builder: (context, snapshot) { + final text = snapshot.data ?? AppInfo.fallbackVersion; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Motorcycle Dash v$text', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white38, + fontSize: 12, + letterSpacing: 0.4, + ), + ), + ); + }, + ), ); } } diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index ca71d93..86058e8 100644 --- a/lib/services/alerts/road_alert_service.dart +++ b/lib/services/alerts/road_alert_service.dart @@ -256,6 +256,15 @@ class RoadAlertService { } } + void simulateAlert(RoadAlert alert) { + if (!_ref.read(roadAlertsEnabledProvider)) return; + _cachedAlerts = [alert, ..._cachedAlerts]; + _alertsController.add(_cachedAlerts); + if (_ref.read(audioAlertsEnabledProvider)) { + _announceAlertAudio(alert); + } + } + void dispose() { if (kDebugMode) { debugPrint('[RoadAlerts] Disposing service resources'); diff --git a/pubspec.lock b/pubspec.lock index d05daa7..e33d6ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -558,7 +558,7 @@ packages: source: hosted version: "4.1.2" image: - dependency: transitive + dependency: "direct dev" description: name: image sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" @@ -750,7 +750,7 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" diff --git a/pubspec.yaml b/pubspec.yaml index ac712c6..920e250 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: flutter_tts: ^4.2.5 share_plus: ^13.3.0 http: ^1.3.0 + package_info_plus: ^10.2.1 dev_dependencies: flutter_test: @@ -68,6 +69,7 @@ dev_dependencies: drift_dev: ^2.34.5 flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.4.8 + image: ^4.9.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec