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 <noreply@anthropic.com>
This commit is contained in:
Janez T
2026-06-12 17:28:56 +02:00
co-authored by Claude Fable 5
parent ba27843166
commit 365bd76cbc
5 changed files with 55 additions and 9 deletions
+21
View File
@@ -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<String, String>? 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<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
mapyOutdoor,
esriWorldImagery,
googleHybrid,
googleRoadmap,
@@ -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) {
+24 -7
View File
@@ -63,14 +63,31 @@ class MapTab extends StatefulWidget {
class _MapTabState extends State<MapTab> 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<String, TileProvider> _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<MapTab> with AutomaticKeepAliveClientMixin {
else if (!_currentLayer.isWms)
flutter_map.TileLayer(
urlTemplate: _currentLayer.urlTemplate,
tileProvider: _tileProvider,
tileProvider: _tileProviderFor(_currentLayer),
userAgentPackageName: 'com.meshcore.sar',
maxZoom: _currentLayer.maxZoom,
),
+3
View File
@@ -114,6 +114,9 @@ class _OfflineMapScreenState extends State<OfflineMapScreen> {
children: [
TileLayer(
urlTemplate: provider.selectedLayer.urlTemplate,
tileProvider: NetworkTileProvider(
headers: provider.selectedLayer.headers ?? const {},
),
userAgentPackageName: 'com.meshcore.sar',
maxZoom: provider.selectedLayer.maxZoom,
),
+6 -2
View File
@@ -84,6 +84,7 @@ class TileDownloadService {
required int maxZoom,
required String urlTemplate,
String? displayName,
Map<String, String>? 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<String, String>? 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<String, String>? 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) {