Add other providers, max zoom per tile type

This commit is contained in:
stopflock
2025-10-05 15:08:27 -05:00
parent 5976ab4bab
commit a08d61fb98
12 changed files with 148 additions and 23 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "查看配置文件",

View File

@@ -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,
),
],
),
];
}
}

View File

@@ -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<void> _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<void> _saveTileProviders(SharedPreferences prefs) async {
try {
final providersJson = jsonEncode(

View File

@@ -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,
),
),
),