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 '../../database/database.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../replay/replay_screen.dart';
|
import '../replay/replay_screen.dart';
|
||||||
|
import '../map/map_screen.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class RideDetailScreen extends ConsumerStatefulWidget {
|
class RideDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -71,7 +72,10 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
||||||
),
|
),
|
||||||
if (r.motorcycleId != null)
|
if (r.motorcycleId != null)
|
||||||
OutlinedButton(
|
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}'),
|
onPressed: () => context.push('/garage/${r.motorcycleId}'),
|
||||||
child: const Text('View Specs'),
|
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'),
|
_buildSectionHeader('SUMMARY'),
|
||||||
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
|
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
|
||||||
_buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'),
|
_buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'),
|
||||||
|
|
@ -155,10 +197,9 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirm == true && mounted) {
|
if (confirm == true && mounted) {
|
||||||
final db = ref.read(databaseProvider);
|
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.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.rideEvents)..where((e) => e.rideId.equals(r.id))).go();
|
||||||
await (db.delete(db.rides)..where((ride) => ride.id.equals(r.id))).go();
|
await (db.delete(db.rides)..where((ride) => ride.id.equals(r.id))).go();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:drift/drift.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../core/theme/map_style.dart';
|
|
||||||
import '../../services/location_service.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 {
|
class MapScreen extends ConsumerStatefulWidget {
|
||||||
final int? rideId;
|
final int? rideId;
|
||||||
|
|
@ -15,13 +17,17 @@ class MapScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MapScreenState extends ConsumerState<MapScreen> {
|
class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
GoogleMapController? _mapController;
|
|
||||||
MapType _currentMapType = MapType.normal;
|
|
||||||
|
|
||||||
bool _isLoadingHistory = true;
|
bool _isLoadingHistory = true;
|
||||||
final Set<Polyline> _polylines = {};
|
List<AppLatLng> _routePoints = [];
|
||||||
final Set<Marker> _markers = {};
|
List<AppMapMarker> _eventMarkers = [];
|
||||||
LatLng? _initialCameraPosition;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -30,98 +36,127 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
_loadRideHistory();
|
_loadRideHistory();
|
||||||
} else {
|
} else {
|
||||||
_isLoadingHistory = false;
|
_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 {
|
Future<void> _loadRideHistory() async {
|
||||||
final db = ref.read(databaseProvider);
|
final db = ref.read(databaseProvider);
|
||||||
|
|
||||||
// Fetch telemetry for route
|
|
||||||
final telemetry = await (db.select(db.telemetrySamples)
|
final telemetry = await (db.select(db.telemetrySamples)
|
||||||
..where((t) => t.rideId.equals(widget.rideId!))
|
..where((t) => t.rideId.equals(widget.rideId!))
|
||||||
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
|
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]))
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
// Fetch events for markers
|
|
||||||
final events = await (db.select(db.rideEvents)
|
final events = await (db.select(db.rideEvents)
|
||||||
..where((e) => e.rideId.equals(widget.rideId!))
|
..where((e) => e.rideId.equals(widget.rideId!)))
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
if (telemetry.isNotEmpty) {
|
if (telemetry.isNotEmpty) {
|
||||||
final List<LatLng> points = telemetry
|
_routePoints = telemetry
|
||||||
.where((t) => t.latitude != null && t.longitude != null)
|
.where((t) => t.latitude != null && t.longitude != null)
|
||||||
.map((t) => LatLng(t.latitude!, t.longitude!))
|
.map((t) => AppLatLng(t.latitude!, t.longitude!))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (points.isNotEmpty) {
|
if (_routePoints.isNotEmpty) {
|
||||||
_initialCameraPosition = points.first;
|
_initialCenter = _routePoints.first;
|
||||||
_polylines.add(
|
|
||||||
Polyline(
|
|
||||||
polylineId: const PolylineId('route'),
|
|
||||||
points: points,
|
|
||||||
color: Colors.cyanAccent,
|
|
||||||
width: 5,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_eventMarkers = [];
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
if (event.latitude != null && event.longitude != null) {
|
if (event.latitude != null && event.longitude != null) {
|
||||||
BitmapDescriptor icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed);
|
Color markerColor = Colors.redAccent;
|
||||||
if (event.eventType == 'Extreme Lean') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueOrange);
|
if (event.eventType.contains('Corner')) markerColor = Colors.orangeAccent;
|
||||||
if (event.eventType == 'Hard Acceleration') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen);
|
if (event.eventType.contains('Acceleration')) markerColor = Colors.greenAccent;
|
||||||
if (event.eventType == 'High Speed') icon = BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueViolet);
|
if (event.eventType.contains('Speed')) markerColor = Colors.purpleAccent;
|
||||||
|
|
||||||
_markers.add(
|
_eventMarkers.add(
|
||||||
Marker(
|
AppMapMarker(
|
||||||
markerId: MarkerId(event.id.toString()),
|
id: event.id.toString(),
|
||||||
position: LatLng(event.latitude!, event.longitude!),
|
position: AppLatLng(event.latitude!, event.longitude!),
|
||||||
icon: icon,
|
label: event.eventType,
|
||||||
infoWindow: InfoWindow(
|
iconWidget: Icon(Icons.location_on, color: markerColor, size: 36),
|
||||||
title: event.eventType,
|
onTap: () {
|
||||||
snippet: 'Value: ${event.value.toStringAsFixed(2)}',
|
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(() {
|
setState(() {
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Adjust camera to fit route if map is ready
|
|
||||||
_fitRoute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fitRoute() {
|
List<AppMapMarker> _getFilteredMarkers() {
|
||||||
if (_mapController == null || _polylines.isEmpty) return;
|
return _eventMarkers.where((m) {
|
||||||
|
final label = m.label ?? '';
|
||||||
final points = _polylines.first.points;
|
if (label.contains('Acceleration') && !_showHardAccel) return false;
|
||||||
if (points.isEmpty) return;
|
if (label.contains('Braking') && !_showHardBraking) return false;
|
||||||
|
if (label.contains('Corner') && !_showCornering) return false;
|
||||||
double minLat = points.first.latitude;
|
if (label.contains('Speed') && !_showSpeedMilestones) return false;
|
||||||
double maxLat = points.first.latitude;
|
return true;
|
||||||
double minLng = points.first.longitude;
|
}).toList();
|
||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -130,85 +165,164 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
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(
|
if (widget.rideId != null) {
|
||||||
initialCameraPosition: CameraPosition(
|
// Historical Route View
|
||||||
target: currentLatLng,
|
final polylines = _routePoints.isNotEmpty
|
||||||
zoom: 17.0,
|
? [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,
|
body: Stack(
|
||||||
compassEnabled: true,
|
children: [
|
||||||
onMapCreated: (controller) {
|
UnifiedMapView(
|
||||||
_mapController = controller;
|
center: _initialCenter ?? _fallbackLocation,
|
||||||
_mapController?.setMapStyle(darkMapStyle);
|
zoom: 15.0,
|
||||||
},
|
polylines: polylines,
|
||||||
);
|
markers: _getFilteredMarkers(),
|
||||||
},
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
_buildEventFilterOverlay(),
|
||||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.rideId != null ? 'Ride ${widget.rideId} Map' : 'Map'),
|
title: const Text('Live Map'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
PopupMenuButton<AppMapType>(
|
||||||
icon: Icon(
|
icon: const Icon(Icons.layers),
|
||||||
_currentMapType == MapType.normal
|
onSelected: (type) {
|
||||||
? Icons.satellite
|
ref.read(mapProviderTypeNotifierProvider.notifier).setMapProviderType(type);
|
||||||
: Icons.map,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_currentMapType = _currentMapType == MapType.normal
|
|
||||||
? MapType.satellite
|
|
||||||
: MapType.normal;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 'package:drift/drift.dart' hide Column;
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
|
import '../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../services/map/unified_map_view.dart';
|
||||||
|
|
||||||
class ReplayScreen extends ConsumerStatefulWidget {
|
class ReplayScreen extends ConsumerStatefulWidget {
|
||||||
final int rideId;
|
final int rideId;
|
||||||
|
|
@ -16,12 +16,10 @@ class ReplayScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
GoogleMapController? _mapController;
|
|
||||||
|
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<TelemetrySample> _telemetry = [];
|
List<TelemetrySample> _telemetry = [];
|
||||||
final Set<Polyline> _polylines = {};
|
List<AppLatLng> _routePoints = [];
|
||||||
|
|
||||||
// Playback state
|
// Playback state
|
||||||
bool _isPlaying = false;
|
bool _isPlaying = false;
|
||||||
double _playbackSpeed = 1.0;
|
double _playbackSpeed = 1.0;
|
||||||
|
|
@ -33,7 +31,7 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_playbackTimer?.cancel();
|
_playbackTimer?.cancel();
|
||||||
|
|
@ -43,26 +41,15 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
final db = ref.read(databaseProvider);
|
final db = ref.read(databaseProvider);
|
||||||
final telemetry = await (db.select(db.telemetrySamples)
|
final telemetry = await (db.select(db.telemetrySamples)
|
||||||
..where((t) => t.rideId.equals(widget.rideId))
|
..where((t) => t.rideId.equals(widget.rideId))
|
||||||
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
|
..orderBy([(t) => OrderingTerm(expression: t.timestamp)]))
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
if (telemetry.isNotEmpty) {
|
if (telemetry.isNotEmpty) {
|
||||||
final List<LatLng> points = telemetry
|
_routePoints = telemetry
|
||||||
.where((t) => t.latitude != null && t.longitude != null)
|
.where((t) => t.latitude != null && t.longitude != null)
|
||||||
.map((t) => LatLng(t.latitude!, t.longitude!))
|
.map((t) => AppLatLng(t.latitude!, t.longitude!))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (points.isNotEmpty) {
|
|
||||||
_polylines.add(
|
|
||||||
Polyline(
|
|
||||||
polylineId: const PolylineId('route'),
|
|
||||||
points: points,
|
|
||||||
color: Colors.cyanAccent,
|
|
||||||
width: 5,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -70,36 +57,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
_telemetry = telemetry;
|
_telemetry = telemetry;
|
||||||
_isLoading = false;
|
_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() {
|
void _togglePlayback() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPlaying = !_isPlaying;
|
_isPlaying = !_isPlaying;
|
||||||
|
|
@ -114,42 +74,24 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
_playbackTimer?.cancel();
|
_playbackTimer?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleNextFrame() {
|
void _scheduleNextFrame() {
|
||||||
if (!_isPlaying || _currentIndex >= _telemetry.length - 1) {
|
if (!_isPlaying || _currentIndex >= _telemetry.length - 1) {
|
||||||
setState(() => _isPlaying = false);
|
setState(() => _isPlaying = false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentPoint = _telemetry[_currentIndex];
|
final currentPoint = _telemetry[_currentIndex];
|
||||||
final nextPoint = _telemetry[_currentIndex + 1];
|
final nextPoint = _telemetry[_currentIndex + 1];
|
||||||
|
|
||||||
final diffMs = nextPoint.timestamp.difference(currentPoint.timestamp).inMilliseconds;
|
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();
|
final waitMs = (diffMs.clamp(0, 1000) / _playbackSpeed).round();
|
||||||
|
|
||||||
_playbackTimer = Timer(Duration(milliseconds: waitMs), () {
|
_playbackTimer = Timer(Duration(milliseconds: waitMs), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex++;
|
_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();
|
_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.')));
|
if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.')));
|
||||||
|
|
||||||
final currentSample = _telemetry[_currentIndex];
|
final currentSample = _telemetry[_currentIndex];
|
||||||
final Set<Marker> markers = {};
|
final currentPos = (currentSample.latitude != null && currentSample.longitude != null)
|
||||||
if (currentSample.latitude != null && currentSample.longitude != null) {
|
? AppLatLng(currentSample.latitude!, currentSample.longitude!)
|
||||||
markers.add(
|
: (_routePoints.isNotEmpty ? _routePoints.first : const AppLatLng(0, 0));
|
||||||
Marker(
|
|
||||||
markerId: const MarkerId('current'),
|
final riderMarker = AppMapMarker(
|
||||||
position: LatLng(currentSample.latitude!, currentSample.longitude!),
|
id: 'replay_rider',
|
||||||
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
|
position: currentPos,
|
||||||
rotation: currentSample.heading ?? 0,
|
heading: currentSample.heading ?? 0.0,
|
||||||
flat: true,
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
|
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Map
|
// Map View
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
GoogleMap(
|
UnifiedMapView(
|
||||||
initialCameraPosition: const CameraPosition(target: LatLng(0, 0), zoom: 15),
|
center: currentPos,
|
||||||
polylines: _polylines,
|
zoom: 17.0,
|
||||||
markers: markers,
|
markers: [riderMarker],
|
||||||
onMapCreated: (controller) {
|
polylines: [polyline],
|
||||||
_mapController = controller;
|
|
||||||
controller.setMapStyle(darkMapStyle);
|
|
||||||
_fitRoute();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
// Telemetry Overlay
|
// Telemetry Overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 16, left: 16, right: 16,
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -224,13 +173,16 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Controls
|
// Controls Container
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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(
|
Slider(
|
||||||
value: _currentIndex.toDouble(),
|
value: _currentIndex.toDouble(),
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|
@ -248,13 +200,9 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.restart_alt),
|
icon: const Icon(Icons.restart_alt),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() { _currentIndex = 0; });
|
setState(() {
|
||||||
if (_mapController != null && _telemetry.isNotEmpty) {
|
_currentIndex = 0;
|
||||||
final t = _telemetry.first;
|
});
|
||||||
if (t.latitude != null && t.longitude != null) {
|
|
||||||
_mapController!.animateCamera(CameraUpdate.newLatLng(LatLng(t.latitude!, t.longitude!)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
@ -265,13 +213,13 @@ class _ReplayScreenState extends ConsumerState<ReplayScreen> {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _changeSpeed,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.white24),
|
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../services/sensor_fusion_service.dart';
|
import '../../services/sensor_fusion_service.dart';
|
||||||
|
import '../../services/map/map_provider_interface.dart';
|
||||||
|
import '../../services/map/map_providers.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
|
|
@ -9,11 +11,67 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mapType = ref.watch(mapProviderTypeNotifierProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
children: [
|
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(
|
const Text(
|
||||||
'Sensors',
|
'Sensors',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
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:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -315,6 +323,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_native_splash:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -597,6 +613,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.12.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -629,6 +653,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -669,6 +709,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
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:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -845,6 +893,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
polylabel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: polylabel
|
||||||
|
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -861,6 +917,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.2"
|
version: "6.5.2"
|
||||||
|
proj4dart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: proj4dart
|
||||||
|
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1130,6 +1194,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
unicode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: unicode
|
||||||
|
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
universal_io:
|
universal_io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1250,6 +1322,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ dependencies:
|
||||||
geolocator: ^14.0.3
|
geolocator: ^14.0.3
|
||||||
sensors_plus: ^7.1.0
|
sensors_plus: ^7.1.0
|
||||||
google_maps_flutter: ^2.18.0
|
google_maps_flutter: ^2.18.0
|
||||||
|
flutter_map: ^7.0.2
|
||||||
|
latlong2: ^0.9.1
|
||||||
drift: ^2.34.3
|
drift: ^2.34.3
|
||||||
sqlite3_flutter_libs: ^0.6.0+eol
|
sqlite3_flutter_libs: ^0.6.0+eol
|
||||||
shared_preferences: ^2.5.5
|
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