any_map 1.0.0 copy "any_map: ^1.0.0" to clipboard
any_map: ^1.0.0 copied to clipboard

Unified map abstraction for Flutter. One API to swap Google Maps, MapLibre, or OSM. Routing, traffic, geofencing, clustering, analytics, and 30+ features.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:any_map/any_map.dart';
import 'package:any_map_maplibre/any_map_maplibre.dart';
import 'package:any_map_osm/any_map_osm.dart';

void main() => runApp(const AnyMapExampleApp());

class AnyMapExampleApp extends StatelessWidget {
  const AnyMapExampleApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'any_map Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true),
      home: const MapDemoPage(),
    );
  }
}

// ── Constants ──

enum MapStyle {
  streets('Streets', 'https://tiles.openfreemap.org/styles/liberty'),
  dark('Dark', 'https://tiles.openfreemap.org/styles/dark'),
  satellite('Satellite', 'https://tiles.openfreemap.org/styles/positron'),
  bright('Bright', 'https://tiles.openfreemap.org/styles/bright');
  final String label; final String url;
  const MapStyle(this.label, this.url);
}

enum MapBackend { maplibre, osm }

const _hyderabadCenter = AnyLatLng(17.3850, 78.4867);
const _landmarks = [
  AnyMarker(id: 'charminar', position: AnyLatLng(17.3616, 78.4747), title: 'Charminar'),
  AnyMarker(id: 'hitech', position: AnyLatLng(17.4435, 78.3772), title: 'HITEC City'),
  AnyMarker(id: 'hussain_sagar', position: AnyLatLng(17.4239, 78.4738), title: 'Hussain Sagar'),
  AnyMarker(id: 'golconda', position: AnyLatLng(17.3833, 78.4011), title: 'Golconda Fort'),
  AnyMarker(id: 'ramoji', position: AnyLatLng(17.2543, 78.6808), title: 'Ramoji Film City'),
];

class _RouteOption {
  final String label; final AnyLatLng origin; final AnyLatLng destination;
  const _RouteOption(this.label, this.origin, this.destination);
}

const _routeOptions = [
  _RouteOption('Charminar \u2192 HITEC City', AnyLatLng(17.3616, 78.4747), AnyLatLng(17.4435, 78.3772)),
  _RouteOption('Golconda \u2192 Ramoji', AnyLatLng(17.3833, 78.4011), AnyLatLng(17.2543, 78.6808)),
  _RouteOption('Hussain Sagar \u2192 Charminar', AnyLatLng(17.4239, 78.4738), AnyLatLng(17.3616, 78.4747)),
];

// ── Main Page ──

class MapDemoPage extends StatefulWidget {
  const MapDemoPage({super.key});
  @override
  State<MapDemoPage> createState() => _MapDemoPageState();
}

class _MapDemoPageState extends State<MapDemoPage> with TickerProviderStateMixin {
  // Map state
  MapBackend _backend = MapBackend.maplibre;
  MapStyle _mapStyle = MapStyle.streets;
  AnyMapController? _controller;
  bool _is3DView = false;

  // Route state
  AnyRoute? _route;
  bool _isRouting = false;
  List<AnyPolyline> _polylines = [];
  List<AnyMarker> _allMarkers = List.of(_landmarks);
  bool _showTrafficColors = true;
  AnyTravelMode _travelMode = AnyTravelMode.driving;

  // Search
  final _searchCtrl = TextEditingController();
  List<AnyPlace> _searchResults = [];
  bool _isSearching = false;
  bool _showSearch = false;

  // Navigation
  Timer? _navTimer;
  int _navIndex = 0;
  bool _isNavigating = false;
  int _currentStepIndex = 0;
  double _navBearing = 0;
  int _uiTick = 0;

  // Trip analytics
  AnyTripLogger? _tripLogger;

  // Geofencing
  final _geoEngine = AnyGeofenceEngine();
  StreamSubscription? _geoSub;
  String? _lastGeoEvent;

  // Rerouting
  late final AnyRerouteEngine _rerouter;

  // Layer toggles
  bool _showCircles = false;
  bool _showPolygons = false;
  bool _showClustering = false;
  bool _showGeoJson = false;
  bool _animatingPolyline = false;
  Timer? _animTimer;
  double _animProgress = 0;

  // Marker popup
  AnyMarker? _selectedMarker;

  // Camera stream — live zoom display
  StreamSubscription<AnyCameraPosition>? _cameraSub;
  double _liveZoom = 12.0;

  // Simulated location (Feature 2 demo)
  bool _showLocationLayer = false;
  Timer? _simLocTimer;
  _SimLocationProvider? _simLocProvider;

  // Distance Matrix demo
  bool _matrixLoading = false;
  String? _matrixResult;

  // Nearby POI demo
  bool _nearbyLoading = false;
  List<AnyMarker> _nearbyMarkers = [];

  // Route Optimizer demo
  bool _optimizerLoading = false;
  AnyOptimizedRoute? _optimizedRoute;

  // Map Matching demo
  bool _matcherLoading = false;
  List<AnyPolyline> _matchedPolylines = [];

  // Demo circles around landmarks
  final _demoCircles = const [
    AnyCircle(id: 'circle_charminar', center: AnyLatLng(17.3616, 78.4747), radius: 400, fillColor: Color(0x3300BCD4), strokeColor: Color(0xFF00BCD4), strokeWidth: 2),
    AnyCircle(id: 'circle_hitech', center: AnyLatLng(17.4435, 78.3772), radius: 600, fillColor: Color(0x33FF9800), strokeColor: Color(0xFFFF9800), strokeWidth: 2),
    AnyCircle(id: 'circle_golconda', center: AnyLatLng(17.3833, 78.4011), radius: 500, fillColor: Color(0x339C27B0), strokeColor: Color(0xFF9C27B0), strokeWidth: 2),
  ];

  // Demo polygon — Old City Hyderabad
  final _demoPolygons = const [
    AnyPolygon(
      id: 'old_city',
      points: [AnyLatLng(17.370, 78.460), AnyLatLng(17.370, 78.490), AnyLatLng(17.350, 78.490), AnyLatLng(17.345, 78.475), AnyLatLng(17.350, 78.460)],
      fillColor: Color(0x22E91E63), strokeColor: Color(0xFFE91E63), strokeWidth: 3,
    ),
  ];

  // Cluster markers — random points around Hyderabad
  late final List<AnyMarker> _clusterMarkers = _generateClusterMarkers();
  List<AnyMarker> _generateClusterMarkers() {
    final rng = math.Random(42);
    return List.generate(60, (i) {
      final lat = 17.3 + rng.nextDouble() * 0.2;
      final lng = 78.35 + rng.nextDouble() * 0.3;
      return AnyMarker(id: 'cluster_$i', position: AnyLatLng(lat, lng), title: 'Point $i');
    });
  }

  // GeoJSON — Hyderabad metro line (simplified)
  final _demoGeoJson = AnyGeoJsonLayer(
    id: 'metro',
    geoJson: '{"type":"FeatureCollection","features":['
        '{"type":"Feature","properties":{"name":"Metro Blue Line"},"geometry":{"type":"LineString","coordinates":[[78.3772,17.4435],[78.4100,17.4400],[78.4400,17.4300],[78.4600,17.4200],[78.4747,17.3616]]}},'
        '{"type":"Feature","properties":{"name":"Miyapur Station"},"geometry":{"type":"Point","coordinates":[78.3772,17.4435]}},'
        '{"type":"Feature","properties":{"name":"Ameerpet Station"},"geometry":{"type":"Point","coordinates":[78.4400,17.4300]}},'
        '{"type":"Feature","properties":{"name":"Charminar Station"},"geometry":{"type":"Point","coordinates":[78.4747,17.3616]}}'
        ']}',
    lineColor: const Color(0xFF2196F3),
    lineWidth: 4.0,
    pointColor: const Color(0xFFFF5722),
  );

  AnyMapAdapter get _adapter => switch (_backend) {
    MapBackend.maplibre => MapLibreAdapter(styleUrl: _mapStyle.url),
    MapBackend.osm => OsmAdapter(),
  };

  @override
  void initState() {
    super.initState();
    _rerouter = AnyRerouteEngine(provider: OsrmRoutingProvider());
    // Setup geofences around landmarks
    _geoEngine.addGeofence(AnyGeofence(id: 'charminar_zone', label: 'Charminar', center: const AnyLatLng(17.3616, 78.4747), radius: 300));
    _geoEngine.addGeofence(AnyGeofence(id: 'hitech_zone', label: 'HITEC City', center: const AnyLatLng(17.4435, 78.3772), radius: 500));
    _geoSub = _geoEngine.events.listen((t) {
      if (!mounted) return;
      final msg = switch (t.event) {
        AnyGeofenceEvent.enter => 'Entered ${t.geofence.label} zone',
        AnyGeofenceEvent.exit => 'Left ${t.geofence.label} zone',
        AnyGeofenceEvent.dwell => 'Dwelling in ${t.geofence.label}',
      };
      setState(() => _lastGeoEvent = msg);
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2)));
    });
  }

  void _onMapCreated(AnyMapController c) {
    _controller = c;
    if (_backend == MapBackend.maplibre && c is MapLibreController) {
      c.enable3DBuildings();
    }
    // Feature 1: subscribe to camera stream for live zoom badge
    _cameraSub?.cancel();
    _cameraSub = c.cameraPositionStream.listen((pos) {
      if (mounted) setState(() => _liveZoom = pos.zoom);
    });
  }

  // ── Search ──

  Future<void> _performSearch(String q) async {
    if (q.trim().isEmpty) { setState(() => _searchResults = []); return; }
    setState(() => _isSearching = true);
    final r = await NominatimSearchProvider().search(q, near: _hyderabadCenter, radiusKm: 100, limit: 8);
    if (mounted) setState(() { _searchResults = r; _isSearching = false; });
  }

  void _goToPlace(AnyPlace p) {
    setState(() { _showSearch = false; _searchResults = []; _searchCtrl.clear(); });
    setState(() => _allMarkers = [..._landmarks, AnyMarker(id: 'search_${p.id}', position: p.position, title: p.name)]);
    _controller?.animateCamera(AnyCameraPosition(target: p.position, zoom: 16, tilt: _is3DView ? 45 : 0));
  }

  // ── Routing ──

  Future<void> _computeRoute(_RouteOption opt) async {
    if (_isRouting) return;
    _stopNavigation();
    setState(() => _isRouting = true);

    final result = await OsrmRoutingProvider().getRoute(AnyRouteRequest(
      origin: opt.origin, destination: opt.destination,
      mode: _travelMode,
      includeAnnotations: true, includeSpeedLimits: true,
    ));

    if (!mounted) return;
    if (result.isSuccess) {
      final route = result.route!;
      _rerouter.setRoute(route, AnyRouteRequest(origin: opt.origin, destination: opt.destination, mode: _travelMode, includeAnnotations: true, includeSpeedLimits: true));
      setState(() {
        _route = route; _currentStepIndex = 0;
        _polylines = _showTrafficColors && route.segments.isNotEmpty
            ? route.toTrafficPolylines(idPrefix: 'route')
            : [route.toPolyline(id: 'route', width: 6.0)];
        _allMarkers = [..._landmarks, AnyMarker(id: 'origin', position: opt.origin, title: 'Start'), AnyMarker(id: 'dest', position: opt.destination, title: 'End')];
      });
      _controller?.fitBounds(route.bounds, padding: 80);
      if (mounted) {
        final badges = <String>[]; if (route.hasBridges) badges.add('Flyover'); if (route.hasTunnels) badges.add('Tunnel'); if (route.hasTolls) badges.add('Toll');
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${route.durationText} \u2022 ${route.distanceText}${badges.isNotEmpty ? ' \u2022 ${badges.join(", ")}' : ''}'), duration: const Duration(seconds: 3)));
      }
    } else if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Routing failed: ${result.error}')));
    }
    setState(() => _isRouting = false);
  }

  void _clearRoute() {
    _stopNavigation(); _rerouter.clearRoute();
    setState(() { _route = null; _polylines = []; _allMarkers = List.of(_landmarks); _currentStepIndex = 0; });
  }

  void _toggleTrafficColors() {
    setState(() => _showTrafficColors = !_showTrafficColors);
    if (_route != null) {
      setState(() => _polylines = _showTrafficColors && _route!.segments.isNotEmpty ? _route!.toTrafficPolylines(idPrefix: 'route') : [_route!.toPolyline(id: 'route', width: 6.0)]);
    }
  }

  // ── Navigation Simulation ──

  void _startNavigation() {
    if (_route == null || _route!.geometry.isEmpty) return;
    final pts = _route!.geometry;
    _tripLogger = AnyTripLogger(fuelConfig: const AnyFuelConfig(fuelType: AnyFuelType.petrol, consumptionPer100km: 8.0));
    _tripLogger!.start();
    setState(() { _isNavigating = true; _navIndex = 0; _currentStepIndex = 0; });
    if (pts.length > 1) _navBearing = _bearing(pts[0], pts[1]);
    _controller?.moveCamera(AnyCameraPosition(target: pts.first, zoom: 17.5, tilt: 65, bearing: _navBearing));
    _updateNavMarker(pts.first);
    _navTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
      if (!mounted || _route == null) { t.cancel(); return; }
      final pts = _route!.geometry;
      double moved = 0; final start = _navIndex;
      while (_navIndex < pts.length - 1 && moved < 30) { moved += pts[_navIndex].distanceTo(pts[_navIndex + 1]); _navIndex++; }
      if (_navIndex >= pts.length - 1) { _finishNavigation(); return; }
      final cur = pts[_navIndex];
      _tripLogger?.addWaypoint(AnyTripWaypoint(position: cur, speed: moved / 0.2, heading: _navBearing, timestamp: DateTime.now()));
      _geoEngine.updateLocation(cur);
      final ahead = _lookAhead(pts, _navIndex, 100);
      _navBearing = _lerpAngle(_navBearing, _bearing(cur, pts[ahead]), 0.3);
      _uiTick++;
      if (_uiTick % 3 == 0 || start == 0) { _updateNavMarker(cur); _updateStep(cur); }
      _controller?.moveCamera(AnyCameraPosition(target: cur, zoom: 17.5, tilt: 65, bearing: _navBearing));
    });
  }

  void _finishNavigation() {
    final summary = _tripLogger?.stop();
    _stopNavigation();
    if (mounted && summary != null) _showTripSummary(summary);
  }

  void _stopNavigation() {
    _navTimer?.cancel(); _navTimer = null; _uiTick = 0;
    if (_isNavigating) setState(() { _isNavigating = false; _navIndex = 0; });
  }

  void _updateNavMarker(AnyLatLng pos) {
    final p = _navIndex / _route!.geometry.length;
    final d = _route!.distanceMeters * (1.0 - p);
    final t = _route!.durationSeconds * (1.0 - p);
    setState(() => _allMarkers = [..._landmarks, AnyMarker(id: 'origin', position: _route!.geometry.first, title: 'Start'), AnyMarker(id: 'dest', position: _route!.geometry.last, title: 'End'), AnyMarker(id: 'nav', position: pos, title: '${_fmtDist(d)} \u2022 ${_fmtTime(t)}', rotation: _navBearing)]);
  }

  void _updateStep(AnyLatLng cur) {
    if (_route == null) return;
    for (int i = _currentStepIndex; i < _route!.steps.length; i++) {
      if (cur.distanceTo(_route!.steps[i].startLocation) < 50 && i > _currentStepIndex) { setState(() => _currentStepIndex = i); break; }
    }
  }

  void _showTripSummary(AnyTripSummary s) {
    showDialog(context: context, builder: (ctx) => AlertDialog(
      title: const Text('Trip Summary'), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
        _row(Icons.straighten, 'Distance', '${s.distanceKm.toStringAsFixed(1)} km'),
        _row(Icons.schedule, 'Duration', s.durationText),
        _row(Icons.speed, 'Avg Speed', '${s.averageSpeedKmh.toStringAsFixed(0)} km/h'),
        if (s.fuelConsumedLiters != null) _row(Icons.local_gas_station, 'Fuel', '${s.fuelConsumedLiters!.toStringAsFixed(1)} L'),
        if (s.co2EmittedGrams != null) _row(Icons.eco, 'CO\u2082', '${(s.co2EmittedGrams! / 1000).toStringAsFixed(1)} kg'),
        _row(Icons.star, 'Eco Score', '${s.ecoScore}/100'),
        if (s.harshBrakingCount > 0) _row(Icons.warning, 'Harsh Braking', '${s.harshBrakingCount}x'),
        if (s.harshAccelerationCount > 0) _row(Icons.warning_amber, 'Harsh Accel', '${s.harshAccelerationCount}x'),
      ]), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))],
    ));
  }

  Widget _row(IconData ic, String l, String v) => Padding(padding: const EdgeInsets.symmetric(vertical: 3), child: Row(children: [Icon(ic, size: 18, color: Colors.grey), const SizedBox(width: 8), Text('$l: ', style: const TextStyle(fontWeight: FontWeight.w500)), Text(v)]));

  // ── Helpers ──

  int _lookAhead(List<AnyLatLng> pts, int from, double dist) { double a = 0; int i = from; while (i < pts.length - 1 && a < dist) { a += pts[i].distanceTo(pts[i + 1]); i++; } return i; }
  double _lerpAngle(double f, double t, double a) { double d = (t - f) % 360; if (d > 180) d -= 360; if (d < -180) d += 360; return (f + d * a) % 360; }
  double _bearing(AnyLatLng f, AnyLatLng t) { final dL = (t.longitude - f.longitude) * math.pi / 180; final l1 = f.latitude * math.pi / 180; final l2 = t.latitude * math.pi / 180; return (math.atan2(math.sin(dL) * math.cos(l2), math.cos(l1) * math.sin(l2) - math.sin(l1) * math.cos(l2) * math.cos(dL)) * 180 / math.pi + 360) % 360; }
  String _fmtDist(double m) => m < 1000 ? '${m.round()} m' : '${(m / 1000).toStringAsFixed(1)} km';
  String _fmtTime(double s) { if (s < 60) return '${s.round()}s'; final m = (s / 60).round(); if (m < 60) return '$m min'; return '${m ~/ 60}h ${m % 60}m'; }
  IconData _maneuverIcon(String? m) { if (m == null) return Icons.circle_outlined; if (m.contains('left')) return Icons.turn_left; if (m.contains('right')) return Icons.turn_right; if (m.contains('uturn') || m.contains('u-turn')) return Icons.u_turn_left; if (m.contains('arrive') || m.contains('destination')) return Icons.flag; if (m.contains('depart') || m.contains('start')) return Icons.trip_origin; if (m.contains('roundabout')) return Icons.roundabout_left; if (m.contains('merge')) return Icons.merge; if (m.contains('ramp') || m.contains('exit')) return Icons.ramp_right; return Icons.straight; }
  IconData _placeIcon(String? c) { if (c == null) return Icons.place; if (c.contains('tourism')) return Icons.attractions; if (c.contains('amenity')) return Icons.restaurant; if (c.contains('shop')) return Icons.shopping_cart; return Icons.place; }
  IconData _modeIcon(AnyTravelMode m) => switch (m) { AnyTravelMode.driving => Icons.directions_car, AnyTravelMode.walking => Icons.directions_walk, AnyTravelMode.cycling => Icons.directions_bike, AnyTravelMode.transit => Icons.directions_bus };

  // ── Layer toggles ──

  void _toggleLayerSheet() {
    showModalBottomSheet(context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, setSheetState) {
      return Padding(padding: const EdgeInsets.all(16), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
        Text('Map Layers', style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        SwitchListTile(title: const Text('Circles (landmark radius)'), subtitle: const Text('AnyCircle — 3 circles around landmarks'), value: _showCircles, onChanged: (v) { setSheetState(() {}); setState(() => _showCircles = v); }),
        SwitchListTile(title: const Text('Polygon (Old City area)'), subtitle: const Text('AnyPolygon — pink outlined region'), value: _showPolygons, onChanged: (v) { setSheetState(() {}); setState(() => _showPolygons = v); }),
        SwitchListTile(title: const Text('Clustering (60 markers)'), subtitle: const Text('AnyClusterEngine — grouped markers'), value: _showClustering, onChanged: (v) { setSheetState(() {}); setState(() => _showClustering = v); }),
        SwitchListTile(title: const Text('GeoJSON (Metro line)'), subtitle: const Text('AnyGeoJsonLayer — parsed from JSON'), value: _showGeoJson, onChanged: (v) { setSheetState(() {}); setState(() { _showGeoJson = v; _applyGeoJson(); }); }),
        SwitchListTile(title: const Text('User Location Layer'), subtitle: const Text('AnyUserLocationLayer — pulsing dot + heading'), value: _showLocationLayer, onChanged: (v) { setSheetState(() {}); _toggleLocationLayer(v); }),
        if (_route != null) SwitchListTile(title: const Text('Animate route drawing'), subtitle: const Text('AnyAnimatedPolyline — progressive draw'), value: _animatingPolyline, onChanged: (v) { setSheetState(() {}); setState(() => _animatingPolyline = v); if (v) { _startAnimatePolyline(); } else { _stopAnimatePolyline(); } }),
      ]));
    }));
  }

  void _applyGeoJson() {
    if (_showGeoJson) {
      final lines = _demoGeoJson.toPolylines();
      final markers = _demoGeoJson.toMarkers();
      setState(() {
        _polylines = [..._polylines, ...lines];
        _allMarkers = [..._allMarkers, ...markers];
      });
    } else {
      setState(() {
        _polylines = _polylines.where((p) => !p.id.startsWith('metro')).toList();
        _allMarkers = _allMarkers.where((m) => !m.id.startsWith('metro')).toList();
      });
    }
  }

  // ── Location Layer (Feature 2) ──

  void _toggleLocationLayer(bool v) {
    setState(() => _showLocationLayer = v);
    if (v) {
      _simLocProvider = _SimLocationProvider(center: _hyderabadCenter);
      _simLocProvider!.startUpdates();
    } else {
      _simLocTimer?.cancel();
      _simLocProvider?.dispose();
      _simLocProvider = null;
    }
  }

  // ── Distance Matrix ──

  Future<void> _showDistanceMatrix() async {
    setState(() { _matrixLoading = true; _matrixResult = null; });
    final matrix = AnyDistanceMatrix();
    final origins = _landmarks.take(3).map((m) => m.position).toList();
    final dests = _landmarks.skip(2).take(3).map((m) => m.position).toList();
    final result = await matrix.calculate(origins: origins, destinations: dests);
    if (!mounted) return;
    setState(() => _matrixLoading = false);
    if (!result.isSuccess) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Matrix failed: ${result.error}')));
      return;
    }
    final rows = <String>[];
    for (var i = 0; i < origins.length; i++) {
      final cells = result.matrix[i].map((c) => c.durationText ?? '–').join(' | ');
      rows.add('${_landmarks[i].title}: $cells');
    }
    setState(() => _matrixResult = rows.join('\n'));
    showDialog(context: context, builder: (ctx) => AlertDialog(
      title: const Text('Distance Matrix'),
      content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
        Text('Origins: Charminar, HITEC, Hussain Sagar\nDestinations: Hussain Sagar, Golconda, Ramoji', style: Theme.of(context).textTheme.bodySmall),
        const SizedBox(height: 8),
        Text(_matrixResult ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
      ]),
      actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))],
    ));
  }

  // ── Nearby POI ──

  Future<void> _findNearbyPlaces(AnyPlaceType type) async {
    setState(() => _nearbyLoading = true);
    final places = AnyPlaces();
    final results = await places.nearby(location: _hyderabadCenter, type: type, radius: 1000, limit: 10);
    if (!mounted) return;
    final markers = results.map((p) => AnyMarker(
      id: 'poi_${p.id}',
      position: p.position,
      title: p.name,
      snippet: '${p.distanceText ?? ''} ${p.openingHours != null ? '• ${p.openingHours}' : ''}',
    )).toList();
    setState(() { _nearbyMarkers = markers; _nearbyLoading = false; _allMarkers = [..._landmarks, ...markers]; });
    if (markers.isEmpty && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No results found nearby')));
    } else if (mounted) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Found ${markers.length} ${type.name} nearby')));
    }
  }

  void _clearNearby() => setState(() { _nearbyMarkers = []; _allMarkers = List.of(_landmarks); });

  // ── Route Optimizer ──

  Future<void> _optimizeRoute() async {
    setState(() { _optimizerLoading = true; _optimizedRoute = null; });
    final stops = _landmarks.take(4).map((m) => m.position).toList();
    final optimizer = AnyRouteOptimizer();
    final result = await optimizer.optimize(stops: stops);
    if (!mounted) return;
    setState(() => _optimizerLoading = false);
    if (!result.isSuccess) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Optimizer failed: ${result.error}')));
      return;
    }
    setState(() {
      _optimizedRoute = result;
      _polylines = [..._polylines.where((p) => !p.id.startsWith('opt_')), result.toPolyline(id: 'opt_route')];
    });
    if (result.bounds != null) _controller?.fitBoundsWithInsets(result.bounds!, insets: const EdgeInsets.all(64));
    final orderStr = result.waypointOrder
        .where((i) => i < _landmarks.length)
        .map((i) => _landmarks[i].title ?? 'Stop $i')
        .join(' → ');
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      content: Text('Optimized: ${result.durationText} • ${result.distanceText}\nOrder: $orderStr'),
      duration: const Duration(seconds: 4),
    ));
  }

  // ── Map Matching ──

  Future<void> _runMapMatching() async {
    setState(() => _matcherLoading = true);
    // Simulate noisy GPS trace along Charminar → HITEC City road
    final rawTrace = [
      AnyGpsPoint(position: const AnyLatLng(17.3620, 78.4750), timestamp: 1700000000, accuracy: 20),
      AnyGpsPoint(position: const AnyLatLng(17.3700, 78.4710), timestamp: 1700000030, accuracy: 25),
      AnyGpsPoint(position: const AnyLatLng(17.3800, 78.4680), timestamp: 1700000060, accuracy: 20),
      AnyGpsPoint(position: const AnyLatLng(17.3900, 78.4500), timestamp: 1700000090, accuracy: 30),
      AnyGpsPoint(position: const AnyLatLng(17.4000, 78.4200), timestamp: 1700000120, accuracy: 20),
      AnyGpsPoint(position: const AnyLatLng(17.4200, 78.4000), timestamp: 1700000150, accuracy: 25),
      AnyGpsPoint(position: const AnyLatLng(17.4400, 78.3800), timestamp: 1700000180, accuracy: 20),
    ];
    final matcher = AnyMapMatcher();
    final result = await matcher.match(rawTrace);
    if (!mounted) return;
    setState(() => _matcherLoading = false);
    if (!result.isSuccess) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Matching failed: ${result.error}')));
      return;
    }
    final rawPoly = AnyPolyline(id: 'raw_gps', points: rawTrace.map((p) => p.position).toList(), color: const Color(0xFFFF9800), width: 3);
    final snappedPoly = result.toPolyline(id: 'snapped');
    setState(() => _matchedPolylines = [rawPoly, snappedPoly]);
    setState(() => _polylines = [..._polylines.where((p) => !p.id.startsWith('raw_') && !p.id.startsWith('snapped')), rawPoly, snappedPoly]);
    final conf = result.confidence != null ? ' (${(result.confidence! * 100).round()}% confidence)' : '';
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Snapped to road$conf — orange=raw GPS, purple=snapped')));
  }

  void _clearMatching() {
    setState(() {
      _matchedPolylines = [];
      _polylines = _polylines.where((p) => !p.id.startsWith('raw_') && !p.id.startsWith('snapped') && !p.id.startsWith('opt_')).toList();
    });
  }

  // ── Advanced Features Sheet ──

  void _showAdvancedSheet() {
    showModalBottomSheet(context: context, isScrollControlled: true, builder: (ctx) => StatefulBuilder(builder: (ctx, setSheetState) {
      return DraggableScrollableSheet(
        initialChildSize: 0.55,
        maxChildSize: 0.85,
        minChildSize: 0.3,
        expand: false,
        builder: (_, scrollCtrl) => SingleChildScrollView(
          controller: scrollCtrl,
          child: Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
            Center(child: Container(width: 36, height: 4, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2)))),
            Text('Advanced Features', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
            const SizedBox(height: 16),

            // Distance Matrix
            _advancedCard(
              icon: Icons.grid_on,
              title: 'Distance Matrix',
              subtitle: 'OSRM Table API — 3×3 travel-time grid in one call',
              isLoading: _matrixLoading,
              onTap: () { Navigator.pop(ctx); _showDistanceMatrix(); },
            ),

            // Nearby POI
            _advancedCard(
              icon: Icons.place_outlined,
              title: 'Nearby Places (Overpass)',
              subtitle: 'Find hospitals, restaurants, ATMs near Hyderabad',
              isLoading: _nearbyLoading,
              trailing: _nearbyMarkers.isNotEmpty ? IconButton(icon: const Icon(Icons.close, size: 18), onPressed: () { Navigator.pop(ctx); _clearNearby(); }) : null,
              onTap: null,
              child: Wrap(spacing: 6, runSpacing: 4, children: [
                for (final type in [AnyPlaceType.hospital, AnyPlaceType.restaurant, AnyPlaceType.atm, AnyPlaceType.fuelStation, AnyPlaceType.pharmacy, AnyPlaceType.hotel])
                  ActionChip(
                    label: Text(type.name, style: const TextStyle(fontSize: 11)),
                    onPressed: _nearbyLoading ? null : () { Navigator.pop(ctx); _findNearbyPlaces(type); },
                  ),
              ]),
            ),

            // Route Optimizer
            _advancedCard(
              icon: Icons.route,
              title: 'Route Optimizer (TSP)',
              subtitle: 'OSRM Trip API — optimal order for 4 Hyderabad stops',
              isLoading: _optimizerLoading,
              trailing: _optimizedRoute != null ? IconButton(icon: const Icon(Icons.close, size: 18), onPressed: () { Navigator.pop(ctx); _clearMatching(); }) : null,
              onTap: () { Navigator.pop(ctx); _optimizeRoute(); },
            ),

            // Map Matching
            _advancedCard(
              icon: Icons.timeline,
              title: 'GPS Map Matching',
              subtitle: 'OSRM Match API — snap noisy GPS trace to roads',
              isLoading: _matcherLoading,
              trailing: _matchedPolylines.isNotEmpty ? IconButton(icon: const Icon(Icons.close, size: 18), onPressed: () { Navigator.pop(ctx); _clearMatching(); }) : null,
              onTap: () { Navigator.pop(ctx); _runMapMatching(); },
            ),

            // AnyPlacesSearchField demo
            const SizedBox(height: 4),
            ListTile(
              contentPadding: EdgeInsets.zero,
              leading: CircleAvatar(radius: 18, backgroundColor: Theme.of(context).colorScheme.primaryContainer, child: Icon(Icons.search, size: 18, color: Theme.of(context).colorScheme.primary)),
              title: const Text('AnyPlacesSearchField'),
              subtitle: const Text('Tap the main search bar — it uses the full widget'),
            ),
          ])),
        ),
      );
    }));
  }

  Widget _advancedCard({
    required IconData icon,
    required String title,
    required String subtitle,
    bool isLoading = false,
    VoidCallback? onTap,
    Widget? trailing,
    Widget? child,
  }) {
    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(12),
        child: Padding(padding: const EdgeInsets.all(12), child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
          Row(children: [
            CircleAvatar(radius: 18, backgroundColor: Theme.of(context).colorScheme.primaryContainer, child: isLoading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary)),
            const SizedBox(width: 12),
            Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              Text(title, style: Theme.of(context).textTheme.titleSmall),
              Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
            ])),
                    if (trailing != null) trailing,
            if (onTap != null && trailing == null) const Icon(Icons.chevron_right, size: 18),
          ]),
          if (child != null) ...[const SizedBox(height: 8), child],
        ])),
      ),
    );
  }

  void _startAnimatePolyline() {
    if (_route == null) return;
    final animPoly = AnyAnimatedPolyline(id: 'anim_route', points: _route!.geometry, width: 6.0, color: const Color(0xFF4285F4), animationDuration: const Duration(seconds: 5));
    _animProgress = 0;
    // Clear existing route polylines and animate
    setState(() => _polylines = []);
    _animTimer = Timer.periodic(const Duration(milliseconds: 50), (t) {
      if (!mounted || _animProgress >= 1.0) { t.cancel(); setState(() => _animatingPolyline = false); return; }
      _animProgress += 0.01;
      setState(() => _polylines = [animPoly.toPolylineAt(_animProgress)]);
    });
  }

  void _stopAnimatePolyline() {
    _animTimer?.cancel();
    if (_route != null) {
      setState(() => _polylines = _showTrafficColors && _route!.segments.isNotEmpty ? _route!.toTrafficPolylines(idPrefix: 'route') : [_route!.toPolyline(id: 'route', width: 6.0)]);
    }
  }

  // Compute effective markers including cluster markers
  List<AnyMarker> get _effectiveMarkers {
    var markers = List.of(_allMarkers);
    if (_showClustering) markers = [...markers, ..._clusterMarkers];
    return markers;
  }

  @override
  void dispose() {
    _navTimer?.cancel();
    _animTimer?.cancel();
    _searchCtrl.dispose();
    _geoSub?.cancel();
    _cameraSub?.cancel();
    _simLocTimer?.cancel();
    _simLocProvider?.dispose();
    _geoEngine.dispose();
    _rerouter.dispose();
    super.dispose();
  }

  // ── BUILD ──

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Stack(children: [
      Column(children: [
        Expanded(child: AnyMapWidget(
          key: ValueKey('${_backend}_${_mapStyle.name}'),
          adapter: _adapter,
          initialCamera: AnyCameraPosition(target: _hyderabadCenter, zoom: 12, tilt: _is3DView ? 45 : 0),
          markers: _effectiveMarkers.map((m) => m.copyWith(onTap: () => setState(() => _selectedMarker = m))).toList(),
          polylines: _polylines,
          polygons: _showPolygons ? _demoPolygons : const [],
          circles: _showCircles ? _demoCircles : const [],
          onMapCreated: _onMapCreated,
          onTap: (_) { if (_showSearch) setState(() => _showSearch = false); setState(() => _selectedMarker = null); },
          onLongPress: (ll) { _controller?.animateCamera(AnyCameraPosition(target: ll, zoom: 18, tilt: 75)); setState(() => _is3DView = true); },
          myLocationEnabled: false, compassEnabled: true, tiltGesturesEnabled: true, rotateGesturesEnabled: true,
          onError: (err) {
            if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Map error: ${err.message}'), backgroundColor: Colors.red));
          },
        )),
        if (_route != null && !_showSearch && !_isNavigating) _buildDirectionsPanel(),
      ]),
      // Top bar
      SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: [_buildTopBar(), if (_showSearch) _buildSearchPanel()])),
      // Controls
      Positioned(right: 12, bottom: _route != null && !_isNavigating ? 260 : 24, child: _buildControls()),
      // Nav overlay (Google Maps style)
      if (_isNavigating && _route != null) ...[
        Positioned(top: 0, left: 0, right: 0, child: _buildNavBanner()),
        Positioned(bottom: 0, left: 0, right: 0, child: _buildNavBottomBar()),
      ],
      // Feature 2: User location layer
      if (_showLocationLayer && _simLocProvider != null)
        AnyUserLocationLayer(controller: _controller, locationProvider: _simLocProvider!),
      // Feature 3: Marker popup
      if (_selectedMarker != null)
        AnyMarkerPopup(
          controller: _controller,
          marker: _selectedMarker!,
          onClose: () => setState(() => _selectedMarker = null),
        ),
      // Feature 1: Live zoom badge (camera stream demo)
      Positioned(
        left: 12, bottom: _route != null && !_isNavigating ? 270 : 90,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
          decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(8)),
          child: Text('z ${_liveZoom.toStringAsFixed(1)}', style: const TextStyle(color: Colors.white, fontSize: 11, fontFamily: 'monospace')),
        ),
      ),
      // Geofence chip
      if (_lastGeoEvent != null && !_isNavigating)
        Positioned(bottom: _route != null ? 290 : 110, left: 12, child: Chip(avatar: const Icon(Icons.fence, size: 16), label: Text(_lastGeoEvent!, style: const TextStyle(fontSize: 11)), onDeleted: () => setState(() => _lastGeoEvent = null))),
    ]));
  }

  // ── Top Bar ──

  Widget _buildTopBar() => Container(
    margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
    decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 8, offset: const Offset(0, 2))]),
    child: Column(mainAxisSize: MainAxisSize.min, children: [
      // Search
      Row(children: [
        const SizedBox(width: 12), Icon(Icons.search, color: Theme.of(context).colorScheme.outline), const SizedBox(width: 8),
        Expanded(child: TextField(controller: _searchCtrl, decoration: const InputDecoration(hintText: 'Search places in Hyderabad...', border: InputBorder.none, contentPadding: EdgeInsets.symmetric(vertical: 14)), onTap: () => setState(() => _showSearch = true), onChanged: _performSearch)),
        if (_searchCtrl.text.isNotEmpty) IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () { _searchCtrl.clear(); setState(() { _searchResults = []; _showSearch = false; }); }),
        if (_backend == MapBackend.maplibre) PopupMenuButton<MapStyle>(icon: const Icon(Icons.layers_outlined), tooltip: 'Map Style', onSelected: (s) => setState(() { _mapStyle = s; _controller = null; }),
          itemBuilder: (_) => MapStyle.values.map((s) => PopupMenuItem(value: s, child: Row(children: [if (s == _mapStyle) Icon(Icons.check, size: 18, color: Theme.of(context).colorScheme.primary) else const SizedBox(width: 18), const SizedBox(width: 8), Text(s.label)]))).toList()),
        const SizedBox(width: 4),
      ]),
      // Backend + Travel mode
      Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
        Expanded(child: SegmentedButton<MapBackend>(
          segments: const [ButtonSegment(value: MapBackend.maplibre, label: Text('MapLibre'), icon: Icon(Icons.map, size: 16)), ButtonSegment(value: MapBackend.osm, label: Text('OSM'), icon: Icon(Icons.public, size: 16))],
          selected: {_backend}, onSelectionChanged: (v) => setState(() { _backend = v.first; _controller = null; }),
          style: const ButtonStyle(visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
        )),
      ])),
    ]),
  );

  // ── Search Panel ──

  Widget _buildSearchPanel() => Container(
    margin: const EdgeInsets.fromLTRB(12, 4, 12, 0), constraints: const BoxConstraints(maxHeight: 350),
    decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8)]),
    child: _isSearching ? const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator()))
        : _searchResults.isEmpty ? _buildRouteChips()
        : ListView.builder(shrinkWrap: true, padding: EdgeInsets.zero, itemCount: _searchResults.length, itemBuilder: (_, i) {
            final p = _searchResults[i];
            return ListTile(dense: true, leading: Icon(_placeIcon(p.category), color: Theme.of(context).colorScheme.primary), title: Text(p.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Text(p.address, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall), onTap: () => _goToPlace(p));
          }),
  );

  Widget _buildRouteChips() => Padding(padding: const EdgeInsets.all(12), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
    // Travel mode selector
    Text('Travel Mode', style: Theme.of(context).textTheme.titleSmall),
    const SizedBox(height: 6),
    SegmentedButton<AnyTravelMode>(
      segments: AnyTravelMode.values.map((m) => ButtonSegment(value: m, icon: Icon(_modeIcon(m), size: 18), label: Text(m.name[0].toUpperCase() + m.name.substring(1), style: const TextStyle(fontSize: 11)))).toList(),
      selected: {_travelMode}, onSelectionChanged: (v) => setState(() => _travelMode = v.first),
      style: const ButtonStyle(visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
    ),
    const SizedBox(height: 12),
    // Routes
    Text('Popular Routes', style: Theme.of(context).textTheme.titleSmall),
    const SizedBox(height: 6),
    Wrap(spacing: 8, runSpacing: 8, children: _routeOptions.map((r) => ActionChip(avatar: const Icon(Icons.directions, size: 16), label: Text(r.label, style: const TextStyle(fontSize: 11)), onPressed: () { setState(() => _showSearch = false); _computeRoute(r); })).toList()),
    const SizedBox(height: 8),
    Text('Long-press map for street-level view', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.outline)),
  ]));

  // ── Controls ──

  Widget _buildControls() => Column(mainAxisSize: MainAxisSize.min, children: [
    // Advanced features sheet
    FloatingActionButton.small(heroTag: 'advanced', onPressed: _showAdvancedSheet, tooltip: 'Advanced Features', child: const Icon(Icons.science_outlined, size: 20)),
    const SizedBox(height: 8),
    // Layers toggle sheet
    FloatingActionButton.small(heroTag: 'layers_sheet', onPressed: _toggleLayerSheet, tooltip: 'Layers', child: const Icon(Icons.stacked_line_chart, size: 20)),
    const SizedBox(height: 8),
    if (_backend == MapBackend.maplibre) FloatingActionButton.small(heroTag: '3d', onPressed: () { setState(() => _is3DView = !_is3DView); _controller?.animateCamera(AnyCameraPosition(target: _hyderabadCenter, zoom: _is3DView ? 16 : 13, tilt: _is3DView ? 60 : 0, bearing: _is3DView ? 30 : 0)); }, child: Text(_is3DView ? '2D' : '3D', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
    const SizedBox(height: 8),
    if (_route != null) FloatingActionButton.small(heroTag: 'traffic', onPressed: _toggleTrafficColors, backgroundColor: _showTrafficColors ? Colors.green : null, child: const Icon(Icons.traffic, size: 20)),
    if (_route != null) const SizedBox(height: 8),
    FloatingActionButton.small(heroTag: 'fit', onPressed: () { if (_route != null) { _controller?.fitBounds(_route!.bounds, padding: 80); } else { _controller?.fitBounds(AnyLatLngBounds.fromPoints(_landmarks.map((m) => m.position).toList()), padding: 64); } }, child: const Icon(Icons.fit_screen, size: 20)),
    if (_route != null) ...[
      const SizedBox(height: 8),
      FloatingActionButton.small(heroTag: 'nav', backgroundColor: _isNavigating ? Colors.red : Theme.of(context).colorScheme.primary, foregroundColor: Colors.white, onPressed: _isNavigating ? _stopNavigation : _startNavigation, child: Icon(_isNavigating ? Icons.stop : Icons.navigation, size: 20)),
      const SizedBox(height: 8),
      FloatingActionButton.small(heroTag: 'clear', onPressed: _clearRoute, child: const Icon(Icons.close, size: 20)),
    ],
    if (_route == null) ...[
      const SizedBox(height: 8),
      FloatingActionButton.extended(heroTag: 'route', onPressed: _isRouting ? null : () => _computeRoute(_routeOptions.first),
        icon: _isRouting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Icon(Icons.directions, size: 18),
        label: Text(_isRouting ? 'Loading...' : 'Route', style: const TextStyle(fontSize: 13))),
    ],
  ]);

  // ── Navigation Banner (Google Maps style green bar) ──

  Widget _buildNavBanner() {
    final steps = _route!.steps;
    final step = steps.isNotEmpty && _currentStepIndex < steps.length ? steps[_currentStepIndex] : null;
    final next = steps.isNotEmpty && _currentStepIndex + 1 < steps.length ? steps[_currentStepIndex + 1] : null;
    if (step == null) return const SizedBox.shrink();

    return SafeArea(child: Container(
      margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
      decoration: BoxDecoration(color: const Color(0xFF1B873B), borderRadius: BorderRadius.circular(14), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))]),
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        // Main instruction
        Padding(padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Icon(_maneuverIcon(step.maneuver), size: 48, color: Colors.white),
          const SizedBox(width: 14),
          Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
            if (step.distanceMeters > 0) Text(_fmtDist(step.distanceMeters), style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white, height: 1.1)),
            const SizedBox(height: 2),
            Text(step.roadName?.isNotEmpty == true ? step.roadName! : step.instruction, style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis),
            if (step.roadRef != null) Text(step.roadRef!, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
          ])),
          // Speed limit
          if (step.speedLimit != null) Container(width: 44, height: 44, decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.white, border: Border.all(color: Colors.red, width: 3)), child: Center(child: Text('${step.speedLimit!.speedKmh.round()}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)))),
        ])),
        // Lane guidance
        if (step.lanes.isNotEmpty) Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), color: Colors.black.withValues(alpha: 0.15),
          child: Row(mainAxisAlignment: MainAxisAlignment.center, children: step.lanes.map((l) => Container(margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration(color: l.isActive ? Colors.white : Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4)),
            child: Icon(l.directions.contains('left') ? Icons.arrow_back : l.directions.contains('right') ? Icons.arrow_forward : Icons.arrow_upward, size: 14, color: l.isActive ? const Color(0xFF1B873B) : Colors.white.withValues(alpha: 0.5)))).toList())),
        // Bridge/tunnel/toll alert
        if (step.annotation?.isBridge == true || step.annotation?.isTunnel == true || step.annotation?.isToll == true)
          Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), color: Colors.amber.shade700,
            child: Row(children: [const Icon(Icons.info_outline, size: 14, color: Colors.white), const SizedBox(width: 6),
              if (step.annotation?.isBridge == true) const Text('Flyover ahead  ', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.bold)),
              if (step.annotation?.isTunnel == true) const Text('Tunnel ahead  ', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.bold)),
              if (step.annotation?.isToll == true) const Text('Toll road  ', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.bold))])),
        // "Then" preview
        if (next != null) Container(width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: const BoxDecoration(color: Color(0xFF145A28), borderRadius: BorderRadius.only(bottomLeft: Radius.circular(14), bottomRight: Radius.circular(14))),
          child: Row(children: [const Text('Then  ', style: TextStyle(fontSize: 13, color: Colors.white70)), Icon(_maneuverIcon(next.maneuver), size: 18, color: Colors.white70), const SizedBox(width: 6), Expanded(child: Text(next.roadName?.isNotEmpty == true ? next.roadName! : next.instruction, style: const TextStyle(fontSize: 13, color: Colors.white70), maxLines: 1, overflow: TextOverflow.ellipsis))])),
      ]),
    ));
  }

  // ── Navigation Bottom Bar ──

  Widget _buildNavBottomBar() {
    final p = _route!.geometry.isNotEmpty ? _navIndex / _route!.geometry.length : 0.0;
    final rd = _route!.distanceMeters * (1.0 - p);
    final rt = _route!.durationSeconds * (1.0 - p);
    final eta = DateTime.now().add(Duration(seconds: rt.round()));

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
      decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 6, offset: const Offset(0, -2))]),
      child: SafeArea(top: false, child: Row(children: [
        IconButton(icon: const Icon(Icons.close), iconSize: 28, onPressed: _stopNavigation, style: IconButton.styleFrom(backgroundColor: Colors.grey.shade200)),
        const Spacer(),
        Column(mainAxisSize: MainAxisSize.min, children: [
          Text(_fmtTime(rt), style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green.shade700)),
          Text('${_fmtDist(rd)}  \u2022  ${eta.hour}:${eta.minute.toString().padLeft(2, '0')}', style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
        ]),
        const Spacer(),
        IconButton(icon: const Icon(Icons.my_location), iconSize: 28, onPressed: () { if (_route != null && _navIndex < _route!.geometry.length) _controller?.moveCamera(AnyCameraPosition(target: _route!.geometry[_navIndex], zoom: 17.5, tilt: 65, bearing: _navBearing)); }, style: IconButton.styleFrom(backgroundColor: Colors.grey.shade200)),
      ])),
    );
  }

  // ── Directions Panel ──

  Widget _buildDirectionsPanel() {
    final r = _route!;
    return Container(
      constraints: const BoxConstraints(maxHeight: 220),
      decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface, boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, -2))]),
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        // Header
        Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), color: Theme.of(context).colorScheme.primaryContainer,
          child: Row(children: [
            Icon(_modeIcon(_travelMode), color: Theme.of(context).colorScheme.onPrimaryContainer, size: 20),
            const SizedBox(width: 10),
            Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
              Text('${r.durationText}  \u2022  ${r.distanceText}', style: Theme.of(context).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold)),
              Row(children: [
                Text('${r.steps.length} steps \u2022 ${_travelMode.name}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7))),
                if (r.hasBridges) _badge('Flyover', Colors.blue), if (r.hasTunnels) _badge('Tunnel', Colors.grey), if (r.hasTolls) _badge('Toll', Colors.orange),
              ]),
            ])),
            IconButton(icon: const Icon(Icons.navigation), onPressed: _startNavigation, color: Theme.of(context).colorScheme.onPrimaryContainer),
            IconButton(icon: const Icon(Icons.close, size: 20), onPressed: _clearRoute, color: Theme.of(context).colorScheme.onPrimaryContainer),
          ])),
        // Steps
        Flexible(child: ListView.separated(padding: EdgeInsets.zero, itemCount: r.steps.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (_, i) {
          final s = r.steps[i]; final active = i == _currentStepIndex && _isNavigating;
          return ListTile(dense: true, selected: active, selectedTileColor: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
            leading: CircleAvatar(radius: 14, backgroundColor: active ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.5), child: Icon(_maneuverIcon(s.maneuver), size: 16, color: active ? Colors.white : Theme.of(context).colorScheme.primary)),
            title: Text(s.instruction, style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis),
            subtitle: Row(children: [
              if (s.annotation?.isBridge == true) const Padding(padding: EdgeInsets.only(right: 4), child: Icon(Icons.move_up, size: 12, color: Colors.blue)),
              if (s.annotation?.isTunnel == true) const Padding(padding: EdgeInsets.only(right: 4), child: Icon(Icons.subway, size: 12, color: Colors.grey)),
              if (s.speedLimit != null) Text('${s.speedLimit!.speedKmh.round()} km/h', style: const TextStyle(fontSize: 10, color: Colors.red)),
              if (s.lanes.isNotEmpty) Text(' \u2022 ${s.lanes.where((l) => l.isActive).length} lanes', style: const TextStyle(fontSize: 10, color: Colors.grey)),
              if (s.roadRef != null) Text(' \u2022 ${s.roadRef}', style: const TextStyle(fontSize: 10, color: Colors.grey)),
            ]),
            trailing: s.distanceMeters > 0 ? Text(_fmtDist(s.distanceMeters), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.outline)) : null,
            onTap: () => _controller?.animateCamera(AnyCameraPosition(target: s.startLocation, zoom: 16, tilt: _is3DView ? 45 : 0)),
          );
        })),
      ]),
    );
  }

  Widget _badge(String t, Color c) => Container(margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration(color: c.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)), child: Text(t, style: TextStyle(fontSize: 9, color: c, fontWeight: FontWeight.bold)));
}

// ── Simulated location provider for demo ──

/// A demo [AnyLocationProvider] that slowly moves a location around
/// Hyderabad for the [AnyUserLocationLayer] demonstration.
class _SimLocationProvider extends AnyLocationProvider {
  final AnyLatLng center;
  final _controller = StreamController<AnyUserLocation>.broadcast();
  Timer? _timer;
  double _angle = 0;

  _SimLocationProvider({required this.center});

  @override
  Stream<AnyUserLocation> get locationStream => _controller.stream;

  @override
  Future<void> startUpdates({Duration interval = const Duration(seconds: 1), double distanceFilter = 5.0}) async {
    _timer = Timer.periodic(const Duration(milliseconds: 800), (_) {
      _angle = (_angle + 3) % 360;
      final rad = _angle * math.pi / 180;
      final lat = center.latitude + math.sin(rad) * 0.005;
      final lng = center.longitude + math.cos(rad) * 0.005;
      _controller.add(AnyUserLocation(
        position: AnyLatLng(lat, lng),
        heading: _angle,
        accuracy: 12,
        speed: 8.3,
        timestamp: DateTime.now(),
      ));
    });
  }

  @override
  Future<void> stopUpdates() async => _timer?.cancel();

  @override
  Future<AnyUserLocation?> getLastLocation() async => null;

  @override
  Future<bool> requestPermission() async => true;

  @override
  Future<bool> isServiceEnabled() async => true;

  @override
  void dispose() {
    _timer?.cancel();
    _controller.close();
  }
}
0
likes
150
points
97
downloads

Documentation

API reference

Publisher

verified publisherramprasadsreerama.co.in

Weekly Downloads

Unified map abstraction for Flutter. One API to swap Google Maps, MapLibre, or OSM. Routing, traffic, geofencing, clustering, analytics, and 30+ features.

Repository (GitHub)
View/report issues

Topics

#maps #google-maps #maplibre #openstreetmap #navigation

License

MIT (license)

Dependencies

flutter

More

Packages that depend on any_map