From a08d61fb98d509f0ba34c48364ac164aaa646778 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 5 Oct 2025 15:08:27 -0500 Subject: [PATCH] Add other providers, max zoom per tile type --- README.md | 5 ++-- lib/dev_config.dart | 2 +- lib/localizations/de.json | 8 ++++- lib/localizations/en.json | 5 ++-- lib/localizations/es.json | 8 ++++- lib/localizations/fr.json | 8 ++++- lib/localizations/it.json | 8 ++++- lib/localizations/pt.json | 8 ++++- lib/localizations/zh.json | 8 ++++- lib/models/tile_provider.dart | 40 +++++++++++++++++++++++++ lib/state/settings_state.dart | 22 ++++++++++++++ lib/widgets/map/map_overlays.dart | 49 ++++++++++++++++++++++++------- 12 files changed, 148 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c699221..d2bca4a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with - **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details - **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes) - **Work completely offline** with downloadable map areas and device data, plus upload queue -- **Multiple map types** including satellite imagery from Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support +- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support - **Editing Ability** to update existing device locations and properties - **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets @@ -22,7 +22,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with ## Key Features ### Map & Navigation -- **Multi-source tiles**: Switch between OpenStreetMap, Esri imagery, Mapbox, and any custom providers +- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers - **Offline-first design**: Download a region for complete offline operation - **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions - **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red) @@ -79,7 +79,6 @@ cp lib/keys.dart.example lib/keys.dart ## Roadmap ### Needed Bugfixes -- Map providers - No seach or navigation while offline (the thing we do now) - Let user search in release builds, just not navigate - Swap in tsbichof avoidance routing API diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 6f6445d..9b37dec 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -79,7 +79,7 @@ const int kMaxUserDownloadZoomSpan = 7; // Download area limits and constants const int kMaxReasonableTileCount = 20000; const int kAbsoluteMaxTileCount = 50000; -const int kAbsoluteMaxZoom = 19; +const int kAbsoluteMaxZoom = 23; // Camera icon configuration const double kCameraIconDiameter = 20.0; diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 52d41b3..166fb9c 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -184,6 +184,11 @@ "attribution": "Zuschreibung", "attributionHint": "© Karten-Anbieter", "attributionRequired": "Zuschreibung ist erforderlich", + "maxZoom": "Max Zoom-Stufe", + "maxZoomHint": "Maximale Zoom-Stufe (1-23)", + "maxZoomRequired": "Max Zoom ist erforderlich", + "maxZoomInvalid": "Max Zoom muss eine Zahl sein", + "maxZoomRange": "Max Zoom muss zwischen {} und {} liegen", "fetchPreview": "Vorschau Laden", "previewTileLoaded": "Vorschau-Kachel erfolgreich geladen", "previewTileFailed": "Vorschau laden fehlgeschlagen: {}", @@ -201,7 +206,8 @@ }, "mapTiles": { "title": "Karten-Kacheln", - "manageProviders": "Anbieter Verwalten" + "manageProviders": "Anbieter Verwalten", + "attribution": "Karten-Zuschreibung" }, "profileEditor": { "viewProfile": "Profil Anzeigen", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index b55a10f..1c8f65d 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -192,7 +192,7 @@ "attributionHint": "© Map Provider", "attributionRequired": "Attribution is required", "maxZoom": "Max Zoom Level", - "maxZoomHint": "Maximum zoom level (1-19)", + "maxZoomHint": "Maximum zoom level (1-23)", "maxZoomRequired": "Max zoom is required", "maxZoomInvalid": "Max zoom must be a number", "maxZoomRange": "Max zoom must be between {} and {}", @@ -213,7 +213,8 @@ }, "mapTiles": { "title": "Map Tiles", - "manageProviders": "Manage Providers" + "manageProviders": "Manage Providers", + "attribution": "Map Attribution" }, "profileEditor": { "viewProfile": "View Profile", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 099c07b..afc114c 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -191,6 +191,11 @@ "attribution": "Atribución", "attributionHint": "© Proveedor de Mapas", "attributionRequired": "La atribución es requerida", + "maxZoom": "Nivel de Zoom Máximo", + "maxZoomHint": "Nivel de zoom máximo (1-23)", + "maxZoomRequired": "El zoom máximo es requerido", + "maxZoomInvalid": "El zoom máximo debe ser un número", + "maxZoomRange": "El zoom máximo debe estar entre {} y {}", "fetchPreview": "Obtener Vista Previa", "previewTileLoaded": "Tile de vista previa cargado exitosamente", "previewTileFailed": "Falló al obtener vista previa: {}", @@ -208,7 +213,8 @@ }, "mapTiles": { "title": "Tiles de Mapa", - "manageProviders": "Gestionar Proveedores" + "manageProviders": "Gestionar Proveedores", + "attribution": "Atribución del Mapa" }, "profileEditor": { "viewProfile": "Ver Perfil", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 623a97f..4226618 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -191,6 +191,11 @@ "attribution": "Attribution", "attributionHint": "© Fournisseur de Cartes", "attributionRequired": "L'attribution est requise", + "maxZoom": "Niveau de Zoom Maximum", + "maxZoomHint": "Niveau de zoom maximum (1-23)", + "maxZoomRequired": "Le zoom maximum est requis", + "maxZoomInvalid": "Le zoom maximum doit être un nombre", + "maxZoomRange": "Le zoom maximum doit être entre {} et {}", "fetchPreview": "Récupérer Aperçu", "previewTileLoaded": "Tuile d'aperçu chargée avec succès", "previewTileFailed": "Échec de récupération de l'aperçu: {}", @@ -208,7 +213,8 @@ }, "mapTiles": { "title": "Tuiles de Carte", - "manageProviders": "Gérer Fournisseurs" + "manageProviders": "Gérer Fournisseurs", + "attribution": "Attribution de Carte" }, "profileEditor": { "viewProfile": "Voir Profil", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 8cb41ac..094e6cc 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -191,6 +191,11 @@ "attribution": "Attribuzione", "attributionHint": "© Fornitore Mappe", "attributionRequired": "L'attribuzione è obbligatoria", + "maxZoom": "Livello Zoom Massimo", + "maxZoomHint": "Livello di zoom massimo (1-23)", + "maxZoomRequired": "Il zoom massimo è obbligatorio", + "maxZoomInvalid": "Il zoom massimo deve essere un numero", + "maxZoomRange": "Il zoom massimo deve essere tra {} e {}", "fetchPreview": "Ottieni Anteprima", "previewTileLoaded": "Tile di anteprima caricato con successo", "previewTileFailed": "Impossibile ottenere l'anteprima: {}", @@ -208,7 +213,8 @@ }, "mapTiles": { "title": "Tile Mappa", - "manageProviders": "Gestisci Fornitori" + "manageProviders": "Gestisci Fornitori", + "attribution": "Attribuzione Mappa" }, "profileEditor": { "viewProfile": "Visualizza Profilo", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 57a179a..235cbc0 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -191,6 +191,11 @@ "attribution": "Atribuição", "attributionHint": "© Provedor de Mapas", "attributionRequired": "Atribuição é obrigatória", + "maxZoom": "Nível de Zoom Máximo", + "maxZoomHint": "Nível de zoom máximo (1-23)", + "maxZoomRequired": "Zoom máximo é obrigatório", + "maxZoomInvalid": "Zoom máximo deve ser um número", + "maxZoomRange": "Zoom máximo deve estar entre {} e {}", "fetchPreview": "Buscar Preview", "previewTileLoaded": "Tile de preview carregado com sucesso", "previewTileFailed": "Falha ao buscar preview: {}", @@ -208,7 +213,8 @@ }, "mapTiles": { "title": "Tiles do Mapa", - "manageProviders": "Gerenciar Provedores" + "manageProviders": "Gerenciar Provedores", + "attribution": "Atribuição do Mapa" }, "profileEditor": { "viewProfile": "Ver Perfil", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 8e0e4f6..d4e4dc2 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -191,6 +191,11 @@ "attribution": "归属", "attributionHint": "© 地图提供商", "attributionRequired": "归属为必填项", + "maxZoom": "最大缩放级别", + "maxZoomHint": "最大缩放级别 (1-23)", + "maxZoomRequired": "最大缩放为必填项", + "maxZoomInvalid": "最大缩放必须为数字", + "maxZoomRange": "最大缩放必须在 {} 和 {} 之间", "fetchPreview": "获取预览", "previewTileLoaded": "预览瓦片加载成功", "previewTileFailed": "获取预览失败:{}", @@ -208,7 +213,8 @@ }, "mapTiles": { "title": "地图瓦片", - "manageProviders": "管理提供商" + "manageProviders": "管理提供商", + "attribution": "地图归属" }, "profileEditor": { "viewProfile": "查看配置文件", diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index d563991..7a8df50 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -179,6 +179,46 @@ class DefaultTileProviders { ), ], ), + TileProvider( + id: 'usgs', + name: 'USGS', + tileTypes: [ + TileType( + id: 'usgs_imagery_only', + name: 'Imagery Only', + urlTemplate: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}', + attribution: 'USGS The National Map', + maxZoom: 23, + ), + TileType( + id: 'usgs_imagery_topo', + name: 'Imagery Topo', + urlTemplate: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer/tile/{z}/{y}/{x}', + attribution: 'USGS The National Map', + maxZoom: 23, + ), + TileType( + id: 'usgs_topo', + name: 'USGS Topo', + urlTemplate: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}', + attribution: 'USGS The National Map', + maxZoom: 23, + ), + ], + ), + TileProvider( + id: 'opentopomap_memomaps', + name: 'OpenTopoMap/Memomaps', + tileTypes: [ + TileType( + id: 'opentopomap_topo', + name: 'Topographic', + urlTemplate: 'https://tile.memomaps.de/tilegen/{z}/{y}/{x}.png', + attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © OpenTopoMap (CC-BY-SA)', + maxZoom: 18, + ), + ], + ), ]; } } diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 835de1f..0834954 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -153,6 +153,9 @@ class SettingsState extends ChangeNotifier { _tileProviders = providersList .map((json) => TileProvider.fromJson(json)) .toList(); + + // Migration: Add any missing built-in providers + await _addMissingBuiltinProviders(prefs); } } catch (e) { debugPrint('Error loading tile providers: $e'); @@ -166,6 +169,25 @@ class SettingsState extends ChangeNotifier { } } + /// Add any built-in providers that are missing from user's configuration + Future _addMissingBuiltinProviders(SharedPreferences prefs) async { + final defaultProviders = DefaultTileProviders.createDefaults(); + final existingProviderIds = _tileProviders.map((p) => p.id).toSet(); + bool hasUpdates = false; + + for (final defaultProvider in defaultProviders) { + if (!existingProviderIds.contains(defaultProvider.id)) { + _tileProviders.add(defaultProvider); + hasUpdates = true; + debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}'); + } + } + + if (hasUpdates) { + await _saveTileProviders(prefs); + } + } + Future _saveTileProviders(SharedPreferences prefs) async { try { final providersJson = jsonEncode( diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0b357a3..f22d8e1 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -27,6 +27,27 @@ class MapOverlays extends StatelessWidget { this.onSearchPressed, }); + /// Show full attribution text in a dialog + void _showAttributionDialog(BuildContext context, String attribution) { + final locService = LocalizationService.instance; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(locService.t('mapTiles.attribution')), + content: SelectableText( + attribution, + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(locService.t('actions.close')), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Stack( @@ -97,17 +118,23 @@ class MapOverlays extends StatelessWidget { Positioned( bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom), left: 10, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(0.9), - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Text( - attribution!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface, + child: GestureDetector( + onTap: () => _showAttributionDialog(context, attribution!), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(0.9), + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + constraints: const BoxConstraints(maxWidth: 250), + child: Text( + attribution!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), ),