diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart index 95af67d..726dddb 100644 --- a/lib/features/history/ride_detail_screen.dart +++ b/lib/features/history/ride_detail_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../database/database.dart'; import '../../services/export_service.dart'; import '../replay/replay_screen.dart'; +import '../map/map_screen.dart'; import 'package:go_router/go_router.dart'; class RideDetailScreen extends ConsumerStatefulWidget { @@ -71,7 +72,10 @@ class _RideDetailScreenState extends ConsumerState { ), if (r.motorcycleId != null) OutlinedButton( - style: OutlinedButton.styleFrom(foregroundColor: Colors.cyanAccent, side: const BorderSide(color: Colors.cyanAccent)), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.cyanAccent, + side: const BorderSide(color: Colors.cyanAccent), + ), onPressed: () => context.push('/garage/${r.motorcycleId}'), child: const Text('View Specs'), ), @@ -79,6 +83,44 @@ class _RideDetailScreenState extends ConsumerState { ), ), ], + + // Map Preview Card + Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black45, + border: Border.all(color: Colors.white12), + ), + child: Row( + children: [ + const Icon(Icons.map, color: Colors.cyanAccent, size: 36), + const SizedBox(width: 16), + const 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)), + ], + ), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => MapScreen(rideId: r.id)), + ); + }, + child: const Text('VIEW ROUTE'), + ), + ], + ), + ), + ), + _buildSectionHeader('SUMMARY'), _buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'), _buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'), @@ -155,10 +197,9 @@ class _RideDetailScreenState extends ConsumerState { ], ), ); - + if (confirm == true && mounted) { final db = ref.read(databaseProvider); - // Also delete telemetry and events 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(); diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index eeaa0d4..7bb49f9 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:geolocator/geolocator.dart'; import '../../database/database.dart'; -import '../../core/theme/map_style.dart'; import '../../services/location_service.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/map_providers.dart'; +import '../../services/map/unified_map_view.dart'; class MapScreen extends ConsumerStatefulWidget { final int? rideId; @@ -15,13 +17,17 @@ class MapScreen extends ConsumerStatefulWidget { } class _MapScreenState extends ConsumerState { - GoogleMapController? _mapController; - MapType _currentMapType = MapType.normal; - bool _isLoadingHistory = true; - final Set _polylines = {}; - final Set _markers = {}; - LatLng? _initialCameraPosition; + List _routePoints = []; + List _eventMarkers = []; + AppLatLng? _initialCenter; + AppLatLng _fallbackLocation = const AppLatLng(37.7749, -122.4194); // SF Default + + // Event category filter states + bool _showHardAccel = true; + bool _showHardBraking = true; + bool _showCornering = true; + bool _showSpeedMilestones = true; @override void initState() { @@ -30,98 +36,127 @@ class _MapScreenState extends ConsumerState { _loadRideHistory(); } else { _isLoadingHistory = false; + _initLiveLocation(); } } - + + Future _initLiveLocation() async { + final locationService = ref.read(locationServiceProvider); + await locationService.requestPermissions(); + + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null && mounted) { + setState(() { + _fallbackLocation = AppLatLng(lastPos.latitude, lastPos.longitude); + }); + } else { + final currentPos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(timeLimit: Duration(seconds: 3)), + ).catchError((_) => Position( + longitude: -122.4194, + latitude: 37.7749, + timestamp: DateTime.now(), + accuracy: 0, + altitude: 0, + heading: 0, + speed: 0, + speedAccuracy: 0, + altitudeAccuracy: 0, + headingAccuracy: 0, + )); + + if (mounted) { + setState(() { + _fallbackLocation = AppLatLng(currentPos.latitude, currentPos.longitude); + }); + } + } + } catch (_) {} + } + Future _loadRideHistory() async { final db = ref.read(databaseProvider); - - // Fetch telemetry for route + final telemetry = await (db.select(db.telemetrySamples) - ..where((t) => t.rideId.equals(widget.rideId!)) - ..orderBy([(t) => OrderingTerm(expression: t.timestamp)]) - ).get(); - - // Fetch events for markers + ..where((t) => t.rideId.equals(widget.rideId!)) + ..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)])) + .get(); + final events = await (db.select(db.rideEvents) - ..where((e) => e.rideId.equals(widget.rideId!)) - ).get(); - + ..where((e) => e.rideId.equals(widget.rideId!))) + .get(); + if (telemetry.isNotEmpty) { - final List points = telemetry - .where((t) => t.latitude != null && t.longitude != null) - .map((t) => LatLng(t.latitude!, t.longitude!)) - .toList(); - - if (points.isNotEmpty) { - _initialCameraPosition = points.first; - _polylines.add( - Polyline( - polylineId: const PolylineId('route'), - points: points, - color: Colors.cyanAccent, - width: 5, - ) - ); + _routePoints = telemetry + .where((t) => t.latitude != null && t.longitude != null) + .map((t) => AppLatLng(t.latitude!, t.longitude!)) + .toList(); + + if (_routePoints.isNotEmpty) { + _initialCenter = _routePoints.first; } } - + + _eventMarkers = []; for (final event in events) { if (event.latitude != null && event.longitude != null) { - BitmapDescriptor icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed); - if (event.eventType == 'Extreme Lean') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueOrange); - if (event.eventType == 'Hard Acceleration') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen); - if (event.eventType == 'High Speed') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueViolet); - - _markers.add( - Marker( - markerId: MarkerId(event.id.toString()), - position: LatLng(event.latitude!, event.longitude!), - icon: icon, - infoWindow: InfoWindow( - title: event.eventType, - snippet: 'Value: ${event.value.toStringAsFixed(2)}', - ), - ) + Color markerColor = Colors.redAccent; + if (event.eventType.contains('Corner')) markerColor = Colors.orangeAccent; + if (event.eventType.contains('Acceleration')) markerColor = Colors.greenAccent; + if (event.eventType.contains('Speed')) markerColor = Colors.purpleAccent; + + _eventMarkers.add( + AppMapMarker( + id: event.id.toString(), + position: AppLatLng(event.latitude!, event.longitude!), + label: event.eventType, + iconWidget: Icon(Icons.location_on, color: markerColor, size: 36), + onTap: () { + if (mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(event.eventType), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Time: ${event.timestamp.toIso8601String().substring(11, 19)}'), + Text('Value: ${event.value.toStringAsFixed(2)}'), + if (event.latitude != null && event.longitude != null) + Text('Lat/Lng: ${event.latitude!.toStringAsFixed(4)}, ${event.longitude!.toStringAsFixed(4)}'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + }, + ), ); } } - + setState(() { _isLoadingHistory = false; }); - - // Adjust camera to fit route if map is ready - _fitRoute(); } - - void _fitRoute() { - if (_mapController == null || _polylines.isEmpty) return; - - final points = _polylines.first.points; - if (points.isEmpty) return; - - double minLat = points.first.latitude; - double maxLat = points.first.latitude; - double minLng = points.first.longitude; - double maxLng = points.first.longitude; - - for (final p in points) { - if (p.latitude < minLat) minLat = p.latitude; - if (p.latitude > maxLat) maxLat = p.latitude; - if (p.longitude < minLng) minLng = p.longitude; - if (p.longitude > maxLng) maxLng = p.longitude; - } - - _mapController!.animateCamera( - CameraUpdate.newLatLngBounds( - LatLngBounds( - southwest: LatLng(minLat, minLng), - northeast: LatLng(maxLat, maxLng), - ), - 50.0, // padding - ) - ); + + List _getFilteredMarkers() { + return _eventMarkers.where((m) { + final label = m.label ?? ''; + if (label.contains('Acceleration') && !_showHardAccel) return false; + if (label.contains('Braking') && !_showHardBraking) return false; + if (label.contains('Corner') && !_showCornering) return false; + if (label.contains('Speed') && !_showSpeedMilestones) return false; + return true; + }).toList(); } @override @@ -130,85 +165,164 @@ class _MapScreenState extends ConsumerState { return const Scaffold(body: Center(child: CircularProgressIndicator())); } - Widget mapContent; - - if (widget.rideId != null) { - // Historical Ride View - mapContent = GoogleMap( - initialCameraPosition: CameraPosition( - target: _initialCameraPosition ?? const LatLng(0, 0), - zoom: 15.0, - ), - mapType: _currentMapType, - polylines: _polylines, - markers: _markers, - myLocationEnabled: false, - onMapCreated: (controller) { - _mapController = controller; - _fitRoute(); - }, - ); - } else { - // Live Tracking View - final locationAsync = ref.watch(locationStreamProvider); - - mapContent = locationAsync.when( - data: (position) { - final currentLatLng = LatLng(position.latitude, position.longitude); - - if (_mapController != null) { - _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: currentLatLng, - zoom: 17.0, - bearing: position.heading, - ), - ), - ); - } + final activeProviderType = ref.watch(mapProviderTypeNotifierProvider); + final isFollowMode = ref.watch(mapFollowStateProvider); - return GoogleMap( - initialCameraPosition: CameraPosition( - target: currentLatLng, - zoom: 17.0, + if (widget.rideId != null) { + // Historical Route View + final polylines = _routePoints.isNotEmpty + ? [AppPolyline(id: 'history_route', points: _routePoints, color: Colors.cyanAccent, width: 5.0)] + : []; + + return Scaffold( + appBar: AppBar( + title: Text('Ride #${widget.rideId} Route'), + actions: [ + IconButton( + tooltip: 'Switch Map Provider', + icon: Icon(activeProviderType == AppMapType.openStreetMap ? Icons.map : Icons.map_outlined), + onPressed: () { + final nextType = activeProviderType == AppMapType.openStreetMap + ? AppMapType.googleMaps + : AppMapType.openStreetMap; + ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(nextType); + }, ), - mapType: _currentMapType, - myLocationEnabled: true, - myLocationButtonEnabled: true, - compassEnabled: true, - onMapCreated: (controller) { - _mapController = controller; - _mapController?.setMapStyle(darkMapStyle); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), + ], + ), + body: Stack( + children: [ + UnifiedMapView( + center: _initialCenter ?? _fallbackLocation, + zoom: 15.0, + polylines: polylines, + markers: _getFilteredMarkers(), + ), + _buildEventFilterOverlay(), + ], + ), ); } + // Live Tracking View + final locationAsync = ref.watch(locationStreamProvider); + + final currentPos = locationAsync.when( + data: (pos) => AppLatLng(pos.latitude, pos.longitude), + error: (_, __) => _fallbackLocation, + loading: () => _fallbackLocation, + ); + + final currentHeading = locationAsync.when( + data: (pos) => pos.heading, + error: (_, __) => 0.0, + loading: () => 0.0, + ); + + final motorcycleMarker = AppMapMarker( + id: 'motorcycle_live', + position: currentPos, + heading: currentHeading, + iconWidget: const Icon( + Icons.navigation, + color: Colors.cyanAccent, + size: 40, + ), + ); + return Scaffold( appBar: AppBar( - title: Text(widget.rideId != null ? 'Ride ${widget.rideId} Map' : 'Map'), + title: const Text('Live Map'), actions: [ - IconButton( - icon: Icon( - _currentMapType == MapType.normal - ? Icons.satellite - : Icons.map, - ), - onPressed: () { - setState(() { - _currentMapType = _currentMapType == MapType.normal - ? MapType.satellite - : MapType.normal; - }); + PopupMenuButton( + icon: const Icon(Icons.layers), + onSelected: (type) { + ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(type); }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: AppMapType.openStreetMap, + child: Text('OpenStreetMap (Primary)'), + ), + const PopupMenuItem( + value: AppMapType.googleMaps, + child: Text('Google Maps'), + ), + ], ), ], ), - body: mapContent, + body: Stack( + children: [ + UnifiedMapView( + center: currentPos, + zoom: 17.0, + markers: [motorcycleMarker], + ), + if (!isFollowMode) + Positioned( + bottom: 24, + right: 24, + child: FloatingActionButton.extended( + backgroundColor: Colors.cyanAccent, + foregroundColor: Colors.black, + icon: const Icon(Icons.my_location), + label: const Text('RECENTER', style: TextStyle(fontWeight: FontWeight.bold)), + onPressed: () { + ref.read(mapFollowStateProvider.notifier).recenter(); + }, + ), + ), + ], + ), + ); + } + + Widget _buildEventFilterOverlay() { + return Positioned( + top: 10, + left: 10, + right: 10, + child: Card( + color: Colors.black.withValues(alpha: 0.7), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: const Text('Accel'), + selected: _showHardAccel, + onSelected: (val) => setState(() => _showHardAccel = val), + selectedColor: Colors.green.withValues(alpha: 0.4), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Braking'), + selected: _showHardBraking, + onSelected: (val) => setState(() => _showHardBraking = val), + selectedColor: Colors.red.withValues(alpha: 0.4), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Cornering'), + selected: _showCornering, + onSelected: (val) => setState(() => _showCornering = val), + selectedColor: Colors.orange.withValues(alpha: 0.4), + ), + const SizedBox(width: 6), + FilterChip( + label: const Text('Speed'), + selected: _showSpeedMilestones, + onSelected: (val) => setState(() => _showSpeedMilestones = val), + selectedColor: Colors.purple.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + ), ); } } diff --git a/lib/features/replay/replay_screen.dart b/lib/features/replay/replay_screen.dart index 0a6fb67..3f4cece 100644 --- a/lib/features/replay/replay_screen.dart +++ b/lib/features/replay/replay_screen.dart @@ -1,10 +1,10 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'dart:async'; -import '../../core/theme/map_style.dart'; import 'package:drift/drift.dart' hide Column; import '../../database/database.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/unified_map_view.dart'; class ReplayScreen extends ConsumerStatefulWidget { final int rideId; @@ -16,12 +16,10 @@ class ReplayScreen extends ConsumerStatefulWidget { } class _ReplayScreenState extends ConsumerState { - GoogleMapController? _mapController; - bool _isLoading = true; List _telemetry = []; - final Set _polylines = {}; - + List _routePoints = []; + // Playback state bool _isPlaying = false; double _playbackSpeed = 1.0; @@ -33,7 +31,7 @@ class _ReplayScreenState extends ConsumerState { super.initState(); _loadData(); } - + @override void dispose() { _playbackTimer?.cancel(); @@ -43,26 +41,15 @@ class _ReplayScreenState extends ConsumerState { Future _loadData() async { final db = ref.read(databaseProvider); final telemetry = await (db.select(db.telemetrySamples) - ..where((t) => t.rideId.equals(widget.rideId)) - ..orderBy([(t) => OrderingTerm(expression: t.timestamp)]) - ).get(); + ..where((t) => t.rideId.equals(widget.rideId)) + ..orderBy([(t) => OrderingTerm(expression: t.timestamp)])) + .get(); if (telemetry.isNotEmpty) { - final List points = telemetry + _routePoints = telemetry .where((t) => t.latitude != null && t.longitude != null) - .map((t) => LatLng(t.latitude!, t.longitude!)) + .map((t) => AppLatLng(t.latitude!, t.longitude!)) .toList(); - - if (points.isNotEmpty) { - _polylines.add( - Polyline( - polylineId: const PolylineId('route'), - points: points, - color: Colors.cyanAccent, - width: 5, - ) - ); - } } if (mounted) { @@ -70,36 +57,9 @@ class _ReplayScreenState extends ConsumerState { _telemetry = telemetry; _isLoading = false; }); - _fitRoute(); } } - void _fitRoute() { - if (_mapController == null || _polylines.isEmpty) return; - - final points = _polylines.first.points; - if (points.isEmpty) return; - - double minLat = points.first.latitude; - double maxLat = points.first.latitude; - double minLng = points.first.longitude; - double maxLng = points.first.longitude; - - for (final p in points) { - if (p.latitude < minLat) minLat = p.latitude; - if (p.latitude > maxLat) maxLat = p.latitude; - if (p.longitude < minLng) minLng = p.longitude; - if (p.longitude > maxLng) maxLng = p.longitude; - } - - _mapController!.animateCamera( - CameraUpdate.newLatLngBounds( - LatLngBounds(southwest: LatLng(minLat, minLng), northeast: LatLng(maxLat, maxLng)), - 50.0, - ) - ); - } - void _togglePlayback() { setState(() { _isPlaying = !_isPlaying; @@ -114,42 +74,24 @@ class _ReplayScreenState extends ConsumerState { _playbackTimer?.cancel(); } } - + void _scheduleNextFrame() { if (!_isPlaying || _currentIndex >= _telemetry.length - 1) { setState(() => _isPlaying = false); return; } - + final currentPoint = _telemetry[_currentIndex]; final nextPoint = _telemetry[_currentIndex + 1]; - + final diffMs = nextPoint.timestamp.difference(currentPoint.timestamp).inMilliseconds; - // Cap at 1 second to avoid massive pauses if GPS dropped out final waitMs = (diffMs.clamp(0, 1000) / _playbackSpeed).round(); - + _playbackTimer = Timer(Duration(milliseconds: waitMs), () { if (mounted) { setState(() { _currentIndex++; }); - - // Follow camera - if (_mapController != null) { - final t = _telemetry[_currentIndex]; - if (t.latitude != null && t.longitude != null) { - _mapController!.animateCamera( - CameraUpdate.newCameraPosition( - CameraPosition( - target: LatLng(t.latitude!, t.longitude!), - zoom: 18.0, - bearing: t.heading ?? 0, - ) - ) - ); - } - } - _scheduleNextFrame(); } }); @@ -177,41 +119,48 @@ class _ReplayScreenState extends ConsumerState { if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.'))); final currentSample = _telemetry[_currentIndex]; - final Set markers = {}; - if (currentSample.latitude != null && currentSample.longitude != null) { - markers.add( - Marker( - markerId: const MarkerId('current'), - position: LatLng(currentSample.latitude!, currentSample.longitude!), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan), - rotation: currentSample.heading ?? 0, - flat: true, - ) - ); - } + final currentPos = (currentSample.latitude != null && currentSample.longitude != null) + ? AppLatLng(currentSample.latitude!, currentSample.longitude!) + : (_routePoints.isNotEmpty ? _routePoints.first : const AppLatLng(0, 0)); + + final riderMarker = AppMapMarker( + id: 'replay_rider', + position: currentPos, + heading: currentSample.heading ?? 0.0, + iconWidget: const Icon( + Icons.navigation, + color: Colors.cyanAccent, + size: 38, + ), + ); + + final polyline = AppPolyline( + id: 'replay_route', + points: _routePoints, + color: Colors.cyanAccent, + width: 5.0, + ); return Scaffold( appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')), body: Column( children: [ - // Map + // Map View Expanded( flex: 3, child: Stack( children: [ - GoogleMap( - initialCameraPosition: const CameraPosition(target: LatLng(0, 0), zoom: 15), - polylines: _polylines, - markers: markers, - onMapCreated: (controller) { - _mapController = controller; - controller.setMapStyle(darkMapStyle); - _fitRoute(); - }, + UnifiedMapView( + center: currentPos, + zoom: 17.0, + markers: [riderMarker], + polylines: [polyline], ), // Telemetry Overlay Positioned( - top: 16, left: 16, right: 16, + top: 16, + left: 16, + right: 16, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -224,13 +173,16 @@ class _ReplayScreenState extends ConsumerState { ], ), ), - // Controls + // Controls Container Container( padding: const EdgeInsets.all(16), color: Theme.of(context).colorScheme.surface, child: Column( children: [ - Text(currentSample.timestamp.toLocal().toString().split('.')[0], style: const TextStyle(color: Colors.cyanAccent)), + Text( + currentSample.timestamp.toLocal().toString().split('.')[0], + style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold), + ), Slider( value: _currentIndex.toDouble(), min: 0, @@ -248,13 +200,9 @@ class _ReplayScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.restart_alt), onPressed: () { - setState(() { _currentIndex = 0; }); - if (_mapController != null && _telemetry.isNotEmpty) { - final t = _telemetry.first; - if (t.latitude != null && t.longitude != null) { - _mapController!.animateCamera(CameraUpdate.newLatLng(LatLng(t.latitude!, t.longitude!))); - } - } + setState(() { + _currentIndex = 0; + }); }, ), const SizedBox(width: 16), @@ -265,13 +213,13 @@ class _ReplayScreenState extends ConsumerState { const SizedBox(width: 16), TextButton( onPressed: _changeSpeed, - child: Text('${_playbackSpeed}x'), - ) + child: Text('${_playbackSpeed}x', style: const TextStyle(fontWeight: FontWeight.bold)), + ), ], - ) + ), ], ), - ) + ), ], ), ); @@ -283,7 +231,7 @@ class _ReplayScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white24), + border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)), ), child: Column( children: [ diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 2709b9d..2f9e932 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/sensor_fusion_service.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/map_providers.dart'; import '../../database/database.dart'; class SettingsScreen extends ConsumerWidget { @@ -9,11 +11,67 @@ class SettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mapType = ref.watch(mapProviderTypeNotifierProvider); + return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( padding: const EdgeInsets.all(16.0), children: [ + const Text( + 'Map & Provider Settings', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), + ), + const SizedBox(height: 16), + Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.map, color: Colors.cyanAccent), + SizedBox(width: 12), + 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)), + ], + ), + ), + ], + ), + 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); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 32), const Text( 'Sensors', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent), diff --git a/lib/services/map/google_map_provider.dart b/lib/services/map/google_map_provider.dart new file mode 100644 index 0000000..8845098 --- /dev/null +++ b/lib/services/map/google_map_provider.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart' as gmaps; +import '../../core/theme/map_style.dart'; +import 'map_provider_interface.dart'; + +class GoogleMapProvider implements AppMapProvider { + gmaps.GoogleMapController? _controller; + + @override + AppMapType get type => AppMapType.googleMaps; + + @override + String get name => 'Google Maps'; + + @override + Widget buildMapView({ + required BuildContext context, + required AppLatLng center, + required double zoom, + required List markers, + required List polylines, + required bool isFollowMode, + required MapConfig config, + required ValueChanged? onPositionChanged, + required VoidCallback? onUserInteraction, + }) { + final target = gmaps.LatLng(center.latitude, center.longitude); + + if (isFollowMode && _controller != null) { + _controller!.animateCamera( + gmaps.CameraUpdate.newLatLngZoom(target, zoom), + ); + } + + final gMarkers = markers.map((m) { + return gmaps.Marker( + markerId: gmaps.MarkerId(m.id), + position: gmaps.LatLng(m.position.latitude, m.position.longitude), + rotation: m.heading, + onTap: m.onTap, + ); + }).toSet(); + + final gPolylines = polylines.map((p) { + return gmaps.Polyline( + polylineId: gmaps.PolylineId(p.id), + points: p.points.map((pt) => gmaps.LatLng(pt.latitude, pt.longitude)).toList(), + color: p.color, + width: p.width.toInt(), + ); + }).toSet(); + + return gmaps.GoogleMap( + initialCameraPosition: gmaps.CameraPosition( + target: target, + zoom: zoom, + ), + markers: gMarkers, + polylines: gPolylines, + style: darkMapStyle, + myLocationEnabled: false, + zoomControlsEnabled: false, + compassEnabled: false, + onMapCreated: (controller) { + _controller = controller; + }, + onCameraMoveStarted: () { + onUserInteraction?.call(); + }, + onCameraMove: (position) { + onPositionChanged?.call(AppLatLng(position.target.latitude, position.target.longitude)); + }, + ); + } +} diff --git a/lib/services/map/map_provider_interface.dart b/lib/services/map/map_provider_interface.dart new file mode 100644 index 0000000..5b2f298 --- /dev/null +++ b/lib/services/map/map_provider_interface.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +enum AppMapType { openStreetMap, googleMaps } + +class AppLatLng { + final double latitude; + final double longitude; + + const AppLatLng(this.latitude, this.longitude); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppLatLng && + runtimeType == other.runtimeType && + latitude == other.latitude && + longitude == other.longitude; + + @override + int get hashCode => latitude.hashCode ^ longitude.hashCode; +} + +class AppMapMarker { + final String id; + final AppLatLng position; + final Widget? iconWidget; + final double heading; // degrees (0-360) + final String? label; + final VoidCallback? onTap; + + const AppMapMarker({ + required this.id, + required this.position, + this.iconWidget, + this.heading = 0.0, + this.label, + this.onTap, + }); +} + +class AppPolyline { + final String id; + final List points; + final Color color; + final double width; + + const AppPolyline({ + required this.id, + required this.points, + this.color = Colors.cyanAccent, + this.width = 4.0, + }); +} + +class MapConfig { + final String tileUrlTemplate; + final String attributionText; + final String userAgent; + final double minZoom; + final double maxZoom; + + const MapConfig({ + this.tileUrlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + this.attributionText = '© OpenStreetMap contributors', + this.userAgent = 'MotorcycleDashboard/1.0', + this.minZoom = 3.0, + this.maxZoom = 19.0, + }); +} + +abstract class AppMapProvider { + AppMapType get type; + String get name; + + Widget buildMapView({ + required BuildContext context, + required AppLatLng center, + required double zoom, + required List markers, + required List polylines, + required bool isFollowMode, + required MapConfig config, + required ValueChanged? onPositionChanged, + required VoidCallback? onUserInteraction, + }); +} diff --git a/lib/services/map/map_providers.dart b/lib/services/map/map_providers.dart new file mode 100644 index 0000000..299f8da --- /dev/null +++ b/lib/services/map/map_providers.dart @@ -0,0 +1,71 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'map_provider_interface.dart'; +import 'open_street_map_provider.dart'; +import 'google_map_provider.dart'; + +final mapConfigProvider = Provider((ref) { + return const MapConfig(); +}); + +class MapProviderTypeNotifier extends StateNotifier { + static const String _prefKey = 'selected_map_provider_type'; + + MapProviderTypeNotifier() : super(AppMapType.openStreetMap) { + _loadFromPrefs(); + } + + Future _loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final savedVal = prefs.getString(_prefKey); + if (savedVal != null) { + if (savedVal == 'googleMaps') { + state = AppMapType.googleMaps; + } else { + state = AppMapType.openStreetMap; + } + } + } + + Future setMapProviderType(AppMapType type) async { + state = type; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefKey, type.name); + } +} + +final mapProviderTypeNotifierProvider = + StateNotifierProvider((ref) { + return MapProviderTypeNotifier(); +}); + +final activeMapProvider = Provider((ref) { + final type = ref.watch(mapProviderTypeNotifierProvider); + switch (type) { + case AppMapType.openStreetMap: + return OpenStreetMapProvider(); + case AppMapType.googleMaps: + return GoogleMapProvider(); + } +}); + +class MapFollowNotifier extends StateNotifier { + MapFollowNotifier() : super(true); + + void setFollow(bool follow) { + state = follow; + } + + void userInteracted() { + state = false; + } + + void recenter() { + state = true; + } +} + +final mapFollowStateProvider = + StateNotifierProvider((ref) { + return MapFollowNotifier(); +}); diff --git a/lib/services/map/open_street_map_provider.dart b/lib/services/map/open_street_map_provider.dart new file mode 100644 index 0000000..f5cb8c4 --- /dev/null +++ b/lib/services/map/open_street_map_provider.dart @@ -0,0 +1,100 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' as ll; +import 'map_provider_interface.dart'; + +class OpenStreetMapProvider implements AppMapProvider { + final MapController _mapController = MapController(); + + @override + AppMapType get type => AppMapType.openStreetMap; + + @override + String get name => 'OpenStreetMap'; + + @override + Widget buildMapView({ + required BuildContext context, + required AppLatLng center, + required double zoom, + required List markers, + required List polylines, + required bool isFollowMode, + required MapConfig config, + required ValueChanged? onPositionChanged, + required VoidCallback? onUserInteraction, + }) { + final centerLl = ll.LatLng(center.latitude, center.longitude); + + if (isFollowMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + _mapController.move(centerLl, zoom); + } catch (_) {} + }); + } + + return FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: centerLl, + initialZoom: zoom, + minZoom: config.minZoom, + maxZoom: config.maxZoom, + onPositionChanged: (position, hasGesture) { + if (hasGesture) { + onUserInteraction?.call(); + } + if (position.center != null && onPositionChanged != null) { + onPositionChanged(AppLatLng(position.center!.latitude, position.center!.longitude)); + } + }, + ), + children: [ + TileLayer( + urlTemplate: config.tileUrlTemplate, + userAgentPackageName: 'com.example.motorcycle_dash', + ), + PolylineLayer( + polylines: polylines.map((p) { + return Polyline( + points: p.points.map((pt) => ll.LatLng(pt.latitude, pt.longitude)).toList(), + strokeWidth: p.width, + color: p.color, + ); + }).toList(), + ), + MarkerLayer( + markers: markers.map((m) { + return Marker( + point: ll.LatLng(m.position.latitude, m.position.longitude), + width: 50, + height: 50, + child: GestureDetector( + onTap: m.onTap, + child: Transform.rotate( + angle: (m.heading * math.pi) / 180.0, + child: m.iconWidget ?? + const Icon( + Icons.navigation, + color: Colors.cyanAccent, + size: 36, + ), + ), + ), + ); + }).toList(), + ), + RichAttributionWidget( + attributions: [ + TextSourceAttribution( + config.attributionText, + textStyle: const TextStyle(color: Colors.white70, fontSize: 10), + ), + ], + ), + ], + ); + } +} diff --git a/lib/services/map/unified_map_view.dart b/lib/services/map/unified_map_view.dart new file mode 100644 index 0000000..3be9218 --- /dev/null +++ b/lib/services/map/unified_map_view.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'map_provider_interface.dart'; +import 'map_providers.dart'; + +class UnifiedMapView extends ConsumerWidget { + final AppLatLng center; + final double zoom; + final List markers; + final List polylines; + final ValueChanged? onPositionChanged; + final VoidCallback? onUserInteraction; + + const UnifiedMapView({ + super.key, + required this.center, + this.zoom = 16.0, + this.markers = const [], + this.polylines = const [], + this.onPositionChanged, + this.onUserInteraction, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = ref.watch(activeMapProvider); + final config = ref.watch(mapConfigProvider); + final isFollowMode = ref.watch(mapFollowStateProvider); + + return provider.buildMapView( + context: context, + center: center, + zoom: zoom, + markers: markers, + polylines: polylines, + isFollowMode: isFollowMode, + config: config, + onPositionChanged: onPositionChanged, + onUserInteraction: () { + ref.read(mapFollowStateProvider.notifier).userInteracted(); + onUserInteraction?.call(); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9497841..6867c66 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -209,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" dart_style: dependency: transitive description: @@ -315,6 +323,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" + url: "https://pub.dev" + source: hosted + version: "7.0.2" flutter_native_splash: dependency: "direct dev" description: @@ -597,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -629,6 +653,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -669,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -845,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" pool: dependency: transitive description: @@ -861,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" pub_semver: dependency: transitive description: @@ -1130,6 +1194,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" universal_io: dependency: transitive description: @@ -1250,6 +1322,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6789a8a..f405003 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: geolocator: ^14.0.3 sensors_plus: ^7.1.0 google_maps_flutter: ^2.18.0 + flutter_map: ^7.0.2 + latlong2: ^0.9.1 drift: ^2.34.3 sqlite3_flutter_libs: ^0.6.0+eol shared_preferences: ^2.5.5 diff --git a/test/map_provider_test.dart b/test/map_provider_test.dart new file mode 100644 index 0000000..35f8ce9 --- /dev/null +++ b/test/map_provider_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; +import 'package:motorcycle_dash/services/map/map_providers.dart'; +import 'package:motorcycle_dash/services/map/open_street_map_provider.dart'; +import 'package:motorcycle_dash/services/map/google_map_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Map Provider Architecture Tests', () { + test('AppLatLng equality and initialization', () { + const p1 = AppLatLng(37.7749, -122.4194); + const p2 = AppLatLng(37.7749, -122.4194); + const p3 = AppLatLng(34.0522, -118.2437); + + expect(p1, equals(p2)); + expect(p1, isNot(equals(p3))); + }); + + test('MapConfig defaults', () { + const config = MapConfig(); + expect(config.tileUrlTemplate, contains('openstreetmap.org')); + expect(config.attributionText, contains('OpenStreetMap')); + expect(config.userAgent, contains('MotorcycleDashboard')); + }); + + test('OpenStreetMapProvider instance properties', () { + final osmProvider = OpenStreetMapProvider(); + expect(osmProvider.type, equals(AppMapType.openStreetMap)); + expect(osmProvider.name, equals('OpenStreetMap')); + }); + + test('GoogleMapProvider instance properties', () { + final googleProvider = GoogleMapProvider(); + expect(googleProvider.type, equals(AppMapType.googleMaps)); + expect(googleProvider.name, equals('Google Maps')); + }); + + test('MapFollowNotifier state toggles', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(mapFollowStateProvider.notifier); + + expect(container.read(mapFollowStateProvider), isTrue); + + notifier.userInteracted(); + expect(container.read(mapFollowStateProvider), isFalse); + + notifier.recenter(); + expect(container.read(mapFollowStateProvider), isTrue); + }); + + test('ActiveMapProvider resolves correct instance', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Default is OpenStreetMap + final active = container.read(activeMapProvider); + expect(active.type, equals(AppMapType.openStreetMap)); + + // Switch to Google Maps + container.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(AppMapType.googleMaps); + final activeGoogle = container.read(activeMapProvider); + expect(activeGoogle.type, equals(AppMapType.googleMaps)); + }); + }); +}