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