junny_map 0.2.0
junny_map: ^0.2.0 copied to clipboard
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})';
}