From 365bd76cbc936f4e655f453067dfc634475cc12e Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 12 Jun 2026 17:28:56 +0200 Subject: [PATCH] feat: Add Mapy.cz Outdoor (OSM + LIDAR) base map layer - New MapLayerType.mapyOutdoor with Referer header support (tile server rejects requests without mapy.com Referer) - Headered layers get a dedicated NetworkTileProvider sharing the offline tile cache - Offline region downloads pass layer headers through TileDownloadService Co-Authored-By: Claude Fable 5 --- lib/models/map_layer.dart | 21 +++++++++++++++ lib/providers/offline_tiles_provider.dart | 1 + lib/screens/map_tab.dart | 31 ++++++++++++++++++----- lib/screens/offline_map_screen.dart | 3 +++ lib/services/tile_download_service.dart | 8 ++++-- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/lib/models/map_layer.dart b/lib/models/map_layer.dart index 6020ee1..7ff0d38 100644 --- a/lib/models/map_layer.dart +++ b/lib/models/map_layer.dart @@ -12,6 +12,8 @@ enum MapLayerType { // Kept for stored preference compatibility after MBTiles removal. vectorMbtiles, wmsBase, + // Appended last: stored layer preference is the enum index. + mapyOutdoor, } class MapLayer { @@ -21,6 +23,9 @@ class MapLayer { final String attribution; final double maxZoom; + /// Extra HTTP headers required by the tile server (e.g. Referer). + final Map? headers; + // WMS specific properties final bool isWms; final String? wmsBaseUrl; @@ -36,6 +41,7 @@ class MapLayer { required this.urlTemplate, required this.attribution, required this.maxZoom, + this.headers, this.isWms = false, this.wmsBaseUrl, this.wmsLayers, @@ -64,6 +70,9 @@ class MapLayer { case MapLayerType.vectorMbtiles: // Legacy value kept only for preference migration compatibility. return name; + case MapLayerType.mapyOutdoor: + // Brand name, not translated. + return name; case MapLayerType.wmsBase: // For WMS layers, use the name (will be localized separately) return name; @@ -86,6 +95,17 @@ class MapLayer { maxZoom: 17.49, // OpenTopoMap maximum (just below level 18) ); + /// Mapy.cz outdoor map: OSM data with LIDAR-based shaded relief. + /// The tile server rejects requests without a mapy.com Referer. + static const mapyOutdoor = MapLayer( + type: MapLayerType.mapyOutdoor, + name: 'Mapy.cz Outdoor (OSM + LIDAR)', + urlTemplate: 'https://mapserver.mapy.cz/turist-en/retina/{z}-{x}-{y}', + attribution: '© Seznam.cz, a.s., © OpenStreetMap contributors', + maxZoom: 19, + headers: {'Referer': 'https://mapy.com/'}, + ); + static const esriWorldImagery = MapLayer( type: MapLayerType.esriWorldImagery, name: 'ESRI Satellite', @@ -160,6 +180,7 @@ class MapLayer { static const List allLayers = [ openStreetMap, openTopoMap, + mapyOutdoor, esriWorldImagery, googleHybrid, googleRoadmap, diff --git a/lib/providers/offline_tiles_provider.dart b/lib/providers/offline_tiles_provider.dart index b68a85a..d66b905 100644 --- a/lib/providers/offline_tiles_provider.dart +++ b/lib/providers/offline_tiles_provider.dart @@ -266,6 +266,7 @@ class OfflineTilesProvider extends ChangeNotifier { maxZoom: _maxZoom, urlTemplate: _selectedLayer.urlTemplate, displayName: _selectedLayer.name, + headers: _selectedLayer.headers, ); await for (final event in stream) { diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index a77f0b3..c937ee6 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -63,14 +63,31 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); + static final MapCachingProvider _tileCachingProvider = + OfflineMapCachingProvider( + BuiltInMapCachingProvider.getOrCreateInstance( + maxCacheSize: 10_000_000_000, + overrideFreshAge: const Duration(days: 365), + ), + ); static final TileProvider _tileProvider = NetworkTileProvider( - cachingProvider: OfflineMapCachingProvider( - BuiltInMapCachingProvider.getOrCreateInstance( - maxCacheSize: 10_000_000_000, - overrideFreshAge: const Duration(days: 365), - ), - ), + cachingProvider: _tileCachingProvider, ); + // Providers for layers that need extra HTTP headers (e.g. Mapy.cz Referer), + // cached per URL template so they are not recreated on every build. + static final Map _headeredTileProviders = {}; + + static TileProvider _tileProviderFor(MapLayer layer) { + final headers = layer.headers; + if (headers == null) return _tileProvider; + return _headeredTileProviders.putIfAbsent( + layer.urlTemplate, + () => NetworkTileProvider( + headers: headers, + cachingProvider: _tileCachingProvider, + ), + ); + } // DO NOT create a new LocationTrackingService instance here // Use the singleton from AppProvider instead via _locationService getter final MapMarkerService _markerService = MapMarkerService(); @@ -2974,7 +2991,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { else if (!_currentLayer.isWms) flutter_map.TileLayer( urlTemplate: _currentLayer.urlTemplate, - tileProvider: _tileProvider, + tileProvider: _tileProviderFor(_currentLayer), userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, ), diff --git a/lib/screens/offline_map_screen.dart b/lib/screens/offline_map_screen.dart index 21209e7..d7eac71 100644 --- a/lib/screens/offline_map_screen.dart +++ b/lib/screens/offline_map_screen.dart @@ -114,6 +114,9 @@ class _OfflineMapScreenState extends State { children: [ TileLayer( urlTemplate: provider.selectedLayer.urlTemplate, + tileProvider: NetworkTileProvider( + headers: provider.selectedLayer.headers ?? const {}, + ), userAgentPackageName: 'com.meshcore.sar', maxZoom: provider.selectedLayer.maxZoom, ), diff --git a/lib/services/tile_download_service.dart b/lib/services/tile_download_service.dart index e565ed1..15d90d6 100644 --- a/lib/services/tile_download_service.dart +++ b/lib/services/tile_download_service.dart @@ -84,6 +84,7 @@ class TileDownloadService { required int maxZoom, required String urlTemplate, String? displayName, + Map? headers, int maxConcurrency = 6, int rateLimit = 30, }) { @@ -96,6 +97,7 @@ class TileDownloadService { maxZoom: maxZoom, urlTemplate: urlTemplate, displayName: displayName, + headers: headers, maxConcurrency: maxConcurrency, rateLimit: rateLimit, ); @@ -110,6 +112,7 @@ class TileDownloadService { required int maxZoom, required String urlTemplate, String? displayName, + Map? headers, required int maxConcurrency, required int rateLimit, }) async { @@ -184,7 +187,7 @@ class TileDownloadService { break; } - final future = _downloadSingleTile(tile, urlTemplate, styleHash) + final future = _downloadSingleTile(tile, urlTemplate, styleHash, headers) .then((event) { if (!controller.isClosed) { controller.add(event); @@ -220,6 +223,7 @@ class TileDownloadService { TileCoord tile, String urlTemplate, String styleHash, + Map? extraHeaders, ) async { final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z); @@ -241,7 +245,7 @@ class TileDownloadService { try { final response = await _httpClient.get( Uri.parse(url), - headers: {'User-Agent': 'MeshCoreSAR/1.0'}, + headers: {'User-Agent': 'MeshCoreSAR/1.0', ...?extraHeaders}, ); if (response.statusCode != 200) {