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:
parent
7e8b909ff4
commit
ba29dc2a02
|
|
@ -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'),
|
||||
|
|
@ -158,7 +200,6 @@ 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();
|
||||
|
|
|
|||
|
|
@ -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,59 +36,109 @@ 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();
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]))
|
||||
.get();
|
||||
|
||||
// Fetch events for markers
|
||||
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
|
||||
_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) {
|
||||
_initialCameraPosition = points.first;
|
||||
_polylines.add(
|
||||
Polyline(
|
||||
polylineId: const PolylineId('route'),
|
||||
points: points,
|
||||
color: Colors.cyanAccent,
|
||||
width: 5,
|
||||
)
|
||||
);
|
||||
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);
|
||||
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;
|
||||
|
||||
_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)}',
|
||||
_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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,38 +146,17 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
|||
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;
|
||||
final activeProviderType = ref.watch(mapProviderTypeNotifierProvider);
|
||||
final isFollowMode = ref.watch(mapFollowStateProvider);
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: currentLatLng,
|
||||
zoom: 17.0,
|
||||
),
|
||||
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')),
|
||||
);
|
||||
}
|
||||
// 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(widget.rideId != null ? 'Ride ${widget.rideId} Map' : 'Map'),
|
||||
title: Text('Ride #${widget.rideId} Route'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_currentMapType == MapType.normal
|
||||
? Icons.satellite
|
||||
: Icons.map,
|
||||
),
|
||||
tooltip: 'Switch Map Provider',
|
||||
icon: Icon(activeProviderType == AppMapType.openStreetMap ? Icons.map : Icons.map_outlined),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentMapType = _currentMapType == MapType.normal
|
||||
? MapType.satellite
|
||||
: MapType.normal;
|
||||
});
|
||||
final nextType = activeProviderType == AppMapType.openStreetMap
|
||||
? AppMapType.googleMaps
|
||||
: AppMapType.openStreetMap;
|
||||
ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(nextType);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: mapContent,
|
||||
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: const Text('Live Map'),
|
||||
actions: [
|
||||
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: 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +16,9 @@ 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;
|
||||
|
|
@ -44,25 +42,14 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
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();
|
||||
..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;
|
||||
|
|
@ -125,7 +85,6 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
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), () {
|
||||
|
|
@ -133,23 +92,6 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
|||
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: [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
80
pubspec.lock
80
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue