From 225d07b440ca6c04045216186a934d35f8e6cda7 Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 23 May 2026 11:41:06 +0200 Subject: [PATCH] add stadiamaps.com --- lib/main.dart | 6 +- lib/models/app_settings.dart | 21 ++ lib/screens/app_settings_screen.dart | 217 +++++++++++++++++ lib/screens/channel_message_path_screen.dart | 4 +- lib/screens/line_of_sight_map_screen.dart | 4 +- lib/screens/map_cache_screen.dart | 30 ++- lib/screens/map_screen.dart | 4 +- lib/screens/path_trace_map.dart | 4 +- lib/services/app_settings_service.dart | 19 ++ lib/services/map_tile_cache_service.dart | 230 +++++++++++++++++-- 10 files changed, 507 insertions(+), 32 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index cd622811..cb07e04c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,7 +40,9 @@ void main() async { final bleDebugLogService = BleDebugLogService(); final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); - final mapTileCacheService = MapTileCacheService(); + final mapTileCacheService = MapTileCacheService( + appSettingsService: appSettingsService, + ); final chatTextScaleService = ChatTextScaleService(); final translationService = TranslationService(appSettingsService); final uiViewStateService = UiViewStateService(); @@ -172,7 +174,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: translationService), ChangeNotifierProvider.value(value: uiViewStateService), Provider.value(value: storage), - Provider.value(value: mapTileCacheService), + ChangeNotifierProvider.value(value: mapTileCacheService), ChangeNotifierProvider.value(value: timeoutPredictionService), ], child: Consumer( diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 4e95311f..0c4cfe1e 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -91,6 +91,9 @@ class AppSettings { final Map? mapCacheBounds; final int mapCacheMinZoom; final int mapCacheMaxZoom; + final String mapRasterSourceId; + final String mapTileEndpointId; + final String? mapTileApiKey; final bool notificationsEnabled; final bool notifyOnNewMessage; final bool notifyOnNewChannelMessage; @@ -144,6 +147,9 @@ class AppSettings { this.mapCacheBounds, this.mapCacheMinZoom = 10, this.mapCacheMaxZoom = 15, + this.mapRasterSourceId = 'osm_standard', + this.mapTileEndpointId = 'standard', + this.mapTileApiKey, this.notificationsEnabled = true, this.notifyOnNewMessage = true, this.notifyOnNewChannelMessage = true, @@ -204,6 +210,9 @@ class AppSettings { 'map_cache_bounds': mapCacheBounds, 'map_cache_min_zoom': mapCacheMinZoom, 'map_cache_max_zoom': mapCacheMaxZoom, + 'map_raster_source_id': mapRasterSourceId, + 'map_tile_endpoint_id': mapTileEndpointId, + 'map_tile_api_key': mapTileApiKey, 'notifications_enabled': notificationsEnabled, 'notify_on_new_message': notifyOnNewMessage, 'notify_on_new_channel_message': notifyOnNewChannelMessage, @@ -267,6 +276,10 @@ class AppSettings { ), mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10, mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15, + mapRasterSourceId: + json['map_raster_source_id'] as String? ?? 'osm_standard', + mapTileEndpointId: json['map_tile_endpoint_id'] as String? ?? 'standard', + mapTileApiKey: json['map_tile_api_key'] as String?, notificationsEnabled: json['notifications_enabled'] as bool? ?? true, notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true, notifyOnNewChannelMessage: @@ -374,6 +387,9 @@ class AppSettings { Object? mapCacheBounds = _unset, int? mapCacheMinZoom, int? mapCacheMaxZoom, + String? mapRasterSourceId, + String? mapTileEndpointId, + Object? mapTileApiKey = _unset, bool? notificationsEnabled, bool? notifyOnNewMessage, bool? notifyOnNewChannelMessage, @@ -422,6 +438,11 @@ class AppSettings { : mapCacheBounds as Map?, mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom, mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom, + mapRasterSourceId: mapRasterSourceId ?? this.mapRasterSourceId, + mapTileEndpointId: mapTileEndpointId ?? this.mapTileEndpointId, + mapTileApiKey: mapTileApiKey == _unset + ? this.mapTileApiKey + : mapTileApiKey as String?, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage, notifyOnNewChannelMessage: diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 94b3efe8..cf81be02 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -8,6 +8,7 @@ import '../l10n/l10n.dart'; import '../models/app_settings.dart'; import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; +import '../services/map_tile_cache_service.dart'; import '../services/notification_service.dart'; import '../services/translation_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; @@ -529,6 +530,39 @@ class AppSettingsScreen extends StatelessWidget { onTap: () => _showUnitsDialog(context, settingsService), ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.layers_outlined), + title: const Text('Raster Tile Source'), + subtitle: Text( + _mapRasterSourceSummary(settingsService.settings), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMapRasterSourceDialog(context, settingsService), + ), + if (_isStadiaSource(settingsService.settings)) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.public_outlined), + title: const Text('Stadia Endpoint'), + subtitle: Text( + _mapRasterEndpointSummary(settingsService.settings), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => + _showMapRasterEndpointDialog(context, settingsService), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.key_outlined), + title: const Text('Stadia API Key'), + subtitle: Text(_mapApiKeySummary(settingsService.settings)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMapApiKeyDialog(context, settingsService), + ), + ], + const Divider(height: 1), ListTile( leading: const Icon(Icons.download_outlined), title: Text(context.l10n.appSettings_offlineMapCache), @@ -553,6 +587,189 @@ class AppSettingsScreen extends StatelessWidget { ); } + String _mapRasterSourceSummary(AppSettings settings) { + final source = MapRasterSourceCatalog.fromSettings(settings); + return '${source.label} - ${source.description}'; + } + + bool _isStadiaSource(AppSettings settings) { + return MapRasterSourceCatalog.fromSettings(settings).isStadia; + } + + String _mapRasterEndpointSummary(AppSettings settings) { + final endpoint = MapRasterEndpointCatalog.fromSettings(settings); + return '${endpoint.label} - ${endpoint.description}'; + } + + String _mapApiKeySummary(AppSettings settings) { + final apiKey = settings.mapTileApiKey?.trim(); + if (apiKey == null || apiKey.isEmpty) { + return 'Required for mobile and non-local Stadia Maps usage'; + } + return 'Configured: ${_maskApiKey(apiKey)}'; + } + + String _maskApiKey(String value) { + if (value.length <= 8) return '********'; + return '${value.substring(0, 4)}...${value.substring(value.length - 4)}'; + } + + void _showMapRasterSourceDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + String selectedId = settingsService.settings.mapRasterSourceId; + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setState) => AlertDialog( + title: const Text('Raster Tile Source'), + content: SizedBox( + width: 360, + child: RadioGroup( + groupValue: selectedId, + onChanged: (value) { + if (value == null) return; + setState(() { + selectedId = value; + }); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in MapRasterSourceCatalog.presets) + RadioListTile( + value: option.id, + title: Text(option.label), + subtitle: Text(option.description), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () async { + await settingsService.setMapRasterSourceId(selectedId); + if (!dialogContext.mounted) return; + Navigator.pop(dialogContext); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ), + ); + } + + void _showMapRasterEndpointDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + String selectedId = settingsService.settings.mapTileEndpointId; + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setState) => AlertDialog( + title: const Text('Stadia Endpoint'), + content: SizedBox( + width: 360, + child: RadioGroup( + groupValue: selectedId, + onChanged: (value) { + if (value == null) return; + setState(() { + selectedId = value; + }); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final option in MapRasterEndpointCatalog.presets) + RadioListTile( + value: option.id, + title: Text(option.label), + subtitle: Text(option.description), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () async { + await settingsService.setMapTileEndpointId(selectedId); + if (!dialogContext.mounted) return; + Navigator.pop(dialogContext); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ), + ); + } + + void _showMapApiKeyDialog( + BuildContext context, + AppSettingsService settingsService, + ) { + final controller = TextEditingController( + text: settingsService.settings.mapTileApiKey ?? '', + ); + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Stadia API Key'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter your Stadia Maps API key. This app uses it for raster tile requests on mobile and other non-local environments.', + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'sk_...', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.common_cancel), + ), + TextButton( + onPressed: () async { + await settingsService.setMapTileApiKey(controller.text); + if (!dialogContext.mounted) return; + Navigator.pop(dialogContext); + }, + child: Text(context.l10n.common_save), + ), + ], + ), + ); + } + Widget _buildTranslationCard( BuildContext context, AppSettingsService settingsService, diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index afb2b906..a05c4e15 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -441,7 +441,7 @@ class _ChannelMessagePathMapScreenState builder: (context, connector, _) { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; - final tileCache = context.read(); + final tileCache = context.watch(); final primaryPath = _selectPrimaryPath( widget.message.pathBytes, widget.message.pathVariants, @@ -559,7 +559,7 @@ class _ChannelMessagePathMapScreenState ), children: [ TileLayer( - urlTemplate: kMapTileUrlTemplate, + urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index f908f5ea..f1b18e9d 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -391,7 +391,7 @@ class _LineOfSightMapScreenState extends State { Widget build(BuildContext context) { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; - final tileCache = context.read(); + final tileCache = context.watch(); final endpoints = _visibleEndpoints(); final mapPoints = [ if (_start != null) _start!.point, @@ -470,7 +470,7 @@ class _LineOfSightMapScreenState extends State { ), children: [ TileLayer( - urlTemplate: kMapTileUrlTemplate, + urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index 4057e0ec..dda17132 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -171,6 +171,7 @@ class _MapCacheScreenState extends State { Future _startDownload() async { final bounds = _selectedBounds; + final cacheService = context.read(); if (bounds == null) { showDismissibleSnackBar( context, @@ -187,6 +188,16 @@ class _MapCacheScreenState extends State { return; } + if (!cacheService.source.allowsBulkDownload) { + showDismissibleSnackBar( + context, + content: Text( + 'Offline bulk downloads are disabled for ${cacheService.source.label} in this app configuration.', + ), + ); + return; + } + final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -209,8 +220,6 @@ class _MapCacheScreenState extends State { if (confirmed != true || !mounted) return; - final cacheService = context.read(); - setState(() { _isDownloading = true; _completedTiles = 0; @@ -278,7 +287,8 @@ class _MapCacheScreenState extends State { @override Widget build(BuildContext context) { - final tileCache = context.read(); + final tileCache = context.watch(); + final source = tileCache.source; final selectedBounds = _selectedBounds; final l10n = context.l10n; final isDesktop = _isDesktopPlatform(defaultTargetPlatform); @@ -319,7 +329,7 @@ class _MapCacheScreenState extends State { ), children: [ TileLayer( - urlTemplate: kMapTileUrlTemplate, + urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, @@ -423,6 +433,13 @@ class _MapCacheScreenState extends State { }, ), Text(l10n.mapCache_estimatedTiles(_estimatedTiles)), + if (!source.allowsBulkDownload) ...[ + const SizedBox(height: 8), + Text( + 'Offline bulk downloads are disabled for ${source.label}.', + style: TextStyle(color: Colors.orange[800]), + ), + ], if (_isDownloading) ...[ const SizedBox(height: 8), LinearProgressIndicator(value: progressValue), @@ -441,7 +458,10 @@ class _MapCacheScreenState extends State { child: ElevatedButton.icon( icon: const Icon(Icons.download), label: Text(l10n.mapCache_downloadTilesButton), - onPressed: _isDownloading || selectedBounds == null + onPressed: + _isDownloading || + selectedBounds == null || + !source.allowsBulkDownload ? null : _startDownload, ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index be133240..887a8e0e 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -205,7 +205,7 @@ class _MapScreenState extends State { Widget build(BuildContext context) { return Consumer3( builder: (context, connector, settingsService, pathHistory, child) { - final tileCache = context.read(); + final tileCache = context.watch(); final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final settings = settingsService.settings; final allContacts = connector.allContacts; @@ -563,7 +563,7 @@ class _MapScreenState extends State { ), children: [ TileLayer( - urlTemplate: kMapTileUrlTemplate, + urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c810570d..8d4d469e 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -535,7 +535,7 @@ class _PathTraceMapScreenState extends State { builder: (context, connector, _) { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; - final tileCache = context.read(); + final tileCache = context.watch(); return Scaffold( appBar: AppBar( @@ -909,7 +909,7 @@ class _PathTraceMapScreenState extends State { ), children: [ TileLayer( - urlTemplate: kMapTileUrlTemplate, + urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 7b3d5848..517d22ee 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -112,6 +112,25 @@ class AppSettingsService extends ChangeNotifier { ); } + Future setMapRasterSourceId(String value) async { + await updateSettings(_settings.copyWith(mapRasterSourceId: value)); + } + + Future setMapTileEndpointId(String value) async { + await updateSettings(_settings.copyWith(mapTileEndpointId: value)); + } + + Future setMapTileApiKey(String? value) async { + final normalized = value?.trim(); + await updateSettings( + _settings.copyWith( + mapTileApiKey: (normalized == null || normalized.isEmpty) + ? null + : normalized, + ), + ); + } + Future setNotificationsEnabled(bool value) async { await updateSettings(_settings.copyWith(notificationsEnabled: value)); } diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index e0d4e573..5cbdac5c 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -2,11 +2,169 @@ import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_map/flutter_map.dart'; -const String kMapTileUrlTemplate = - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +import '../models/app_settings.dart'; +import 'app_settings_service.dart'; + +enum MapRasterSourcePreset { + osmStandard('osm_standard'), + stamenTerrain('stamen_terrain'), + alidadeSmoothDark('alidade_smooth_dark'), + outdoors('outdoors'), + osmBright('osm_bright'); + + const MapRasterSourcePreset(this.id); + + final String id; + + static MapRasterSourcePreset fromId(String id) { + for (final value in values) { + if (value.id == id) return value; + } + return MapRasterSourcePreset.osmStandard; + } +} + +enum MapRasterEndpointPreset { + standard('standard'), + eu('eu'); + + const MapRasterEndpointPreset(this.id); + + final String id; + + static MapRasterEndpointPreset fromId(String id) { + for (final value in values) { + if (value.id == id) return value; + } + return MapRasterEndpointPreset.standard; + } +} + +@immutable +class MapRasterSourceDefinition { + const MapRasterSourceDefinition({ + required this.id, + required this.label, + required this.description, + this.isStadia = false, + this.allowsBulkDownload = false, + }); + + final String id; + final String label; + final String description; + final bool isStadia; + final bool allowsBulkDownload; +} + +@immutable +class MapRasterEndpointDefinition { + const MapRasterEndpointDefinition({ + required this.id, + required this.label, + required this.description, + required this.host, + }); + + final String id; + final String label; + final String description; + final String host; +} + +class MapRasterSourceCatalog { + static const MapRasterSourceDefinition osmStandard = + MapRasterSourceDefinition( + id: 'osm_standard', + label: 'OpenStreetMap Standard', + description: 'Direct tiles from tile.openstreetmap.org', + ); + static const MapRasterSourceDefinition stamenTerrain = + MapRasterSourceDefinition( + id: 'stamen_terrain', + label: 'Stamen Terrain', + description: 'Terrain-focused style with hill shading', + isStadia: true, + allowsBulkDownload: true, + ); + static const MapRasterSourceDefinition alidadeSmoothDark = + MapRasterSourceDefinition( + id: 'alidade_smooth_dark', + label: 'Alidade Smooth Dark', + description: 'Dark basemap with smooth contrast', + isStadia: true, + allowsBulkDownload: true, + ); + static const MapRasterSourceDefinition outdoors = MapRasterSourceDefinition( + id: 'outdoors', + label: 'Outdoors', + description: 'Outdoor-focused map with trails and terrain context', + isStadia: true, + allowsBulkDownload: true, + ); + static const MapRasterSourceDefinition osmBright = MapRasterSourceDefinition( + id: 'osm_bright', + label: 'OSM Bright', + description: 'Bright general-purpose OpenStreetMap style', + isStadia: true, + allowsBulkDownload: true, + ); + + static const List presets = [ + osmStandard, + stamenTerrain, + alidadeSmoothDark, + outdoors, + osmBright, + ]; + + static MapRasterSourceDefinition fromSettings(AppSettings settings) { + final preset = MapRasterSourcePreset.fromId(settings.mapRasterSourceId); + switch (preset) { + case MapRasterSourcePreset.osmStandard: + return osmStandard; + case MapRasterSourcePreset.alidadeSmoothDark: + return alidadeSmoothDark; + case MapRasterSourcePreset.outdoors: + return outdoors; + case MapRasterSourcePreset.osmBright: + return osmBright; + case MapRasterSourcePreset.stamenTerrain: + return stamenTerrain; + } + } +} + +class MapRasterEndpointCatalog { + static const MapRasterEndpointDefinition standard = + MapRasterEndpointDefinition( + id: 'standard', + label: 'Standard Endpoint', + description: 'Global CDN routing to the fastest Stadia server', + host: 'tiles.stadiamaps.com', + ); + static const MapRasterEndpointDefinition eu = MapRasterEndpointDefinition( + id: 'eu', + label: 'EU Endpoint', + description: 'Route tile requests to Stadia EU servers', + host: 'tiles-eu.stadiamaps.com', + ); + + static const List presets = [standard, eu]; + + static MapRasterEndpointDefinition fromSettings(AppSettings settings) { + final preset = MapRasterEndpointPreset.fromId(settings.mapTileEndpointId); + switch (preset) { + case MapRasterEndpointPreset.eu: + return eu; + case MapRasterEndpointPreset.standard: + return standard; + } + } +} class MapTileCacheProgress { final int completed; @@ -32,28 +190,40 @@ class MapTileCacheResult { }); } -class MapTileCacheService { +class MapTileCacheService extends ChangeNotifier { static const String cacheKey = 'map_tile_cache'; static const String userAgentPackageName = 'com.meshcore.open'; static const int defaultMinZoom = 10; static const int defaultMaxZoom = 15; + final AppSettingsService appSettingsService; final BaseCacheManager cacheManager; late final TileProvider tileProvider; - MapTileCacheService({BaseCacheManager? cacheManager}) - : cacheManager = - cacheManager ?? - CacheManager( - Config( - cacheKey, - stalePeriod: const Duration(days: 365), - maxNrOfCacheObjects: 200000, - ), - ) { + MapTileCacheService({ + required this.appSettingsService, + BaseCacheManager? cacheManager, + }) : cacheManager = + cacheManager ?? + CacheManager( + Config( + cacheKey, + stalePeriod: const Duration(days: 365), + maxNrOfCacheObjects: 200000, + ), + ) { tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager); + appSettingsService.addListener(_handleSettingsChanged); } + MapRasterSourceDefinition get source => + MapRasterSourceCatalog.fromSettings(appSettingsService.settings); + + MapRasterEndpointDefinition get endpoint => + MapRasterEndpointCatalog.fromSettings(appSettingsService.settings); + + String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); + Map get defaultHeaders => { 'User-Agent': 'flutter_map ($userAgentPackageName)', }; @@ -89,6 +259,7 @@ class MapTileCacheService { final total = estimateTileCount(bounds, safeMin, safeMax); final authHeaders = headers ?? defaultHeaders; final safeConcurrency = math.max(1, concurrentDownloads); + final currentTemplate = urlTemplate; int completed = 0; int failed = 0; @@ -124,7 +295,7 @@ class MapTileCacheService { final tileBounds = _tileBoundsForBounds(bounds, zoom); for (int x = tileBounds.minX; x <= tileBounds.maxX; x++) { for (int y = tileBounds.minY; y <= tileBounds.maxY; y++) { - final url = _buildTileUrl(x, y, zoom); + final url = _buildTileUrl(x, y, zoom, urlTemplate: currentTemplate); await queueDownload(url); } } @@ -167,6 +338,16 @@ class MapTileCacheService { ); } + void _handleSettingsChanged() { + notifyListeners(); + } + + @override + void dispose() { + appSettingsService.removeListener(_handleSettingsChanged); + super.dispose(); + } + _TileBounds _tileBoundsForBounds(LatLngBounds bounds, int zoom) { final north = _clampLatitude(bounds.north); final south = _clampLatitude(bounds.south); @@ -185,6 +366,21 @@ class MapTileCacheService { ); } + String _buildUrlTemplate(AppSettings settings) { + final source = MapRasterSourceCatalog.fromSettings(settings); + if (!source.isStadia) { + return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + } + final endpoint = MapRasterEndpointCatalog.fromSettings(settings); + final apiKey = settings.mapTileApiKey?.trim(); + final base = 'https://${endpoint.host}/tiles/${source.id}/{z}/{x}/{y}.png'; + if (apiKey == null || apiKey.isEmpty) { + return base; + } + final query = Uri(queryParameters: {'api_key': apiKey}).query; + return '$base?$query'; + } + int _lonToTileX(double lon, int zoom, int maxIndex) { final n = 1 << zoom; final value = ((lon + 180.0) / 360.0 * n).floor(); @@ -205,8 +401,8 @@ class MapTileCacheService { return lat.clamp(-maxLat, maxLat); } - String _buildTileUrl(int x, int y, int zoom) { - return kMapTileUrlTemplate + String _buildTileUrl(int x, int y, int zoom, {required String urlTemplate}) { + return urlTemplate .replaceAll('{z}', zoom.toString()) .replaceAll('{x}', x.toString()) .replaceAll('{y}', y.toString());