goodmap 0.1.0 copy "goodmap: ^0.1.0" to clipboard
goodmap: ^0.1.0 copied to clipboard

Beautiful, theme-aware Flutter map components — a native MapLibre flat map and a 3D globe with points, labels and animated great-circle arcs. Inspired by mapcn.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:goodmap/goodmap.dart';

import 'demo_data.dart';
import 'demo_widgets.dart';
import 'globe_demo.dart';

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

const LatLng _sfCenter = LatLng(37.7749, -122.4194);

/// Fixed drop spots for the native GL-symbol (asset) markers.
const List<LatLng> _glPinSpots = [
  LatLng(37.7890, -122.4010),
  LatLng(37.7760, -122.4160),
  LatLng(37.8080, -122.4200),
];

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  ThemeMode _mode = ThemeMode.light;
  bool _customTheme = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'goodmap example',
      debugShowCheckedModeBanner: false,
      themeMode: _mode,
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        brightness: Brightness.dark,
      ),
      home: DemoHome(
        customTheme: _customTheme,
        isDark: _mode == ThemeMode.dark,
        onToggleBrightness: () => setState(
          () => _mode = _mode == ThemeMode.light
              ? ThemeMode.dark
              : ThemeMode.light,
        ),
        onToggleCustomTheme: () => setState(() => _customTheme = !_customTheme),
      ),
    );
  }
}

class DemoHome extends StatefulWidget {
  const DemoHome({
    required this.customTheme,
    required this.isDark,
    required this.onToggleBrightness,
    required this.onToggleCustomTheme,
    super.key,
  });

  final bool customTheme;
  final bool isDark;
  final VoidCallback onToggleBrightness;
  final VoidCallback onToggleCustomTheme;

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  GoodMapController? _controller;
  PopupId? _activePopup;
  bool _showGlobe = false;

  // Live marker (updateMarker on a timer).
  MarkerId? _liveMarker;
  Timer? _liveTimer;
  double _routeT = 0;

  // Native GL-symbol (asset) markers.
  final List<MarkerId> _glPins = [];

  // Polylines / routes.
  PolylineId? _poiRoute;
  PolylineId? _ferryLine;

  @override
  void dispose() {
    _liveTimer?.cancel();
    super.dispose();
  }

  void _onMapReady(GoodMapController c) {
    _controller = c;
    for (final poi in kPois) {
      c.addMarker(
        MarkerOptions(
          position: poi.position,
          alignment: Alignment.bottomCenter,
          child: PoiPin(poi: poi),
          onTap: () => _showPoiPopup(poi),
        ),
      );
    }
    c.fitBounds(poiBounds(), padding: const EdgeInsets.all(72));
    setState(() {}); // enable the control panel
  }

  void _showPoiPopup(Poi poi) {
    final c = _controller!;
    if (_activePopup != null) c.hidePopup(_activePopup!);
    late PopupId id;
    id = c.showPopup(
      poi.position,
      PoiPopupCard(
        poi: poi,
        onClose: () {
          c.hidePopup(id);
          _activePopup = null;
        },
      ),
    );
    _activePopup = id;
  }

  // --- Camera demos -------------------------------------------------------

  void _flyToBridge() =>
      _controller?.flyTo(const LatLng(37.8199, -122.4783), zoom: 14);

  void _fitAll() =>
      _controller?.fitBounds(poiBounds(), padding: const EdgeInsets.all(72));

  void _tiltAndRotate() => _controller?.animateTo(
    const CameraPosition(target: _sfCenter, zoom: 12.5, bearing: 45, tilt: 45),
  );

  // "Mapa mundi": zoom out to the whole flat world map.
  void _worldView() => _controller?.animateTo(
    const CameraPosition(target: LatLng(20, 0), zoom: 1),
  );

  // --- Routes / polylines -------------------------------------------------

  void _togglePoiRoute() {
    final c = _controller;
    if (c == null) return;
    if (_poiRoute != null) {
      c.removePolyline(_poiRoute!);
      setState(() => _poiRoute = null);
      return;
    }
    final id = c.addPolyline(
      [for (final p in kPois) p.position],
      color: Theme.of(context).colorScheme.primary,
      width: 5,
    );
    setState(() => _poiRoute = id);
  }

  // --- Live marker --------------------------------------------------------

  void _toggleLiveMarker() {
    final c = _controller;
    if (c == null) return;
    if (_liveMarker != null) {
      _liveTimer?.cancel();
      _liveTimer = null;
      c.removeMarker(_liveMarker!);
      if (_ferryLine != null) c.removePolyline(_ferryLine!);
      _ferryLine = null;
      setState(() => _liveMarker = null);
      return;
    }
    // Draw the route the ferry follows, then animate a marker along it.
    _ferryLine = c.addPolyline(
      const [...kFerryRoute, LatLng(37.7955, -122.3937)],
      color: Colors.teal,
      width: 4,
    );
    _routeT = 0;
    final id = c.addMarker(
      const MarkerOptions(
        position: LatLng(37.7955, -122.3937),
        alignment: Alignment.bottomCenter,
        child: LiveBadge(label: 'Ferry · live'),
      ),
    );
    _liveTimer = Timer.periodic(const Duration(milliseconds: 700), (_) {
      _routeT = (_routeT + 0.18) % kFerryRoute.length;
      final seg = _routeT.floor();
      final frac = _routeT - seg;
      final a = kFerryRoute[seg];
      final b = kFerryRoute[(seg + 1) % kFerryRoute.length];
      final pos = LatLng(
        a.latitude + (b.latitude - a.latitude) * frac,
        a.longitude + (b.longitude - a.longitude) * frac,
      );
      c.updateMarker(
        id,
        MarkerOptions(
          position: pos,
          alignment: Alignment.bottomCenter,
          child: const LiveBadge(label: 'Ferry · live'),
        ),
      );
    });
    setState(() => _liveMarker = id);
  }

  // --- Native GL-symbol markers -------------------------------------------

  void _toggleGlPins() {
    final c = _controller;
    if (c == null) return;
    if (_glPins.isNotEmpty) {
      for (final id in _glPins) {
        c.removeMarker(id);
      }
      setState(() => _glPins.clear());
      return;
    }
    final ids = <MarkerId>[];
    for (final spot in _glPinSpots) {
      ids.add(
        c.addMarker(
          MarkerOptions(
            position: spot,
            image: MarkerImage.asset(
              'assets/pin_teal.png',
              size: const Size(40, 40),
            ),
          ),
        ),
      );
    }
    setState(() => _glPins.addAll(ids));
  }

  GoodMapTheme? _mapTheme(BuildContext context) {
    if (!widget.customTheme) return null;
    final scheme = Theme.of(context).colorScheme;
    return GoodMapTheme.fromColorScheme(scheme).copyWith(
      controlBackground: Colors.deepOrange,
      controlForeground: Colors.white,
    );
  }

  @override
  Widget build(BuildContext context) {
    final ready = _controller != null;
    return Scaffold(
      appBar: AppBar(
        title: SegmentedButton<bool>(
          showSelectedIcon: false,
          segments: const [
            ButtonSegment(value: false, label: Text('Flat'), icon: Icon(Icons.map_outlined)),
            ButtonSegment(value: true, label: Text('Globe'), icon: Icon(Icons.public)),
          ],
          selected: {_showGlobe},
          onSelectionChanged: (s) => setState(() => _showGlobe = s.first),
        ),
        actions: [
          if (!_showGlobe)
            IconButton(
              tooltip: widget.customTheme
                  ? 'Default control theme'
                  : 'Custom control theme',
              icon: Icon(
                widget.customTheme ? Icons.palette : Icons.palette_outlined,
              ),
              onPressed: widget.onToggleCustomTheme,
            ),
          IconButton(
            tooltip: 'Toggle light/dark',
            icon: Icon(widget.isDark ? Icons.light_mode : Icons.dark_mode),
            onPressed: widget.onToggleBrightness,
          ),
        ],
      ),
      body: _showGlobe
          ? const GlobeDemo()
          : Column(
              children: [
                Expanded(
                  child: GoodMap(
                    initialCenter: _sfCenter,
                    initialZoom: 11,
                    controls: const GoodControls(zoom: true, compass: true),
                    theme: _mapTheme(context),
                    onMapReady: _onMapReady,
                  ),
                ),
                _ControlPanel(
                  enabled: ready,
                  liveOn: _liveMarker != null,
                  glOn: _glPins.isNotEmpty,
                  routeOn: _poiRoute != null,
                  onFitAll: _fitAll,
                  onFlyToBridge: _flyToBridge,
                  onTilt: _tiltAndRotate,
                  onWorld: _worldView,
                  onToggleRoute: _togglePoiRoute,
                  onToggleLive: _toggleLiveMarker,
                  onToggleGl: _toggleGlPins,
                  onClearPopups: () {
                    _controller?.clearPopups();
                    _activePopup = null;
                  },
                ),
              ],
            ),
    );
  }
}

class _ControlPanel extends StatelessWidget {
  const _ControlPanel({
    required this.enabled,
    required this.liveOn,
    required this.glOn,
    required this.routeOn,
    required this.onFitAll,
    required this.onFlyToBridge,
    required this.onTilt,
    required this.onWorld,
    required this.onToggleRoute,
    required this.onToggleLive,
    required this.onToggleGl,
    required this.onClearPopups,
  });

  final bool enabled;
  final bool liveOn;
  final bool glOn;
  final bool routeOn;
  final VoidCallback onFitAll;
  final VoidCallback onFlyToBridge;
  final VoidCallback onTilt;
  final VoidCallback onWorld;
  final VoidCallback onToggleRoute;
  final VoidCallback onToggleLive;
  final VoidCallback onToggleGl;
  final VoidCallback onClearPopups;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 8,
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'Tap a pin for a popup. Try the demos:',
                style: Theme.of(context).textTheme.labelMedium,
              ),
              const SizedBox(height: 8),
              Container(
                alignment: Alignment.center,
                width: double.infinity,
                child: Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    _DemoButton(
                      icon: Icons.fit_screen,
                      label: 'Fit all',
                      onTap: enabled ? onFitAll : null,
                    ),
                    _DemoButton(
                      icon: Icons.flight_takeoff,
                      label: 'Fly to bridge',
                      onTap: enabled ? onFlyToBridge : null,
                    ),
                    _DemoButton(
                      icon: Icons.threed_rotation,
                      label: 'Tilt & rotate',
                      onTap: enabled ? onTilt : null,
                    ),
                    _DemoButton(
                      icon: Icons.public,
                      label: 'World',
                      onTap: enabled ? onWorld : null,
                    ),
                    _DemoButton(
                      icon: routeOn ? Icons.timeline : Icons.route,
                      label: routeOn ? 'Hide route' : 'POI route',
                      selected: routeOn,
                      onTap: enabled ? onToggleRoute : null,
                    ),
                    _DemoButton(
                      icon: liveOn ? Icons.stop_circle : Icons.directions_boat,
                      label: liveOn ? 'Stop ferry' : 'Live ferry',
                      selected: liveOn,
                      onTap: enabled ? onToggleLive : null,
                    ),
                    _DemoButton(
                      icon: glOn ? Icons.layers_clear : Icons.place,
                      label: glOn ? 'Clear GL pins' : 'Drop GL pins',
                      selected: glOn,
                      onTap: enabled ? onToggleGl : null,
                    ),
                    _DemoButton(
                      icon: Icons.close_fullscreen,
                      label: 'Clear popups',
                      onTap: enabled ? onClearPopups : null,
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _DemoButton extends StatelessWidget {
  const _DemoButton({
    required this.icon,
    required this.label,
    required this.onTap,
    this.selected = false,
  });

  final IconData icon;
  final String label;
  final VoidCallback? onTap;
  final bool selected;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    final style = selected
        ? FilledButton.styleFrom()
        : FilledButton.styleFrom(
            backgroundColor: scheme.secondaryContainer,
            foregroundColor: scheme.onSecondaryContainer,
          );
    return FilledButton.icon(
      style: style,
      onPressed: onTap,
      icon: Icon(icon, size: 18),
      label: Text(label),
    );
  }
}
14
likes
0
points
131
downloads

Publisher

verified publisherildeberto.xyz

Weekly Downloads

Beautiful, theme-aware Flutter map components — a native MapLibre flat map and a 3D globe with points, labels and animated great-circle arcs. Inspired by mapcn.

Repository (GitHub)
View/report issues

Topics

#map #maps #globe #maplibre #geo

License

unknown (license)

Dependencies

flutter, maplibre_gl

More

Packages that depend on goodmap