From 225d07b440ca6c04045216186a934d35f8e6cda7 Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 23 May 2026 11:41:06 +0200 Subject: [PATCH 1/8] 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()); From fc67d8d5aa7eb5819940bba786c1bdb926e79c1c Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 23 May 2026 12:40:56 +0200 Subject: [PATCH 2/8] show cached tiles --- lib/screens/map_cache_screen.dart | 100 +++++++++++++ lib/services/map_tile_cache_service.dart | 183 +++++++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index dda17132..d6ce8489 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -31,6 +31,11 @@ class _MapCacheScreenState extends State { bool _isDownloading = false; int _completedTiles = 0; int _failedTiles = 0; + List _cachedTiles = const []; + int _cachedTileBytes = 0; + bool _isLoadingCachedTiles = false; + double _overlayZoom = 2.0; + LatLngBounds? _visibleBounds; @override void initState() { @@ -113,8 +118,10 @@ class _MapCacheScreenState extends State { _minZoom = safeMin; _maxZoom = safeMax; _selectedBounds = bounds; + _visibleBounds = bounds; }); _updateEstimate(); + _refreshCachedTiles(); if (bounds != null) { _mapController.fitCamera( CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), @@ -253,6 +260,8 @@ class _MapCacheScreenState extends State { result.failed, ) : context.l10n.mapCache_cachedTiles(result.downloaded); + await _refreshCachedTiles(); + if (!mounted) return; showDismissibleSnackBar(context, content: Text(message)); } @@ -279,17 +288,61 @@ class _MapCacheScreenState extends State { final cacheService = context.read(); await cacheService.clearCache(); if (!mounted) return; + await _refreshCachedTiles(); + if (!mounted) return; showDismissibleSnackBar( context, content: Text(context.l10n.mapCache_offlineCacheCleared), ); } + Future _refreshCachedTiles() async { + if (!mounted) return; + setState(() { + _isLoadingCachedTiles = true; + }); + final cacheService = context.read(); + final inventory = await cacheService.getCachedTileInventory(); + if (!mounted) return; + setState(() { + _cachedTiles = inventory.tiles; + _cachedTileBytes = inventory.totalBytes; + _isLoadingCachedTiles = false; + }); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + @override Widget build(BuildContext context) { final tileCache = context.watch(); final source = tileCache.source; final selectedBounds = _selectedBounds; + final activeCachedTiles = tileCache.filterTilesForActiveSource( + _cachedTiles, + ); + final cachedInSelection = tileCache.countTilesForBounds( + activeCachedTiles, + bounds: selectedBounds, + minZoom: _minZoom, + maxZoom: _maxZoom, + ); + final overlayPolygons = tileCache.buildCachedTilePolygons( + activeCachedTiles, + zoom: _overlayZoom.round().clamp(0, 19).toInt(), + visibleBounds: _visibleBounds, + ); + final cacheSummary = + 'Source: ${source.label}\n' + 'Cached tiles for source: ${activeCachedTiles.length}\n' + 'Cached in selected area/zoom: $cachedInSelection\n' + 'Approx cache size: ${_formatBytes(_cachedTileBytes)}'; final l10n = context.l10n; final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final progressValue = _estimatedTiles == 0 @@ -326,6 +379,20 @@ class _MapCacheScreenState extends State { ) : const KeyboardOptions.disabled(), ), + onPositionChanged: (camera, hasGesture) { + final nextZoom = camera.zoom; + final nextBounds = camera.visibleBounds; + if (_overlayZoom != nextZoom || + _visibleBounds?.north != nextBounds.north || + _visibleBounds?.south != nextBounds.south || + _visibleBounds?.east != nextBounds.east || + _visibleBounds?.west != nextBounds.west) { + setState(() { + _overlayZoom = nextZoom; + _visibleBounds = nextBounds; + }); + } + }, ), children: [ TileLayer( @@ -346,9 +413,26 @@ class _MapCacheScreenState extends State { ), ], ), + if (overlayPolygons.isNotEmpty) + PolygonLayer(polygons: overlayPolygons), ], ), if (isDesktop) _buildDesktopMapControls(), + Positioned( + top: 12, + left: isDesktop ? 84 : 12, + child: Card( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + _isLoadingCachedTiles + ? 'Loading cached tiles...' + : 'Visible cached tiles at z${_overlayZoom.round()}: ${overlayPolygons.length}', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), Positioned( top: 12, right: 12, @@ -433,6 +517,22 @@ class _MapCacheScreenState extends State { }, ), Text(l10n.mapCache_estimatedTiles(_estimatedTiles)), + const SizedBox(height: 12), + TextFormField( + key: ValueKey( + '$cacheSummary|${activeCachedTiles.length}|$cachedInSelection|$_cachedTileBytes', + ), + initialValue: cacheSummary, + readOnly: true, + minLines: 4, + maxLines: 4, + decoration: InputDecoration( + labelText: _isLoadingCachedTiles + ? 'Cached tiles' + : 'Cached tile summary', + border: const OutlineInputBorder(), + ), + ), if (!source.allowsBulkDownload) ...[ const SizedBox(height: 8), Text( diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 5cbdac5c..c0fc9a6b 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -190,6 +190,33 @@ class MapTileCacheResult { }); } +class CachedTileInfo { + final String key; + final String host; + final String sourceId; + final int zoom; + final int x; + final int y; + final int length; + + const CachedTileInfo({ + required this.key, + required this.host, + required this.sourceId, + required this.zoom, + required this.x, + required this.y, + required this.length, + }); +} + +class CachedTileInventory { + final List tiles; + final int totalBytes; + + const CachedTileInventory({required this.tiles, required this.totalBytes}); +} + class MapTileCacheService extends ChangeNotifier { static const String cacheKey = 'map_tile_cache'; static const String userAgentPackageName = 'com.meshcore.open'; @@ -224,6 +251,8 @@ class MapTileCacheService extends ChangeNotifier { String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); + CacheManager get _concreteCacheManager => cacheManager as CacheManager; + Map get defaultHeaders => { 'User-Agent': 'flutter_map ($userAgentPackageName)', }; @@ -232,6 +261,98 @@ class MapTileCacheService extends ChangeNotifier { await cacheManager.emptyCache(); } + Future getCachedTileInventory() async { + final repo = _concreteCacheManager.config.repo; + await repo.open(); + final objects = await repo.getAllObjects(); + final tiles = []; + int totalBytes = 0; + + for (final object in objects) { + totalBytes += object.length ?? 0; + final tile = _parseCachedTile(object); + if (tile != null) { + tiles.add(tile); + } + } + + return CachedTileInventory(tiles: tiles, totalBytes: totalBytes); + } + + List filterTilesForActiveSource( + Iterable tiles, + ) { + final activeSource = source; + if (!activeSource.isStadia) { + return tiles + .where( + (tile) => + tile.sourceId == MapRasterSourceCatalog.osmStandard.id && + tile.host == 'tile.openstreetmap.org', + ) + .toList(); + } + + final activeEndpoint = endpoint; + return tiles + .where( + (tile) => + tile.sourceId == activeSource.id && + tile.host == activeEndpoint.host, + ) + .toList(); + } + + int countTilesForBounds( + Iterable tiles, { + LatLngBounds? bounds, + required int minZoom, + required int maxZoom, + }) { + if (bounds == null) return 0; + final safeMin = math.min(minZoom, maxZoom); + final safeMax = math.max(minZoom, maxZoom); + return tiles.where((tile) { + if (tile.zoom < safeMin || tile.zoom > safeMax) { + return false; + } + final tileBounds = _tileBoundsForTile(tile.x, tile.y, tile.zoom); + return _boundsIntersect(bounds, tileBounds); + }).length; + } + + List buildCachedTilePolygons( + Iterable tiles, { + required int zoom, + LatLngBounds? visibleBounds, + int limit = 250, + }) { + final polygons = []; + for (final tile in tiles) { + if (tile.zoom != zoom) continue; + final tileBounds = _tileBoundsForTile(tile.x, tile.y, tile.zoom); + if (visibleBounds != null && + !_boundsIntersect(visibleBounds, tileBounds)) { + continue; + } + polygons.add( + Polygon( + points: [ + tileBounds.northWest, + tileBounds.northEast, + tileBounds.southEast, + tileBounds.southWest, + ], + borderStrokeWidth: 0.6, + color: const Color(0x5532A852), + borderColor: const Color(0xCC2F8F46), + ), + ); + if (polygons.length >= limit) break; + } + return polygons; + } + int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) { final safeMin = math.min(minZoom, maxZoom); final safeMax = math.max(minZoom, maxZoom); @@ -381,6 +502,42 @@ class MapTileCacheService extends ChangeNotifier { return '$base?$query'; } + CachedTileInfo? _parseCachedTile(CacheObject object) { + final uri = Uri.tryParse(object.key); + if (uri == null) return null; + final segments = uri.pathSegments; + + if (segments.length >= 3 && + segments[segments.length - 3].isNotEmpty && + segments[segments.length - 2].isNotEmpty) { + final zoom = int.tryParse(segments[segments.length - 3]); + final x = int.tryParse(segments[segments.length - 2]); + final ySegment = segments.last; + final y = int.tryParse(ySegment.split('.').first); + + if (zoom == null || x == null || y == null) { + return null; + } + + var sourceId = MapRasterSourceCatalog.osmStandard.id; + if (segments.length >= 5 && segments[0] == 'tiles') { + sourceId = segments[1]; + } + + return CachedTileInfo( + key: object.key, + host: uri.host, + sourceId: sourceId, + zoom: zoom, + x: x, + y: y, + length: object.length ?? 0, + ); + } + + return null; + } + int _lonToTileX(double lon, int zoom, int maxIndex) { final n = 1 << zoom; final value = ((lon + 180.0) / 360.0 * n).floor(); @@ -401,6 +558,32 @@ class MapTileCacheService extends ChangeNotifier { return lat.clamp(-maxLat, maxLat); } + LatLngBounds _tileBoundsForTile(int x, int y, int zoom) { + return LatLngBounds.unsafe( + north: _tileYToLat(y, zoom), + south: _tileYToLat(y + 1, zoom), + east: _tileXToLon(x + 1, zoom), + west: _tileXToLon(x, zoom), + ); + } + + double _tileXToLon(int x, int zoom) { + final n = 1 << zoom; + return x / n * 360.0 - 180.0; + } + + double _tileYToLat(int y, int zoom) { + final n = math.pi - (2.0 * math.pi * y) / (1 << zoom); + return 180.0 / math.pi * math.atan(0.5 * (math.exp(n) - math.exp(-n))); + } + + bool _boundsIntersect(LatLngBounds a, LatLngBounds b) { + return a.west <= b.east && + a.east >= b.west && + a.south <= b.north && + a.north >= b.south; + } + String _buildTileUrl(int x, int y, int zoom, {required String urlTemplate}) { return urlTemplate .replaceAll('{z}', zoom.toString()) From 09a83c4f2286dc35f12fa9d9f888556af80807fc Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 23 May 2026 17:21:23 +0200 Subject: [PATCH 3/8] add high dpi stadiamaps --- lib/screens/channels_screen.dart | 2 +- lib/services/map_tile_cache_service.dart | 38 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 8b05b8ce..0737a7d0 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -273,7 +273,7 @@ class _ChannelsScreenState extends State ), buildDefaultDragHandles: false, itemCount: filteredChannels.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { if (newIndex > oldIndex) newIndex -= 1; final reordered = List.from( filteredChannels, diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index c0fc9a6b..0dccc7b2 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -29,7 +29,9 @@ enum MapRasterSourcePreset { enum MapRasterEndpointPreset { standard('standard'), - eu('eu'); + standard2x('standard_2x'), + eu('eu'), + eu2x('eu_2x'); const MapRasterEndpointPreset(this.id); @@ -67,12 +69,14 @@ class MapRasterEndpointDefinition { required this.label, required this.description, required this.host, + this.scaleSuffix = '', }); final String id; final String label; final String description; final String host; + final String scaleSuffix; } class MapRasterSourceCatalog { @@ -146,20 +150,44 @@ class MapRasterEndpointCatalog { description: 'Global CDN routing to the fastest Stadia server', host: 'tiles.stadiamaps.com', ); + static const MapRasterEndpointDefinition standard2x = + MapRasterEndpointDefinition( + id: 'standard_2x', + label: 'Standard Endpoint (@2x)', + description: 'Global Stadia endpoint with HiDPI raster tiles', + host: 'tiles.stadiamaps.com', + scaleSuffix: '@2x', + ); 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 MapRasterEndpointDefinition eu2x = MapRasterEndpointDefinition( + id: 'eu_2x', + label: 'EU Endpoint (@2x)', + description: 'EU Stadia endpoint with HiDPI raster tiles', + host: 'tiles-eu.stadiamaps.com', + scaleSuffix: '@2x', + ); - static const List presets = [standard, eu]; + static const List presets = [ + standard, + standard2x, + eu, + eu2x, + ]; static MapRasterEndpointDefinition fromSettings(AppSettings settings) { final preset = MapRasterEndpointPreset.fromId(settings.mapTileEndpointId); switch (preset) { + case MapRasterEndpointPreset.standard2x: + return standard2x; case MapRasterEndpointPreset.eu: return eu; + case MapRasterEndpointPreset.eu2x: + return eu2x; case MapRasterEndpointPreset.standard: return standard; } @@ -494,7 +522,8 @@ class MapTileCacheService extends ChangeNotifier { } final endpoint = MapRasterEndpointCatalog.fromSettings(settings); final apiKey = settings.mapTileApiKey?.trim(); - final base = 'https://${endpoint.host}/tiles/${source.id}/{z}/{x}/{y}.png'; + final base = + 'https://${endpoint.host}/tiles/${source.id}/{z}/{x}/{y}${endpoint.scaleSuffix}.png'; if (apiKey == null || apiKey.isEmpty) { return base; } @@ -513,7 +542,8 @@ class MapTileCacheService extends ChangeNotifier { final zoom = int.tryParse(segments[segments.length - 3]); final x = int.tryParse(segments[segments.length - 2]); final ySegment = segments.last; - final y = int.tryParse(ySegment.split('.').first); + final yString = ySegment.split('.').first.replaceAll('@2x', ''); + final y = int.tryParse(yString); if (zoom == null || x == null || y == null) { return null; From 11a4f9a373604f0b6b0013552c5766620c55f070 Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 24 May 2026 11:37:56 +0200 Subject: [PATCH 4/8] try OpenStreetMap Dark --- lib/screens/channel_message_path_screen.dart | 1 + lib/screens/line_of_sight_map_screen.dart | 1 + lib/screens/map_cache_screen.dart | 1 + lib/screens/map_screen.dart | 1 + lib/screens/path_trace_map.dart | 1 + lib/services/map_tile_cache_service.dart | 40 ++++++++++++++++++++ 6 files changed, 45 insertions(+) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index a05c4e15..5f0e7ef8 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -561,6 +561,7 @@ class _ChannelMessagePathMapScreenState TileLayer( urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, + tileBuilder: tileCache.tileBuilder, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index f1b18e9d..f99f8853 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -472,6 +472,7 @@ class _LineOfSightMapScreenState extends State { TileLayer( urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, + tileBuilder: tileCache.tileBuilder, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index d6ce8489..ac0af5ee 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -398,6 +398,7 @@ class _MapCacheScreenState extends State { TileLayer( urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, + tileBuilder: tileCache.tileBuilder, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 887a8e0e..5409c296 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -565,6 +565,7 @@ class _MapScreenState extends State { TileLayer( urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, + tileBuilder: tileCache.tileBuilder, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 8d4d469e..fdc83616 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -911,6 +911,7 @@ class _PathTraceMapScreenState extends State { TileLayer( urlTemplate: tileCache.urlTemplate, tileProvider: tileCache.tileProvider, + tileBuilder: tileCache.tileBuilder, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 0dccc7b2..6525106a 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -10,6 +10,7 @@ import 'app_settings_service.dart'; enum MapRasterSourcePreset { osmStandard('osm_standard'), + osmDark('osm_dark'), stamenTerrain('stamen_terrain'), alidadeSmoothDark('alidade_smooth_dark'), outdoors('outdoors'), @@ -86,6 +87,11 @@ class MapRasterSourceCatalog { label: 'OpenStreetMap Standard', description: 'Direct tiles from tile.openstreetmap.org', ); + static const MapRasterSourceDefinition osmDark = MapRasterSourceDefinition( + id: 'osm_dark', + label: 'OpenStreetMap Dark', + description: 'Standard OpenStreetMap tiles with an inverted dark filter', + ); static const MapRasterSourceDefinition stamenTerrain = MapRasterSourceDefinition( id: 'stamen_terrain', @@ -119,6 +125,7 @@ class MapRasterSourceCatalog { static const List presets = [ osmStandard, + osmDark, stamenTerrain, alidadeSmoothDark, outdoors, @@ -130,6 +137,8 @@ class MapRasterSourceCatalog { switch (preset) { case MapRasterSourcePreset.osmStandard: return osmStandard; + case MapRasterSourcePreset.osmDark: + return osmDark; case MapRasterSourcePreset.alidadeSmoothDark: return alidadeSmoothDark; case MapRasterSourcePreset.outdoors: @@ -279,6 +288,13 @@ class MapTileCacheService extends ChangeNotifier { String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); + TileBuilder? get tileBuilder => isInvertedOsmDarkSource + ? _osmDarkTileBuilder + : null; + + bool get isInvertedOsmDarkSource => + source.id == MapRasterSourceCatalog.osmDark.id; + CacheManager get _concreteCacheManager => cacheManager as CacheManager; Map get defaultHeaders => { @@ -622,6 +638,30 @@ class MapTileCacheService extends ChangeNotifier { } } +Widget _osmDarkTileBuilder( + BuildContext _, + Widget tileWidget, + TileImage __, +) { + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 1.33, 0, 0, 0, 0, + 0, 1.33, 0, 0, 0, + 0, 0, 1.33, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.5740000009536743, -1.4299999475479126, -0.14399999380111694, 0, 255, + -0.4259999990463257, -0.429999977350235, -0.14399999380111694, 0, 255, + -0.4259999990463257, -1.4299999475479126, 0.8559999465942383, 0, 255, + 0, 0, 0, 1, 0, + ]), + child: tileWidget, + ), + ); +} + class CachedNetworkTileProvider extends TileProvider { final BaseCacheManager cacheManager; From bdd0d3dee24e15a100bff2e89da1d7a145a22333 Mon Sep 17 00:00:00 2001 From: ericz Date: Sun, 24 May 2026 11:55:50 +0200 Subject: [PATCH 5/8] make raster tile source scollable --- lib/screens/app_settings_screen.dart | 43 +++++++++-------- lib/services/map_tile_cache_service.dart | 59 +++++++++++++++++------- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index cf81be02..f062f128 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -626,24 +626,31 @@ class AppSettingsScreen extends StatelessWidget { 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), - ), - ], + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(dialogContext).size.height * 0.6, + ), + child: SingleChildScrollView( + 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), + ), + ], + ), + ), ), ), ), diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 6525106a..3db69c2a 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -288,9 +288,8 @@ class MapTileCacheService extends ChangeNotifier { String get urlTemplate => _buildUrlTemplate(appSettingsService.settings); - TileBuilder? get tileBuilder => isInvertedOsmDarkSource - ? _osmDarkTileBuilder - : null; + TileBuilder? get tileBuilder => + isInvertedOsmDarkSource ? _osmDarkTileBuilder : null; bool get isInvertedOsmDarkSource => source.id == MapRasterSourceCatalog.osmDark.id; @@ -638,24 +637,52 @@ class MapTileCacheService extends ChangeNotifier { } } -Widget _osmDarkTileBuilder( - BuildContext _, - Widget tileWidget, - TileImage __, -) { +Widget _osmDarkTileBuilder(BuildContext _, Widget tileWidget, TileImage tile) { return ColorFiltered( colorFilter: const ColorFilter.matrix([ - 1.33, 0, 0, 0, 0, - 0, 1.33, 0, 0, 0, - 0, 0, 1.33, 0, 0, - 0, 0, 0, 1, 0, + 1.33, + 0, + 0, + 0, + 0, + 0, + 1.33, + 0, + 0, + 0, + 0, + 0, + 1.33, + 0, + 0, + 0, + 0, + 0, + 1, + 0, ]), child: ColorFiltered( colorFilter: const ColorFilter.matrix([ - 0.5740000009536743, -1.4299999475479126, -0.14399999380111694, 0, 255, - -0.4259999990463257, -0.429999977350235, -0.14399999380111694, 0, 255, - -0.4259999990463257, -1.4299999475479126, 0.8559999465942383, 0, 255, - 0, 0, 0, 1, 0, + 0.5740000009536743, + -1.4299999475479126, + -0.14399999380111694, + 0, + 255, + -0.4259999990463257, + -0.429999977350235, + -0.14399999380111694, + 0, + 255, + -0.4259999990463257, + -1.4299999475479126, + 0.8559999465942383, + 0, + 255, + 0, + 0, + 0, + 1, + 0, ]), child: tileWidget, ), From 531b85b8aae8e720182fe257cd606862250f2a87 Mon Sep 17 00:00:00 2001 From: ericz Date: Mon, 25 May 2026 11:56:01 +0200 Subject: [PATCH 6/8] add OSM Auto Map --- lib/models/app_settings.dart | 7 ++-- lib/screens/map_cache_screen.dart | 4 +- lib/services/map_tile_cache_service.dart | 48 +++++++++++++++++++++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 0c4cfe1e..3b0d64d0 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -147,8 +147,8 @@ class AppSettings { this.mapCacheBounds, this.mapCacheMinZoom = 10, this.mapCacheMaxZoom = 15, - this.mapRasterSourceId = 'osm_standard', - this.mapTileEndpointId = 'standard', + this.mapRasterSourceId = 'osm_auto', + this.mapTileEndpointId = 'standard_2x', this.mapTileApiKey, this.notificationsEnabled = true, this.notifyOnNewMessage = true, @@ -276,8 +276,7 @@ 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', + mapRasterSourceId: json['map_raster_source_id'] as String? ?? 'osm_auto', mapTileEndpointId: json['map_tile_endpoint_id'] as String? ?? 'standard', mapTileApiKey: json['map_tile_api_key'] as String?, notificationsEnabled: json['notifications_enabled'] as bool? ?? true, diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index ac0af5ee..e5abe00b 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -426,9 +426,7 @@ class _MapCacheScreenState extends State { child: Padding( padding: const EdgeInsets.all(8), child: Text( - _isLoadingCachedTiles - ? 'Loading cached tiles...' - : 'Visible cached tiles at z${_overlayZoom.round()}: ${overlayPolygons.length}', + 'Z: ${_overlayZoom.round()}:', style: const TextStyle(fontSize: 12), ), ), diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 3db69c2a..ae29ab98 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -9,6 +9,7 @@ import '../models/app_settings.dart'; import 'app_settings_service.dart'; enum MapRasterSourcePreset { + osmAuto('osm_auto'), osmStandard('osm_standard'), osmDark('osm_dark'), stamenTerrain('stamen_terrain'), @@ -24,7 +25,7 @@ enum MapRasterSourcePreset { for (final value in values) { if (value.id == id) return value; } - return MapRasterSourcePreset.osmStandard; + return MapRasterSourcePreset.osmAuto; } } @@ -81,6 +82,12 @@ class MapRasterEndpointDefinition { } class MapRasterSourceCatalog { + static const MapRasterSourceDefinition osmAuto = MapRasterSourceDefinition( + id: 'osm_auto', + label: 'OpenStreetMap Auto', + description: + 'Automatically uses OpenStreetMap Standard or Dark from the app theme', + ); static const MapRasterSourceDefinition osmStandard = MapRasterSourceDefinition( id: 'osm_standard', @@ -124,6 +131,7 @@ class MapRasterSourceCatalog { ); static const List presets = [ + osmAuto, osmStandard, osmDark, stamenTerrain, @@ -135,6 +143,8 @@ class MapRasterSourceCatalog { static MapRasterSourceDefinition fromSettings(AppSettings settings) { final preset = MapRasterSourcePreset.fromId(settings.mapRasterSourceId); switch (preset) { + case MapRasterSourcePreset.osmAuto: + return osmAuto; case MapRasterSourcePreset.osmStandard: return osmStandard; case MapRasterSourcePreset.osmDark: @@ -149,6 +159,40 @@ class MapRasterSourceCatalog { return stamenTerrain; } } + + static MapRasterSourceDefinition resolveFromSettings( + AppSettings settings, { + Brightness? platformBrightness, + }) { + final selected = fromSettings(settings); + if (selected.id != osmAuto.id) return selected; + + return _prefersDarkTheme( + settings.themeMode, + platformBrightness: platformBrightness, + ) + ? osmDark + : osmStandard; + } + + static bool _prefersDarkTheme( + String themeMode, { + Brightness? platformBrightness, + }) { + switch (themeMode) { + case 'dark': + return true; + case 'light': + return false; + default: + return (platformBrightness ?? + WidgetsBinding + .instance + .platformDispatcher + .platformBrightness) == + Brightness.dark; + } + } } class MapRasterEndpointCatalog { @@ -281,7 +325,7 @@ class MapTileCacheService extends ChangeNotifier { } MapRasterSourceDefinition get source => - MapRasterSourceCatalog.fromSettings(appSettingsService.settings); + MapRasterSourceCatalog.resolveFromSettings(appSettingsService.settings); MapRasterEndpointDefinition get endpoint => MapRasterEndpointCatalog.fromSettings(appSettingsService.settings); From 29e551d6cbd7285ba19de55fd8b14076b9d124c6 Mon Sep 17 00:00:00 2001 From: ericz Date: Mon, 25 May 2026 22:24:38 +0200 Subject: [PATCH 7/8] add Documentation --- README.md | 2 +- documentation/settings.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 041ea6e9..08bb2017 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh - **Live Map View**: Real-time visualization of mesh network nodes on an interactive map - **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range - **Location Sharing**: Share GPS coordinates and custom markers with contacts -- **Offline Maps**: Download map tiles for offline use in remote areas +- **Offline Maps**: Download map tiles for offline use in remote areas (with [StadiaMaps](https://stadiamaps.com/pricing/) Free Subscription API-Key) - **MGRS Coordinates**: Support for Military Grid Reference System coordinate format ### Device Management diff --git a/documentation/settings.md b/documentation/settings.md index 74e8c933..53ef277f 100644 --- a/documentation/settings.md +++ b/documentation/settings.md @@ -83,6 +83,10 @@ Not shown on web. Controls on-device message translation powered by a locally-do - **Show Other Nodes**: Toggle room/sensor markers - **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week - **Units**: Metric / Imperial +- **Raster Tile Source**: Sets the MAP theme and with that from where to get the map tile data: + - OpenStreetMap (Auto/Standard/Dark) is provided by the free OpenStreetMap tile server. (Can only be used for live view or already cached.) + - Stamen Terrain / AlidadeSmooth Dark / Outdoors / OSM Bright are [StadiaMaps.com raster tile maps](https://stadiamaps.com/products/maps/interactive-basemaps/) for which you can choose an Hosted Endpoint (Worldwide / Europe hosted) and have to provide the API key to your subscription. (You can cache these maps for offline Map usage.) + There will be no account provided by meshcore-open. You will have to get you own subscription. StadiaMaps offers a [free](https://stadiamaps.com/pricing/) subscription to download up to 200'000 Standart Raster Basemap tiles. - **Offline Map Cache**: Navigate to tile download screen ### Debug From ecde5e110952a7fa8433cb6fbb7899ab5ae2c11f Mon Sep 17 00:00:00 2001 From: ericz Date: Sat, 13 Jun 2026 20:01:43 +0200 Subject: [PATCH 8/8] fix StadiaMaps Dark and add demo. --- lib/models/app_settings.dart | 13 ++++++ lib/screens/app_settings_screen.dart | 22 ++++++---- lib/services/map_tile_cache_service.dart | 54 ++++++++++++++++++------ 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index ed8a20a7..82b86248 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -76,6 +76,8 @@ class Cyr2LatProfile { class AppSettings { static const Object _unset = Object(); + static const String stadiaDemo = + '51bd0381-4685-4666-bae8-48940f6d77c0'; final bool clearPathOnMaxRetry; final bool mapShowRepeaters; @@ -125,6 +127,17 @@ class AppSettings { final List cyr2latProfiles; final String selectedCyr2latProfileId; + String get effectiveMapTileApiKey { + final apiKey = mapTileApiKey?.trim(); + if (apiKey == null || apiKey.isEmpty) { + return stadiaDemo; + } + return apiKey; + } + + bool get usesstadiaDemo => + effectiveMapTileApiKey == stadiaDemo; + Map get cyr2latCharMap { final profile = cyr2latProfiles.firstWhere( (p) => p.id == selectedCyr2latProfileId, diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 10fa66cd..90a0a132 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -1019,11 +1019,9 @@ class AppSettingsScreen extends StatelessWidget { } String _mapApiKeySummary(BuildContext context, AppSettings settings) { - final apiKey = settings.mapTileApiKey?.trim(); - if (apiKey == null || apiKey.isEmpty) { - return context.l10n.appSettings_stadiaApiKeyRequired; - } - return context.l10n.appSettings_stadiaApiKeyConfigured(_maskApiKey(apiKey)); + return context.l10n.appSettings_stadiaApiKeyConfigured( + _maskApiKey(settings.effectiveMapTileApiKey), + ); } String _maskApiKey(String value) { @@ -1153,9 +1151,13 @@ class AppSettingsScreen extends StatelessWidget { BuildContext context, AppSettingsService settingsService, ) { - final controller = TextEditingController( - text: settingsService.settings.mapTileApiKey ?? '', + final currentApiKey = settingsService.settings.mapTileApiKey?.trim() ?? ''; + final maskedApiKey = _maskApiKey( + currentApiKey.isEmpty + ? AppSettings.stadiaDemo + : currentApiKey, ); + final controller = TextEditingController(text: maskedApiKey); showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -1173,6 +1175,7 @@ class AppSettingsScreen extends StatelessWidget { autofocus: true, autocorrect: false, enableSuggestions: false, + autofillHints: const [AutofillHints.password], decoration: const InputDecoration( border: OutlineInputBorder(), hintText: '4e1bf343-3d91-4d9c-a8e1-1234567890ab', @@ -1188,7 +1191,10 @@ class AppSettingsScreen extends StatelessWidget { ), TextButton( onPressed: () async { - await settingsService.setMapTileApiKey(controller.text); + final apiKey = controller.text.trim(); + await settingsService.setMapTileApiKey( + apiKey == maskedApiKey ? currentApiKey : apiKey, + ); if (!dialogContext.mounted) return; Navigator.pop(dialogContext); }, diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index df3c3fc7..07584132 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -305,8 +305,22 @@ class MapTileCacheService extends ChangeNotifier { appSettingsService.addListener(_handleSettingsChanged); } - MapRasterSourceDefinition get source => - MapRasterSourceCatalog.fromSettings(appSettingsService.settings); + MapRasterSourceDefinition get source { + final selectedSource = MapRasterSourceCatalog.fromSettings( + appSettingsService.settings, + ); + if (!selectedSource.allowsBulkDownload || + !appSettingsService.settings.usesstadiaDemo) { + return selectedSource; + } + return MapRasterSourceDefinition( + id: selectedSource.id, + label: selectedSource.label, + description: selectedSource.description, + isStadia: selectedSource.isStadia, + allowsBulkDownload: false, // Explicitly disable bulk download for demo. + ); + } MapRasterEndpointDefinition get endpoint => MapRasterEndpointCatalog.fromSettings(appSettingsService.settings); @@ -315,6 +329,26 @@ class MapTileCacheService extends ChangeNotifier { TileBuilder? get tileBuilder => null; + static bool shouldApplyDarkFilterForSettings( + AppSettings settings, + Brightness brightness, + ) { + switch (MapRasterSourcePreset.fromId(settings.mapRasterSourceId)) { + case MapRasterSourcePreset.osmDark: + case MapRasterSourcePreset.outdoorsDark: + case MapRasterSourcePreset.osmBrightDark: + return true; + case MapRasterSourcePreset.osmAuto: + return brightness == Brightness.dark; + case MapRasterSourcePreset.osmStandard: + case MapRasterSourcePreset.stamenTerrain: + case MapRasterSourcePreset.alidadeSmoothDark: + case MapRasterSourcePreset.outdoors: + case MapRasterSourcePreset.osmBright: + return false; + } + } + static const ColorFilter _darkMapFilter = ColorFilter.matrix([ -0.0850, -0.2861, @@ -345,7 +379,6 @@ class MapTileCacheService extends ChangeNotifier { }; Widget buildTileLayer(BuildContext context, {double opacity = 1}) { - final source = this.source; Widget layer = TileLayer( urlTemplate: urlTemplate, tileProvider: tileProvider, @@ -354,12 +387,10 @@ class MapTileCacheService extends ChangeNotifier { maxZoom: 19, ); - final shouldApplyDarkFilter = - source == MapRasterSourceCatalog.osmDark || - source == MapRasterSourceCatalog.outdoorsDark || - source == MapRasterSourceCatalog.osmBrightDark || - (source == MapRasterSourceCatalog.osmAuto && - Theme.of(context).brightness == Brightness.dark); + final shouldApplyDarkFilter = shouldApplyDarkFilterForSettings( + appSettingsService.settings, + Theme.of(context).brightness, + ); if (shouldApplyDarkFilter) { layer = ColorFiltered(colorFilter: _darkMapFilter, child: layer); @@ -608,12 +639,9 @@ class MapTileCacheService extends ChangeNotifier { return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; } final endpoint = MapRasterEndpointCatalog.fromSettings(settings); - final apiKey = settings.mapTileApiKey?.trim(); + final apiKey = settings.effectiveMapTileApiKey; final base = 'https://${endpoint.host}/tiles/${source.id}/{z}/{x}/{y}${endpoint.scaleSuffix}.png'; - if (apiKey == null || apiKey.isEmpty) { - return base; - } final query = Uri(queryParameters: {'api_key': apiKey}).query; return '$base?$query'; }