292 lines
8.8 KiB
Dart
292 lines
8.8 KiB
Dart
import 'dart:async';
|
|
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 '../routes/aws_location_route_provider.dart';
|
|
import 'map_provider_interface.dart';
|
|
|
|
/// AppMapProvider implementation backed by Amazon Location Service (AWS Maps V2 raster satellite).
|
|
class AwsMapProvider implements AppMapProvider {
|
|
final String apiKey;
|
|
final String region;
|
|
|
|
AwsMapProvider({
|
|
String? apiKey,
|
|
String? region,
|
|
}) : apiKey = apiKey ??
|
|
const String.fromEnvironment(
|
|
'AWS_MAPS_API_KEY',
|
|
defaultValue: AwsLocationRouteProvider.defaultApiKey,
|
|
),
|
|
region = region ??
|
|
const String.fromEnvironment(
|
|
'AWS_MAPS_REGION',
|
|
defaultValue: AwsLocationRouteProvider.defaultRegion,
|
|
);
|
|
|
|
@override
|
|
AppMapType get type => AppMapType.awsMaps;
|
|
|
|
@override
|
|
String get name => 'Amazon Satellite';
|
|
|
|
@override
|
|
Widget buildMapView({
|
|
required BuildContext context,
|
|
required AppLatLng center,
|
|
required double zoom,
|
|
double bearing = 0.0,
|
|
required List<AppMapMarker> markers,
|
|
required List<AppPolyline> polylines,
|
|
required bool isFollowMode,
|
|
required MapConfig config,
|
|
required ValueChanged<AppLatLng>? onPositionChanged,
|
|
required VoidCallback? onUserInteraction,
|
|
}) {
|
|
final isDefaultOsm =
|
|
config.tileUrlTemplate == const MapConfig().tileUrlTemplate &&
|
|
!config.isDarkMode;
|
|
final effectiveConfig = (!isDefaultOsm || config.tileUrlTemplate.contains('amazonaws.com'))
|
|
? config
|
|
: MapConfig(
|
|
tileUrlTemplate:
|
|
'https://maps.geo.$region.amazonaws.com/v2/tiles/raster.satellite/{z}/{x}/{y}?key=$apiKey',
|
|
attributionText: '© AWS, Maxar, Earthstar Geographics',
|
|
userAgent: 'MotorcycleDashboard/1.0',
|
|
minZoom: 1.0,
|
|
maxZoom: 19.0,
|
|
);
|
|
|
|
return _AwsMapView(
|
|
center: center,
|
|
zoom: zoom,
|
|
bearing: bearing,
|
|
markers: markers,
|
|
polylines: polylines,
|
|
isFollowMode: isFollowMode,
|
|
config: effectiveConfig,
|
|
onPositionChanged: onPositionChanged,
|
|
onUserInteraction: onUserInteraction,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AwsMapView extends StatefulWidget {
|
|
final AppLatLng center;
|
|
final double zoom;
|
|
final double bearing;
|
|
final List<AppMapMarker> markers;
|
|
final List<AppPolyline> polylines;
|
|
final bool isFollowMode;
|
|
final MapConfig config;
|
|
final ValueChanged<AppLatLng>? onPositionChanged;
|
|
final VoidCallback? onUserInteraction;
|
|
|
|
const _AwsMapView({
|
|
required this.center,
|
|
required this.zoom,
|
|
this.bearing = 0.0,
|
|
required this.markers,
|
|
required this.polylines,
|
|
required this.isFollowMode,
|
|
required this.config,
|
|
required this.onPositionChanged,
|
|
required this.onUserInteraction,
|
|
});
|
|
|
|
@override
|
|
State<_AwsMapView> createState() => _AwsMapViewState();
|
|
}
|
|
|
|
class _AwsMapViewState extends State<_AwsMapView> {
|
|
late final MapController _mapController;
|
|
ll.LatLng? _lastFollowPos;
|
|
DateTime? _lastFollowTime;
|
|
Timer? _pendingFollowTimer;
|
|
|
|
static const int _followThrottleMs = 250;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapController = MapController();
|
|
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
|
|
}
|
|
|
|
void _applyFollowMove(ll.LatLng target, double zoom) {
|
|
_lastFollowPos = target;
|
|
_lastFollowTime = DateTime.now();
|
|
try {
|
|
_mapController.move(target, zoom);
|
|
} catch (_) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
try {
|
|
_mapController.move(target, zoom);
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _AwsMapView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
final centerLl =
|
|
ll.LatLng(widget.center.latitude, widget.center.longitude);
|
|
final zoomChanged = (oldWidget.zoom - widget.zoom).abs() > 0.01;
|
|
final bearingChanged = (oldWidget.bearing - widget.bearing).abs() > 0.1;
|
|
final justBecameFollowMode = !oldWidget.isFollowMode && widget.isFollowMode;
|
|
|
|
if (widget.isFollowMode) {
|
|
final now = DateTime.now();
|
|
final posDelta = _lastFollowPos == null
|
|
? 1.0
|
|
: (centerLl.latitude - _lastFollowPos!.latitude).abs() +
|
|
(centerLl.longitude - _lastFollowPos!.longitude).abs();
|
|
final hasMoved = posDelta > 0.00001;
|
|
final elapsedMs = _lastFollowTime == null
|
|
? _followThrottleMs + 1
|
|
: now.difference(_lastFollowTime!).inMilliseconds;
|
|
|
|
final shouldMoveNow = justBecameFollowMode ||
|
|
_lastFollowPos == null ||
|
|
zoomChanged ||
|
|
(hasMoved && (elapsedMs >= _followThrottleMs || posDelta > 0.001));
|
|
|
|
if (shouldMoveNow) {
|
|
_pendingFollowTimer?.cancel();
|
|
_pendingFollowTimer = null;
|
|
_applyFollowMove(centerLl, widget.zoom);
|
|
} else if (hasMoved) {
|
|
_pendingFollowTimer?.cancel();
|
|
final remainingMs = (_followThrottleMs - elapsedMs).clamp(16, _followThrottleMs);
|
|
_pendingFollowTimer = Timer(Duration(milliseconds: remainingMs), () {
|
|
_pendingFollowTimer = null;
|
|
if (mounted && widget.isFollowMode) {
|
|
final latestCenter = ll.LatLng(
|
|
widget.center.latitude,
|
|
widget.center.longitude,
|
|
);
|
|
_applyFollowMove(latestCenter, widget.zoom);
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
_pendingFollowTimer?.cancel();
|
|
_pendingFollowTimer = null;
|
|
if (zoomChanged) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
try {
|
|
_mapController.move(_mapController.camera.center, widget.zoom);
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (bearingChanged) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
try {
|
|
_mapController.rotate(widget.bearing);
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pendingFollowTimer?.cancel();
|
|
_mapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final centerLl =
|
|
ll.LatLng(widget.center.latitude, widget.center.longitude);
|
|
final media = MediaQuery.of(context);
|
|
final sizeKey =
|
|
'${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}';
|
|
|
|
return FlutterMap(
|
|
key: ValueKey('aws_map_$sizeKey'),
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: centerLl,
|
|
initialZoom: widget.zoom,
|
|
initialRotation: widget.bearing,
|
|
minZoom: widget.config.minZoom,
|
|
maxZoom: widget.config.maxZoom,
|
|
backgroundColor: const Color(0xFF0A0F14),
|
|
onPositionChanged: (position, hasGesture) {
|
|
if (hasGesture) {
|
|
widget.onUserInteraction?.call();
|
|
}
|
|
if (widget.onPositionChanged != null) {
|
|
final c = position.center;
|
|
widget.onPositionChanged!(AppLatLng(c.latitude, c.longitude));
|
|
}
|
|
},
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
key: ValueKey(
|
|
'${widget.config.tileUrlTemplate}_${widget.config.isDarkMode}'),
|
|
urlTemplate: widget.config.tileUrlTemplate,
|
|
subdomains: widget.config.subdomains,
|
|
userAgentPackageName: 'app.motorsignal.motorcycle',
|
|
tileBuilder:
|
|
widget.config.isDarkMode ? darkModeTileBuilder : null,
|
|
maxZoom: 20,
|
|
maxNativeZoom: 19,
|
|
),
|
|
PolylineLayer(
|
|
polylines: widget.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: widget.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(
|
|
widget.config.attributionText,
|
|
textStyle: const TextStyle(color: Colors.white70, fontSize: 10),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|