any_map 0.1.0 copy "any_map: ^0.1.0" to clipboard
any_map: ^0.1.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;

  // 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();
    }
  }

  // ── 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(); }); }),
        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();
      });
    }
  }

  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(); _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,
          polylines: _polylines,
          polygons: _showPolygons ? _demoPolygons : const [],
          circles: _showCircles ? _demoCircles : const [],
          onMapCreated: _onMapCreated,
          onTap: (_) { if (_showSearch) setState(() => _showSearch = false); },
          onLongPress: (ll) { _controller?.animateCamera(AnyCameraPosition(target: ll, zoom: 18, tilt: 75)); setState(() => _is3DView = true); },
          myLocationEnabled: false, compassEnabled: true, tiltGesturesEnabled: true, rotateGesturesEnabled: true,
        )),
        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()),
      ],
      // Geofence chip
      if (_lastGeoEvent != null && !_isNavigating)
        Positioned(bottom: _route != null ? 270 : 90, 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: [
    // 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)));
}
0
likes
140
points
46
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