MotorSignal-App/lib/services/map/aws_map_provider.dart

318 lines
9.7 KiB
Dart

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';
import 'map_providers.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>
with TickerProviderStateMixin {
late final MapController _mapController;
AnimationController? _animController;
ll.LatLng? _lastFollowPos;
DateTime? _lastFollowTime;
@override
void initState() {
super.initState();
_mapController = MapController();
_lastFollowPos = ll.LatLng(widget.center.latitude, widget.center.longitude);
}
void _animateCameraTo({
required ll.LatLng target,
required double targetZoom,
required double targetBearing,
required Duration duration,
Curve curve = Curves.easeOutCubic,
}) {
if (!mounted) return;
_animController?.stop();
_animController?.dispose();
_animController = null;
final startCenter = _mapController.camera.center;
final startZoom = _mapController.camera.zoom;
final startBearing = _mapController.camera.rotation;
final bearingDiff = shortestAngleDiff(startBearing, targetBearing);
final endBearing = startBearing + bearingDiff;
_animController = AnimationController(
vsync: this,
duration: duration,
);
final curved = CurvedAnimation(parent: _animController!, curve: curve);
final startLat = startCenter.latitude;
final startLng = startCenter.longitude;
final endLat = target.latitude;
final endLng = target.longitude;
_animController!.addListener(() {
if (!mounted) return;
final t = curved.value;
final lat = startLat + (endLat - startLat) * t;
final lng = startLng + (endLng - startLng) * t;
final z = startZoom + (targetZoom - startZoom) * t;
final b = startBearing + (endBearing - startBearing) * t;
try {
_mapController.move(ll.LatLng(lat, lng), z);
_mapController.rotate(normalizeAngle(b));
} catch (_) {}
});
_animController!.forward();
}
@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.000005;
final intervalMs = _lastFollowTime == null
? 250
: now.difference(_lastFollowTime!).inMilliseconds.clamp(100, 450);
final isInitialOrLargeJump =
justBecameFollowMode || _lastFollowPos == null || posDelta > 0.008;
if (isInitialOrLargeJump) {
_animController?.stop();
_lastFollowPos = centerLl;
_lastFollowTime = now;
try {
_mapController.move(centerLl, widget.zoom);
_mapController.rotate(normalizeAngle(widget.bearing));
} catch (_) {}
} else if (hasMoved || bearingChanged || zoomChanged) {
_lastFollowPos = centerLl;
_lastFollowTime = now;
_animateCameraTo(
target: centerLl,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: Duration(milliseconds: intervalMs),
curve: Curves.linear,
);
}
} else {
if (bearingChanged) {
_animateCameraTo(
target: _mapController.camera.center,
targetZoom: widget.zoom,
targetBearing: widget.bearing,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
);
} else if (zoomChanged) {
try {
_mapController.move(_mapController.camera.center, widget.zoom);
} catch (_) {}
}
}
}
@override
void dispose() {
_animController?.stop();
_animController?.dispose();
_animController = null;
_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),
),
],
),
],
);
}
}