Skip to main content

junny_map 0.2.0 copy "junny_map: ^0.2.0" to clipboard
junny_map: ^0.2.0 copied to clipboard

PlatformAndroid

Shared Tianditu map, geocoding, POI search, and geolocator services for Flutter applications.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart';
import 'package:junny_map/junny_map.dart';

import 'example_localizations.dart';

const GeoCoordinate _defaultCoordinate = GeoCoordinate(
  latitude: 30.5928,
  longitude: 114.3055,
  system: CoordinateSystem.gcj02,
);

enum _BaseMapSource { tianditu, openFreeMap }

enum _StatusLifetime { transient, persistent }

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final String tiandituKey = await _loadTiandituKey();
  runApp(MyApp(tiandituKey: tiandituKey));
}

Future<String> _loadTiandituKey() async {
  try {
    final String content = await rootBundle.loadString('env/test.env');
    for (final String rawLine in content.split('\n')) {
      final String line = rawLine.trim();
      if (line.isEmpty || line.startsWith('#')) {
        continue;
      }
      final int separatorIndex = line.indexOf('=');
      if (separatorIndex <= 0) {
        continue;
      }
      final String variableName = line.substring(0, separatorIndex).trim();
      if (variableName != 'TIANDITU_KEY') {
        continue;
      }
      String value = line.substring(separatorIndex + 1).trim();
      if (value.length >= 2 &&
          ((value.startsWith('"') && value.endsWith('"')) ||
              (value.startsWith("'") && value.endsWith("'")))) {
        value = value.substring(1, value.length - 1);
      }
      return value.trim();
    }
  } on Object {
    // 本地 env 文件不存在时仍允许使用 OpenFreeMap 和设备定位。
  }
  return '';
}

/// `junny_map` 的可运行功能测试应用。
class MyApp extends StatelessWidget {
  /// 创建测试应用。
  const MyApp({
    super.key,
    this.tiandituKey = '',
    this.enableMap = true,
    this.locationProvider,
  });

  /// 从本地 env 文件加载的天地图 Key。
  final String tiandituKey;

  /// 是否创建原生地图视图;测试环境可关闭以运行纯 Flutter Widget 测试。
  final bool enableMap;

  /// 可选的设备定位提供者;未传入时使用平台默认实现。
  final LocationProvider? locationProvider;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (BuildContext context) =>
          ExampleLocalizations.of(context).appTitle,
      localizationsDelegates: <LocalizationsDelegate<dynamic>>[
        ExampleLocalizations.delegate,
        JunnyMapLocalizations.delegate,
        ...GlobalMaterialLocalizations.delegates,
      ],
      supportedLocales: ExampleLocalizations.supportedLocales,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0B6E69)),
        scaffoldBackgroundColor: const Color(0xFFF5F7F7),
      ),
      home: MapDemoPage(
        tiandituKey: tiandituKey,
        enableMap: enableMap,
        locationProvider: locationProvider,
      ),
    );
  }
}

/// 展示地图、POI 搜索、逆地理编码和设备定位的测试页。
class MapDemoPage extends StatefulWidget {
  /// 创建测试页。
  const MapDemoPage({
    super.key,
    this.tiandituKey = '',
    this.enableMap = true,
    this.locationProvider,
  });

  /// 天地图服务 Key。
  final String tiandituKey;

  /// 是否创建原生地图视图。
  final bool enableMap;

  /// 可选的设备定位提供者;未传入时使用平台默认实现。
  final LocationProvider? locationProvider;

  @override
  State<MapDemoPage> createState() => _MapDemoPageState();
}

class _MapDemoPageState extends State<MapDemoPage> {
  final MapController _tiandituMapController = MapController();
  final JunnyOpenFreeMapController _openFreeMapController =
      JunnyOpenFreeMapController();
  final TextEditingController _searchController = TextEditingController();
  late final LocationProvider _locationProvider;
  late final JunnyMapLocationController _locationController;

  TdtServiceOptions? _services;
  TdtSearchClient? _searchClient;
  TdtGeocodingClient? _geocodingClient;
  List<TdtPoi> _pois = const <TdtPoi>[];
  GeoCoordinate? _deviceCoordinate;
  GeoCoordinate? _selectedCoordinate;
  GeoBounds? _searchBounds;
  String? _statusText;
  bool _isSearching = false;
  bool _isReverseGeocoding = false;
  bool _isLocating = false;
  LocationProviderErrorCode? _locationErrorCode;
  _BaseMapSource _baseMapSource = _BaseMapSource.tianditu;
  GeoCoordinate _currentCenter = _defaultCoordinate;
  int _searchLevel = 12;
  double _currentZoom = 12;
  double _currentRotation = 0;
  bool _isFollowingDevice = false;
  int _tiandituTileErrorCount = 0;
  int _searchRequestId = 0;
  int _reverseGeocodeRequestId = 0;
  int _interactionRevision = 0;
  int _statusToken = 0;
  int _statusVersion = 0;
  Timer? _statusTimer;

  static const double _minimumZoom = 1;
  static const double _maximumZoom = 18;
  static const int _tiandituTileErrorThreshold = 3;
  static const Duration _transientStatusDuration = Duration(seconds: 3);
  static const LocationRequestSettings _locationRequestSettings =
      LocationRequestSettings(
        accuracy: LocationAccuracyPreset.high,
        timeLimit: Duration(seconds: 15),
      );

  @override
  void initState() {
    super.initState();
    _locationProvider =
        widget.locationProvider ?? const GeolocatorLocationProvider();
    _locationController = JunnyMapLocationController(
      provider: _locationProvider,
      settings: _locationRequestSettings,
    );
    _locationController.addListener(_handleLibraryLocationChanged);
    final String apiKey = widget.tiandituKey.trim();
    if (apiKey.isEmpty) {
      _baseMapSource = _BaseMapSource.openFreeMap;
      _statusText = null;
      return;
    }
    final TdtServiceOptions services = TdtServiceOptions(apiKey: apiKey);
    _services = services;
    _searchClient = TdtSearchClient(options: services);
    _geocodingClient = TdtGeocodingClient(options: services);
  }

  @override
  void dispose() {
    _locationController
      ..removeListener(_handleLibraryLocationChanged)
      ..dispose();
    _statusTimer?.cancel();
    _searchController.dispose();
    _searchClient?.dispose();
    _geocodingClient?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final ExampleLocalizations localizations = ExampleLocalizations.of(context);
    final bool hasMap =
        _baseMapSource == _BaseMapSource.openFreeMap || _services != null;
    final bool showSearch = hasMap;
    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.appTitle),
        actions: <Widget>[
          if (hasMap)
            ValueListenableBuilder<TextEditingValue>(
              valueListenable: _searchController,
              builder:
                  (
                    BuildContext context,
                    TextEditingValue value,
                    Widget? child,
                  ) {
                    final bool canClear = _hasClearableState(value.text);
                    return IconButton(
                      onPressed: canClear ? _clearResults : null,
                      icon: const Icon(Icons.clear_all),
                      tooltip: localizations.clearResults,
                    );
                  },
            ),
        ],
      ),
      body: SafeArea(
        child: Stack(
          fit: StackFit.expand,
          children: <Widget>[
            Positioned.fill(child: _buildMap(hasMap)),
            if (showSearch)
              Positioned(
                top: 12,
                left: 16,
                right: 76,
                child: JunnyMapSearchBar(
                  controller: _searchController,
                  isBusy: _isSearching,
                  enabled: _searchClient != null,
                  onClear: _clearResults,
                  onSubmitted: _searchPlaces,
                ),
              ),
            Positioned(
              top: 12,
              right: 16,
              child: _MapControls(
                selectedSource: _baseMapSource,
                tiandituAvailable: _services != null,
                onSourceChanged: _changeBaseMapSource,
              ),
            ),
            if (hasMap && _pois.isNotEmpty)
              Positioned(
                top: 76,
                left: 16,
                right: 16,
                child: JunnyMapSearchResultsPanel(
                  pois: _pois,
                  onSelected: _selectPoi,
                ),
              ),
            if (hasMap)
              JunnyMapControlLayer(
                onZoomIn: _zoomIn,
                onZoomOut: _zoomOut,
                onResetNorth: _resetNorth,
                currentZoom: _currentZoom,
                currentRotation: _currentRotation,
                minimumZoom: _minimumZoom,
                maximumZoom: _maximumZoom,
                hasCurrentLocation: _deviceCoordinate != null,
                isFollowingLocation: _isFollowingDevice,
                isLocating: _isLocating,
                onLocate: _handleLocationAction,
              ),
            if (hasMap && _statusText != null)
              Positioned(
                left: 16,
                right: 84,
                bottom: 20,
                child: JunnyMapStatusCard(
                  message: _statusText!,
                  onOpenAppSettings: _canOpenAppSettings
                      ? _openAppSettings
                      : null,
                  onOpenLocationSettings: _canOpenLocationSettings
                      ? _openLocationSettings
                      : null,
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildMap(bool hasMap) {
    if (!widget.enableMap) {
      return const ColoredBox(color: Color(0xFFEFF4F3));
    }
    if (!hasMap) {
      return _MissingKeyView(
        isBusy: _isLocating,
        coordinate: _deviceCoordinate,
        statusMessage: _statusText,
        onLocate: _locateDevice,
        canOpenAppSettings: _canOpenAppSettings,
        canOpenLocationSettings: _canOpenLocationSettings,
        onOpenAppSettings: _openAppSettings,
        onOpenLocationSettings: _openLocationSettings,
      );
    }
    if (_baseMapSource == _BaseMapSource.openFreeMap) {
      return _buildOpenFreeMap();
    }
    if (_services != null) {
      return _buildTiandituMap();
    }
    return _buildOpenFreeMap();
  }

  Widget _buildOpenFreeMap() {
    return JunnyOpenFreeMap(
      initialCenter: _currentCenter,
      initialZoom: _currentZoom,
      minZoom: _minimumZoom,
      maxZoom: _maximumZoom,
      style: JunnyOpenFreeMapStyle.liberty,
      controller: _openFreeMapController,
      locationController: _locationController,
      markers: _buildOpenFreeMapMarkers(),
      onTap: _handleCoordinateTap,
      onCameraChanged: _handleOpenFreeMapCamera,
      onStyleLoaded: _handleOpenFreeMapStyleLoaded,
      onStyleLoadError: _handleOpenFreeMapStyleError,
    );
  }

  Widget _buildTiandituMap() {
    return JunnyTdtMap(
      services: _services!,
      initialCenter: _toLatLng(_currentCenter),
      initialZoom: _currentZoom,
      minZoom: _minimumZoom,
      maxZoom: _maximumZoom,
      controller: _tiandituMapController,
      locationController: _locationController,
      markers: _buildMarkers(),
      onTap: _handleMapTap,
      onMapEvent: _handleMapEvent,
      onTileError: _handleTiandituTileError,
    );
  }

  void _changeBaseMapSource(_BaseMapSource source) {
    if (_baseMapSource == source ||
        (source == _BaseMapSource.tianditu && _services == null)) {
      return;
    }
    setState(() {
      _baseMapSource = source;
      _tiandituTileErrorCount = 0;
      _clearStatus();
    });
  }

  void _handleMapEvent(MapEvent event) {
    final camera = event.camera;
    final Size cameraSize = camera.size;
    if (!camera.zoom.isFinite ||
        !camera.center.latitude.isFinite ||
        !camera.center.longitude.isFinite ||
        !cameraSize.width.isFinite ||
        !cameraSize.height.isFinite ||
        cameraSize.width <= 0 ||
        cameraSize.height <= 0) {
      return;
    }
    final bounds = camera.visibleBounds;
    if (!bounds.southWest.latitude.isFinite ||
        !bounds.southWest.longitude.isFinite ||
        !bounds.northEast.latitude.isFinite ||
        !bounds.northEast.longitude.isFinite) {
      return;
    }
    final int zoom = camera.zoom.round();
    _searchBounds = GeoBounds(
      southwest: GeoCoordinate(
        latitude: bounds.southWest.latitude,
        longitude: bounds.southWest.longitude,
        system: CoordinateSystem.gcj02,
      ),
      northeast: GeoCoordinate(
        latitude: bounds.northEast.latitude,
        longitude: bounds.northEast.longitude,
        system: CoordinateSystem.gcj02,
      ),
    );
    _searchLevel = zoom.clamp(1, 18).toInt();
    _currentCenter = _fromLatLng(camera.center);
    bool nextIsFollowingDevice = _isFollowingDevice;
    if (_deviceCoordinate != null && _isUserCameraInteraction(event.source)) {
      nextIsFollowingDevice = false;
    }
    if (mounted &&
        (_currentZoom != camera.zoom ||
            _currentRotation != camera.rotation ||
            _isFollowingDevice != nextIsFollowingDevice)) {
      setState(() {
        _currentZoom = camera.zoom;
        _currentRotation = camera.rotation;
        _isFollowingDevice = nextIsFollowingDevice;
      });
    }
  }

  void _handleOpenFreeMapCamera(JunnyOpenFreeMapCamera camera) {
    final GeoBounds? visibleBounds = camera.visibleBounds;
    if (visibleBounds != null) {
      _searchBounds = visibleBounds;
    }
    _searchLevel = camera.zoom.round().clamp(1, 18).toInt();
    _currentCenter = camera.center;
    bool nextIsFollowingDevice = _isFollowingDevice;
    if (_deviceCoordinate != null && camera.isUserInteraction) {
      nextIsFollowingDevice = false;
    }
    if (visibleBounds == null) {
      if (mounted && _isFollowingDevice != nextIsFollowingDevice) {
        setState(() {
          _isFollowingDevice = nextIsFollowingDevice;
        });
      }
      return;
    }
    if (mounted &&
        (_currentZoom != camera.zoom ||
            _currentRotation != camera.bearing ||
            _isFollowingDevice != nextIsFollowingDevice)) {
      setState(() {
        _currentZoom = camera.zoom;
        _currentRotation = camera.bearing;
        _isFollowingDevice = nextIsFollowingDevice;
      });
    }
  }

  void _handleOpenFreeMapStyleLoaded() {
    if (!mounted || _baseMapSource != _BaseMapSource.openFreeMap) {
      return;
    }
    if (_statusText == ExampleLocalizations.of(context).mapStyleLoadFailed) {
      setState(() {
        _clearStatus();
      });
    }
  }

  void _handleOpenFreeMapStyleError(Object _) {
    if (!mounted || _baseMapSource != _BaseMapSource.openFreeMap) {
      return;
    }
    setState(() {
      _replaceStatus(ExampleLocalizations.of(context).mapStyleLoadFailed);
    });
  }

  void _handleTiandituTileError(Object error) {
    if (!mounted || _baseMapSource != _BaseMapSource.tianditu) {
      return;
    }
    _tiandituTileErrorCount++;
    if (_tiandituTileErrorCount < _tiandituTileErrorThreshold) {
      return;
    }
    setState(() {
      _baseMapSource = _BaseMapSource.openFreeMap;
      _tiandituTileErrorCount = 0;
      _replaceStatus(ExampleLocalizations.of(context).tiandituFallback);
    });
  }

  bool _isUserCameraInteraction(MapEventSource source) {
    switch (source) {
      case MapEventSource.dragStart:
      case MapEventSource.onDrag:
      case MapEventSource.dragEnd:
      case MapEventSource.multiFingerGestureStart:
      case MapEventSource.onMultiFinger:
      case MapEventSource.multiFingerEnd:
      case MapEventSource.flingAnimationController:
      case MapEventSource.doubleTapZoomAnimationController:
      case MapEventSource.scrollWheel:
      case MapEventSource.cursorKeyboardRotation:
      case MapEventSource.keyboard:
      case MapEventSource.custom:
        return true;
      case MapEventSource.mapController:
      case MapEventSource.tap:
      case MapEventSource.secondaryTap:
      case MapEventSource.longPress:
      case MapEventSource.doubleTap:
      case MapEventSource.doubleTapHold:
      case MapEventSource.interactiveFlagsChanged:
      case MapEventSource.fitCamera:
      case MapEventSource.nonRotatedSizeChange:
        return false;
    }
  }

  List<Marker> _buildMarkers() {
    final JunnyMapLocalizations mapLocalizations = JunnyMapLocalizations.of(
      context,
    );
    final List<Marker> markers = <Marker>[];
    for (final TdtPoi poi in _pois) {
      final GeoCoordinate? coordinate = poi.coordinate;
      if (coordinate == null) {
        continue;
      }
      final String name = mapLocalizations.poiName(poi.name);
      markers.add(
        Marker(
          point: _toLatLng(coordinate),
          width: 42,
          height: 42,
          child: Tooltip(
            message: name,
            child: const Icon(Icons.location_on, color: Colors.red, size: 38),
          ),
        ),
      );
    }

    final GeoCoordinate? selectedCoordinate = _selectedCoordinate;
    if (selectedCoordinate != null) {
      markers.add(
        Marker(
          point: _toLatLng(selectedCoordinate),
          width: 42,
          height: 42,
          child: Tooltip(
            message: mapLocalizations.selectedLocation,
            child: Icon(Icons.location_on, color: Color(0xFF2563EB), size: 38),
          ),
        ),
      );
    }
    return markers;
  }

  List<JunnyOpenFreeMapMarker> _buildOpenFreeMapMarkers() {
    final JunnyMapLocalizations mapLocalizations = JunnyMapLocalizations.of(
      context,
    );
    final List<JunnyOpenFreeMapMarker> markers = <JunnyOpenFreeMapMarker>[];
    for (final TdtPoi poi in _pois) {
      final GeoCoordinate? coordinate = poi.coordinate;
      if (coordinate == null) {
        continue;
      }
      final String name = mapLocalizations.poiName(poi.name);
      markers.add(
        JunnyOpenFreeMapMarker(
          coordinate: coordinate,
          size: const Size(42, 42),
          alignment: Alignment.bottomCenter,
          child: Tooltip(
            message: name,
            child: const Icon(Icons.location_on, color: Colors.red, size: 38),
          ),
        ),
      );
    }

    final GeoCoordinate? selectedCoordinate = _selectedCoordinate;
    if (selectedCoordinate != null) {
      markers.add(
        JunnyOpenFreeMapMarker(
          coordinate: selectedCoordinate,
          size: const Size(42, 42),
          alignment: Alignment.bottomCenter,
          child: Tooltip(
            message: mapLocalizations.selectedLocation,
            child: Icon(Icons.location_on, color: Color(0xFF2563EB), size: 38),
          ),
        ),
      );
    }
    return markers;
  }

  bool _hasClearableState(String searchText) {
    return searchText.trim().isNotEmpty ||
        _pois.isNotEmpty ||
        _selectedCoordinate != null ||
        _statusText != null ||
        _locationErrorCode != null ||
        _isSearching ||
        _isReverseGeocoding ||
        _isLocating;
  }

  int _beginOperationStatus(String message) {
    final int statusToken = ++_statusToken;
    _setStatusValue(message);
    return statusToken;
  }

  void _finishOperationStatus(
    int statusToken,
    String message, {
    _StatusLifetime lifetime = _StatusLifetime.persistent,
  }) {
    if (statusToken != _statusToken) {
      return;
    }
    _setStatusValue(message, lifetime: lifetime);
  }

  void _replaceStatus(
    String? message, {
    _StatusLifetime lifetime = _StatusLifetime.persistent,
  }) {
    _statusToken++;
    _setStatusValue(message, lifetime: lifetime);
  }

  void _clearStatus() {
    _replaceStatus(null);
  }

  void _setStatusValue(
    String? message, {
    _StatusLifetime lifetime = _StatusLifetime.persistent,
  }) {
    _statusTimer?.cancel();
    _statusTimer = null;
    _statusText = message;
    final int statusVersion = ++_statusVersion;
    if (message == null || lifetime != _StatusLifetime.transient) {
      return;
    }
    _statusTimer = Timer(_transientStatusDuration, () {
      if (!mounted || statusVersion != _statusVersion) {
        return;
      }
      setState(() {
        if (statusVersion != _statusVersion) {
          return;
        }
        _statusText = null;
        _statusTimer = null;
        _statusToken++;
        _statusVersion++;
      });
    });
  }

  Future<void> _searchPlaces(String keyword) async {
    final TdtSearchClient? client = _searchClient;
    final String query = keyword.trim();
    final int requestId = ++_searchRequestId;
    if (client == null || query.isEmpty) {
      if (!mounted) {
        return;
      }
      setState(() {
        _isSearching = false;
        _replaceStatus(
          ExampleLocalizations.of(context).noKeyword,
          lifetime: _StatusLifetime.transient,
        );
      });
      return;
    }

    _interactionRevision++;
    late final int statusToken;
    setState(() {
      statusToken = _beginOperationStatus(
        ExampleLocalizations.of(context).searching(query),
      );
      _isSearching = true;
      _pois = const <TdtPoi>[];
      _selectedCoordinate = null;
      _locationErrorCode = null;
    });
    try {
      final TdtSearchResult result = await client.searchByText(
        keyword: query,
        bounds: _searchBounds ?? _defaultSearchBounds,
        level: _searchLevel,
        count: 20,
      );
      if (!mounted || requestId != _searchRequestId) {
        return;
      }
      setState(() {
        _pois = result.pois;
        _finishOperationStatus(
          statusToken,
          result.pois.isEmpty
              ? ExampleLocalizations.of(context).noSearchResult
              : ExampleLocalizations.of(
                  context,
                ).searchResultSummary(result.pois.length),
          lifetime: _StatusLifetime.transient,
        );
      });
    } catch (error) {
      if (!mounted || requestId != _searchRequestId) {
        return;
      }
      setState(() {
        _finishOperationStatus(statusToken, _errorMessage(error));
      });
    } finally {
      if (mounted && requestId == _searchRequestId) {
        setState(() {
          _isSearching = false;
        });
      }
    }
  }

  GeoBounds get _defaultSearchBounds {
    return const GeoBounds(
      southwest: GeoCoordinate(
        latitude: 30.30,
        longitude: 114.00,
        system: CoordinateSystem.gcj02,
      ),
      northeast: GeoCoordinate(
        latitude: 30.90,
        longitude: 114.60,
        system: CoordinateSystem.gcj02,
      ),
    );
  }

  Future<void> _handleMapTap(TapPosition _, LatLng point) async {
    await _handleCoordinateTap(_fromLatLng(point));
  }

  Future<void> _handleCoordinateTap(GeoCoordinate coordinate) async {
    final TdtGeocodingClient? client = _geocodingClient;
    if (client == null) {
      return;
    }
    final int requestId = ++_reverseGeocodeRequestId;
    _interactionRevision++;
    late final int statusToken;
    setState(() {
      statusToken = _beginOperationStatus(
        ExampleLocalizations.of(context).mapPositionQuery,
      );
      _isReverseGeocoding = true;
      _selectedCoordinate = coordinate;
      _locationErrorCode = null;
    });
    try {
      final TdtReverseGeocodeResult result = await client.reverseGeocode(
        coordinate,
      );
      if (!mounted || requestId != _reverseGeocodeRequestId) {
        return;
      }
      setState(() {
        _finishOperationStatus(
          statusToken,
          _addressText(result),
          lifetime: _StatusLifetime.transient,
        );
      });
    } catch (error) {
      if (!mounted || requestId != _reverseGeocodeRequestId) {
        return;
      }
      setState(() {
        _finishOperationStatus(statusToken, _errorMessage(error));
      });
    } finally {
      if (mounted && requestId == _reverseGeocodeRequestId) {
        setState(() {
          _isReverseGeocoding = false;
        });
      }
    }
  }

  Future<void> _locateDevice() async {
    if (_locationController.isLocating) {
      return;
    }
    final int interactionRevision = ++_interactionRevision;
    late final int statusToken;
    setState(() {
      statusToken = _beginOperationStatus(
        ExampleLocalizations.of(context).locationRequested,
      );
      _isLocating = true;
      _locationErrorCode = null;
    });
    final LocationSnapshot? snapshot = await _locationController.locate(
      requestPermission: true,
      follow: interactionRevision == _interactionRevision,
    );
    if (!mounted) {
      return;
    }
    final LocationProviderException? error = _locationController.error;
    if (snapshot == null) {
      setState(() {
        _isLocating = false;
        _locationErrorCode = error?.code;
        _finishOperationStatus(
          statusToken,
          _errorMessage(error ?? StateError('定位失败')),
        );
      });
      return;
    }
    setState(() {
      _deviceCoordinate = snapshot.coordinate;
      _isLocating = false;
      _locationErrorCode = null;
      _isFollowingDevice = _locationController.isFollowing;
      _selectedCoordinate = null;
      _finishOperationStatus(
        statusToken,
        ExampleLocalizations.of(context).currentLocation(
          _formatCoordinate(snapshot.coordinate),
          snapshot.accuracyMeters.toStringAsFixed(1),
          snapshot.isMocked,
        ),
        lifetime: _StatusLifetime.transient,
      );
    });
  }

  void _handleLibraryLocationChanged() {
    if (!mounted) {
      return;
    }
    final LocationSnapshot? snapshot = _locationController.latestLocation;
    final LocationProviderException? error = _locationController.error;
    if (snapshot == null && error == null) {
      return;
    }
    setState(() {
      if (snapshot != null) {
        _deviceCoordinate = snapshot.coordinate;
      }
      _isLocating = _locationController.isLocating;
      _isFollowingDevice = _locationController.isFollowing;
      _locationErrorCode = error?.code;
      if (error != null) {
        _replaceStatus(_errorMessage(error));
      }
    });
  }

  void _handleLocationAction() {
    _locateDevice();
  }

  void _selectPoi(TdtPoi poi) {
    final GeoCoordinate? coordinate = poi.coordinate;
    if (coordinate == null) {
      setState(() {
        _replaceStatus(ExampleLocalizations.of(context).noPoiCoordinate);
      });
      return;
    }
    _searchRequestId++;
    _interactionRevision++;
    setState(() {
      _isSearching = false;
      _isFollowingDevice = false;
      _selectedCoordinate = coordinate;
      _pois = const <TdtPoi>[];
      _replaceStatus(
        ExampleLocalizations.of(context).poiStatus(
          ExampleLocalizations.of(context).poiName(poi.name),
          poi.address,
        ),
        lifetime: _StatusLifetime.transient,
      );
    });
    _moveActiveMap(coordinate, 16, animate: true);
  }

  void _zoomIn() {
    _changeZoom(1);
  }

  void _zoomOut() {
    _changeZoom(-1);
  }

  void _changeZoom(double delta) {
    final double targetZoom = (_currentZoom + delta)
        .clamp(_minimumZoom, _maximumZoom)
        .toDouble();
    _moveActiveMap(_currentCenter, targetZoom);
  }

  void _resetNorth() {
    if (_baseMapSource == _BaseMapSource.openFreeMap) {
      if (_openFreeMapController.isReady) {
        unawaited(_openFreeMapController.animate(bearing: 0));
      }
      return;
    }
    _tiandituMapController.rotate(0, id: 'reset-north');
  }

  void _moveActiveMap(
    GeoCoordinate center,
    double zoom, {
    bool animate = false,
  }) {
    if (_baseMapSource == _BaseMapSource.openFreeMap) {
      if (!_openFreeMapController.isReady) {
        return;
      }
      final Future<void> operation = animate
          ? _openFreeMapController.animate(center: center, zoom: zoom)
          : _openFreeMapController.move(center: center, zoom: zoom);
      unawaited(operation);
      return;
    }
    _tiandituMapController.move(_toLatLng(center), zoom);
  }

  void _clearResults() {
    _searchRequestId++;
    _reverseGeocodeRequestId++;
    _interactionRevision++;
    _searchController.clear();
    setState(() {
      _isSearching = false;
      _isReverseGeocoding = false;
      _pois = const <TdtPoi>[];
      _selectedCoordinate = null;
      _locationErrorCode = null;
      _clearStatus();
    });
  }

  bool get _canOpenAppSettings {
    return _locationErrorCode == LocationProviderErrorCode.permissionDenied ||
        _locationErrorCode ==
            LocationProviderErrorCode.permissionPermanentlyDenied;
  }

  bool get _canOpenLocationSettings {
    return _locationErrorCode == LocationProviderErrorCode.serviceDisabled ||
        _locationErrorCode ==
            LocationProviderErrorCode.networkLocationUnavailable ||
        _locationErrorCode == LocationProviderErrorCode.timeout ||
        _locationErrorCode == LocationProviderErrorCode.positionUnavailable;
  }

  Future<void> _openAppSettings() async {
    final bool opened = await _locationProvider.openAppSettings();
    if (!mounted) {
      return;
    }
    setState(() {
      _replaceStatus(
        opened
            ? ExampleLocalizations.of(context).appSettingsOpened
            : ExampleLocalizations.of(context).appSettingsUnavailable,
      );
    });
  }

  Future<void> _openLocationSettings() async {
    final bool opened = await _locationProvider.openLocationSettings();
    if (!mounted) {
      return;
    }
    setState(() {
      _replaceStatus(
        opened
            ? ExampleLocalizations.of(context).locationSettingsOpened
            : ExampleLocalizations.of(context).locationSettingsUnavailable,
      );
    });
  }

  String _addressText(TdtReverseGeocodeResult result) {
    final String? formattedAddress = result.formattedAddress;
    if (formattedAddress != null && formattedAddress.isNotEmpty) {
      return formattedAddress;
    }
    final TdtAddressComponent? component = result.component;
    final List<String> parts = <String>[
      if (component?.province != null) component!.province!,
      if (component?.city != null) component!.city!,
      if (component?.district != null) component!.district!,
      if (component?.road != null) component!.road!,
      if (component?.roadNumber != null) component!.roadNumber!,
      if (component?.poi != null) component!.poi!,
    ];
    return parts.isEmpty
        ? ExampleLocalizations.of(context).noAddressFromTianditu
        : parts.join(' ');
  }

  String _errorMessage(Object error) {
    if (error is LocationProviderException) {
      switch (error.code) {
        case LocationProviderErrorCode.serviceDisabled:
          return ExampleLocalizations.of(context).locationServiceDisabled;
        case LocationProviderErrorCode.permissionDenied:
          return ExampleLocalizations.of(context).permissionDenied;
        case LocationProviderErrorCode.permissionPermanentlyDenied:
          return ExampleLocalizations.of(context).permissionPermanentlyDenied;
        case LocationProviderErrorCode.positionUnavailable:
          return ExampleLocalizations.of(context).positionUnavailable;
        case LocationProviderErrorCode.networkLocationUnavailable:
          return ExampleLocalizations.of(context).networkUnavailable;
        case LocationProviderErrorCode.timeout:
          return ExampleLocalizations.of(context).locationTimeout;
        case LocationProviderErrorCode.mockedLocation:
          return ExampleLocalizations.of(context).mockedLocationRejected;
        case LocationProviderErrorCode.insufficientAccuracy:
          return ExampleLocalizations.of(context).insufficientAccuracy;
        case LocationProviderErrorCode.unknown:
          return ExampleLocalizations.of(
            context,
          ).locationUnknown(error.message);
      }
    }
    if (error is TdtApiException) {
      final Object? responseBody = error.responseBody;
      if (responseBody is Map) {
        final Object? rawStatus = responseBody['status'];
        if (rawStatus is Map) {
          final Object? description = rawStatus['cndesc'];
          if (description != null && description.toString().trim().isNotEmpty) {
            return ExampleLocalizations.of(
              context,
            ).tdtRequestFailed(description.toString());
          }
        }
      }
      return ExampleLocalizations.of(context).tdtRequestFailed(error.message);
    }
    if (error is JunnyMapException) {
      return error.message;
    }
    return ExampleLocalizations.of(context).requestFailed(error);
  }
}

class _MapControls extends StatelessWidget {
  const _MapControls({
    required this.selectedSource,
    required this.tiandituAvailable,
    required this.onSourceChanged,
  });

  final _BaseMapSource selectedSource;
  final bool tiandituAvailable;
  final ValueChanged<_BaseMapSource> onSourceChanged;

  @override
  Widget build(BuildContext context) {
    final Color surfaceColor = Theme.of(context).colorScheme.surface;
    return Material(
      elevation: 3,
      borderRadius: BorderRadius.circular(8),
      color: surfaceColor,
      clipBehavior: Clip.antiAlias,
      child: SizedBox(
        width: 48,
        height: 48,
        child: PopupMenuButton<_BaseMapSource>(
          tooltip: ExampleLocalizations.of(context).switchMap,
          padding: EdgeInsets.zero,
          icon: const Icon(Icons.layers_outlined, size: 22),
          onSelected: onSourceChanged,
          itemBuilder: (BuildContext context) {
            return <PopupMenuEntry<_BaseMapSource>>[
              PopupMenuItem<_BaseMapSource>(
                value: _BaseMapSource.tianditu,
                enabled: tiandituAvailable,
                child: _MapSourceMenuItem(
                  icon: Icons.map_outlined,
                  label: ExampleLocalizations.of(context).tianditu,
                  selected: selectedSource == _BaseMapSource.tianditu,
                ),
              ),
              PopupMenuItem<_BaseMapSource>(
                value: _BaseMapSource.openFreeMap,
                child: _MapSourceMenuItem(
                  icon: Icons.public,
                  label: 'OpenFreeMap',
                  selected: selectedSource == _BaseMapSource.openFreeMap,
                ),
              ),
            ];
          },
        ),
      ),
    );
  }
}

class _MapSourceMenuItem extends StatelessWidget {
  const _MapSourceMenuItem({
    required this.icon,
    required this.label,
    required this.selected,
  });

  final IconData icon;
  final String label;
  final bool selected;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Icon(icon, size: 20),
        const SizedBox(width: 12),
        Expanded(child: Text(label)),
        if (selected) const Icon(Icons.check, size: 18),
      ],
    );
  }
}

class _MissingKeyView extends StatelessWidget {
  const _MissingKeyView({
    required this.isBusy,
    required this.coordinate,
    required this.statusMessage,
    required this.onLocate,
    required this.canOpenAppSettings,
    required this.canOpenLocationSettings,
    required this.onOpenAppSettings,
    required this.onOpenLocationSettings,
  });

  final bool isBusy;
  final GeoCoordinate? coordinate;
  final String? statusMessage;
  final VoidCallback onLocate;
  final bool canOpenAppSettings;
  final bool canOpenLocationSettings;
  final VoidCallback onOpenAppSettings;
  final VoidCallback onOpenLocationSettings;

  @override
  Widget build(BuildContext context) {
    final ExampleLocalizations localizations = ExampleLocalizations.of(context);
    return ColoredBox(
      color: const Color(0xFFEFF4F3),
      child: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              const Icon(Icons.map_outlined, size: 64, color: Colors.teal),
              const SizedBox(height: 20),
              Text(
                localizations.missingKey,
                style: Theme.of(context).textTheme.headlineSmall,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 12),
              Text(
                localizations.mapServicesUnavailable,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 8),
              Text(
                localizations.locationDoesNotNeedKey,
                textAlign: TextAlign.center,
              ),
              if (coordinate != null) ...<Widget>[
                const SizedBox(height: 20),
                SelectableText(_formatCoordinate(coordinate!)),
              ],
              if (statusMessage != null) ...<Widget>[
                const SizedBox(height: 12),
                JunnyMapStatusCard(
                  message: statusMessage!,
                  onOpenAppSettings: canOpenAppSettings
                      ? onOpenAppSettings
                      : null,
                  onOpenLocationSettings: canOpenLocationSettings
                      ? onOpenLocationSettings
                      : null,
                ),
              ],
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: isBusy ? null : onLocate,
                icon: const Icon(Icons.my_location),
                label: Text(
                  isBusy ? localizations.locating : localizations.testLocation,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

LatLng _toLatLng(GeoCoordinate coordinate) {
  return LatLng(coordinate.latitude, coordinate.longitude);
}

GeoCoordinate _fromLatLng(LatLng coordinate) {
  return GeoCoordinate(
    latitude: coordinate.latitude,
    longitude: coordinate.longitude,
    system: CoordinateSystem.gcj02,
  );
}

String _formatCoordinate(GeoCoordinate coordinate) {
  return '${coordinate.latitude.toStringAsFixed(6)}, '
      '${coordinate.longitude.toStringAsFixed(6)} (${coordinate.system.label})';
}
0
likes
150
points
115
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Shared Tianditu map, geocoding, POI search, and geolocator services for Flutter applications.

Repository (GitHub)

License

MIT (license)

Dependencies

dio, flutter, flutter_map, geolocator, http, latlong2, maplibre

More

Packages that depend on junny_map

Packages that implement junny_map