feat: implement unified map service with support for Google Maps and OpenStreetMap, alongside new ride detail, replay, and map visualization screens.

This commit is contained in:
Developer 2026-08-27 03:36:38 -04:00
parent 7e8b909ff4
commit ba29dc2a02
12 changed files with 952 additions and 263 deletions

View File

@ -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<RideDetailScreen> {
),
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<RideDetailScreen> {
),
),
],
// 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<RideDetailScreen> {
],
),
);
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();

View File

@ -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<MapScreen> {
GoogleMapController? _mapController;
MapType _currentMapType = MapType.normal;
bool _isLoadingHistory = true;
final Set<Polyline> _polylines = {};
final Set<Marker> _markers = {};
LatLng? _initialCameraPosition;
List<AppLatLng> _routePoints = [];
List<AppMapMarker> _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<MapScreen> {
_loadRideHistory();
} else {
_isLoadingHistory = false;
_initLiveLocation();
}
}
Future<void> _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<void> _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<LatLng> 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<AppMapMarker> _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<MapScreen> {
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)]
: <AppPolyline>[];
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<AppMapType>(
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),
),
],
),
),
),
),
);
}
}

View File

@ -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<ReplayScreen> {
GoogleMapController? _mapController;
bool _isLoading = true;
List<TelemetrySample> _telemetry = [];
final Set<Polyline> _polylines = {};
List<AppLatLng> _routePoints = [];
// Playback state
bool _isPlaying = false;
double _playbackSpeed = 1.0;
@ -33,7 +31,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
super.initState();
_loadData();
}
@override
void dispose() {
_playbackTimer?.cancel();
@ -43,26 +41,15 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
Future<void> _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<LatLng> 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<ReplayScreen> {
_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<ReplayScreen> {
_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<ReplayScreen> {
if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.')));
final currentSample = _telemetry[_currentIndex];
final Set<Marker> 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<ReplayScreen> {
],
),
),
// 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<ReplayScreen> {
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<ReplayScreen> {
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<ReplayScreen> {
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: [

View File

@ -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<AppMapType>(
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<AppMapType> 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),

View File

@ -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<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? 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));
},
);
}
}

View File

@ -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<AppLatLng> 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<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? onPositionChanged,
required VoidCallback? onUserInteraction,
});
}

View File

@ -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<MapConfig>((ref) {
return const MapConfig();
});
class MapProviderTypeNotifier extends StateNotifier<AppMapType> {
static const String _prefKey = 'selected_map_provider_type';
MapProviderTypeNotifier() : super(AppMapType.openStreetMap) {
_loadFromPrefs();
}
Future<void> _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<void> setMapProviderType(AppMapType type) async {
state = type;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKey, type.name);
}
}
final mapProviderTypeNotifierProvider =
StateNotifierProvider<MapProviderTypeNotifier, AppMapType>((ref) {
return MapProviderTypeNotifier();
});
final activeMapProvider = Provider<AppMapProvider>((ref) {
final type = ref.watch(mapProviderTypeNotifierProvider);
switch (type) {
case AppMapType.openStreetMap:
return OpenStreetMapProvider();
case AppMapType.googleMaps:
return GoogleMapProvider();
}
});
class MapFollowNotifier extends StateNotifier<bool> {
MapFollowNotifier() : super(true);
void setFollow(bool follow) {
state = follow;
}
void userInteracted() {
state = false;
}
void recenter() {
state = true;
}
}
final mapFollowStateProvider =
StateNotifierProvider<MapFollowNotifier, bool>((ref) {
return MapFollowNotifier();
});

View File

@ -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<AppMapMarker> markers,
required List<AppPolyline> polylines,
required bool isFollowMode,
required MapConfig config,
required ValueChanged<AppLatLng>? 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),
),
],
),
],
);
}
}

View File

@ -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<AppMapMarker> markers;
final List<AppPolyline> polylines;
final ValueChanged<AppLatLng>? 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();
},
);
}
}

View File

@ -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:

View File

@ -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

View File

@ -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));
});
});
}