diff --git a/build_keys.conf.example b/build_keys.conf.example index 5638a972..6441ae68 100644 --- a/build_keys.conf.example +++ b/build_keys.conf.example @@ -1,10 +1,14 @@ -# Local OSM client ID configuration for builds +# Local build key configuration # Copy this file to build_keys.conf and fill in your values # This file is gitignored to keep your keys secret # # Get your client IDs from: -# Production: https://www.openstreetmap.org/oauth2/applications +# Production: https://www.openstreetmap.org/oauth2/applications # Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications OSM_PROD_CLIENTID=your_production_client_id_here -OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here \ No newline at end of file +OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here + +# Optional: Stadia Maps API key for vector tiles +# Get a free key from https://stadiamaps.com +STADIA_API_KEY= \ No newline at end of file diff --git a/do_builds.sh b/do_builds.sh index 94dbac67..c0b880b7 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -62,9 +62,10 @@ if [ ! -f "build_keys.conf" ]; then exit 1 fi -echo "Loading OSM client IDs from build_keys.conf..." +echo "Loading keys from build_keys.conf..." OSM_PROD_CLIENTID=$(read_from_file "OSM_PROD_CLIENTID") OSM_SANDBOX_CLIENTID=$(read_from_file "OSM_SANDBOX_CLIENTID") +STADIA_API_KEY=$(read_from_file "STADIA_API_KEY") # Check required keys if [ -z "$OSM_PROD_CLIENTID" ]; then @@ -80,6 +81,14 @@ fi # Build the dart-define arguments DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID" +# Optional keys +if [ -n "$STADIA_API_KEY" ]; then + DART_DEFINE_ARGS="$DART_DEFINE_ARGS --dart-define=STADIA_API_KEY=$STADIA_API_KEY" + echo " Stadia Maps API key: configured" +else + echo " Stadia Maps API key: not set (vector tiles will require manual key entry)" +fi + # Run tests before building echo "Running tests..." flutter test || exit 1 diff --git a/lib/app_state.dart b/lib/app_state.dart index 2688946f..e2a96622 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -11,6 +11,7 @@ import 'models/operator_profile.dart'; import 'models/osm_node.dart'; import 'models/pending_upload.dart'; import 'models/suspected_location.dart'; +import 'models/service_endpoint.dart'; import 'models/tile_provider.dart'; import 'models/search_result.dart'; import 'services/offline_area_service.dart'; @@ -31,6 +32,7 @@ import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/search_state.dart'; import 'state/session_state.dart'; +import 'state/service_registry.dart'; import 'state/settings_state.dart'; import 'state/suspected_location_state.dart'; import 'state/upload_queue_state.dart'; @@ -167,6 +169,14 @@ class AppState extends ChangeNotifier { bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get isCheckingMessages => _messagesState.isChecking; + // API endpoint settings + List get routingEndpoints => _settingsState.routingEndpoints; + List get enabledRoutingEndpoints => _settingsState.enabledRoutingEndpoints; + List get overpassEndpoints => _settingsState.overpassEndpoints; + List get enabledOverpassEndpoints => _settingsState.enabledOverpassEndpoints; + ServiceRegistry get routingRegistry => _settingsState.routingRegistry; + ServiceRegistry get overpassRegistry => _settingsState.overpassRegistry; + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; @@ -763,6 +773,8 @@ class AppState extends ChangeNotifier { await _settingsState.setDistanceUnit(unit); } + // Endpoint registry methods are accessed via routingRegistry/overpassRegistry directly + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 903b92a9..2c82f3ac 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -142,7 +142,7 @@ const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance b const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = 2000; // Default maximum number of nodes to render on the map at once (clustering handles visual density) // NSI (Name Suggestion Index) configuration const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful @@ -163,8 +163,31 @@ const int kMaxReasonableTileCount = 20000; const int kAbsoluteMaxTileCount = 50000; const int kAbsoluteMaxZoom = 23; +// Direction cone zoom gating +const int kDirectionConeMinZoomLevel = 14; + +// Direction cone arc smoothness +const int kDirectionConeArcPoints = 36; // points per 90 degrees +const int kDirectionConeMinArcPoints = 12; // minimum for narrow FOVs + // Node icon configuration const double kNodeIconDiameter = 18.0; + +// Node marker zoom scaling +const double kNodeIconReferenceZoom = 15.0; +const double kNodeIconScalePerZoom = 2.0; // px per zoom level +const double kNodeIconMinDiameter = 8.0; +const double kNodeIconMaxDiameter = 28.0; + +/// Returns marker diameter scaled by current zoom level +double getScaledNodeDiameter(double zoom) { + final scaled = kNodeIconDiameter + (zoom - kNodeIconReferenceZoom) * kNodeIconScalePerZoom; + return scaled.clamp(kNodeIconMinDiameter, kNodeIconMaxDiameter); +} + +// Clustering +const int kNodeClusterMaxZoomLevel = 13; // clustering active at zoom <= 13; disabled at zoom >= 14 +const double kClusterIconDiameter = 32.0; const double _kNodeRingThicknessBase = 2.5; const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue diff --git a/lib/keys.dart b/lib/keys.dart index 7c193815..f4f84bc3 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -1,5 +1,4 @@ -// OpenStreetMap OAuth client IDs for this app. -// These must be provided via --dart-define at build time. +// Build-time API keys, provided via --dart-define. /// Whether OSM OAuth secrets were provided at build time. /// When false, the app should force simulate mode. @@ -17,4 +16,7 @@ String get kOsmProdClientId { String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); return fromBuild; -} \ No newline at end of file +} + +// Stadia Maps API key (optional — vector tiles won't load without it). +const kStadiaApiKey = String.fromEnvironment('STADIA_API_KEY'); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 2c8e745f..b3f8653a 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -74,7 +74,23 @@ "advancedSettings": "Erweiterte Einstellungen", "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "proximityAlerts": "Näherungswarnungen", - "networkStatusIndicator": "Netzwerkstatus-Anzeige" + "networkStatusIndicator": "Netzwerkstatus-Anzeige", + "apiEndpoints": "API-Endpunkte", + "apiEndpointsDescription": "Prioritätsreihenfolge der API-Endpunkte konfigurieren, benutzerdefinierte Endpunkte hinzufügen oder auf Standard zurücksetzen.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knotenquelle", + "invalidUrl": "Geben Sie eine gültige HTTPS-URL ein", + "addEndpoint": "Endpunkt hinzufügen", + "editEndpoint": "Endpunkt bearbeiten", + "resetToDefaults": "Auf Standard zurücksetzen", + "endpointName": "Endpunktname", + "endpointUrl": "Endpunkt-URL", + "endpointMaxRetries": "Max. Wiederholungen", + "endpointTimeout": "Timeout (Sekunden)", + "endpointEnabled": "Aktiviert", + "noEnabledEndpoints": "Mindestens ein Endpunkt muss aktiviert bleiben", + "confirmResetEndpoints": "Alle Endpunkte auf Standardkonfiguration zurücksetzen?", + "builtInEndpoint": "Eingebaut" }, "proximityAlerts": { "getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte", @@ -284,7 +300,12 @@ "fetchPreview": "Vorschau Laden", "previewTileLoaded": "Vorschau-Kachel erfolgreich geladen", "previewTileFailed": "Vorschau laden fehlgeschlagen: {}", - "save": "Speichern" + "save": "Speichern", + "rasterTiles": "Raster", + "vectorTiles": "Vektor", + "styleUrl": "Stil-URL", + "styleUrlHint": "https://beispiel.com/style.json", + "styleUrlRequired": "Stil-URL ist erforderlich" }, "profiles": { "nodeProfiles": "Knoten-Profile", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 0c27a461..9e4e5883 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -111,7 +111,23 @@ "advancedSettings": "Advanced Settings", "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "proximityAlerts": "Proximity Alerts", - "networkStatusIndicator": "Network Status Indicator" + "networkStatusIndicator": "Network Status Indicator", + "apiEndpoints": "API Endpoints", + "apiEndpointsDescription": "Configure API endpoint priority order, add custom endpoints, or reset to defaults.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Node Source", + "invalidUrl": "Enter a valid HTTPS URL", + "addEndpoint": "Add Endpoint", + "editEndpoint": "Edit Endpoint", + "resetToDefaults": "Reset to Defaults", + "endpointName": "Endpoint Name", + "endpointUrl": "Endpoint URL", + "endpointMaxRetries": "Max Retries", + "endpointTimeout": "Timeout (seconds)", + "endpointEnabled": "Enabled", + "noEnabledEndpoints": "At least one endpoint must remain enabled", + "confirmResetEndpoints": "Reset all endpoints to their default configuration?", + "builtInEndpoint": "Built-in" }, "proximityAlerts": { "getNotified": "Get notified when approaching surveillance devices", @@ -321,7 +337,12 @@ "fetchPreview": "Fetch Preview", "previewTileLoaded": "Preview tile loaded successfully", "previewTileFailed": "Failed to fetch preview: {}", - "save": "Save" + "save": "Save", + "rasterTiles": "Raster", + "vectorTiles": "Vector", + "styleUrl": "Style URL", + "styleUrlHint": "https://example.com/style.json", + "styleUrlRequired": "Style URL is required" }, "profiles": { "nodeProfiles": "Node Profiles", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index cd2f80a7..70f28a38 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -111,7 +111,23 @@ "advancedSettings": "Configuración Avanzada", "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "proximityAlerts": "Alertas de Proximidad", - "networkStatusIndicator": "Indicador de Estado de Red" + "networkStatusIndicator": "Indicador de Estado de Red", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar el orden de prioridad de los endpoints de API, agregar endpoints personalizados o restablecer valores predeterminados.", + "apiEndpointRouting": "Enrutamiento", + "apiEndpointNodeSource": "Fuente de nodos", + "invalidUrl": "Ingrese una URL HTTPS válida", + "addEndpoint": "Agregar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Restablecer Valores Predeterminados", + "endpointName": "Nombre del Endpoint", + "endpointUrl": "URL del Endpoint", + "endpointMaxRetries": "Máx. Reintentos", + "endpointTimeout": "Tiempo de espera (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Al menos un endpoint debe permanecer habilitado", + "confirmResetEndpoints": "¿Restablecer todos los endpoints a su configuración predeterminada?", + "builtInEndpoint": "Incorporado" }, "proximityAlerts": { "getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia", @@ -321,7 +337,12 @@ "fetchPreview": "Obtener Vista Previa", "previewTileLoaded": "Tile de vista previa cargado exitosamente", "previewTileFailed": "Falló al obtener vista previa: {}", - "save": "Guardar" + "save": "Guardar", + "rasterTiles": "Ráster", + "vectorTiles": "Vectorial", + "styleUrl": "URL de estilo", + "styleUrlHint": "https://ejemplo.com/style.json", + "styleUrlRequired": "La URL de estilo es obligatoria" }, "profiles": { "nodeProfiles": "Perfiles de Nodos", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 18d4ba1e..4755cf7e 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -111,7 +111,23 @@ "advancedSettings": "Paramètres Avancés", "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "proximityAlerts": "Alertes de Proximité", - "networkStatusIndicator": "Indicateur de Statut Réseau" + "networkStatusIndicator": "Indicateur de Statut Réseau", + "apiEndpoints": "Points d'accès API", + "apiEndpointsDescription": "Configurer l'ordre de priorité des points d'accès API, ajouter des points d'accès personnalisés ou réinitialiser aux valeurs par défaut.", + "apiEndpointRouting": "Routage", + "apiEndpointNodeSource": "Source des nœuds", + "invalidUrl": "Entrez une URL HTTPS valide", + "addEndpoint": "Ajouter un Point d'accès", + "editEndpoint": "Modifier le Point d'accès", + "resetToDefaults": "Réinitialiser aux Valeurs par Défaut", + "endpointName": "Nom du Point d'accès", + "endpointUrl": "URL du Point d'accès", + "endpointMaxRetries": "Max. Tentatives", + "endpointTimeout": "Délai d'attente (secondes)", + "endpointEnabled": "Activé", + "noEnabledEndpoints": "Au moins un point d'accès doit rester activé", + "confirmResetEndpoints": "Réinitialiser tous les points d'accès à leur configuration par défaut ?", + "builtInEndpoint": "Intégré" }, "proximityAlerts": { "getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance", @@ -321,7 +337,12 @@ "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: {}", - "save": "Sauvegarder" + "save": "Sauvegarder", + "rasterTiles": "Raster", + "vectorTiles": "Vectoriel", + "styleUrl": "URL de style", + "styleUrlHint": "https://exemple.com/style.json", + "styleUrlRequired": "L'URL de style est requise" }, "profiles": { "nodeProfiles": "Profils de Nœuds", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 4c573d9a..463bb10d 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -111,7 +111,23 @@ "advancedSettings": "Impostazioni Avanzate", "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "proximityAlerts": "Avvisi di Prossimità", - "networkStatusIndicator": "Indicatore di Stato di Rete" + "networkStatusIndicator": "Indicatore di Stato di Rete", + "apiEndpoints": "Endpoint API", + "apiEndpointsDescription": "Configura l'ordine di priorità degli endpoint API, aggiungi endpoint personalizzati o ripristina i valori predefiniti.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Fonte dei nodi", + "invalidUrl": "Inserisci un URL HTTPS valido", + "addEndpoint": "Aggiungi Endpoint", + "editEndpoint": "Modifica Endpoint", + "resetToDefaults": "Ripristina Predefiniti", + "endpointName": "Nome Endpoint", + "endpointUrl": "URL Endpoint", + "endpointMaxRetries": "Max Tentativi", + "endpointTimeout": "Timeout (secondi)", + "endpointEnabled": "Abilitato", + "noEnabledEndpoints": "Almeno un endpoint deve rimanere abilitato", + "confirmResetEndpoints": "Ripristinare tutti gli endpoint alla configurazione predefinita?", + "builtInEndpoint": "Integrato" }, "proximityAlerts": { "getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza", @@ -321,7 +337,12 @@ "fetchPreview": "Ottieni Anteprima", "previewTileLoaded": "Tile di anteprima caricato con successo", "previewTileFailed": "Impossibile ottenere l'anteprima: {}", - "save": "Salva" + "save": "Salva", + "rasterTiles": "Raster", + "vectorTiles": "Vettoriale", + "styleUrl": "URL dello stile", + "styleUrlHint": "https://esempio.com/style.json", + "styleUrlRequired": "L'URL dello stile è obbligatorio" }, "profiles": { "nodeProfiles": "Profili Nodo", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 40037a0f..3e5024e2 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -111,7 +111,23 @@ "advancedSettings": "Geavanceerde Instellingen", "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "proximityAlerts": "Nabijheids Waarschuwingen", - "networkStatusIndicator": "Netwerk Status Indicator" + "networkStatusIndicator": "Netwerk Status Indicator", + "apiEndpoints": "API-eindpunten", + "apiEndpointsDescription": "Configureer de prioriteitsvolgorde van API-eindpunten, voeg aangepaste eindpunten toe of reset naar standaard.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knooppuntbron", + "invalidUrl": "Voer een geldige HTTPS-URL in", + "addEndpoint": "Eindpunt Toevoegen", + "editEndpoint": "Eindpunt Bewerken", + "resetToDefaults": "Standaardwaarden Herstellen", + "endpointName": "Eindpuntnaam", + "endpointUrl": "Eindpunt-URL", + "endpointMaxRetries": "Max. Pogingen", + "endpointTimeout": "Timeout (seconden)", + "endpointEnabled": "Ingeschakeld", + "noEnabledEndpoints": "Minstens één eindpunt moet ingeschakeld blijven", + "confirmResetEndpoints": "Alle eindpunten terugzetten naar standaardconfiguratie?", + "builtInEndpoint": "Ingebouwd" }, "proximityAlerts": { "getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert", @@ -321,7 +337,12 @@ "fetchPreview": "Haal Voorbeeld Op", "previewTileLoaded": "Voorbeeld tile succesvol geladen", "previewTileFailed": "Kon voorbeeld niet ophalen: {}", - "save": "Opslaan" + "save": "Opslaan", + "rasterTiles": "Raster", + "vectorTiles": "Vector", + "styleUrl": "Stijl-URL", + "styleUrlHint": "https://voorbeeld.com/style.json", + "styleUrlRequired": "Stijl-URL is vereist" }, "profiles": { "nodeProfiles": "Node Profielen", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 5d7a2c1f..e3e89fb7 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -111,7 +111,23 @@ "advancedSettings": "Ustawienia Zaawansowane", "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "proximityAlerts": "Alerty Bliskości", - "networkStatusIndicator": "Wskaźnik Stanu Sieci" + "networkStatusIndicator": "Wskaźnik Stanu Sieci", + "apiEndpoints": "Punkty końcowe API", + "apiEndpointsDescription": "Skonfiguruj kolejność priorytetów punktów końcowych API, dodaj niestandardowe punkty końcowe lub przywróć wartości domyślne.", + "apiEndpointRouting": "Trasowanie", + "apiEndpointNodeSource": "Źródło węzłów", + "invalidUrl": "Wprowadź prawidłowy URL HTTPS", + "addEndpoint": "Dodaj Punkt Końcowy", + "editEndpoint": "Edytuj Punkt Końcowy", + "resetToDefaults": "Przywróć Domyślne", + "endpointName": "Nazwa Punktu Końcowego", + "endpointUrl": "URL Punktu Końcowego", + "endpointMaxRetries": "Maks. Powtórzeń", + "endpointTimeout": "Limit czasu (sekundy)", + "endpointEnabled": "Włączony", + "noEnabledEndpoints": "Co najmniej jeden punkt końcowy musi pozostać włączony", + "confirmResetEndpoints": "Przywrócić wszystkie punkty końcowe do konfiguracji domyślnej?", + "builtInEndpoint": "Wbudowany" }, "proximityAlerts": { "getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru", @@ -321,7 +337,12 @@ "fetchPreview": "Pobierz Podgląd", "previewTileLoaded": "Kafelek podglądu załadowany pomyślnie", "previewTileFailed": "Nie udało się pobrać podglądu: {}", - "save": "Zapisz" + "save": "Zapisz", + "rasterTiles": "Rastrowe", + "vectorTiles": "Wektorowe", + "styleUrl": "URL stylu", + "styleUrlHint": "https://przyklad.com/style.json", + "styleUrlRequired": "URL stylu jest wymagany" }, "profiles": { "nodeProfiles": "Profile Węzłów", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index e135485d..5ffbca75 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -111,7 +111,23 @@ "advancedSettings": "Configurações Avançadas", "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "proximityAlerts": "Alertas de Proximidade", - "networkStatusIndicator": "Indicador de Status de Rede" + "networkStatusIndicator": "Indicador de Status de Rede", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar a ordem de prioridade dos endpoints de API, adicionar endpoints personalizados ou redefinir para os padrões.", + "apiEndpointRouting": "Roteamento", + "apiEndpointNodeSource": "Fonte de nós", + "invalidUrl": "Insira um URL HTTPS válido", + "addEndpoint": "Adicionar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Redefinir para Padrões", + "endpointName": "Nome do Endpoint", + "endpointUrl": "URL do Endpoint", + "endpointMaxRetries": "Máx. Tentativas", + "endpointTimeout": "Tempo limite (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Pelo menos um endpoint deve permanecer habilitado", + "confirmResetEndpoints": "Redefinir todos os endpoints para a configuração padrão?", + "builtInEndpoint": "Integrado" }, "proximityAlerts": { "getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância", @@ -321,7 +337,12 @@ "fetchPreview": "Buscar Preview", "previewTileLoaded": "Tile de preview carregado com sucesso", "previewTileFailed": "Falha ao buscar preview: {}", - "save": "Salvar" + "save": "Salvar", + "rasterTiles": "Raster", + "vectorTiles": "Vetorial", + "styleUrl": "URL do estilo", + "styleUrlHint": "https://exemplo.com/style.json", + "styleUrlRequired": "URL do estilo é obrigatório" }, "profiles": { "nodeProfiles": "Perfis de Nó", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 491a4844..fa258441 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -111,7 +111,23 @@ "advancedSettings": "Gelişmiş Ayarlar", "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "proximityAlerts": "Yakınlık Uyarıları", - "networkStatusIndicator": "Ağ Durumu Göstergesi" + "networkStatusIndicator": "Ağ Durumu Göstergesi", + "apiEndpoints": "API Uç Noktaları", + "apiEndpointsDescription": "API uç noktası öncelik sırasını yapılandırın, özel uç noktalar ekleyin veya varsayılanlara sıfırlayın.", + "apiEndpointRouting": "Yönlendirme", + "apiEndpointNodeSource": "Düğüm kaynağı", + "invalidUrl": "Geçerli bir HTTPS URL'si girin", + "addEndpoint": "Uç Nokta Ekle", + "editEndpoint": "Uç Noktayı Düzenle", + "resetToDefaults": "Varsayılanlara Sıfırla", + "endpointName": "Uç Nokta Adı", + "endpointUrl": "Uç Nokta URL'si", + "endpointMaxRetries": "Maks. Yeniden Deneme", + "endpointTimeout": "Zaman Aşımı (saniye)", + "endpointEnabled": "Etkin", + "noEnabledEndpoints": "En az bir uç nokta etkin kalmalıdır", + "confirmResetEndpoints": "Tüm uç noktalar varsayılan yapılandırmaya sıfırlansın mı?", + "builtInEndpoint": "Yerleşik" }, "proximityAlerts": { "getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al", @@ -321,7 +337,12 @@ "fetchPreview": "Önizleme Getir", "previewTileLoaded": "Önizleme döşemesi başarıyla yüklendi", "previewTileFailed": "Önizleme getirilemedi: {}", - "save": "Kaydet" + "save": "Kaydet", + "rasterTiles": "Raster", + "vectorTiles": "Vektör", + "styleUrl": "Stil URL'si", + "styleUrlHint": "https://ornek.com/style.json", + "styleUrlRequired": "Stil URL'si gerekli" }, "profiles": { "nodeProfiles": "Düğüm Profilleri", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e86696b7..af90b4f2 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -111,7 +111,23 @@ "advancedSettings": "Розширені Налаштування", "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "proximityAlerts": "Сповіщення Про Близькість", - "networkStatusIndicator": "Індикатор Стану Мережі" + "networkStatusIndicator": "Індикатор Стану Мережі", + "apiEndpoints": "Кінцеві точки API", + "apiEndpointsDescription": "Налаштувати порядок пріоритету кінцевих точок API, додати власні кінцеві точки або скинути до стандартних.", + "apiEndpointRouting": "Маршрутизація", + "apiEndpointNodeSource": "Джерело вузлів", + "invalidUrl": "Введіть дійсну HTTPS URL-адресу", + "addEndpoint": "Додати Кінцеву Точку", + "editEndpoint": "Редагувати Кінцеву Точку", + "resetToDefaults": "Скинути до Стандартних", + "endpointName": "Назва Кінцевої Точки", + "endpointUrl": "URL Кінцевої Точки", + "endpointMaxRetries": "Макс. Спроб", + "endpointTimeout": "Тайм-аут (секунди)", + "endpointEnabled": "Увімкнено", + "noEnabledEndpoints": "Принаймні одна кінцева точка повинна залишатися увімкненою", + "confirmResetEndpoints": "Скинути всі кінцеві точки до стандартної конфігурації?", + "builtInEndpoint": "Вбудована" }, "proximityAlerts": { "getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження", @@ -321,7 +337,12 @@ "fetchPreview": "Отримати Попередній Перегляд", "previewTileLoaded": "Плитка попереднього перегляду успішно завантажена", "previewTileFailed": "Не вдалося отримати попередній перегляд: {}", - "save": "Зберегти" + "save": "Зберегти", + "rasterTiles": "Растрові", + "vectorTiles": "Векторні", + "styleUrl": "URL стилю", + "styleUrlHint": "https://приклад.com/style.json", + "styleUrlRequired": "URL стилю є обов'язковим" }, "profiles": { "nodeProfiles": "Профілі Вузлів", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index a62a8585..b19e11b7 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -111,7 +111,23 @@ "advancedSettings": "高级设置", "advancedSettingsSubtitle": "性能、警报和地图提供商设置", "proximityAlerts": "邻近警报", - "networkStatusIndicator": "网络状态指示器" + "networkStatusIndicator": "网络状态指示器", + "apiEndpoints": "API 端点", + "apiEndpointsDescription": "配置 API 端点优先级顺序,添加自定义端点或重置为默认值。", + "apiEndpointRouting": "路由", + "apiEndpointNodeSource": "节点来源", + "invalidUrl": "请输入有效的 HTTPS URL", + "addEndpoint": "添加端点", + "editEndpoint": "编辑端点", + "resetToDefaults": "重置为默认值", + "endpointName": "端点名称", + "endpointUrl": "端点 URL", + "endpointMaxRetries": "最大重试次数", + "endpointTimeout": "超时时间(秒)", + "endpointEnabled": "已启用", + "noEnabledEndpoints": "至少需要保留一个已启用的端点", + "confirmResetEndpoints": "将所有端点重置为默认配置?", + "builtInEndpoint": "内置" }, "proximityAlerts": { "getNotified": "接近监控设备时接收通知", @@ -321,7 +337,12 @@ "fetchPreview": "获取预览", "previewTileLoaded": "预览瓦片加载成功", "previewTileFailed": "获取预览失败:{}", - "save": "保存" + "save": "保存", + "rasterTiles": "栅格", + "vectorTiles": "矢量", + "styleUrl": "样式 URL", + "styleUrlHint": "https://example.com/style.json", + "styleUrlRequired": "样式 URL 为必填项" }, "profiles": { "nodeProfiles": "节点配置文件", diff --git a/lib/models/service_endpoint.dart b/lib/models/service_endpoint.dart new file mode 100644 index 00000000..1b94dd79 --- /dev/null +++ b/lib/models/service_endpoint.dart @@ -0,0 +1,116 @@ +import 'service_registry_entry.dart'; + +/// A configurable API endpoint with optional resilience overrides. +/// +/// Used by [RoutingService] and [OverpassService] as entries in +/// their priority-ordered endpoint lists. +class ServiceEndpoint implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + + /// The endpoint URL (must be HTTPS). + final String url; + + @override + final bool enabled; + @override + final bool isBuiltIn; + + /// Override the service's default max retry count. Null = use default. + final int? maxRetries; + + /// Override the service's default HTTP timeout in seconds. Null = use default. + final int? timeoutSeconds; + + const ServiceEndpoint({ + required this.id, + required this.name, + required this.url, + this.enabled = true, + this.isBuiltIn = false, + this.maxRetries, + this.timeoutSeconds, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + if (maxRetries != null) 'maxRetries': maxRetries, + if (timeoutSeconds != null) 'timeoutSeconds': timeoutSeconds, + }; + + static ServiceEndpoint fromJson(Map json) => ServiceEndpoint( + id: json['id'] as String, + name: json['name'] as String, + url: json['url'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + maxRetries: json['maxRetries'] as int?, + timeoutSeconds: json['timeoutSeconds'] as int?, + ); + + ServiceEndpoint copyWith({ + String? id, + String? name, + String? url, + bool? enabled, + bool? isBuiltIn, + int? maxRetries, + int? timeoutSeconds, + }) => ServiceEndpoint( + id: id ?? this.id, + name: name ?? this.name, + url: url ?? this.url, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn ?? this.isBuiltIn, + maxRetries: maxRetries ?? this.maxRetries, + timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ServiceEndpoint && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Default routing endpoints. +class DefaultServiceEndpoints { + static List routing() => const [ + ServiceEndpoint( + id: 'routing-deflock', + name: 'Deflock Primary', + url: 'https://api.dontgetflocked.com/api/v1/deflock/directions', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'routing-alprwatch', + name: 'ALPRWatch Fallback', + url: 'https://alprwatch.org/api/v1/deflock/directions', + isBuiltIn: true, + ), + ]; + + static List overpass() => const [ + ServiceEndpoint( + id: 'overpass-deflock', + name: 'Deflock Node Source', + url: 'https://overpass.deflock.org/api/interpreter', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'overpass-public', + name: 'Public Overpass', + url: 'https://overpass-api.de/api/interpreter', + isBuiltIn: true, + ), + ]; +} diff --git a/lib/models/service_registry_entry.dart b/lib/models/service_registry_entry.dart new file mode 100644 index 00000000..db671b75 --- /dev/null +++ b/lib/models/service_registry_entry.dart @@ -0,0 +1,22 @@ +/// Shared interface for entries managed by a [ServiceRegistry]. +/// +/// Both [ServiceEndpoint] and (in a future PR) [TileType] implement this, +/// enabling generic list management, persistence, and UI components. +abstract interface class ServiceRegistryEntry { + /// Unique identifier for this entry. + String get id; + + /// Human-readable display name. + String get name; + + /// Whether this entry is active. Disabled entries are skipped by + /// the resilience engine and hidden from primary selection UI. + bool get enabled; + + /// Whether this entry was provided by the app (built-in default). + /// Built-in entries cannot be deleted in production builds. + bool get isBuiltIn; + + /// Serialize to JSON for SharedPreferences persistence. + Map toJson(); +} diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 5304d33e..fee452d5 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,8 +1,18 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../keys.dart'; import '../services/service_policy.dart'; +/// Placeholder token in URL templates that gets replaced with the actual API key. +const kApiKeyPlaceholder = '{api_key}'; + +/// Whether a tile type serves raster (PNG/JPEG) or vector (style JSON) tiles. +enum TileSourceType { + rasterXyz, + vectorStyle, +} + /// A specific tile type within a provider class TileType { final String id; @@ -11,6 +21,8 @@ class TileType { final String attribution; final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type + final TileSourceType sourceType; + final String? styleUrl; // Vector style JSON URL (for vectorStyle types) TileType({ required this.id, @@ -19,8 +31,16 @@ class TileType { required this.attribution, this.previewTile, this.maxZoom = 18, // Default max zoom level + this.sourceType = TileSourceType.rasterXyz, + this.styleUrl, }); + /// Whether this tile type uses vector tiles. + bool get isVector => sourceType == TileSourceType.vectorStyle; + + /// Whether this tile type uses raster tiles. + bool get isRaster => sourceType == TileSourceType.rasterXyz; + /// Create URL for a specific tile, replacing template variables /// /// Supported placeholders: @@ -56,7 +76,7 @@ class TileType { .replaceAll('{y}', y.toString()); if (apiKey != null && apiKey.isNotEmpty) { - url = url.replaceAll('{api_key}', apiKey); + url = url.replaceAll(kApiKeyPlaceholder, apiKey); } return url; @@ -76,7 +96,7 @@ class TileType { } /// Check if this tile type needs an API key - bool get requiresApiKey => urlTemplate.contains('{api_key}'); + bool get requiresApiKey => urlTemplate.contains(kApiKeyPlaceholder); /// The service policy that applies to this tile type's server. /// Cached because [urlTemplate] is immutable. @@ -84,8 +104,10 @@ class TileType { ServicePolicyResolver.resolve(urlTemplate); /// Whether this tile server's usage policy permits offline/bulk downloading. - /// Resolved via [ServicePolicyResolver] from the URL template. - bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + /// Always false for vector tile types (offline download not yet supported). + /// For raster types, resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => + isVector ? false : servicePolicy.allowsOfflineDownload; Map toJson() => { 'id': id, @@ -94,18 +116,33 @@ class TileType { 'attribution': attribution, 'previewTile': previewTile != null ? base64Encode(previewTile!) : null, 'maxZoom': maxZoom, + if (sourceType != TileSourceType.rasterXyz) + 'sourceType': sourceType.name, + if (styleUrl != null) 'styleUrl': styleUrl, }; - static TileType fromJson(Map json) => TileType( - id: json['id'], - name: json['name'], - urlTemplate: json['urlTemplate'], - attribution: json['attribution'], - previewTile: json['previewTile'] != null - ? base64Decode(json['previewTile']) - : null, - maxZoom: json['maxZoom'] ?? 18, // Default to 18 if not specified - ); + static TileType fromJson(Map json) { + final sourceTypeName = json['sourceType'] as String?; + final sourceType = sourceTypeName != null + ? TileSourceType.values.firstWhere( + (e) => e.name == sourceTypeName, + orElse: () => TileSourceType.rasterXyz, + ) + : TileSourceType.rasterXyz; + + return TileType( + id: json['id'], + name: json['name'], + urlTemplate: json['urlTemplate'], + attribution: json['attribution'], + previewTile: json['previewTile'] != null + ? base64Decode(json['previewTile']) + : null, + maxZoom: json['maxZoom'] ?? 18, + sourceType: sourceType, + styleUrl: json['styleUrl'], + ); + } TileType copyWith({ String? id, @@ -114,6 +151,8 @@ class TileType { String? attribution, Uint8List? previewTile, int? maxZoom, + TileSourceType? sourceType, + String? styleUrl, }) => TileType( id: id ?? this.id, name: name ?? this.name, @@ -121,6 +160,8 @@ class TileType { attribution: attribution ?? this.attribution, previewTile: previewTile ?? this.previewTile, maxZoom: maxZoom ?? this.maxZoom, + sourceType: sourceType ?? this.sourceType, + styleUrl: styleUrl ?? this.styleUrl, ); @override @@ -256,6 +297,37 @@ class DefaultTileProviders { ), ], ), + TileProvider( + id: 'stadiamaps_vector', + name: 'Stadia Maps (Vector)', + apiKey: kStadiaApiKey.isNotEmpty ? kStadiaApiKey : null, + tileTypes: [ + TileType( + id: 'stadia_osm_bright', + name: 'OSM Bright', + urlTemplate: 'https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={api_key}', + attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', + maxZoom: 20, + sourceType: TileSourceType.vectorStyle, + styleUrl: 'https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={api_key}', + ), + ], + ), + TileProvider( + id: 'maptiler_vector', + name: 'MapTiler (Vector)', + tileTypes: [ + TileType( + id: 'maptiler_streets', + name: 'Streets', + urlTemplate: 'https://api.maptiler.com/maps/streets-v2/style.json?key={api_key}', + attribution: '© MapTiler © OpenStreetMap contributors', + maxZoom: 20, + sourceType: TileSourceType.vectorStyle, + styleUrl: 'https://api.maptiler.com/maps/streets-v2/style.json?key={api_key}', + ), + ], + ), ]; } } diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index b5630a44..ffc78a30 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'settings/sections/api_endpoints_section.dart'; import 'settings/sections/max_nodes_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/suspected_locations_section.dart'; @@ -35,6 +36,8 @@ class AdvancedSettingsScreen extends StatelessWidget { // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled // Divider(), TileProviderSection(), + Divider(), + ApiEndpointsSection(), ], ), ), diff --git a/lib/screens/settings/sections/api_endpoints_section.dart b/lib/screens/settings/sections/api_endpoints_section.dart new file mode 100644 index 00000000..da4cc510 --- /dev/null +++ b/lib/screens/settings/sections/api_endpoints_section.dart @@ -0,0 +1,280 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../app_state.dart'; +import '../../../models/service_endpoint.dart'; +import '../../../state/service_registry.dart'; +import '../../../services/localization_service.dart'; + +class ApiEndpointsSection extends StatelessWidget { + const ApiEndpointsSection({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Consumer( + builder: (context, appState, _) { + final loc = LocalizationService.instance; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.t('settings.apiEndpoints'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + loc.t('settings.apiEndpointsDescription'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointRouting'), + registry: appState.routingRegistry, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointNodeSource'), + registry: appState.overpassRegistry, + ), + ], + ); + }, + ), + ); + } +} + +class _EndpointRegistryList extends StatelessWidget { + final String label; + final ServiceRegistry registry; + + const _EndpointRegistryList({ + required this.label, + required this.registry, + }); + + bool _isValidHttpsUrl(String url) { + if (url.isEmpty) return false; + final uri = Uri.tryParse(url); + return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty; + } + + @override + Widget build(BuildContext context) { + final loc = LocalizationService.instance; + final entries = registry.entries; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + )), + const SizedBox(height: 8), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: entries.length, + onReorder: (oldIndex, newIndex) { + registry.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final endpoint = entries[index]; + return _EndpointTile( + key: ValueKey(endpoint.id), + endpoint: endpoint, + index: index, + registry: registry, + canDelete: !endpoint.isBuiltIn || kDebugMode, + onToggle: (enabled) { + // Prevent disabling the last enabled endpoint + if (!enabled && registry.enabledEntries.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(loc.t('settings.noEnabledEndpoints'))), + ); + return; + } + registry.addOrUpdate(endpoint.copyWith(enabled: enabled)); + }, + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), + label: Text(loc.t('settings.addEndpoint')), + onPressed: () => _showAddDialog(context), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.restore, size: 18), + label: Text(loc.t('settings.resetToDefaults')), + onPressed: () => _showResetDialog(context), + ), + ], + ), + ], + ); + } + + void _showAddDialog(BuildContext context) { + final loc = LocalizationService.instance; + final nameController = TextEditingController(); + final urlController = TextEditingController(); + String? urlError; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(loc.t('settings.addEndpoint')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointName'), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointUrl'), + border: const OutlineInputBorder(), + errorText: urlError, + hintText: 'https://', + ), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + final url = urlController.text.trim(); + final name = nameController.text.trim(); + if (!_isValidHttpsUrl(url)) { + setState(() => urlError = loc.t('settings.invalidUrl')); + return; + } + if (name.isEmpty) return; + final id = 'custom-${DateTime.now().millisecondsSinceEpoch}'; + registry.addOrUpdate(ServiceEndpoint( + id: id, + name: name, + url: url, + )); + Navigator.pop(context); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ), + ); + } + + void _showResetDialog(BuildContext context) { + final loc = LocalizationService.instance; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(loc.t('settings.resetToDefaults')), + content: Text(loc.t('settings.confirmResetEndpoints')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + registry.resetToDefaults(); + Navigator.pop(dialogContext); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + final ServiceEndpoint endpoint; + final int index; + final ServiceRegistry registry; + final bool canDelete; + final void Function(bool enabled) onToggle; + + const _EndpointTile({ + super.key, + required this.endpoint, + required this.index, + required this.registry, + required this.canDelete, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 20), + ), + title: Text( + endpoint.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: endpoint.enabled ? null : theme.disabledColor, + ), + ), + subtitle: Text( + endpoint.url, + style: theme.textTheme.bodySmall?.copyWith( + color: endpoint.enabled ? theme.hintColor : theme.disabledColor, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: endpoint.enabled, + onChanged: onToggle, + ), + if (canDelete) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () { + try { + registry.delete(endpoint.id); + } on StateError catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/sections/max_nodes_section.dart b/lib/screens/settings/sections/max_nodes_section.dart index 51f8a833..94569d79 100644 --- a/lib/screens/settings/sections/max_nodes_section.dart +++ b/lib/screens/settings/sections/max_nodes_section.dart @@ -34,7 +34,7 @@ class _MaxNodesSectionState extends State { final locService = LocalizationService.instance; final appState = context.watch(); final current = appState.maxNodes; - final showWarning = current > 1000; + final showWarning = current > 5000; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/tile_provider_editor_screen.dart b/lib/screens/tile_provider_editor_screen.dart index d8337001..ff698c93 100644 --- a/lib/screens/tile_provider_editor_screen.dart +++ b/lib/screens/tile_provider_editor_screen.dart @@ -127,7 +127,9 @@ class _TileProviderEditorScreenState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tileType.urlTemplate), + Text(tileType.isVector + ? tileType.styleUrl ?? tileType.urlTemplate + : tileType.urlTemplate), Text( tileType.attribution, style: Theme.of(context).textTheme.bodySmall, @@ -261,6 +263,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { late final TextEditingController _urlController; late final TextEditingController _attributionController; late final TextEditingController _maxZoomController; + late final TextEditingController _styleUrlController; + late TileSourceType _sourceType; Uint8List? _previewTile; bool _isLoadingPreview = false; @@ -272,6 +276,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { _urlController = TextEditingController(text: tileType?.urlTemplate ?? ''); _attributionController = TextEditingController(text: tileType?.attribution ?? ''); _maxZoomController = TextEditingController(text: (tileType?.maxZoom ?? 18).toString()); + _styleUrlController = TextEditingController(text: tileType?.styleUrl ?? ''); + _sourceType = tileType?.sourceType ?? TileSourceType.rasterXyz; _previewTile = tileType?.previewTile; } @@ -281,6 +287,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { _urlController.dispose(); _attributionController.dispose(); _maxZoomController.dispose(); + _styleUrlController.dispose(); super.dispose(); } @@ -291,6 +298,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { builder: (context, child) { final locService = LocalizationService.instance; + final isVector = _sourceType == TileSourceType.vectorStyle; + return AlertDialog( title: Text(widget.tileType != null ? locService.t('tileTypeEditor.editTileType') : locService.t('tileTypeEditor.addTileType')), content: SizedBox( @@ -300,6 +309,26 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Source type selector + SegmentedButton( + segments: [ + ButtonSegment( + value: TileSourceType.rasterXyz, + label: Text(locService.t('tileTypeEditor.rasterTiles')), + icon: const Icon(Icons.grid_on), + ), + ButtonSegment( + value: TileSourceType.vectorStyle, + label: Text(locService.t('tileTypeEditor.vectorTiles')), + icon: const Icon(Icons.route), + ), + ], + selected: {_sourceType}, + onSelectionChanged: (selected) { + setState(() => _sourceType = selected.first); + }, + ), + const SizedBox(height: 16), TextFormField( controller: _nameController, decoration: InputDecoration( @@ -309,26 +338,43 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.nameRequired') : null, ), const SizedBox(height: 16), - TextFormField( - controller: _urlController, - decoration: InputDecoration( - labelText: locService.t('tileTypeEditor.urlTemplate'), - hintText: locService.t('tileTypeEditor.urlTemplateHint'), + if (isVector) ...[ + // Vector: show style URL field + TextFormField( + controller: _styleUrlController, + decoration: InputDecoration( + labelText: locService.t('tileTypeEditor.styleUrl'), + hintText: locService.t('tileTypeEditor.styleUrlHint'), + ), + validator: (value) { + if (value?.trim().isEmpty == true) { + return locService.t('tileTypeEditor.styleUrlRequired'); + } + return null; + }, ), - validator: (value) { - if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired'); - - // Check for either quadkey OR x+y+z placeholders - final hasQuadkey = value!.contains('{quadkey}'); - final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}'); - - if (!hasQuadkey && !hasXYZ) { - return locService.t('tileTypeEditor.urlTemplatePlaceholders'); - } - - return null; - }, - ), + ] else ...[ + // Raster: show URL template field + TextFormField( + controller: _urlController, + decoration: InputDecoration( + labelText: locService.t('tileTypeEditor.urlTemplate'), + hintText: locService.t('tileTypeEditor.urlTemplateHint'), + ), + validator: (value) { + if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired'); + + final hasQuadkey = value!.contains('{quadkey}'); + final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}'); + + if (!hasQuadkey && !hasXYZ) { + return locService.t('tileTypeEditor.urlTemplatePlaceholders'); + } + + return null; + }, + ), + ], const SizedBox(height: 16), TextFormField( controller: _attributionController, @@ -354,32 +400,34 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { return null; }, ), - const SizedBox(height: 16), - Row( - children: [ - TextButton.icon( - onPressed: _isLoadingPreview ? null : _fetchPreviewTile, - icon: _isLoadingPreview - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.preview), - label: Text(locService.t('tileTypeEditor.fetchPreview')), - ), - const SizedBox(width: 8), - if (_previewTile != null) - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - ), - child: Image.memory(_previewTile!, fit: BoxFit.cover), + if (!isVector) ...[ + const SizedBox(height: 16), + Row( + children: [ + TextButton.icon( + onPressed: _isLoadingPreview ? null : _fetchPreviewTile, + icon: _isLoadingPreview + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.preview), + label: Text(locService.t('tileTypeEditor.fetchPreview')), ), - ], - ), + const SizedBox(width: 8), + if (_previewTile != null) + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.memory(_previewTile!, fit: BoxFit.cover), + ), + ], + ), + ], ], ), ), @@ -456,16 +504,26 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { void _saveTileType() { if (!_formKey.currentState!.validate()) return; - final tileTypeId = widget.tileType?.id ?? + final tileTypeId = widget.tileType?.id ?? '${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}'; - + + final isVector = _sourceType == TileSourceType.vectorStyle; + final styleUrl = isVector ? _styleUrlController.text.trim() : null; + // For vector types, use the style URL as the urlTemplate so + // ServicePolicyResolver can still extract the host. + final urlTemplate = isVector + ? (styleUrl ?? '') + : _urlController.text.trim(); + final tileType = TileType( id: tileTypeId, name: _nameController.text.trim(), - urlTemplate: _urlController.text.trim(), + urlTemplate: urlTemplate, attribution: _attributionController.text.trim(), - previewTile: _previewTile, + previewTile: isVector ? null : _previewTile, maxZoom: int.parse(_maxZoomController.text.trim()), + sourceType: _sourceType, + styleUrl: styleUrl, ); widget.onSave(tileType); diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 7b1d6ce7..49f08a8b 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; +import 'package:http/io_client.dart'; import 'package:http/retry.dart'; import '../app_state.dart'; @@ -94,7 +95,11 @@ class DeflockTileProvider extends NetworkTileProvider { VoidCallback? onNetworkSuccess, String configFingerprint = '', }) { - final client = UserAgentClient(RetryClient(Client())); + // Limit per-host connections to avoid exhausting file descriptors when + // flutter_map requests many tiles concurrently during rapid pan/zoom. + final client = UserAgentClient( + RetryClient(IOClient(HttpClient()..maxConnectionsPerHost = 6)), + ); return DeflockTileProvider._( httpClient: client, providerId: providerId, diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f5e99b5..231de6ac 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -60,7 +60,11 @@ class MapDataProvider { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } - // For downloads, always fetch fresh data (don't use cache) + // For downloads, always fetch fresh data (don't use cache). + // Note: passes null generation, so downloads are never cancelled by stale-fetch + // detection and will hold semaphore slots until complete. This is intentional — + // offline downloads should run to completion — but means concurrent downloads + // can block foreground map fetches via the shared semaphore. return _nodeDataManager.fetchWithSplitting(bounds, profiles); } diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 5113134d..92c2bb5f 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -58,29 +58,36 @@ Future> fetchLocalTile({ /// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. /// -/// Uses the same Mercator projection math as [latLonToTile] in -/// offline_tile_utils.dart, but only computes the bounding tile range -/// instead of enumerating every tile at that zoom level. +/// Matches [computeTileList]'s inclusion rules: ±1 tile padding with clamping +/// to `[0, nTiles-1]`. This ensures tiles downloaded along boundary edges +/// (which include the padding) are found during offline lookups. /// /// Note: Y axis is inverted in tile coordinates — north = lower Y. @visibleForTesting bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { - final n = pow(2.0, z); + final int nTiles = 1 << z; + final double n = nTiles.toDouble(); final west = bounds.west; final east = bounds.east; final north = bounds.north; final south = bounds.south; - final minX = ((west + 180.0) / 360.0 * n).floor(); - final maxX = ((east + 180.0) / 360.0 * n).floor(); + int minX = ((west + 180.0) / 360.0 * n).floor(); + int maxX = ((east + 180.0) / 360.0 * n).floor(); // North → lower Y (Mercator projection inverts latitude) - final minY = ((1.0 - log(tan(north * pi / 180.0) + + int minY = ((1.0 - log(tan(north * pi / 180.0) + 1.0 / cos(north * pi / 180.0)) / pi) / 2.0 * n).floor(); - final maxY = ((1.0 - log(tan(south * pi / 180.0) + + int maxY = ((1.0 - log(tan(south * pi / 180.0) + 1.0 / cos(south * pi / 180.0)) / pi) / 2.0 * n).floor(); + // Match computeTileList behavior: expand by ±1 tile and clamp to [0, nTiles - 1]. + minX = max(0, minX - 1); + maxX = min(nTiles - 1, maxX + 1); + minY = max(0, minY - 1); + maxY = min(nTiles - 1, maxY + 1); + return x >= minX && x <= maxX && y >= minY && y <= maxY; } diff --git a/lib/services/node_data_manager.dart b/lib/services/node_data_manager.dart index 2dbaeeb3..2f4e249d 100644 --- a/lib/services/node_data_manager.dart +++ b/lib/services/node_data_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -14,19 +15,107 @@ import 'map_data_submodules/nodes_from_local.dart'; import 'offline_area_service.dart'; import 'offline_areas/offline_area_models.dart'; +/// Resizable async semaphore for limiting concurrent Overpass requests. +class _AsyncSemaphore { + int _maxConcurrent; + int _current = 0; + final _waiters = Queue>(); + + _AsyncSemaphore(int maxConcurrent) : _maxConcurrent = maxConcurrent < 1 ? 1 : maxConcurrent; + + int get maxConcurrent => _maxConcurrent; + + /// Resize the semaphore. If capacity increased, wake up queued waiters. + void resize(int newMax) { + _maxConcurrent = newMax < 1 ? 1 : newMax; + // Wake exactly the number of newly available slots. + // Can't use _current in the loop condition because woken waiters + // haven't incremented it yet (their continuations are microtasks). + var available = _maxConcurrent - _current; + while (available > 0 && _waiters.isNotEmpty) { + _waiters.removeFirst().complete(); + available--; + } + } + + Future run(Future Function() fn) async { + while (_current >= _maxConcurrent) { + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + _current++; + try { + return await fn(); + } finally { + _current--; + if (_waiters.isNotEmpty && _current < _maxConcurrent) { + _waiters.removeFirst().complete(); + } + } + } +} + /// Coordinates node data fetching between cache, Overpass, and OSM API. /// Simple interface: give me nodes for this view with proper caching and error handling. class NodeDataManager extends ChangeNotifier { static final NodeDataManager _instance = NodeDataManager._(); factory NodeDataManager() => _instance; - NodeDataManager._(); - final OverpassService _overpassService = OverpassService(); - final NodeSpatialCache _cache = NodeSpatialCache(); - + NodeDataManager._({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) : _overpassService = overpassService ?? OverpassService(), + _cache = cache ?? NodeSpatialCache(); + + @visibleForTesting + factory NodeDataManager.forTesting({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) => NodeDataManager._(overpassService: overpassService, cache: cache); + + final OverpassService _overpassService; + final NodeSpatialCache _cache; + + // Concurrency limiter for Overpass requests + _AsyncSemaphore? _overpassSemaphore; + Future<_AsyncSemaphore>? _semaphoreInitFuture; + + // Generation counter for cancelling stale fetch requests. + // Each new getNodesFor() call increments this; queued work checks before proceeding. + int _fetchGeneration = 0; + int? _lastLoggedStaleGeneration; + + bool _isStale(int? generation) { + if (generation == null || generation == _fetchGeneration) return false; + if (_lastLoggedStaleGeneration != generation) { + _lastLoggedStaleGeneration = generation; + debugPrint('[NodeDataManager] Fetch generation $generation is stale ' + '(current: $_fetchGeneration), cancelling remaining work'); + } + return true; + } + + @visibleForTesting + void advanceFetchGeneration() => _fetchGeneration++; + + Future<_AsyncSemaphore> _getOrCreateSemaphore() { + return _semaphoreInitFuture ??= _createSemaphore().catchError((e, st) { + _semaphoreInitFuture = null; // Allow retry on next fetch + Error.throwWithStackTrace(e, st); + }); + } + + Future<_AsyncSemaphore> _createSemaphore() async { + final slots = await _overpassService.getSlotCount(); + _overpassSemaphore = _AsyncSemaphore(slots); + debugPrint('[NodeDataManager] Overpass semaphore: $slots slots'); + return _overpassSemaphore!; + } + // Track ongoing user-initiated requests for status reporting final Set _userInitiatedRequests = {}; - + /// Get nodes for the given bounds and profiles. /// Returns cached data immediately if available, otherwise fetches from appropriate source. Future> getNodesFor({ @@ -43,7 +132,7 @@ class NodeDataManager extends ChangeNotifier { if (isUserInitiated) { NetworkStatus.instance.clear(); } - + if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes'); @@ -51,7 +140,7 @@ class NodeDataManager extends ChangeNotifier { } else { // Offline + Production = use local offline areas (instant) final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles); - + // Add offline nodes to cache so they integrate with the rest of the system if (offlineNodes.isNotEmpty) { _cache.addOrUpdateNodes(offlineNodes); @@ -59,7 +148,7 @@ class NodeDataManager extends ChangeNotifier { _cache.markAreaAsFetched(bounds, offlineNodes); notifyListeners(); } - + // Show brief success for user-initiated offline loads with data if (isUserInitiated && offlineNodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -71,7 +160,7 @@ class NodeDataManager extends ChangeNotifier { NetworkStatus.instance.setNoData(); }); } - + return offlineNodes; } } @@ -79,15 +168,15 @@ class NodeDataManager extends ChangeNotifier { // Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI) if (uploadMode == UploadMode.sandbox) { debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API'); - + // Track user-initiated requests for status reporting final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] Sandbox request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -96,7 +185,7 @@ class NodeDataManager extends ChangeNotifier { } else { debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)'); } - + try { final nodes = await fetchOsmApiNodes( bounds: bounds, @@ -104,7 +193,7 @@ class NodeDataManager extends ChangeNotifier { uploadMode: uploadMode, maxResults: 0, ); - + // Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches) if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); @@ -113,10 +202,10 @@ class NodeDataManager extends ChangeNotifier { // Mark area as fetched even with no nodes so UI knows we've checked this area _cache.markAreaAsFetched(bounds, []); } - + // Update UI notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -124,12 +213,12 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Sandbox fetch failed: $e'); - + // Only report errors for user-initiated requests if (isUserInitiated) { if (e is RateLimitError) { @@ -141,7 +230,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e'); } - + // Return whatever we have in cache for this area (likely empty for sandbox) return _cache.getNodesFor(bounds); } finally { @@ -159,13 +248,13 @@ class NodeDataManager extends ChangeNotifier { // Not cached - need to fetch final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + // Only allow one user-initiated request per area at a time if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] User request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests only if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -175,12 +264,19 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Starting background request (no status reporting)'); } + final generation = ++_fetchGeneration; try { - final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated); - + final nodes = await fetchWithSplitting(bounds, profiles, + isUserInitiated: isUserInitiated, generation: generation); + + // If this fetch became stale (user panned away), skip UI updates + if (_isStale(generation)) { + return _cache.getNodesFor(bounds); + } + // Update cache and notify listeners notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -188,14 +284,14 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated request completed successfully'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Fetch failed: $e'); - - // Only report errors for user-initiated requests - if (isUserInitiated) { + + // Skip error reporting for stale requests + if (isUserInitiated && !_isStale(generation)) { if (e is RateLimitError) { NetworkStatus.instance.setRateLimited(); } else if (e.toString().contains('timeout')) { @@ -205,7 +301,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated request failed: $e'); } - + // Return whatever we have in cache for this area return _cache.getNodesFor(bounds); } finally { @@ -215,90 +311,143 @@ class NodeDataManager extends ChangeNotifier { } } - /// Fetch nodes with automatic area splitting if needed + /// Fetch nodes with automatic area splitting if needed. + /// When [generation] is non-null, the request is cancelled if a newer + /// generation has started (user panned/zoomed away). Future> fetchWithSplitting( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, { int splitDepth = 0, + int rateLimitRetries = 0, bool isUserInitiated = false, + int? generation, }) async { const maxSplitDepth = 3; // 4^3 = 64 max sub-areas - + + // Checkpoint 1: bail before entering semaphore + if (_isStale(generation)) return []; + try { // Expand bounds slightly to reduce edge effects - final expandedBounds = _expandBounds(bounds, 1.2); - - final nodes = await _overpassService.fetchNodes( - bounds: expandedBounds, - profiles: profiles, + final expandedBounds = splitDepth == 0 ? expandBounds(bounds, 1.2) : bounds; + + final semaphore = await _getOrCreateSemaphore(); + // Checkpoint 2: stale request woke from queue — don't make HTTP call + final nodes = await semaphore.run>( + () { + if (_isStale(generation)) return Future.value([]); + return _overpassService.fetchNodes( + bounds: expandedBounds, + profiles: profiles, + ); + }, ); - - // Success - cache the data for the expanded area - _cache.markAreaAsFetched(expandedBounds, nodes); + + // Cache real data even if stale (valid for if user pans back). + // Skip marking area if stale and got empty result (short-circuited). + if (nodes.isNotEmpty || !_isStale(generation)) { + _cache.markAreaAsFetched(expandedBounds, nodes); + if (nodes.isNotEmpty) { + notifyListeners(); // Progressive rendering: each quadrant renders immediately + } + } return nodes; - + } on NodeLimitError { // Hit node limit or timeout - split area if not too deep if (splitDepth >= maxSplitDepth) { debugPrint('[NodeDataManager] Max split depth reached, giving up'); return []; } - + + // Checkpoint 3: don't spawn 4 new sub-requests for stale fetch + if (_isStale(generation)) return []; + debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)'); - + // Only report splitting status for user-initiated requests if (isUserInitiated && splitDepth == 0) { NetworkStatus.instance.setSplitting(); } - - return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated); - + + return _fetchSplitAreas(bounds, profiles, splitDepth + 1, + isUserInitiated: isUserInitiated, generation: generation); + } on RateLimitError { - // Rate limited - wait and return empty - debugPrint('[NodeDataManager] Rate limited, backing off'); - await Future.delayed(const Duration(seconds: 30)); - return []; + if (rateLimitRetries >= 2) { + debugPrint('[NodeDataManager] Max rate limit retries reached, giving up'); + return []; + } + + // Checkpoint 4: don't wait up to 2 minutes for a stale request + if (_isStale(generation)) return []; + + debugPrint('[NodeDataManager] Rate limited, polling for slot (retry ${rateLimitRetries + 1}/2)'); + if (isUserInitiated) NetworkStatus.instance.setRateLimited(); + + // Poll until slot available; resize semaphore with fresh slot count + final slots = await _overpassService.waitForSlot(); + + // Checkpoint 5: became stale during the wait + if (_isStale(generation)) return []; + + _overpassSemaphore?.resize(slots); + debugPrint('[NodeDataManager] Semaphore resized to $slots slots'); + + return fetchWithSplitting( + bounds, profiles, + splitDepth: splitDepth, + rateLimitRetries: rateLimitRetries + 1, + isUserInitiated: isUserInitiated, + generation: generation, + ); } } - /// Fetch data by splitting area into quadrants + /// Fetch data by splitting area into quadrants (parallel) Future> _fetchSplitAreas( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, int splitDepth, { bool isUserInitiated = false, + int? generation, }) async { - final quadrants = _splitBounds(bounds); - final allNodes = []; - - for (final quadrant in quadrants) { - try { - final nodes = await fetchWithSplitting( - quadrant, - profiles, - splitDepth: splitDepth, - isUserInitiated: isUserInitiated, - ); - allNodes.addAll(nodes); - } catch (e) { - debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); - // Continue with other quadrants - } - } - + // Checkpoint 6: don't spawn quadrants for stale tree + if (_isStale(generation)) return []; + + final quadrants = splitBounds(bounds); + + final results = await Future.wait( + quadrants.map((quadrant) async { + try { + return await fetchWithSplitting( + quadrant, profiles, + splitDepth: splitDepth, + isUserInitiated: isUserInitiated, + generation: generation, + ); + } catch (e) { + debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); + return []; + } + }), + ); + + final allNodes = results.expand((nodes) => nodes).toList(); debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes'); return allNodes; } /// Split bounds into 4 quadrants - List _splitBounds(LatLngBounds bounds) { + @visibleForTesting + static List splitBounds(LatLngBounds bounds) { final centerLat = (bounds.north + bounds.south) / 2; final centerLng = (bounds.east + bounds.west) / 2; - + return [ // Southwest LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)), - // Southeast + // Southeast LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)), // Northwest LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)), @@ -307,20 +456,6 @@ class NodeDataManager extends ChangeNotifier { ]; } - /// Expand bounds by given factor around center point - LatLngBounds _expandBounds(LatLngBounds bounds, double factor) { - final centerLat = (bounds.north + bounds.south) / 2; - final centerLng = (bounds.east + bounds.west) / 2; - - final latSpan = (bounds.north - bounds.south) * factor / 2; - final lngSpan = (bounds.east - bounds.west) * factor / 2; - - return LatLngBounds( - LatLng(centerLat - latSpan, centerLng - lngSpan), - LatLng(centerLat + latSpan, centerLng + lngSpan), - ); - } - /// Add or update nodes in cache (for upload queue integration) void addOrUpdateNodes(List nodes) { _cache.addOrUpdateNodes(nodes); @@ -347,7 +482,7 @@ class NodeDataManager extends ChangeNotifier { }) async { // Clear any cached data for this area _cache.clear(); - + // Re-fetch as user-initiated request await getNodesFor( bounds: bounds, @@ -374,16 +509,16 @@ class NodeDataManager extends ChangeNotifier { Future preloadOfflineNodes() async { try { final offlineAreaService = OfflineAreaService(); - + for (final area in offlineAreaService.offlineAreas) { if (area.status != OfflineAreaStatus.complete) continue; - + // Load nodes from this offline area final nodes = await fetchLocalNodes( bounds: area.bounds, profiles: [], // Empty profiles = load all nodes ); - + if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); // Mark the offline area as having coverage so submit buttons work @@ -391,7 +526,7 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}'); } } - + notifyListeners(); } catch (e) { debugPrint('[NodeDataManager] Error preloading offline nodes: $e'); @@ -400,4 +535,19 @@ class NodeDataManager extends ChangeNotifier { /// Get cache statistics String get cacheStats => _cache.stats.toString(); -} \ No newline at end of file +} + +/// Expand bounds by given factor around center point. +/// Shared by [NodeDataManager] (fetch expansion) and [MapDataManager] (render expansion). +LatLngBounds expandBounds(LatLngBounds bounds, double factor) { + final centerLat = (bounds.north + bounds.south) / 2; + final centerLng = (bounds.east + bounds.west) / 2; + + final latSpan = (bounds.north - bounds.south) * factor / 2; + final lngSpan = (bounds.east - bounds.west) * factor / 2; + + return LatLngBounds( + LatLng(centerLat - latSpan, centerLng - lngSpan), + LatLng(centerLat + latSpan, centerLng + lngSpan), + ); +} diff --git a/lib/services/node_spatial_cache.dart b/lib/services/node_spatial_cache.dart index 35d90442..4e27fd90 100644 --- a/lib/services/node_spatial_cache.dart +++ b/lib/services/node_spatial_cache.dart @@ -13,6 +13,9 @@ class NodeSpatialCache { factory NodeSpatialCache() => _instance; NodeSpatialCache._(); + @visibleForTesting + NodeSpatialCache.forTesting(); + final List _fetchedAreas = []; final Map _nodes = {}; // nodeId -> node diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 05b8c186..343b294e 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -6,6 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import '../models/node_profile.dart'; import '../models/osm_node.dart'; +import '../models/service_endpoint.dart'; +import '../app_state.dart'; import '../dev_config.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -15,54 +17,68 @@ import 'service_policy.dart'; class OverpassService { static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter'; static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter'; + static const String _statusEndpoint = 'https://overpass-api.de/api/status'; + static const int defaultSlotCount = 4; static const _policy = ResiliencePolicy( maxRetries: 3, httpTimeout: Duration(seconds: 45), ); final http.Client _client; - /// Optional override endpoint. When null, uses [defaultEndpoint]. - final String? _endpointOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - OverpassService({http.Client? client, String? endpoint}) + OverpassService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _endpointOverride = endpoint; + _endpointsOverride = endpoints; - /// Resolve the primary endpoint: constructor override or default. - String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledOverpassEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized (e.g., in tests) + } + return DefaultServiceEndpoints.overpass(); + } /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ required LatLngBounds bounds, required List profiles, - ResiliencePolicy? policy, + int? maxRetries, }) async { if (profiles.isEmpty) return []; final query = _buildQuery(bounds, profiles); - final endpoint = _primaryEndpoint; - final canFallback = _endpointOverride == null; - final effectivePolicy = policy ?? _policy; - - return executeWithFallback>( - primaryUrl: endpoint, - fallbackUrl: canFallback ? fallbackEndpoint : null, - execute: (url) => _attemptFetch(url, query, effectivePolicy), + + final effectivePolicy = maxRetries != null + ? ResiliencePolicy( + maxRetries: maxRetries, + httpTimeout: _policy.httpTimeout, + ) + : _policy; + + return executeWithEndpointList>( + endpoints: _endpoints, + execute: (url) => _attemptFetch(url, query), classifyError: _classifyError, - policy: effectivePolicy, + defaultPolicy: effectivePolicy, ); } /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). - Future> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async { + Future> _attemptFetch(String endpoint, String query) async { debugPrint('[OverpassService] POST $endpoint'); try { final response = await _client.post( Uri.parse(endpoint), body: {'data': query}, - ).timeout(policy.httpTimeout); + ).timeout(_policy.httpTimeout); if (response.statusCode == 200) { return _parseResponse(response.body); @@ -108,6 +124,60 @@ class OverpassService { return ErrorDisposition.retry; } + /// Query Overpass /api/status to get the rate limit (slot count per IP). + Future getSlotCount() async { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final match = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (match != null) return int.parse(match.group(1)!); + } + } catch (e) { + debugPrint('[OverpassService] Failed to get slot count: $e'); + } + return defaultSlotCount; + } + + /// Poll /api/status until a slot is available. Returns observed slot count. + Future waitForSlot({ + Duration maxWait = const Duration(minutes: 2), + Duration Function()? elapsedFn, + }) async { + final stopwatch = Stopwatch()..start(); + final elapsed = elapsedFn ?? () => stopwatch.elapsed; + int observedSlots = defaultSlotCount; + + while (elapsed() < maxWait) { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final slotMatch = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (slotMatch != null) observedSlots = int.parse(slotMatch.group(1)!); + + if (response.body.contains('slots available now')) return observedSlots; + + final match = RegExp(r'in (\d+) seconds').firstMatch(response.body); + if (match != null) { + final wait = int.parse(match.group(1)!).clamp(1, 30); + debugPrint('[OverpassService] Waiting $wait seconds for slot'); + await Future.delayed(Duration(seconds: wait)); + continue; + } + } + } catch (e) { + debugPrint('[OverpassService] Status check failed: $e'); + } + + await Future.delayed(const Duration(seconds: 5)); + } + + debugPrint('[OverpassService] Max wait time exceeded, proceeding anyway'); + return observedSlots; + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart index d7309d67..09aa6ee3 100644 --- a/lib/services/provider_tile_cache_manager.dart +++ b/lib/services/provider_tile_cache_manager.dart @@ -27,6 +27,20 @@ class ProviderTileCacheManager { /// Whether the manager has been initialized. static bool get isInitialized => _baseCacheDir != null; + /// Returns the cache directory path for a provider/tileType combination. + /// + /// Useful for packages that manage their own file caching (e.g. + /// `vector_map_tiles`) but should write into the same directory tree. + static String getCacheDirectory({ + required String providerId, + required String tileTypeId, + }) { + if (_baseCacheDir == null) { + throw StateError('ProviderTileCacheManager.init() must be called'); + } + return p.join(_baseCacheDir!, providerId, tileTypeId); + } + /// Get or create a cache store for a specific provider/tile type combination. /// /// Synchronous after [init] has been called. The cache store lazily creates @@ -37,16 +51,13 @@ class ProviderTileCacheManager { required ServicePolicy policy, int? maxCacheBytes, }) { - if (_baseCacheDir == null) { - throw StateError( - 'ProviderTileCacheManager.init() must be called before getOrCreate()', - ); - } - final key = '$providerId/$tileTypeId'; if (_stores.containsKey(key)) return _stores[key]!; - final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId); + final cacheDir = getCacheDirectory( + providerId: providerId, + tileTypeId: tileTypeId, + ); final store = ProviderTileCacheStore( cacheDirectory: cacheDir, @@ -66,9 +77,12 @@ class ProviderTileCacheManager { // Use the store's clear method to properly reset its internal state await store.clear(); // Don't remove from registry - let it be reused with clean state - } else if (_baseCacheDir != null) { + } else if (isInitialized) { // Fallback for stores not in registry - final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); + final cacheDir = Directory(getCacheDirectory( + providerId: providerId, + tileTypeId: tileTypeId, + )); if (await cacheDir.exists()) { await cacheDir.delete(recursive: true); } diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart index 3087a8f6..c0421ea9 100644 --- a/lib/services/provider_tile_cache_store.dart +++ b/lib/services/provider_tile_cache_store.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:collection'; import 'dart:convert'; import 'dart:io'; @@ -28,10 +27,6 @@ class ProviderTileCacheStore implements MapCachingProvider { /// [putTile] call to avoid blocking construction. int? _estimatedSize; - /// Semaphore to limit concurrent file I/O operations and prevent - /// "too many open files" errors during heavy tile loading. - static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations - /// Throttle: don't re-scan more than once per minute. DateTime? _lastPruneCheck; @@ -57,16 +52,9 @@ class ProviderTileCacheStore implements MapCachingProvider { final metaFile = File(p.join(cacheDirectory, '$key.meta')); try { - // Use semaphore to limit concurrent file I/O operations - final result = await _ioSemaphore.execute(() async { - final bytes = await tileFile.readAsBytes(); - final metaJson = json.decode(await metaFile.readAsString()) - as Map; - return (bytes: bytes, metaJson: metaJson); - }); - - final bytes = result.bytes; - final metaJson = result.metaJson; + final bytes = await tileFile.readAsBytes(); + final metaJson = json.decode(await metaFile.readAsString()) + as Map; final metadata = CachedMapTileMetadata( staleAt: DateTime.fromMillisecondsSinceEpoch( @@ -132,13 +120,10 @@ class ProviderTileCacheStore implements MapCachingProvider { // Write .tile before .meta: if we crash between the two writes, the // read path's both-must-exist check sees a miss rather than an orphan .meta. - // Use semaphore to limit concurrent file I/O and prevent "too many open files" errors. - await _ioSemaphore.execute(() async { - if (bytes != null) { - await tileFile.writeAsBytes(bytes); - } - await metaFile.writeAsString(metaJson); - }); + if (bytes != null) { + await tileFile.writeAsBytes(bytes); + } + await metaFile.writeAsString(metaJson); // Reset size estimate so it resyncs from disk on next check. // This avoids drift from overwrites where the old size isn't subtracted. @@ -248,10 +233,15 @@ class ProviderTileCacheStore implements MapCachingProvider { if (tileFiles.isEmpty) return; - // Sort by modification time, oldest first - final stats = await Future.wait( - tileFiles.map((f) async => (file: f, stat: await f.stat())), - ); + // Sort by modification time, oldest first. + // Batch stat calls to avoid opening too many file handles at once. + final stats = <({File file, FileStat stat})>[]; + for (var i = 0; i < tileFiles.length; i += 50) { + final end = (i + 50 < tileFiles.length) ? i + 50 : tileFiles.length; + stats.addAll(await Future.wait( + tileFiles.sublist(i, end).map((f) async => (file: f, stat: await f.stat())), + )); + } stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified)); var freedBytes = 0; @@ -265,19 +255,14 @@ class ProviderTileCacheStore implements MapCachingProvider { final metaFile = File(p.join(cacheDirectory, '$key.meta')); try { - final deletedBytes = await _ioSemaphore.execute(() async { - await entry.file.delete(); - var bytes = entry.stat.size; - if (await metaFile.exists()) { - final metaStat = await metaFile.stat(); - await metaFile.delete(); - bytes += metaStat.size; - } - return bytes; - }); - - freedBytes += deletedBytes; + await entry.file.delete(); + freedBytes += entry.stat.size; evictedKeys.add(key); + if (await metaFile.exists()) { + final metaStat = await metaFile.stat(); + await metaFile.delete(); + freedBytes += metaStat.size; + } } catch (e) { debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); } @@ -333,44 +318,3 @@ class ProviderTileCacheStore implements MapCachingProvider { @visibleForTesting Future forceEviction() => _evictIfNeeded(); } - -/// Simple semaphore to limit concurrent operations and prevent resource exhaustion. -class _Semaphore { - final int maxCount; - int _currentCount; - final Queue> _waitQueue = Queue>(); - - _Semaphore(this.maxCount) : _currentCount = maxCount; - - /// Acquire a permit. Returns a Future that completes when a permit is available. - Future acquire() { - if (_currentCount > 0) { - _currentCount--; - return Future.value(); - } else { - final completer = Completer(); - _waitQueue.add(completer); - return completer.future; - } - } - - /// Release a permit, potentially unblocking a waiting operation. - void release() { - if (_waitQueue.isNotEmpty) { - final completer = _waitQueue.removeFirst(); - completer.complete(); - } else { - _currentCount++; - } - } - - /// Execute a function while holding a permit. - Future execute(Future Function() operation) async { - await acquire(); - try { - return await operation(); - } finally { - release(); - } - } -} diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 072a54af..c8ae5a7c 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; +import '../models/service_endpoint.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -34,17 +35,26 @@ class RoutingService { ); final http.Client _client; - /// Optional override URL. When null, uses [defaultUrl]. - final String? _baseUrlOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - RoutingService({http.Client? client, String? baseUrl}) + RoutingService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _baseUrlOverride = baseUrl; + _endpointsOverride = endpoints; void close() => _client.close(); - /// Resolve the primary URL to use: constructor override or default. - String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledRoutingEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized yet (e.g., in tests) + } + return DefaultServiceEndpoints.routing(); + } // Calculate route between two points Future calculateRoute({ @@ -81,15 +91,11 @@ class RoutingService { 'show_exclusion_zone': false, }; - final primaryUrl = _primaryUrl; - final canFallback = _baseUrlOverride == null; - - return executeWithFallback( - primaryUrl: primaryUrl, - fallbackUrl: canFallback ? fallbackUrl : null, + return executeWithEndpointList( + endpoints: _endpoints, execute: (url) => _postRoute(url, params), classifyError: _classifyError, - policy: _policy, + defaultPolicy: _policy, ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 8fd66cf2..3fa8f42e 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/service_endpoint.dart'; +import '../models/tile_provider.dart' show kApiKeyPlaceholder; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. @@ -15,6 +17,8 @@ enum ServiceType { // Third-party tile services bingTiles, // *.tiles.virtualearth.net mapboxTiles, // api.mapbox.com + stadiaTiles, // tiles.stadiamaps.com + maptilerTiles, // api.maptiler.com // Everything else custom, // user's own infrastructure / unknown @@ -129,6 +133,16 @@ class ServicePolicy { minCacheTtl = null, attributionUrl = null; + /// Vector tile services (Stadia Maps, MapTiler, etc.) + /// Concurrency managed by vector_map_tiles; offline download not yet supported. + const ServicePolicy.vectorTiles() + : maxConcurrentRequests = 0, // managed by vector_map_tiles + minRequestInterval = null, + allowsOfflineDownload = false, + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + /// Custom/self-hosted service — permissive defaults. const ServicePolicy.custom({ int maxConcurrent = 8, @@ -169,6 +183,8 @@ class ServicePolicyResolver { 'taginfo.openstreetmap.org': ServiceType.tagInfo, 'tiles.virtualearth.net': ServiceType.bingTiles, 'api.mapbox.com': ServiceType.mapboxTiles, + 'tiles.stadiamaps.com': ServiceType.stadiaTiles, + 'api.maptiler.com': ServiceType.maptilerTiles, }; /// ServiceType → policy mapping. @@ -180,6 +196,8 @@ class ServicePolicyResolver { ServiceType.tagInfo: const ServicePolicy.tagInfo(), ServiceType.bingTiles: const ServicePolicy.bingTiles(), ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), + ServiceType.stadiaTiles: const ServicePolicy.vectorTiles(), + ServiceType.maptilerTiles: const ServicePolicy.vectorTiles(), ServiceType.custom: const ServicePolicy(), }; @@ -233,7 +251,7 @@ class ServicePolicyResolver { .replaceAll(RegExp(r'\{z\}'), '0') .replaceAll(RegExp(r'\{x\}'), '0') .replaceAll(RegExp(r'\{y\}'), '0') - .replaceAll(RegExp(r'\{api_key\}'), 'key'); + .replaceAll(kApiKeyPlaceholder, 'key'); return Uri.parse(cleaned).host.toLowerCase(); } catch (_) { return null; @@ -255,24 +273,71 @@ enum ErrorDisposition { class ResiliencePolicy { final int maxRetries; final Duration httpTimeout; - final Duration _retryBackoffBase; - final int _retryBackoffMaxMs; + final Duration retryBackoffBase; + final int retryBackoffMaxMs; const ResiliencePolicy({ this.maxRetries = 1, this.httpTimeout = const Duration(seconds: 30), - Duration retryBackoffBase = const Duration(milliseconds: 200), - int retryBackoffMaxMs = 5000, - }) : _retryBackoffBase = retryBackoffBase, - _retryBackoffMaxMs = retryBackoffMaxMs; + this.retryBackoffBase = const Duration(milliseconds: 200), + this.retryBackoffMaxMs = 5000, + }); Duration retryDelay(int attempt) { - final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) - .clamp(0, _retryBackoffMaxMs); + final ms = (retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, retryBackoffMaxMs); return Duration(milliseconds: ms); } } +/// Execute a request against an ordered list of endpoints with retry and fallback. +/// +/// Tries each enabled endpoint in list order. For each endpoint: +/// 1. Applies per-endpoint policy overrides (maxRetries, timeout) over [defaultPolicy] +/// 2. Retries on [ErrorDisposition.retry] errors up to the effective maxRetries +/// 3. On [ErrorDisposition.fallback], skips remaining retries and moves to next endpoint +/// 4. On [ErrorDisposition.abort], rethrows immediately (no further endpoints tried) +/// +/// Throws [StateError] if no endpoints are enabled. +/// Rethrows the last error if all endpoints are exhausted. +Future executeWithEndpointList({ + required List endpoints, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy defaultPolicy = const ResiliencePolicy(), +}) async { + final enabled = endpoints.where((e) => e.enabled).toList(growable: false); + if (enabled.isEmpty) { + throw StateError('No enabled endpoints configured'); + } + + Object? lastError; + for (final endpoint in enabled) { + final effectivePolicy = ResiliencePolicy( + maxRetries: endpoint.maxRetries ?? defaultPolicy.maxRetries, + httpTimeout: endpoint.timeoutSeconds != null + ? Duration(seconds: endpoint.timeoutSeconds!) + : defaultPolicy.httpTimeout, + retryBackoffBase: defaultPolicy.retryBackoffBase, + retryBackoffMaxMs: defaultPolicy.retryBackoffMaxMs, + ); + try { + return await _executeWithRetries( + endpoint.url, + execute, + classifyError, + effectivePolicy, + ); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + lastError = e; + debugPrint('[Resilience] Endpoint ${endpoint.name} failed ($e), trying next'); + } + } + throw lastError!; // All endpoints exhausted +} + /// Execute a request with retry and fallback logic. /// /// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. @@ -282,6 +347,7 @@ class ResiliencePolicy { /// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted /// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error, /// repeats the retry loop against the fallback. +@Deprecated('Use executeWithEndpointList instead') Future executeWithFallback({ required String primaryUrl, required String? fallbackUrl, diff --git a/lib/services/vector_style_service.dart b/lib/services/vector_style_service.dart new file mode 100644 index 00000000..da4ee9f1 --- /dev/null +++ b/lib/services/vector_style_service.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:vector_map_tiles/vector_map_tiles.dart'; +import 'package:vector_tile_renderer/vector_tile_renderer.dart' hide TileLayer; + +import '../models/tile_provider.dart' show kApiKeyPlaceholder; +import 'http_client.dart'; + +/// Singleton service that loads and caches vector tile styles. +/// +/// Replaces [StyleReader] to properly propagate API keys to all sub-URLs +/// (source TileJSON endpoints, tile data URLs, sprite URLs, glyph URLs). +/// The upstream [StyleReader] only handles `{key}` token substitution for +/// non-Mapbox providers, which doesn't work for providers like Stadia Maps +/// that require query-parameter authentication on every request. +/// +/// Features: +/// - In-memory cache keyed by `styleUrl|apiKey` +/// - Deduplication of concurrent loads (same key returns same Future) +/// - `{api_key}` substitution in all URLs derived from the style JSON +/// - [evict] and [clear] methods for cache management +class VectorStyleService { + VectorStyleService._() : _httpClient = UserAgentClient(); + static final VectorStyleService instance = VectorStyleService._(); + + /// Shared HTTP client with User-Agent header for all style/tile/sprite requests. + final http.Client _httpClient; + + /// Cached styles keyed by `styleUrl|apiKey`. + final Map _cache = {}; + + /// In-flight load futures for deduplication. + final Map> _pending = {}; + + /// Load a vector tile style, returning a cached result if available. + /// + /// [styleUrl] is the style JSON URL (may contain `{api_key}`). + /// [apiKey] is substituted into the URL if present. + Future