mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-02 10:10:22 +02:00
Compare commits
31 Commits
v2.7.2-rc
...
feat/resil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7192d3ec | ||
|
|
2833906c68 | ||
|
|
4d1032e56d | ||
|
|
834861bcaf | ||
|
|
ba80b88595 | ||
|
|
ebb7fd090f | ||
|
|
fe401cc04b | ||
|
|
de65cecc6a | ||
|
|
122b303378 | ||
|
|
91e5177056 | ||
|
|
f3f40f36ef | ||
|
|
2d92214bed | ||
|
|
be446fbcbc | ||
|
|
5728b4f70f | ||
|
|
aeb1903bbc | ||
|
|
57df8e83a7 | ||
|
|
bc671c4efe | ||
|
|
4941c2726d | ||
|
|
b56e9325b3 | ||
|
|
30f546be29 | ||
|
|
dc817e5eb7 | ||
|
|
e1cca2f503 | ||
|
|
abd8682b49 | ||
|
|
90a806a10d | ||
|
|
b6bcd23667 | ||
|
|
dba375c63d | ||
|
|
6c52541361 | ||
|
|
fcf7ff7a98 | ||
|
|
206b3afe9d | ||
|
|
e72d557d2a | ||
|
|
25a34aab0b |
4
.github/workflows/workflow.yml
vendored
4
.github/workflows/workflow.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
build-ios:
|
||||
name: Build iOS
|
||||
needs: get-version
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
upload-to-stores:
|
||||
name: Upload to App Stores
|
||||
needs: [get-version, build-android-aab, build-ios]
|
||||
runs-on: macos-latest # Need macOS for iOS uploads
|
||||
runs-on: macos-26 # Need macOS for iOS uploads
|
||||
if: needs.get-version.outputs.should_upload_to_stores == 'true'
|
||||
steps:
|
||||
- name: Download AAB artifact for Google Play
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -73,12 +73,13 @@ fuchsia/build/
|
||||
web/build/
|
||||
|
||||
# ───────────────────────────────
|
||||
# IDE / Editor Settings
|
||||
# IDE / Editor / AI Tool Settings
|
||||
# ───────────────────────────────
|
||||
.idea/
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
# Swap files
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
@@ -17,28 +17,27 @@ if (keystorePropertiesFile.exists()) {
|
||||
android {
|
||||
namespace = "me.deflock.deflockapp"
|
||||
|
||||
// Matches current stable Flutter (compileSdk 34 as of July 2025)
|
||||
compileSdk = 36
|
||||
|
||||
// NDK only needed if you build native plugins; keep your pinned version
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
minSdk = maxOf(flutter.minSdkVersion, 23)
|
||||
targetSdk = 36
|
||||
|
||||
@@ -76,6 +75,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
{
|
||||
"2.9.1": {
|
||||
"content": [
|
||||
"• When hitting node render limit, only render nodes closest to center of viewport.",
|
||||
"• Moved to our own infrastructure for Overpass and routing services, with automatic fallback to public servers."
|
||||
]
|
||||
},
|
||||
"2.9.0": {
|
||||
"content": [
|
||||
"• Caching, tile retries, offline areas, now working properly. Map imagery should load correctly."
|
||||
]
|
||||
},
|
||||
"2.8.1": {
|
||||
"content": [
|
||||
"• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission",
|
||||
"• Added drag handles so profiles can be reordered to customize dropdown order when submitting"
|
||||
]
|
||||
},
|
||||
"2.8.0": {
|
||||
"content": [
|
||||
"• Update dependencies and build chain tools; no code changes"
|
||||
]
|
||||
},
|
||||
"2.7.2": {
|
||||
"content": [
|
||||
"• Now following OSM UserAgent guidelines."
|
||||
"• Now following OSM UserAgent guidelines"
|
||||
]
|
||||
},
|
||||
"2.7.1": {
|
||||
|
||||
@@ -28,7 +28,7 @@ read_from_file() {
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(cat "$file"; echo)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -407,6 +407,10 @@ class AppState extends ChangeNotifier {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
_profileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
@@ -64,9 +64,6 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
|
||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||
|
||||
// Navigation routing configuration
|
||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
|
||||
|
||||
// Overpass API configuration
|
||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// OpenStreetMap OAuth client IDs for this app.
|
||||
// These must be provided via --dart-define at build time.
|
||||
|
||||
/// Whether OSM OAuth secrets were provided at build time.
|
||||
/// When false, the app should force simulate mode.
|
||||
bool get kHasOsmSecrets {
|
||||
const prod = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
return prod.isNotEmpty && sandbox.isNotEmpty;
|
||||
}
|
||||
|
||||
String get kOsmProdClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
|
||||
String get kOsmSandboxClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
@@ -144,7 +144,10 @@
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}",
|
||||
"offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).",
|
||||
"currentTileProvider": "aktuelle Kachel",
|
||||
"noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
@@ -292,13 +295,16 @@
|
||||
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
|
||||
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
|
||||
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten",
|
||||
"attribution": "Karten-Zuschreibung"
|
||||
"attribution": "Karten-Zuschreibung",
|
||||
"mapAttribution": "Kartenquelle: {}",
|
||||
"couldNotOpenLink": "Link konnte nicht geöffnet werden",
|
||||
"openLicense": "Lizenz öffnen: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
@@ -325,7 +331,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen",
|
||||
"neverFetched": "Nie abgerufen",
|
||||
"daysAgo": "vor {} Tagen",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"minutesAgo": "vor {} Minuten",
|
||||
"justNow": "Gerade eben"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Verdächtiger Standort #{}",
|
||||
"ticketNo": "Ticket-Nr.",
|
||||
"address": "Adresse",
|
||||
"street": "Straße",
|
||||
"street": "Straße",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland",
|
||||
"intersectingStreet": "Kreuzende Straße",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"submitAnyway": "Submit Anyway",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Device",
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
"downloadFailed": "Failed to start download: {}",
|
||||
"offlineNotPermitted": "The {} server does not permit offline downloads. Switch to a tile provider that allows offline use (e.g., Bing Maps, Mapbox, or a self-hosted tile server).",
|
||||
"currentTileProvider": "current tile",
|
||||
"noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "How would you like to add a profile?",
|
||||
"createCustomProfile": "Create Custom Profile",
|
||||
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers",
|
||||
"attribution": "Map Attribution"
|
||||
"attribution": "Map Attribution",
|
||||
"mapAttribution": "Map attribution: {}",
|
||||
"couldNotOpenLink": "Could not open link",
|
||||
"openLicense": "Open license: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
@@ -443,7 +449,7 @@
|
||||
"mobileEditors": "Mobile Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Full-featured web editor - always works",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Advanced Android OSM editor",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Failed to update suspected locations",
|
||||
"neverFetched": "Never fetched",
|
||||
"daysAgo": "{} days ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"justNow": "Just now"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Suspected Location #{}",
|
||||
"ticketNo": "Ticket No",
|
||||
"address": "Address",
|
||||
"street": "Street",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"intersectingStreet": "Intersecting Street",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
"downloadFailed": "Error al iniciar la descarga: {}",
|
||||
"offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).",
|
||||
"currentTileProvider": "mosaico actual",
|
||||
"noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
|
||||
"createCustomProfile": "Crear Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores",
|
||||
"attribution": "Atribución del Mapa"
|
||||
"attribution": "Atribución del Mapa",
|
||||
"mapAttribution": "Atribución del mapa: {}",
|
||||
"couldNotOpenLink": "No se pudo abrir el enlace",
|
||||
"openLicense": "Abrir licencia: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Error al actualizar ubicaciones sospechosas",
|
||||
"neverFetched": "Nunca obtenido",
|
||||
"daysAgo": "hace {} días",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"minutesAgo": "hace {} minutos",
|
||||
"justNow": "Ahora mismo"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Ubicación Sospechosa #{}",
|
||||
"ticketNo": "No. de Ticket",
|
||||
"address": "Dirección",
|
||||
"street": "Calle",
|
||||
"street": "Calle",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Calle que Intersecta",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}",
|
||||
"offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).",
|
||||
"currentTileProvider": "tuile actuelle",
|
||||
"noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
|
||||
"createCustomProfile": "Créer Profil Personnalisé",
|
||||
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs",
|
||||
"attribution": "Attribution de Carte"
|
||||
"attribution": "Attribution de Carte",
|
||||
"mapAttribution": "Attribution de la carte : {}",
|
||||
"couldNotOpenLink": "Impossible d'ouvrir le lien",
|
||||
"openLicense": "Ouvrir la licence : {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Échec de la mise à jour des emplacements suspects",
|
||||
"neverFetched": "Jamais récupéré",
|
||||
"daysAgo": "il y a {} jours",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"minutesAgo": "il y a {} minutes",
|
||||
"justNow": "À l'instant"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Emplacement Suspect #{}",
|
||||
"ticketNo": "N° de Ticket",
|
||||
"address": "Adresse",
|
||||
"street": "Rue",
|
||||
"street": "Rue",
|
||||
"city": "Ville",
|
||||
"state": "État",
|
||||
"intersectingStreet": "Rue Transversale",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
"downloadFailed": "Impossibile avviare il download: {}",
|
||||
"offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).",
|
||||
"currentTileProvider": "tile attuale",
|
||||
"noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
|
||||
"createCustomProfile": "Crea Profilo Personalizzato",
|
||||
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
"manageProviders": "Gestisci Fornitori",
|
||||
"attribution": "Attribuzione Mappa"
|
||||
"attribution": "Attribuzione Mappa",
|
||||
"mapAttribution": "Attribuzione mappa: {}",
|
||||
"couldNotOpenLink": "Impossibile aprire il link",
|
||||
"openLicense": "Apri licenza: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Visualizza Profilo",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"operatorName": "Nome operatore",
|
||||
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Aggiornamento posizioni sospette fallito",
|
||||
"neverFetched": "Mai recuperato",
|
||||
"daysAgo": "{} giorni fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"minutesAgo": "{} minuti fa",
|
||||
"justNow": "Proprio ora"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Posizione Sospetta #{}",
|
||||
"ticketNo": "N. Ticket",
|
||||
"address": "Indirizzo",
|
||||
"street": "Via",
|
||||
"street": "Via",
|
||||
"city": "Città",
|
||||
"state": "Stato",
|
||||
"intersectingStreet": "Via che Interseca",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.",
|
||||
"areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.",
|
||||
"downloadStarted": "Download gestart! Tiles en nodes ophalen...",
|
||||
"downloadFailed": "Download starten mislukt: {}"
|
||||
"downloadFailed": "Download starten mislukt: {}",
|
||||
"offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).",
|
||||
"currentTileProvider": "huidige tegel",
|
||||
"noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Gestart",
|
||||
@@ -335,7 +338,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Kaart Tiles",
|
||||
"manageProviders": "Beheer Providers",
|
||||
"attribution": "Kaart Attributie"
|
||||
"attribution": "Kaart Attributie",
|
||||
"mapAttribution": "Kaartbron: {}",
|
||||
"couldNotOpenLink": "Kon link niet openen",
|
||||
"openLicense": "Open licentie: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Bekijk Profiel",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperiaal (mijl, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.",
|
||||
"areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.",
|
||||
"downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...",
|
||||
"downloadFailed": "Nie udało się rozpocząć pobierania: {}"
|
||||
"downloadFailed": "Nie udało się rozpocząć pobierania: {}",
|
||||
"offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).",
|
||||
"currentTileProvider": "bieżący kafelek",
|
||||
"noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Pobieranie Rozpoczęte",
|
||||
@@ -335,7 +338,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Kafelki Mapy",
|
||||
"manageProviders": "Zarządzaj Dostawcami",
|
||||
"attribution": "Atrybucja Mapy"
|
||||
"attribution": "Atrybucja Mapy",
|
||||
"mapAttribution": "Źródło mapy: {}",
|
||||
"couldNotOpenLink": "Nie udało się otworzyć linku",
|
||||
"openLicense": "Otwórz licencję: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Zobacz Profil",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metryczny (km, m)",
|
||||
"imperialDescription": "Imperialny (mila, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
"downloadFailed": "Falha ao iniciar o download: {}",
|
||||
"offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).",
|
||||
"currentTileProvider": "tile atual",
|
||||
"noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
|
||||
"createCustomProfile": "Criar Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
"manageProviders": "Gerenciar Provedores",
|
||||
"attribution": "Atribuição do Mapa"
|
||||
"attribution": "Atribuição do Mapa",
|
||||
"mapAttribution": "Atribuição do mapa: {}",
|
||||
"couldNotOpenLink": "Não foi possível abrir o link",
|
||||
"openLicense": "Abrir licença: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Novo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nome do operador",
|
||||
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "Falha ao atualizar localizações suspeitas",
|
||||
"neverFetched": "Nunca buscado",
|
||||
"daysAgo": "{} dias atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"minutesAgo": "{} minutos atrás",
|
||||
"justNow": "Agora mesmo"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "Localização Suspeita #{}",
|
||||
"ticketNo": "N° do Ticket",
|
||||
"address": "Endereço",
|
||||
"street": "Rua",
|
||||
"street": "Rua",
|
||||
"city": "Cidade",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Rua que Cruza",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.",
|
||||
"areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.",
|
||||
"downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...",
|
||||
"downloadFailed": "İndirme başlatılamadı: {}"
|
||||
"downloadFailed": "İndirme başlatılamadı: {}",
|
||||
"offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).",
|
||||
"currentTileProvider": "mevcut döşeme",
|
||||
"noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "İndirme Başladı",
|
||||
@@ -335,7 +338,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Harita Döşemeleri",
|
||||
"manageProviders": "Sağlayıcıları Yönet",
|
||||
"attribution": "Harita Atfı"
|
||||
"attribution": "Harita Atfı",
|
||||
"mapAttribution": "Harita kaynağı: {}",
|
||||
"couldNotOpenLink": "Bağlantı açılamadı",
|
||||
"openLicense": "Lisansı aç: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profili Görüntüle",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Metrik (km, m)",
|
||||
"imperialDescription": "İmperial (mil, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.",
|
||||
"areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.",
|
||||
"downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}"
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}",
|
||||
"offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).",
|
||||
"currentTileProvider": "поточна плитка",
|
||||
"noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Завантаження Почалося",
|
||||
@@ -335,7 +338,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Плитки Карти",
|
||||
"manageProviders": "Управляти Постачальниками",
|
||||
"attribution": "Атрибуція Карти"
|
||||
"attribution": "Атрибуція Карти",
|
||||
"mapAttribution": "Джерело карти: {}",
|
||||
"couldNotOpenLink": "Не вдалося відкрити посилання",
|
||||
"openLicense": "Відкрити ліцензію: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Переглянути Профіль",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "Метричні (км, м)",
|
||||
"imperialDescription": "Імперські (миля, фут)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
"downloadFailed": "启动下载失败:{}",
|
||||
"offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。",
|
||||
"currentTileProvider": "当前瓦片",
|
||||
"noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
@@ -329,13 +332,16 @@
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商",
|
||||
"attribution": "地图归属"
|
||||
"attribution": "地图归属",
|
||||
"mapAttribution": "地图来源:{}",
|
||||
"couldNotOpenLink": "无法打开链接",
|
||||
"openLicense": "打开许可证:{}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
@@ -362,7 +368,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
@@ -520,7 +526,7 @@
|
||||
"updateFailed": "疑似位置更新失败",
|
||||
"neverFetched": "从未获取",
|
||||
"daysAgo": "{}天前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"minutesAgo": "{}分钟前",
|
||||
"justNow": "刚刚"
|
||||
},
|
||||
@@ -528,7 +534,7 @@
|
||||
"title": "疑似位置 #{}",
|
||||
"ticketNo": "工单号",
|
||||
"address": "地址",
|
||||
"street": "街道",
|
||||
"street": "街道",
|
||||
"city": "城市",
|
||||
"state": "州/省",
|
||||
"intersectingStreet": "交叉街道",
|
||||
@@ -552,4 +558,4 @@
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart';
|
||||
import 'screens/osm_account_screen.dart';
|
||||
import 'screens/upload_queue_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/provider_tile_cache_manager.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
|
||||
@@ -21,13 +22,16 @@ import 'services/deep_link_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
// Initialize version service
|
||||
await VersionService().init();
|
||||
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
// Resolve platform cache directory for per-provider tile caching
|
||||
await ProviderTileCacheManager.init();
|
||||
|
||||
// Initialize deep link service
|
||||
await DeepLinkService().init();
|
||||
DeepLinkService().setNavigatorKey(_navigatorKey);
|
||||
|
||||
@@ -114,6 +114,34 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize profile ordering for existing users (v2.7.3)
|
||||
static Future<void> migrate_2_7_3(AppState appState) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const orderKey = 'profile_order';
|
||||
|
||||
// Check if user already has custom profile ordering
|
||||
if (prefs.containsKey(orderKey)) {
|
||||
debugPrint('[Migration] 2.7.3: Profile order already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with current profile order (preserves existing UI order)
|
||||
final currentProfiles = appState.profiles;
|
||||
final initialOrder = currentProfiles.map((p) => p.id).toList();
|
||||
|
||||
if (initialOrder.isNotEmpty) {
|
||||
await prefs.setStringList(orderKey, initialOrder);
|
||||
debugPrint('[Migration] 2.7.3: Initialized profile order with ${initialOrder.length} profiles');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.7.3 completed: initialized profile ordering');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.7.3 ERROR: Failed to initialize profile ordering: $e');
|
||||
// Don't rethrow - this is non-critical, profiles will just use default order
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -127,6 +155,8 @@ class OneTimeMigrations {
|
||||
return migrate_1_8_0;
|
||||
case '2.1.0':
|
||||
return migrate_2_1_0;
|
||||
case '2.7.3':
|
||||
return migrate_2_7_3;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -269,16 +269,33 @@ class NodeProfile {
|
||||
/// Used as the default `<Existing tags>` option when editing nodes
|
||||
/// All existing tags will flow through as additionalExistingTags
|
||||
static NodeProfile createExistingTagsProfile(OsmNode node) {
|
||||
// Calculate FOV from existing direction ranges if applicable
|
||||
// Only assign FOV if the original direction string actually contained range notation
|
||||
// (e.g., "90-270" or "55-125"), not if it was just single directions (e.g., "90")
|
||||
double? calculatedFov;
|
||||
|
||||
// If node has direction/FOV pairs, check if they all have the same FOV
|
||||
if (node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
final raw = node.tags['direction'] ?? node.tags['camera:direction'];
|
||||
if (raw != null) {
|
||||
// Check if any part of the direction string contains range notation (dash with numbers)
|
||||
final parts = raw.split(';');
|
||||
bool hasRangeNotation = false;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
// Look for range pattern: numbers-numbers (e.g., "90-270", "55-125")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
hasRangeNotation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only calculate FOV if the node originally had range notation
|
||||
if (hasRangeNotation && node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +307,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: false,
|
||||
fov: calculatedFov, // Use calculated FOV from existing direction ranges
|
||||
fov: calculatedFov, // Only use FOV if original had explicit range notation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/service_policy.dart';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
@@ -10,7 +12,7 @@ class TileType {
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
final int maxZoom; // Maximum zoom level for this tile type
|
||||
|
||||
const TileType({
|
||||
TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
@@ -76,6 +78,15 @@ class TileType {
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
/// The service policy that applies to this tile type's server.
|
||||
/// Cached because [urlTemplate] is immutable.
|
||||
late final ServicePolicy servicePolicy =
|
||||
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;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
|
||||
@@ -578,37 +578,41 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: canDownload ? () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,76 +34,101 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: appState.profiles.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
appState.reorderProfiles(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final p = appState.profiles[index];
|
||||
return ListTile(
|
||||
key: ValueKey(p.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Checkbox
|
||||
Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget {
|
||||
subtitle: Text(locService.t('uploadMode.subtitle')),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
// This entire section is gated behind kEnableDevelopmentModes
|
||||
// in osm_account_screen.dart, so all modes are always available here.
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
|
||||
@@ -36,6 +36,7 @@ class AuthService {
|
||||
|
||||
void setUploadMode(UploadMode mode) {
|
||||
_mode = mode;
|
||||
if (mode == UploadMode.simulate || !kHasOsmSecrets) return;
|
||||
final isSandbox = (mode == UploadMode.sandbox);
|
||||
final authBase = isSandbox
|
||||
? 'https://master.apis.dev.openstreetmap.org'
|
||||
@@ -150,7 +151,9 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
await _helper.removeAllTokens();
|
||||
if (_mode != UploadMode.simulate) {
|
||||
await _helper.removeAllTokens();
|
||||
}
|
||||
_displayName = null;
|
||||
return await login();
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.6.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
|
||||
versionsNeedingMigration.add('2.7.3');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
@@ -8,55 +9,110 @@ import 'package:http/http.dart';
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Thrown when a tile load is cancelled (tile scrolled off screen).
|
||||
/// TileLayerManager skips retry for these — the tile is already gone.
|
||||
class TileLoadCancelledException implements Exception {
|
||||
const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
/// Thrown when a tile is not available offline (no offline area or cache hit).
|
||||
/// TileLayerManager skips retry for these — retrying won't help without network.
|
||||
class TileNotAvailableOfflineException implements Exception {
|
||||
const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
/// Custom tile provider that extends NetworkTileProvider to leverage its
|
||||
/// built-in disk cache, RetryClient, ETag revalidation, and abort support,
|
||||
/// while routing URLs through our TileType logic and supporting offline tiles.
|
||||
///
|
||||
/// Each instance is configured for a specific tile provider/type combination
|
||||
/// with frozen config — no AppState lookups at request time (except for the
|
||||
/// global offlineMode toggle).
|
||||
///
|
||||
/// Two runtime paths:
|
||||
/// 1. **Common path** (no offline areas for current provider): delegates to
|
||||
/// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider
|
||||
/// pipeline (disk cache, ETag revalidation, RetryClient, abort support).
|
||||
/// 2. **Offline-first path** (has offline areas or offline mode): returns
|
||||
/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls
|
||||
/// back to HTTP via shared RetryClient on miss.
|
||||
/// DeflockOfflineTileImageProvider — checks disk cache and local tiles
|
||||
/// first, falls back to HTTP via shared RetryClient on miss.
|
||||
class DeflockTileProvider extends NetworkTileProvider {
|
||||
/// The shared HTTP client we own. We keep a reference because
|
||||
/// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient
|
||||
/// will be false (we passed it in), so super.dispose() won't close it.
|
||||
final Client _sharedHttpClient;
|
||||
|
||||
DeflockTileProvider._({required Client httpClient})
|
||||
: _sharedHttpClient = httpClient,
|
||||
/// Frozen config for this provider instance.
|
||||
final String providerId;
|
||||
final models.TileType tileType;
|
||||
final String? apiKey;
|
||||
|
||||
/// Opaque fingerprint of the config this provider was created with.
|
||||
/// Used by [TileLayerManager] to detect config drift after edits.
|
||||
final String configFingerprint;
|
||||
|
||||
/// Caching provider for the offline-first path. The same instance is passed
|
||||
/// to super for the common path — we keep a reference here so we can also
|
||||
/// use it in [DeflockOfflineTileImageProvider].
|
||||
final MapCachingProvider? _cachingProvider;
|
||||
|
||||
/// Called when a tile loads successfully via the network in the offline-first
|
||||
/// path. Used by [TileLayerManager] to reset exponential backoff.
|
||||
VoidCallback? onNetworkSuccess;
|
||||
|
||||
// ignore: use_super_parameters
|
||||
DeflockTileProvider._({
|
||||
required Client httpClient,
|
||||
required this.providerId,
|
||||
required this.tileType,
|
||||
this.apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
this.configFingerprint = '',
|
||||
}) : _sharedHttpClient = httpClient,
|
||||
_cachingProvider = cachingProvider,
|
||||
super(
|
||||
httpClient: httpClient,
|
||||
silenceExceptions: true,
|
||||
cachingProvider: cachingProvider,
|
||||
// Let errors propagate so flutter_map marks tiles as failed
|
||||
// (loadError = true) rather than caching transparent images as
|
||||
// "successfully loaded". The TileLayerManager wires a reset stream
|
||||
// that retries failed tiles after a debounced delay.
|
||||
silenceExceptions: false,
|
||||
);
|
||||
|
||||
factory DeflockTileProvider() {
|
||||
factory DeflockTileProvider({
|
||||
required String providerId,
|
||||
required models.TileType tileType,
|
||||
String? apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
VoidCallback? onNetworkSuccess,
|
||||
String configFingerprint = '',
|
||||
}) {
|
||||
final client = UserAgentClient(RetryClient(Client()));
|
||||
return DeflockTileProvider._(httpClient: client);
|
||||
return DeflockTileProvider._(
|
||||
httpClient: client,
|
||||
providerId: providerId,
|
||||
tileType: tileType,
|
||||
apiKey: apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
configFingerprint: configFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
// Fallback to base implementation if no provider configured
|
||||
return super.getTileUrl(coordinates, options);
|
||||
}
|
||||
|
||||
return selectedTileType.getTileUrl(
|
||||
return tileType.getTileUrl(
|
||||
coordinates.z,
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +122,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
TileLayer options,
|
||||
Future<void> cancelLoading,
|
||||
) {
|
||||
if (!_shouldCheckOfflineCache()) {
|
||||
if (!_shouldCheckOfflineCache(coordinates.z)) {
|
||||
// Common path: no offline areas — delegate to NetworkTileProvider's
|
||||
// full pipeline (disk cache, ETag, RetryClient, abort support).
|
||||
return super.getImageWithCancelLoadingSupport(
|
||||
@@ -77,20 +133,18 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
}
|
||||
|
||||
// Offline-first path: check local tiles first, fall back to network.
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockOfflineTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
httpClient: _sharedHttpClient,
|
||||
headers: headers,
|
||||
cancelLoading: cancelLoading,
|
||||
isOfflineOnly: appState.offlineMode,
|
||||
isOfflineOnly: AppState.instance.offlineMode,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeId: tileType.id,
|
||||
tileUrl: getTileUrl(coordinates, options),
|
||||
cachingProvider: _cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,44 +155,67 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
///
|
||||
/// This avoids the offline-first path (and its filesystem searches) when
|
||||
/// browsing online with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache() {
|
||||
final appState = AppState.instance;
|
||||
|
||||
bool _shouldCheckOfflineCache(int zoom) {
|
||||
// Always use offline path in offline mode
|
||||
if (appState.offlineMode) {
|
||||
if (AppState.instance.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only use offline path if we have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// at this zoom level — tiles outside any area's zoom range go through the
|
||||
// common NetworkTileProvider path for better performance.
|
||||
final offlineService = OfflineAreaService();
|
||||
return offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
return offlineService.hasOfflineAreasForProviderAtZoom(
|
||||
providerId,
|
||||
tileType.id,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
await super.dispose();
|
||||
} finally {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
// Only call super — do NOT close _sharedHttpClient here.
|
||||
// flutter_map calls dispose() whenever the TileLayer widget is recycled
|
||||
// (e.g. provider switch causes a new FlutterMap key), but
|
||||
// TileLayerManager caches and reuses provider instances across switches.
|
||||
// Closing the HTTP client here would leave the cached instance broken —
|
||||
// all future tile requests would fail with "Client closed".
|
||||
//
|
||||
// Since we passed our own httpClient to NetworkTileProvider,
|
||||
// _isInternallyCreatedClient is false, so super.dispose() won't close it
|
||||
// either. The client is closed in [shutdown], called by
|
||||
// TileLayerManager.dispose() when the map is truly torn down.
|
||||
await super.dispose();
|
||||
}
|
||||
|
||||
/// Permanently close the HTTP client. Called by [TileLayerManager.dispose]
|
||||
/// when the map widget is being torn down — NOT by flutter_map's widget
|
||||
/// recycling.
|
||||
void shutdown() {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image provider for the offline-first path.
|
||||
///
|
||||
/// Tries fetchLocalTile() first. On miss (and if online), falls back to an
|
||||
/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and
|
||||
/// returns transparent tiles on errors (consistent with silenceExceptions).
|
||||
/// Checks disk cache and offline areas before falling back to the network.
|
||||
/// Caches successful network fetches to disk so panning back doesn't re-fetch.
|
||||
/// On cancellation, lets in-flight downloads complete and caches the result
|
||||
/// (fire-and-forget) instead of discarding downloaded bytes.
|
||||
///
|
||||
/// **Online mode flow:**
|
||||
/// 1. Disk cache (fast hash-based file read) → hit + fresh → return
|
||||
/// 2. Offline areas (file scan) → hit → return
|
||||
/// 3. Network fetch with conditional headers from stale cache entry
|
||||
/// 4. On cancel → fire-and-forget cache write for the in-flight download
|
||||
/// 5. On 304 → return stale cached bytes, update cache metadata
|
||||
/// 6. On 200 → cache to disk, decode and return
|
||||
/// 7. On error → throw (flutter_map marks tile as failed)
|
||||
///
|
||||
/// **Offline mode flow:**
|
||||
/// 1. Offline areas (primary source — guaranteed available)
|
||||
/// 2. Disk cache (tiles cached from previous online sessions)
|
||||
/// 3. Throw if both miss (flutter_map marks tile as failed)
|
||||
class DeflockOfflineTileImageProvider
|
||||
extends ImageProvider<DeflockOfflineTileImageProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
@@ -150,6 +227,8 @@ class DeflockOfflineTileImageProvider
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
final String tileUrl;
|
||||
final MapCachingProvider? cachingProvider;
|
||||
final VoidCallback? onNetworkSuccess;
|
||||
|
||||
const DeflockOfflineTileImageProvider({
|
||||
required this.coordinates,
|
||||
@@ -161,6 +240,8 @@ class DeflockOfflineTileImageProvider
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
required this.tileUrl,
|
||||
this.cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -173,19 +254,47 @@ class DeflockOfflineTileImageProvider
|
||||
ImageStreamCompleter loadImage(
|
||||
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
final codecFuture = _loadAsync(key, decode, chunkEvents);
|
||||
|
||||
codecFuture.whenComplete(() {
|
||||
chunkEvents.close();
|
||||
});
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: codecFuture,
|
||||
// Chain whenComplete into the codec future so there's a single future
|
||||
// for MultiFrameImageStreamCompleter to handle. Without this, the
|
||||
// whenComplete creates an orphaned future whose errors go unhandled.
|
||||
codec: _loadAsync(key, decode, chunkEvents).whenComplete(() {
|
||||
chunkEvents.close();
|
||||
}),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Try to read a tile from the disk cache. Returns null on miss or error.
|
||||
Future<CachedMapTile?> _getCachedTile() async {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return null;
|
||||
try {
|
||||
return await cachingProvider!.getTile(tileUrl);
|
||||
} on CachedMapTileReadFailure {
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a tile to the disk cache (best-effort, never throws).
|
||||
void _putCachedTile({
|
||||
required Map<String, String> responseHeaders,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return;
|
||||
try {
|
||||
final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders);
|
||||
cachingProvider!
|
||||
.putTile(url: tileUrl, metadata: metadata, bytes: bytes)
|
||||
.catchError((_) {});
|
||||
} catch (_) {
|
||||
// Best-effort: never fail the tile load due to cache write errors.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec> _loadAsync(
|
||||
DeflockOfflineTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
@@ -194,78 +303,169 @@ class DeflockOfflineTileImageProvider
|
||||
Future<Codec> decodeBytes(Uint8List bytes) =>
|
||||
ImmutableBuffer.fromUint8List(bytes).then(decode);
|
||||
|
||||
Future<Codec> transparent() =>
|
||||
decodeBytes(TileProvider.transparentImage);
|
||||
// Track cancellation synchronously via Completer so the catch block
|
||||
// can reliably check it without microtask ordering races.
|
||||
final cancelled = Completer<void>();
|
||||
cancelLoading.then((_) {
|
||||
if (!cancelled.isCompleted) cancelled.complete();
|
||||
}).ignore();
|
||||
|
||||
try {
|
||||
// Track cancellation
|
||||
bool cancelled = false;
|
||||
cancelLoading.then((_) => cancelled = true);
|
||||
|
||||
// Try local tile first — pass captured IDs to avoid a race if the
|
||||
// user switches provider while this async load is in flight.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network if online
|
||||
if (isOfflineOnly) {
|
||||
return await _loadOffline(decodeBytes, cancelled);
|
||||
}
|
||||
|
||||
if (cancelled) return await transparent();
|
||||
if (isOfflineOnly) return await transparent();
|
||||
|
||||
// Fall back to network via shared RetryClient.
|
||||
// Race the download against cancelLoading so we stop waiting if the
|
||||
// tile is pruned mid-flight (the underlying TCP connection is cleaned
|
||||
// up naturally by the shared client).
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (statusCode: response.statusCode, bytes: bytes);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))),
|
||||
]);
|
||||
|
||||
if (cancelled || result.statusCode == 0) return await transparent();
|
||||
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
return await transparent();
|
||||
return await _loadOnline(decodeBytes, cancelled);
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses
|
||||
if (!e.toString().contains('offline')) {
|
||||
debugPrint(
|
||||
'[DeflockTileProvider] Offline-first tile failed '
|
||||
'${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'(${e.runtimeType})');
|
||||
// Cancelled tiles throw — flutter_map handles the error silently.
|
||||
// Preserve TileNotAvailableOfflineException even if the tile was also
|
||||
// cancelled — it has distinct semantics (genuine cache miss) that
|
||||
// matter for diagnostics and future UI indicators.
|
||||
if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) {
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
|
||||
.then(decode);
|
||||
|
||||
// Let real errors propagate so flutter_map marks loadError = true
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Online mode: disk cache → offline areas → network (with caching).
|
||||
Future<Codec> _loadOnline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check disk cache — fast hash-based file read.
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null && !cachedTile.metadata.isStale) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 2. Check offline areas — file scan per area.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network
|
||||
}
|
||||
|
||||
// 3. If cancelled before network, bail.
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
|
||||
// 4. Network fetch with conditional headers from stale cache entry.
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
if (cachedTile != null) {
|
||||
if (cachedTile.metadata.lastModified case final lastModified?) {
|
||||
request.headers[HttpHeaders.ifModifiedSinceHeader] =
|
||||
HttpDate.format(lastModified);
|
||||
}
|
||||
if (cachedTile.metadata.etag case final etag?) {
|
||||
request.headers[HttpHeaders.ifNoneMatchHeader] = etag;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Race the download against cancelLoading.
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (
|
||||
statusCode: response.statusCode,
|
||||
bytes: bytes,
|
||||
headers: response.headers,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (
|
||||
statusCode: 0,
|
||||
bytes: Uint8List(0),
|
||||
headers: <String, String>{},
|
||||
)),
|
||||
]);
|
||||
|
||||
// 6. On cancel — fire-and-forget cache write for the in-flight download
|
||||
// instead of discarding the downloaded bytes.
|
||||
if (cancelled.isCompleted || result.statusCode == 0) {
|
||||
networkFuture.then((r) {
|
||||
if (r.statusCode == 200 && r.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: r.headers, bytes: r.bytes);
|
||||
}
|
||||
}).ignore();
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
// 7. On 304 Not Modified → return stale cached bytes, update metadata.
|
||||
if (result.statusCode == HttpStatus.notModified && cachedTile != null) {
|
||||
_putCachedTile(responseHeaders: result.headers);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 8. On 200 OK → cache to disk, decode and return.
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: result.headers, bytes: result.bytes);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
// 9. Network error — throw so flutter_map marks the tile as failed.
|
||||
// Don't include tileUrl in the exception — it may contain API keys.
|
||||
throw HttpException(
|
||||
'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'returned status ${result.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Offline mode: offline areas → disk cache → throw.
|
||||
Future<Codec> _loadOffline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check offline areas (primary source — guaranteed available).
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} on TileLoadCancelledException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
// Local miss — fall through to disk cache
|
||||
}
|
||||
|
||||
// 2. Check disk cache (tiles cached from previous online sessions).
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 3. Both miss — throw so flutter_map marks the tile as failed.
|
||||
throw const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DeflockOfflineTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
other.tileTypeId == tileTypeId &&
|
||||
other.isOfflineOnly == isOfflineOnly;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
int get hashCode =>
|
||||
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
@@ -7,6 +8,7 @@ import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../http_client.dart';
|
||||
import '../service_policy.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
||||
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||
@@ -58,28 +60,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await _client.get(Uri.parse(url));
|
||||
|
||||
|
||||
// Enforce max 2 concurrent download threads per OSM API usage policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client.get(Uri.parse(url));
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
return nodes;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] Exception: $e');
|
||||
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
|
||||
@@ -19,7 +23,7 @@ Future<List<int>> fetchLocalTile({
|
||||
final appState = AppState.instance;
|
||||
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
|
||||
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
|
||||
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -28,20 +32,21 @@ Future<List<int>> fetchLocalTile({
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
|
||||
if (hasTile) {
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
if (await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
}
|
||||
// O(1) bounds check instead of enumerating all tiles at this zoom level
|
||||
if (!tileInBounds(area.bounds, z, x, y)) continue;
|
||||
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
try {
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.notFound) continue;
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
} on FileSystemException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
@@ -51,6 +56,34 @@ Future<List<int>> fetchLocalTile({
|
||||
return await candidates.first.file.readAsBytes();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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 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();
|
||||
// North → lower Y (Mercator projection inverts latitude)
|
||||
final 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) +
|
||||
1.0 / cos(south * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
String _tilePath(String areaDir, int z, int x, int y) =>
|
||||
'$areaDir/tiles/$z/$x/$y.png';
|
||||
|
||||
|
||||
@@ -33,14 +33,37 @@ class OfflineAreaService {
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
|
||||
return _areas.any((area) =>
|
||||
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId
|
||||
);
|
||||
}
|
||||
|
||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||
/// offline-first path for tiles that will never be found locally.
|
||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||
if (!_initialized) return false;
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId &&
|
||||
zoom >= area.minZoom &&
|
||||
zoom <= area.maxZoom
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset service state and inject areas for unit tests.
|
||||
@visibleForTesting
|
||||
void setAreasForTesting(List<OfflineArea> areas) {
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(areas);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
@@ -213,7 +236,7 @@ class OfflineAreaService {
|
||||
area = OfflineArea(
|
||||
id: id,
|
||||
name: name ?? area?.name ?? '',
|
||||
bounds: bounds,
|
||||
bounds: normalizeBounds(bounds),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_node.dart';
|
||||
import 'offline_tile_utils.dart' show normalizeBounds;
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -71,10 +72,10 @@ class OfflineArea {
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
final bounds = LatLngBounds(
|
||||
final bounds = normalizeBounds(LatLngBounds(
|
||||
LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']),
|
||||
LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']),
|
||||
);
|
||||
));
|
||||
return OfflineArea(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
|
||||
@@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero)
|
||||
/// spans are expanded by epsilon. Call this before storing bounds so that
|
||||
/// `tileInBounds` and [computeTileList] see consistent corner ordering.
|
||||
LatLngBounds normalizeBounds(LatLngBounds bounds) {
|
||||
const double epsilon = 1e-7;
|
||||
double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
// Expand degenerate/flat areas a hair
|
||||
var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
if ((latMax - latMin).abs() < epsilon) {
|
||||
latMin -= epsilon;
|
||||
latMax += epsilon;
|
||||
@@ -20,6 +21,16 @@ Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
lonMin -= epsilon;
|
||||
lonMax += epsilon;
|
||||
}
|
||||
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
|
||||
}
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
final normalized = normalizeBounds(bounds);
|
||||
final double latMin = normalized.south;
|
||||
final double latMax = normalized.north;
|
||||
final double lonMin = normalized.west;
|
||||
final double lonMax = normalized.east;
|
||||
for (int z = zMin; z <= zMax; z++) {
|
||||
final n = pow(2, z).toInt();
|
||||
final minTileRaw = latLonToTileRaw(latMin, lonMin, z);
|
||||
|
||||
@@ -8,97 +8,106 @@ import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Simple Overpass API client with proper HTTP retry logic.
|
||||
/// Simple Overpass API client with retry and fallback logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter';
|
||||
static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const _policy = ResiliencePolicy(
|
||||
maxRetries: 3,
|
||||
httpTimeout: Duration(seconds: 45),
|
||||
);
|
||||
|
||||
final http.Client _client;
|
||||
/// Optional override endpoint. When null, uses [defaultEndpoint].
|
||||
final String? _endpointOverride;
|
||||
|
||||
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
OverpassService({http.Client? client, String? endpoint})
|
||||
: _client = client ?? UserAgentClient(),
|
||||
_endpointOverride = endpoint;
|
||||
|
||||
/// Resolve the primary endpoint: constructor override or default.
|
||||
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Fetch surveillance nodes from Overpass API with retry and fallback.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int maxRetries = 3,
|
||||
ResiliencePolicy? policy,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
|
||||
final query = _buildQuery(bounds, profiles);
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
||||
|
||||
final response = await _client.post(
|
||||
Uri.parse(_endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(kOverpassQueryTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - caller should split area
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Timeout error - also try splitting (complex query)
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
||||
throw NodeLimitError('Query timed out - area too complex');
|
||||
}
|
||||
|
||||
// Rate limit - throw immediately, don't retry
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
// Other HTTP errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
|
||||
} catch (e) {
|
||||
// Handle specific error types without retry
|
||||
if (e is NodeLimitError || e is RateLimitError) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Network/timeout errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('Network error after $maxRetries retries: $e');
|
||||
}
|
||||
}
|
||||
|
||||
throw NetworkError('Max retries exceeded');
|
||||
final endpoint = _primaryEndpoint;
|
||||
final canFallback = _endpointOverride == null;
|
||||
final effectivePolicy = policy ?? _policy;
|
||||
|
||||
return executeWithFallback<List<OsmNode>>(
|
||||
primaryUrl: endpoint,
|
||||
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||
classifyError: _classifyError,
|
||||
policy: effectivePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
|
||||
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
|
||||
debugPrint('[OverpassService] POST $endpoint');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
Uri.parse(endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(policy.httpTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - caller should split area
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Timeout error - also try splitting (complex query)
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
||||
throw NodeLimitError('Query timed out - area too complex');
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
} catch (e) {
|
||||
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
|
||||
rethrow;
|
||||
}
|
||||
throw NetworkError('Network error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static ErrorDisposition _classifyError(Object error) {
|
||||
if (error is NodeLimitError) return ErrorDisposition.abort;
|
||||
if (error is RateLimitError) return ErrorDisposition.fallback;
|
||||
return ErrorDisposition.retry;
|
||||
}
|
||||
|
||||
/// Build Overpass QL query for given bounds and profiles
|
||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
@@ -107,7 +116,7 @@ class OverpassService {
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
@@ -119,38 +128,38 @@ class OverpassService {
|
||||
out body;
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out skel;
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
|
||||
if (type == 'node') {
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// Mark referenced nodes as constrained
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
|
||||
for (final ref in refs) {
|
||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Second pass: create OsmNode objects
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
@@ -161,7 +170,7 @@ out skel;
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
@@ -189,4 +198,4 @@ class NetworkError extends Error {
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
}
|
||||
|
||||
106
lib/services/provider_tile_cache_manager.dart
Normal file
106
lib/services/provider_tile_cache_manager.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'provider_tile_cache_store.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Factory and registry for per-provider [ProviderTileCacheStore] instances.
|
||||
///
|
||||
/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`.
|
||||
/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to
|
||||
/// resolve the platform cache directory. After init, [getOrCreate] is
|
||||
/// synchronous — the cache store lazily creates its directory on first write.
|
||||
class ProviderTileCacheManager {
|
||||
static final Map<String, ProviderTileCacheStore> _stores = {};
|
||||
static String? _baseCacheDir;
|
||||
|
||||
/// Resolve the platform cache directory. Call once at startup.
|
||||
static Future<void> init() async {
|
||||
if (_baseCacheDir != null) return;
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
_baseCacheDir = p.join(cacheDir.path, 'tile_cache');
|
||||
}
|
||||
|
||||
/// Whether the manager has been initialized.
|
||||
static bool get isInitialized => _baseCacheDir != null;
|
||||
|
||||
/// Get or create a cache store for a specific provider/tile type combination.
|
||||
///
|
||||
/// Synchronous after [init] has been called. The cache store lazily creates
|
||||
/// its directory on first write.
|
||||
static ProviderTileCacheStore getOrCreate({
|
||||
required String providerId,
|
||||
required String tileTypeId,
|
||||
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 store = ProviderTileCacheStore(
|
||||
cacheDirectory: cacheDir,
|
||||
maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024,
|
||||
overrideFreshAge: policy.minCacheTtl,
|
||||
);
|
||||
|
||||
_stores[key] = store;
|
||||
return store;
|
||||
}
|
||||
|
||||
/// Delete a specific provider's cache directory and remove the store.
|
||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||
final key = '$providerId/$tileTypeId';
|
||||
final store = _stores.remove(key);
|
||||
if (store != null) {
|
||||
await store.clear();
|
||||
} else if (_baseCacheDir != null) {
|
||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||
if (await cacheDir.exists()) {
|
||||
await cacheDir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get estimated cache sizes for all active stores.
|
||||
///
|
||||
/// Returns a map of `providerId/tileTypeId` → size in bytes.
|
||||
static Future<Map<String, int>> getCacheSizes() async {
|
||||
final sizes = <String, int>{};
|
||||
for (final entry in _stores.entries) {
|
||||
sizes[entry.key] = await entry.value.estimatedSizeBytes;
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/// Remove a store from the registry (e.g., when a provider is disposed).
|
||||
static void unregister(String providerId, String tileTypeId) {
|
||||
_stores.remove('$providerId/$tileTypeId');
|
||||
}
|
||||
|
||||
/// Clear all stores and reset the registry (for testing).
|
||||
@visibleForTesting
|
||||
static Future<void> resetAll() async {
|
||||
for (final store in _stores.values) {
|
||||
await store.clear();
|
||||
}
|
||||
_stores.clear();
|
||||
_baseCacheDir = null;
|
||||
}
|
||||
|
||||
/// Set the base cache directory directly (for testing).
|
||||
@visibleForTesting
|
||||
static void setBaseCacheDir(String dir) {
|
||||
_baseCacheDir = dir;
|
||||
}
|
||||
}
|
||||
315
lib/services/provider_tile_cache_store.dart
Normal file
315
lib/services/provider_tile_cache_store.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Per-provider tile cache implementing flutter_map's [MapCachingProvider].
|
||||
///
|
||||
/// Each instance manages an isolated cache directory with:
|
||||
/// - Deterministic UUID v5 key generation from tile URLs
|
||||
/// - Optional TTL override from [ServicePolicy.minCacheTtl]
|
||||
/// - Configurable max cache size with oldest-modified eviction
|
||||
///
|
||||
/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON
|
||||
/// metadata containing staleAt, lastModified, etag).
|
||||
class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final String cacheDirectory;
|
||||
final int maxCacheBytes;
|
||||
final Duration? overrideFreshAge;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Running estimate of cache size in bytes. Initialized lazily on first
|
||||
/// [putTile] call to avoid blocking construction.
|
||||
int? _estimatedSize;
|
||||
|
||||
/// Throttle: don't re-scan more than once per minute.
|
||||
DateTime? _lastPruneCheck;
|
||||
|
||||
/// One-shot latch for lazy directory creation (safe under concurrent calls).
|
||||
Completer<void>? _directoryReady;
|
||||
|
||||
/// Guard against concurrent eviction runs.
|
||||
bool _isEvicting = false;
|
||||
|
||||
ProviderTileCacheStore({
|
||||
required this.cacheDirectory,
|
||||
this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default
|
||||
this.overrideFreshAge,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async {
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['staleAt'] as int,
|
||||
isUtc: true,
|
||||
),
|
||||
lastModified: metaJson['lastModified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['lastModified'] as int,
|
||||
isUtc: true,
|
||||
)
|
||||
: null,
|
||||
etag: metaJson['etag'] as String?,
|
||||
);
|
||||
|
||||
return (bytes: bytes, metadata: metadata);
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw CachedMapTileReadFailure(
|
||||
url: url,
|
||||
description: 'Failed to read cached tile',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {
|
||||
await _ensureDirectory();
|
||||
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
// Apply minimum TTL override if configured (e.g., OSM 7-day minimum).
|
||||
// Use the later of server-provided staleAt and our minimum to avoid
|
||||
// accidentally shortening a longer server-provided freshness lifetime.
|
||||
final effectiveMetadata = overrideFreshAge != null
|
||||
? (() {
|
||||
final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!);
|
||||
final staleAt = metadata.staleAt.isAfter(overrideStaleAt)
|
||||
? metadata.staleAt
|
||||
: overrideStaleAt;
|
||||
return CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: metadata.lastModified,
|
||||
etag: metadata.etag,
|
||||
);
|
||||
})()
|
||||
: metadata;
|
||||
|
||||
final metaJson = json.encode({
|
||||
'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch,
|
||||
'lastModified':
|
||||
effectiveMetadata.lastModified?.millisecondsSinceEpoch,
|
||||
'etag': effectiveMetadata.etag,
|
||||
});
|
||||
|
||||
// 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.
|
||||
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.
|
||||
_estimatedSize = null;
|
||||
|
||||
// Schedule lazy size check
|
||||
_scheduleEvictionCheck();
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists (lazy creation on first write).
|
||||
///
|
||||
/// Uses a Completer latch so concurrent callers share a single create().
|
||||
/// Safe under Dart's single-threaded event loop: the null check and
|
||||
/// assignment happen in the same synchronous block with no `await`
|
||||
/// between them, so no other microtask can interleave.
|
||||
Future<void> _ensureDirectory() {
|
||||
if (_directoryReady == null) {
|
||||
final completer = Completer<void>();
|
||||
_directoryReady = completer;
|
||||
Directory(cacheDirectory).create(recursive: true).then(
|
||||
(_) => completer.complete(),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
// Reset latch on error so later calls can retry directory creation.
|
||||
if (identical(_directoryReady, completer)) {
|
||||
_directoryReady = null;
|
||||
}
|
||||
completer.completeError(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _directoryReady!.future;
|
||||
}
|
||||
|
||||
/// Generate a cache key from URL using UUID v5 (same as flutter_map built-in).
|
||||
@visibleForTesting
|
||||
static String keyFor(String url) => _uuid.v5(Namespace.url.value, url);
|
||||
|
||||
/// Estimate total cache size (lazy, first call scans directory).
|
||||
Future<int> _getEstimatedSize() async {
|
||||
if (_estimatedSize != null) return _estimatedSize!;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) {
|
||||
_estimatedSize = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
total += await entity.length();
|
||||
}
|
||||
}
|
||||
_estimatedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Schedule eviction if we haven't checked recently.
|
||||
void _scheduleEvictionCheck() {
|
||||
final now = DateTime.now();
|
||||
if (_lastPruneCheck != null &&
|
||||
now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) {
|
||||
return;
|
||||
}
|
||||
_lastPruneCheck = now;
|
||||
|
||||
// Fire-and-forget: eviction is best-effort background work.
|
||||
// _estimatedSize may be momentarily stale between eviction start and
|
||||
// completion, but this is acceptable — the guard only needs to be
|
||||
// approximately correct to prevent unbounded growth, and the throttle
|
||||
// ensures we re-check within a minute.
|
||||
// ignore: discarded_futures
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Evict oldest-modified tiles if cache exceeds size limit.
|
||||
///
|
||||
/// Sorts by file mtime (oldest first), not by last access — true LRU would
|
||||
/// require touching files on every [getTile] read, adding I/O on the hot
|
||||
/// path. In practice write-recency tracks usage well because tiles are
|
||||
/// immutable and flutter_map holds visible tiles in memory.
|
||||
///
|
||||
/// Guarded by [_isEvicting] to prevent concurrent runs from corrupting
|
||||
/// [_estimatedSize].
|
||||
Future<void> _evictIfNeeded() async {
|
||||
if (_isEvicting) return;
|
||||
_isEvicting = true;
|
||||
try {
|
||||
final currentSize = await _getEstimatedSize();
|
||||
if (currentSize <= maxCacheBytes) return;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
// Collect all files, separating .tile and .meta for eviction + orphan cleanup.
|
||||
final tileFiles = <File>[];
|
||||
final metaFiles = <String>{};
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.path.endsWith('.tile')) {
|
||||
tileFiles.add(entity);
|
||||
} else if (entity.path.endsWith('.meta')) {
|
||||
metaFiles.add(p.basenameWithoutExtension(entity.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())),
|
||||
);
|
||||
stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified));
|
||||
|
||||
var freedBytes = 0;
|
||||
final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80%
|
||||
final evictedKeys = <String>{};
|
||||
|
||||
for (final entry in stats) {
|
||||
if (currentSize - freedBytes <= targetSize) break;
|
||||
|
||||
final key = p.basenameWithoutExtension(entry.file.path);
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphan .meta files (no matching .tile file).
|
||||
// Exclude keys we just evicted — their .tile is gone so they're orphans.
|
||||
final remainingTileKeys = tileFiles
|
||||
.map((f) => p.basenameWithoutExtension(f.path))
|
||||
.toSet()
|
||||
..removeAll(evictedKeys);
|
||||
for (final metaKey in metaFiles) {
|
||||
if (!remainingTileKeys.contains(metaKey)) {
|
||||
try {
|
||||
final orphan = File(p.join(cacheDirectory, '$metaKey.meta'));
|
||||
final orphanStat = await orphan.stat();
|
||||
await orphan.delete();
|
||||
freedBytes += orphanStat.size;
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_estimatedSize = currentSize - freedBytes;
|
||||
debugPrint(
|
||||
'[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB '
|
||||
'from $cacheDirectory',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Eviction error: $e');
|
||||
} finally {
|
||||
_isEvicting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cached tiles in this store's directory.
|
||||
Future<void> clear() async {
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_estimatedSize = null;
|
||||
_directoryReady = null; // Allow lazy re-creation
|
||||
_lastPruneCheck = null; // Reset throttle so next write can trigger eviction
|
||||
}
|
||||
|
||||
/// Get the current estimated cache size in bytes.
|
||||
Future<int> get estimatedSizeBytes => _getEstimatedSize();
|
||||
|
||||
/// Force an eviction check, bypassing the throttle.
|
||||
/// Only exposed for testing — production code uses [_scheduleEvictionCheck].
|
||||
@visibleForTesting
|
||||
Future<void> forceEviction() => _evictIfNeeded();
|
||||
}
|
||||
@@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
final double distanceMeters;
|
||||
final double durationSeconds;
|
||||
|
||||
|
||||
const RouteResult({
|
||||
required this.waypoints,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
|
||||
@@ -26,14 +26,27 @@ class RouteResult {
|
||||
}
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
final http.Client _client;
|
||||
static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
|
||||
static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
static const _policy = ResiliencePolicy(
|
||||
maxRetries: 1,
|
||||
httpTimeout: Duration(seconds: 30),
|
||||
);
|
||||
|
||||
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
final http.Client _client;
|
||||
/// Optional override URL. When null, uses [defaultUrl].
|
||||
final String? _baseUrlOverride;
|
||||
|
||||
RoutingService({http.Client? client, String? baseUrl})
|
||||
: _client = client ?? UserAgentClient(),
|
||||
_baseUrlOverride = baseUrl;
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
// Calculate route between two points using alprwatch
|
||||
/// Resolve the primary URL to use: constructor override or default.
|
||||
String get _primaryUrl => _baseUrlOverride ?? defaultUrl;
|
||||
|
||||
// Calculate route between two points
|
||||
Future<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
@@ -53,8 +66,7 @@ class RoutingService {
|
||||
'tags': tags,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final uri = Uri.parse(_baseUrl);
|
||||
|
||||
final params = {
|
||||
'start': {
|
||||
'longitude': start.longitude,
|
||||
@@ -66,11 +78,25 @@ class RoutingService {
|
||||
},
|
||||
'avoidance_distance': avoidanceDistance,
|
||||
'enabled_profiles': enabledProfiles,
|
||||
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
|
||||
'show_exclusion_zone': false,
|
||||
};
|
||||
|
||||
debugPrint('[RoutingService] alprwatch request: $uri $params');
|
||||
|
||||
|
||||
final primaryUrl = _primaryUrl;
|
||||
final canFallback = _baseUrlOverride == null;
|
||||
|
||||
return executeWithFallback<RouteResult>(
|
||||
primaryUrl: primaryUrl,
|
||||
fallbackUrl: canFallback ? fallbackUrl : null,
|
||||
execute: (url) => _postRoute(url, params),
|
||||
classifyError: _classifyError,
|
||||
policy: _policy,
|
||||
);
|
||||
}
|
||||
|
||||
Future<RouteResult> _postRoute(String url, Map<String, dynamic> params) async {
|
||||
final uri = Uri.parse(url);
|
||||
debugPrint('[RoutingService] POST $uri');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
@@ -78,7 +104,7 @@ class RoutingService {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: json.encode(params)
|
||||
).timeout(kNavigationRoutingTimeout);
|
||||
).timeout(_policy.httpTimeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (kDebugMode) {
|
||||
@@ -91,24 +117,25 @@ class RoutingService {
|
||||
: body;
|
||||
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
|
||||
}
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}',
|
||||
statusCode: response.statusCode);
|
||||
}
|
||||
|
||||
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
||||
|
||||
// Check alprwatch response status
|
||||
debugPrint('[RoutingService] response data: $data');
|
||||
|
||||
// Check response status
|
||||
final ok = data['ok'] as bool? ?? false;
|
||||
if ( ! ok ) {
|
||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('alprwatch error: $message');
|
||||
throw RoutingException('API error: $message', isApiError: true);
|
||||
}
|
||||
|
||||
|
||||
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||
if (route == null) {
|
||||
throw RoutingException('No route found between these points');
|
||||
throw RoutingException('No route found between these points', isApiError: true);
|
||||
}
|
||||
|
||||
|
||||
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
@@ -116,19 +143,19 @@ class RoutingService {
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
|
||||
final result = RouteResult(
|
||||
waypoints: waypoints,
|
||||
distanceMeters: distance,
|
||||
durationSeconds: duration,
|
||||
);
|
||||
|
||||
|
||||
debugPrint('[RoutingService] Route calculated: $result');
|
||||
return result;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[RoutingService] Route calculation failed: $e');
|
||||
if (e is RoutingException) {
|
||||
@@ -138,13 +165,26 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ErrorDisposition _classifyError(Object error) {
|
||||
if (error is! RoutingException) return ErrorDisposition.retry;
|
||||
if (error.isApiError) return ErrorDisposition.abort;
|
||||
final status = error.statusCode;
|
||||
if (status != null && status >= 400 && status < 500) {
|
||||
if (status == 429) return ErrorDisposition.fallback;
|
||||
return ErrorDisposition.abort;
|
||||
}
|
||||
return ErrorDisposition.retry;
|
||||
}
|
||||
}
|
||||
|
||||
class RoutingException implements Exception {
|
||||
final String message;
|
||||
|
||||
const RoutingException(this.message);
|
||||
|
||||
final int? statusCode;
|
||||
final bool isApiError;
|
||||
|
||||
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
|
||||
|
||||
@override
|
||||
String toString() => 'RoutingException: $message';
|
||||
}
|
||||
|
||||
@@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Cached search result with expiry.
|
||||
class _CachedResult {
|
||||
final List<SearchResult> results;
|
||||
final DateTime cachedAt;
|
||||
|
||||
_CachedResult(this.results) : cachedAt = DateTime.now();
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
class SearchService {
|
||||
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
|
||||
static const int _maxResults = 5;
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
final _client = UserAgentClient();
|
||||
|
||||
|
||||
/// Client-side result cache, keyed by normalized query + viewbox.
|
||||
/// Required by Nominatim usage policy. Static so all SearchService
|
||||
/// instances share the cache and don't generate redundant requests.
|
||||
static final Map<String, _CachedResult> _resultCache = {};
|
||||
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
@@ -27,23 +45,23 @@ class SearchService {
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
SearchResult? _tryParseCoordinates(String query) {
|
||||
// Remove common separators and normalize
|
||||
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
|
||||
final parts = normalized.split(RegExp(r'\s+'));
|
||||
|
||||
|
||||
if (parts.length != 2) return null;
|
||||
|
||||
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lon = double.tryParse(parts[1]);
|
||||
|
||||
|
||||
if (lat == null || lon == null) return null;
|
||||
|
||||
|
||||
// Basic validation for Earth coordinates
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
|
||||
return SearchResult(
|
||||
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
|
||||
coordinates: LatLng(lat, lon),
|
||||
@@ -51,17 +69,17 @@ class SearchService {
|
||||
type: 'point',
|
||||
);
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
/// Search using Nominatim API with rate limiting and result caching.
|
||||
///
|
||||
/// Nominatim usage policy requires:
|
||||
/// - Max 1 request per second
|
||||
/// - Client-side result caching
|
||||
/// - No auto-complete / typeahead
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
// Normalize the viewbox first so both the cache key and the request
|
||||
// params use the same effective values (rounded + min-span expanded).
|
||||
String? viewboxParam;
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
@@ -80,31 +98,83 @@ class SearchService {
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
viewboxParam = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final cacheKey = _buildCacheKey(query, viewboxParam);
|
||||
|
||||
// Check cache first (Nominatim policy requires client-side caching)
|
||||
final cached = _resultCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
debugPrint('[SearchService] Cache hit for "$query"');
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
if (viewboxParam != null) {
|
||||
params['viewbox'] = viewboxParam;
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
// Rate limit: max 1 request/sec per Nominatim policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.nominatim);
|
||||
try {
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
|
||||
final List<dynamic> jsonResults = json.decode(response.body);
|
||||
final results = jsonResults
|
||||
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
|
||||
// Cache the results
|
||||
_resultCache[cacheKey] = _CachedResult(results);
|
||||
_pruneCache();
|
||||
|
||||
debugPrint('[SearchService] Found ${results.length} results');
|
||||
return results;
|
||||
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SearchService] Search failed: $e');
|
||||
throw Exception('Search failed: $e');
|
||||
Error.throwWithStackTrace(e, stackTrace);
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a cache key from the query and the already-normalized viewbox string.
|
||||
///
|
||||
/// The viewbox should be the same `west,north,east,south` string sent to
|
||||
/// Nominatim (after rounding and min-span expansion) so that requests with
|
||||
/// different raw bounds but the same effective viewbox share a cache entry.
|
||||
String _buildCacheKey(String query, String? viewboxParam) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (viewboxParam == null) return normalizedQuery;
|
||||
return '$normalizedQuery|$viewboxParam';
|
||||
}
|
||||
|
||||
/// Remove expired entries and limit cache size.
|
||||
void _pruneCache() {
|
||||
_resultCache.removeWhere((_, cached) => cached.isExpired);
|
||||
// Limit cache to 50 entries to prevent unbounded growth
|
||||
if (_resultCache.length > 50) {
|
||||
final sortedKeys = _resultCache.keys.toList()
|
||||
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
|
||||
for (final key in sortedKeys.take(_resultCache.length - 50)) {
|
||||
_resultCache.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
449
lib/services/service_policy.dart
Normal file
449
lib/services/service_policy.dart
Normal file
@@ -0,0 +1,449 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Identifies the type of external service being accessed.
|
||||
/// Used by [ServicePolicyResolver] to determine the correct compliance policy.
|
||||
enum ServiceType {
|
||||
// OSMF official services
|
||||
osmEditingApi, // api.openstreetmap.org — editing & data queries
|
||||
osmTileServer, // tile.openstreetmap.org — raster tiles
|
||||
nominatim, // nominatim.openstreetmap.org — geocoding
|
||||
overpass, // overpass-api.de — read-only data queries
|
||||
tagInfo, // taginfo.openstreetmap.org — tag metadata
|
||||
|
||||
// Third-party tile services
|
||||
bingTiles, // *.tiles.virtualearth.net
|
||||
mapboxTiles, // api.mapbox.com
|
||||
|
||||
// Everything else
|
||||
custom, // user's own infrastructure / unknown
|
||||
}
|
||||
|
||||
/// Defines the compliance rules for a specific service.
|
||||
///
|
||||
/// Each policy captures the rate limits, caching requirements, offline
|
||||
/// permissions, and attribution obligations mandated by the service operator.
|
||||
/// When the app talks to official OSMF infrastructure the strict policies
|
||||
/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom]
|
||||
/// provides permissive defaults.
|
||||
class ServicePolicy {
|
||||
/// Max concurrent HTTP connections to this service.
|
||||
/// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114).
|
||||
final int maxConcurrentRequests;
|
||||
|
||||
/// Minimum interval between consecutive requests. Null means no rate limit.
|
||||
final Duration? minRequestInterval;
|
||||
|
||||
/// Whether this endpoint permits offline/bulk downloading of tiles.
|
||||
final bool allowsOfflineDownload;
|
||||
|
||||
/// Whether the client must cache responses (e.g., Nominatim policy).
|
||||
final bool requiresClientCaching;
|
||||
|
||||
/// Minimum cache TTL to enforce regardless of server headers.
|
||||
/// Null means "use server-provided max-age as-is".
|
||||
final Duration? minCacheTtl;
|
||||
|
||||
/// License/attribution URL to display in the attribution dialog.
|
||||
/// Null means no special attribution link is needed.
|
||||
final String? attributionUrl;
|
||||
|
||||
const ServicePolicy({
|
||||
this.maxConcurrentRequests = 8,
|
||||
this.minRequestInterval,
|
||||
this.allowsOfflineDownload = true,
|
||||
this.requiresClientCaching = false,
|
||||
this.minCacheTtl,
|
||||
this.attributionUrl,
|
||||
});
|
||||
|
||||
/// OSM editing API (api.openstreetmap.org)
|
||||
/// Policy: max 2 concurrent download threads.
|
||||
/// https://operations.osmfoundation.org/policies/api/
|
||||
const ServicePolicy.osmEditingApi()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a for API
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// OSM tile server (tile.openstreetmap.org)
|
||||
/// Policy: min 7-day cache, must honor cache headers.
|
||||
/// Concurrency managed by flutter_map's NetworkTileProvider.
|
||||
/// https://operations.osmfoundation.org/policies/tiles/
|
||||
const ServicePolicy.osmTileServer()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true,
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = const Duration(days: 7),
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Nominatim geocoding (nominatim.openstreetmap.org)
|
||||
/// Policy: max 1 req/sec, single machine only, results must be cached.
|
||||
/// https://operations.osmfoundation.org/policies/nominatim/
|
||||
const ServicePolicy.nominatim()
|
||||
: maxConcurrentRequests = 1,
|
||||
minRequestInterval = const Duration(seconds: 1),
|
||||
allowsOfflineDownload = true, // n/a for geocoding
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Overpass API (overpass-api.de)
|
||||
/// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore.
|
||||
const ServicePolicy.overpass()
|
||||
: maxConcurrentRequests = 0, // managed by NodeDataManager
|
||||
minRequestInterval = null, // managed by NodeDataManager
|
||||
allowsOfflineDownload = true, // n/a for data queries
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// TagInfo API (taginfo.openstreetmap.org)
|
||||
const ServicePolicy.tagInfo()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a
|
||||
requiresClientCaching = true, // already cached in NSIService
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Bing Maps tiles (*.tiles.virtualearth.net)
|
||||
const ServicePolicy.bingTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // check Bing ToS separately
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Mapbox tiles (api.mapbox.com)
|
||||
const ServicePolicy.mapboxTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // permitted with valid token
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Custom/self-hosted service — permissive defaults.
|
||||
const ServicePolicy.custom({
|
||||
int maxConcurrent = 8,
|
||||
bool allowsOffline = true,
|
||||
Duration? minInterval,
|
||||
String? attribution,
|
||||
}) : maxConcurrentRequests = maxConcurrent,
|
||||
minRequestInterval = minInterval,
|
||||
allowsOfflineDownload = allowsOffline,
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = attribution;
|
||||
|
||||
@override
|
||||
String toString() => 'ServicePolicy('
|
||||
'maxConcurrent: $maxConcurrentRequests, '
|
||||
'minInterval: $minRequestInterval, '
|
||||
'offlineDownload: $allowsOfflineDownload, '
|
||||
'clientCaching: $requiresClientCaching, '
|
||||
'minCacheTtl: $minCacheTtl, '
|
||||
'attributionUrl: $attributionUrl)';
|
||||
}
|
||||
|
||||
/// Resolves service URLs to their applicable [ServicePolicy].
|
||||
///
|
||||
/// Built-in patterns cover all OSMF official services and common third-party
|
||||
/// tile providers. Falls back to permissive defaults for unrecognized hosts.
|
||||
class ServicePolicyResolver {
|
||||
/// Host → ServiceType mapping for known services.
|
||||
static final Map<String, ServiceType> _hostPatterns = {
|
||||
'api.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'api06.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
||||
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
||||
'overpass-api.de': ServiceType.overpass,
|
||||
'overpass.deflock.org': ServiceType.overpass,
|
||||
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
||||
'tiles.virtualearth.net': ServiceType.bingTiles,
|
||||
'api.mapbox.com': ServiceType.mapboxTiles,
|
||||
};
|
||||
|
||||
/// ServiceType → policy mapping.
|
||||
static final Map<ServiceType, ServicePolicy> _policies = {
|
||||
ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(),
|
||||
ServiceType.osmTileServer: const ServicePolicy.osmTileServer(),
|
||||
ServiceType.nominatim: const ServicePolicy.nominatim(),
|
||||
ServiceType.overpass: const ServicePolicy.overpass(),
|
||||
ServiceType.tagInfo: const ServicePolicy.tagInfo(),
|
||||
ServiceType.bingTiles: const ServicePolicy.bingTiles(),
|
||||
ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(),
|
||||
ServiceType.custom: const ServicePolicy(),
|
||||
};
|
||||
|
||||
/// Resolve a URL to its applicable [ServicePolicy].
|
||||
///
|
||||
/// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
|
||||
/// for unrecognized hosts.
|
||||
static ServicePolicy resolve(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return const ServicePolicy();
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return _policies[entry.value] ?? const ServicePolicy();
|
||||
}
|
||||
}
|
||||
|
||||
return const ServicePolicy();
|
||||
}
|
||||
|
||||
/// Resolve a URL to its [ServiceType].
|
||||
///
|
||||
/// Returns [ServiceType.custom] for unrecognized hosts.
|
||||
static ServiceType resolveType(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return ServiceType.custom;
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceType.custom;
|
||||
}
|
||||
|
||||
/// Look up the [ServicePolicy] for a known [ServiceType].
|
||||
static ServicePolicy resolveByType(ServiceType type) =>
|
||||
_policies[type] ?? const ServicePolicy();
|
||||
|
||||
/// Extract the host from a URL or URL template.
|
||||
static String? _extractHost(String url) {
|
||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
// and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...'
|
||||
try {
|
||||
// Strip template variables from subdomain part for parsing
|
||||
final cleaned = url
|
||||
.replaceAll(RegExp(r'\{0_3\}'), '0')
|
||||
.replaceAll(RegExp(r'\{1_4\}'), '1')
|
||||
.replaceAll(RegExp(r'\{quadkey\}'), 'quadkey')
|
||||
.replaceAll(RegExp(r'\{z\}'), '0')
|
||||
.replaceAll(RegExp(r'\{x\}'), '0')
|
||||
.replaceAll(RegExp(r'\{y\}'), '0')
|
||||
.replaceAll(RegExp(r'\{api_key\}'), 'key');
|
||||
return Uri.parse(cleaned).host.toLowerCase();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the retry/fallback engine should handle an error.
|
||||
enum ErrorDisposition {
|
||||
/// Stop immediately. Don't retry, don't try fallback. (400, business logic)
|
||||
abort,
|
||||
/// Don't retry same server, but DO try fallback endpoint. (429 rate limit)
|
||||
fallback,
|
||||
/// Retry with backoff against same server, then fallback if exhausted. (5xx, network)
|
||||
retry,
|
||||
}
|
||||
|
||||
/// Retry and fallback configuration for resilient HTTP services.
|
||||
class ResiliencePolicy {
|
||||
final int maxRetries;
|
||||
final Duration httpTimeout;
|
||||
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;
|
||||
|
||||
Duration retryDelay(int attempt) {
|
||||
final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt))
|
||||
.clamp(0, _retryBackoffMaxMs);
|
||||
return Duration(milliseconds: ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a request with retry and fallback logic.
|
||||
///
|
||||
/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times.
|
||||
/// 2. On each failure, calls [classifyError] to determine disposition:
|
||||
/// - [ErrorDisposition.abort]: rethrows immediately
|
||||
/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available)
|
||||
/// - [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.
|
||||
Future<T> executeWithFallback<T>({
|
||||
required String primaryUrl,
|
||||
required String? fallbackUrl,
|
||||
required Future<T> Function(String url) execute,
|
||||
required ErrorDisposition Function(Object error) classifyError,
|
||||
ResiliencePolicy policy = const ResiliencePolicy(),
|
||||
}) async {
|
||||
try {
|
||||
return await _executeWithRetries(primaryUrl, execute, classifyError, policy);
|
||||
} catch (e) {
|
||||
// _executeWithRetries rethrows abort/fallback/exhausted-retry errors.
|
||||
// Re-classify only to distinguish abort (which must not fall back) from
|
||||
// fallback/retry-exhausted (which should). This is the one intentional
|
||||
// re-classification — _executeWithRetries cannot short-circuit past the
|
||||
// outer try/catch.
|
||||
if (classifyError(e) == ErrorDisposition.abort) rethrow;
|
||||
if (fallbackUrl == null) rethrow;
|
||||
debugPrint('[Resilience] Primary failed ($e), trying fallback');
|
||||
return _executeWithRetries(fallbackUrl, execute, classifyError, policy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _executeWithRetries<T>(
|
||||
String url,
|
||||
Future<T> Function(String url) execute,
|
||||
ErrorDisposition Function(Object error) classifyError,
|
||||
ResiliencePolicy policy,
|
||||
) async {
|
||||
for (int attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
||||
try {
|
||||
return await execute(url);
|
||||
} catch (e) {
|
||||
final disposition = classifyError(e);
|
||||
if (disposition == ErrorDisposition.abort) rethrow;
|
||||
if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback
|
||||
// disposition == retry
|
||||
if (attempt < policy.maxRetries) {
|
||||
final delay = policy.retryDelay(attempt);
|
||||
debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
rethrow; // retries exhausted, let caller try fallback
|
||||
}
|
||||
}
|
||||
throw StateError('Unreachable'); // loop always returns or throws
|
||||
}
|
||||
|
||||
/// Reusable per-service rate limiter and concurrency controller.
|
||||
///
|
||||
/// Enforces the rate limits and concurrency constraints defined in each
|
||||
/// service's [ServicePolicy]. Call [acquire] before making a request and
|
||||
/// [release] after the request completes.
|
||||
///
|
||||
/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0
|
||||
/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere
|
||||
/// (flutter_map, PR #114) are passed through without blocking.
|
||||
class ServiceRateLimiter {
|
||||
/// Injectable clock for testing. Defaults to [DateTime.now].
|
||||
///
|
||||
/// Override with a deterministic clock (e.g. from `FakeAsync`) so tests
|
||||
/// don't rely on wall-clock time and stay fast and stable under CI load.
|
||||
@visibleForTesting
|
||||
static DateTime Function() clock = DateTime.now;
|
||||
|
||||
/// Per-service timestamps of the last acquired request slot / request start
|
||||
/// (used for rate limiting in [acquire], not updated on completion).
|
||||
static final Map<ServiceType, DateTime> _lastRequestTime = {};
|
||||
|
||||
/// Per-service concurrency semaphores.
|
||||
static final Map<ServiceType, _Semaphore> _semaphores = {};
|
||||
|
||||
/// Acquire a slot: wait for rate limit compliance, then take a connection slot.
|
||||
///
|
||||
/// Blocks if:
|
||||
/// 1. The minimum interval between requests hasn't elapsed yet, or
|
||||
/// 2. All concurrent connection slots are in use.
|
||||
static Future<void> acquire(ServiceType service) async {
|
||||
final policy = ServicePolicyResolver.resolveByType(service);
|
||||
|
||||
// Concurrency: acquire a semaphore slot first so that at most
|
||||
// [policy.maxConcurrentRequests] callers proceed concurrently.
|
||||
// The min-interval check below is only race-free when
|
||||
// maxConcurrentRequests == 1 (currently only Nominatim). For services
|
||||
// with higher concurrency the interval is approximate, which is
|
||||
// acceptable — their policies don't specify a min interval.
|
||||
_Semaphore? semaphore;
|
||||
if (policy.maxConcurrentRequests > 0) {
|
||||
semaphore = _semaphores.putIfAbsent(
|
||||
service,
|
||||
() => _Semaphore(policy.maxConcurrentRequests),
|
||||
);
|
||||
await semaphore.acquire();
|
||||
}
|
||||
|
||||
try {
|
||||
// Rate limit: wait if we sent a request too recently
|
||||
if (policy.minRequestInterval != null) {
|
||||
final lastTime = _lastRequestTime[service];
|
||||
if (lastTime != null) {
|
||||
final elapsed = clock().difference(lastTime);
|
||||
final remaining = policy.minRequestInterval! - elapsed;
|
||||
if (remaining > Duration.zero) {
|
||||
debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms');
|
||||
await Future.delayed(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record request time
|
||||
_lastRequestTime[service] = clock();
|
||||
} catch (_) {
|
||||
// Release the semaphore slot if the rate-limit delay fails,
|
||||
// to avoid permanently leaking a slot.
|
||||
semaphore?.release();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a connection slot after request completes.
|
||||
static void release(ServiceType service) {
|
||||
_semaphores[service]?.release();
|
||||
}
|
||||
|
||||
/// Reset all rate limiter state (for testing).
|
||||
@visibleForTesting
|
||||
static void reset() {
|
||||
_lastRequestTime.clear();
|
||||
_semaphores.clear();
|
||||
clock = DateTime.now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple async counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int _maxCount;
|
||||
int _currentCount = 0;
|
||||
final List<Completer<void>> _waiters = [];
|
||||
|
||||
_Semaphore(this._maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < _maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiters.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiters.isNotEmpty) {
|
||||
final next = _waiters.removeAt(0);
|
||||
next.complete();
|
||||
} else if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
} else {
|
||||
throw StateError(
|
||||
'Semaphore.release() called more times than acquire(); '
|
||||
'currentCount is already zero.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
/// Calculate route using alprwatch
|
||||
/// Calculate route via RoutingService (primary + fallback endpoints).
|
||||
void _calculateRoute() {
|
||||
if (_routeStart == null || _routeEnd == null) return;
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
||||
debugPrint('[NavigationState] Calculating route...');
|
||||
_isCalculating = true;
|
||||
_routingError = null;
|
||||
notifyListeners();
|
||||
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = true;
|
||||
_provisionalPinLocation = null; // Hide provisional pin
|
||||
|
||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
||||
debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
|
||||
notifyListeners();
|
||||
|
||||
}).catchError((error) {
|
||||
|
||||
@@ -6,9 +6,17 @@ import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _profileOrderPrefsKey = 'profile_order';
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||
|
||||
// Test-only getters for accessing private state
|
||||
@visibleForTesting
|
||||
List<NodeProfile> get internalProfiles => _profiles;
|
||||
@visibleForTesting
|
||||
Set<NodeProfile> get internalEnabled => _enabled;
|
||||
|
||||
// Callback for when a profile is deleted (used to clear stale sessions)
|
||||
void Function(NodeProfile)? _onProfileDeleted;
|
||||
@@ -18,10 +26,10 @@ class ProfileState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||
bool isEnabled(NodeProfile p) => _enabled.contains(p);
|
||||
List<NodeProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
_getOrderedProfiles().where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
@@ -34,7 +42,7 @@ class ProfileState extends ChangeNotifier {
|
||||
await ProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
// Load enabled profile IDs and custom order from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
@@ -44,6 +52,9 @@ class ProfileState extends ChangeNotifier {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
|
||||
// Load custom order
|
||||
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||
}
|
||||
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
@@ -70,7 +81,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -84,7 +95,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
|
||||
// Notify about profile deletion so other parts can clean up
|
||||
_onProfileDeleted?.call(p);
|
||||
@@ -92,12 +103,79 @@ class ProfileState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Reorder profiles (for drag-and-drop in settings)
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
final orderedProfiles = _getOrderedProfiles();
|
||||
|
||||
// Standard Flutter reordering logic
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = orderedProfiles.removeAt(oldIndex);
|
||||
orderedProfiles.insert(newIndex, item);
|
||||
|
||||
// Update custom order with new sequence
|
||||
_customOrder = orderedProfiles.map((p) => p.id).toList();
|
||||
_saveCustomOrder();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Get profiles in custom order, with unordered profiles at the end
|
||||
List<NodeProfile> _getOrderedProfiles() {
|
||||
if (_customOrder.isEmpty) {
|
||||
return List.from(_profiles);
|
||||
}
|
||||
|
||||
final ordered = <NodeProfile>[];
|
||||
final profilesById = {for (final p in _profiles) p.id: p};
|
||||
|
||||
// Add profiles in custom order
|
||||
for (final id in _customOrder) {
|
||||
final profile = profilesById[id];
|
||||
if (profile != null) {
|
||||
ordered.add(profile);
|
||||
profilesById.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining profiles that weren't in the custom order
|
||||
ordered.addAll(profilesById.values);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[ProfileState] Failed to save enabled profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save profiles to storage
|
||||
Future<void> _saveProfilesToStorage() async {
|
||||
try {
|
||||
await ProfileService().save(_profiles);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if storage isn't available
|
||||
debugPrint('[ProfileState] Failed to save profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save custom order to disk
|
||||
Future<void> _saveCustomOrder() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[ProfileState] Failed to save custom order: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../keys.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
@@ -41,7 +42,8 @@ class SettingsState extends ChangeNotifier {
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
// Default must account for missing secrets (preview builds) even before init() runs
|
||||
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
@@ -150,8 +152,16 @@ class SettingsState extends ChangeNotifier {
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// In production builds, force production mode if development modes are disabled
|
||||
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
// Override persisted upload mode when the current build configuration
|
||||
// doesn't support it. This handles two cases:
|
||||
// 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes
|
||||
// 2. Production builds — force production (prefs may have sandbox/simulate
|
||||
// from a previous dev build on the same device)
|
||||
if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) {
|
||||
debugPrint('SettingsState: No OSM secrets available, forcing simulate mode');
|
||||
_uploadMode = UploadMode.simulate;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
} else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
_uploadMode = UploadMode.production;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
@@ -258,11 +268,10 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// In production builds, only allow production mode
|
||||
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
mode = UploadMode.production;
|
||||
}
|
||||
// The upload mode dropdown is only visible when kEnableDevelopmentModes is
|
||||
// true (gated in osm_account_screen.dart), so no secrets/dev-mode guards
|
||||
// are needed here. The init() method handles forcing the correct mode on
|
||||
// startup for production builds and builds without OAuth secrets.
|
||||
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -262,16 +262,44 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Guard: provider and tile type must be non-null for a
|
||||
// useful offline area (fetchLocalTile requires exact match).
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.noTileProviderSelected')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
if (!context.mounted) return;
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
@@ -282,10 +310,10 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
tileProviderId: selectedProvider.id,
|
||||
tileProviderName: selectedProvider.name,
|
||||
tileTypeId: selectedTileType.id,
|
||||
tileTypeName: selectedTileType.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
|
||||
@@ -10,6 +10,13 @@ import '../../dev_config.dart';
|
||||
/// Manages data fetching, filtering, and node limit logic for the map.
|
||||
/// Handles profile changes, zoom level restrictions, and node rendering limits.
|
||||
class MapDataManager {
|
||||
final List<OsmNode> Function(LatLngBounds bounds) _getNodesForBounds;
|
||||
|
||||
MapDataManager({
|
||||
List<OsmNode> Function(LatLngBounds bounds)? getNodesForBounds,
|
||||
}) : _getNodesForBounds = getNodesForBounds ??
|
||||
((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds));
|
||||
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
@@ -51,28 +58,42 @@ class MapDataManager {
|
||||
List<OsmNode> allNodes;
|
||||
List<OsmNode> nodesToRender;
|
||||
bool isLimitActive = false;
|
||||
|
||||
int validNodesCount = 0;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
|
||||
if (mapBounds != null) {
|
||||
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
|
||||
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
|
||||
allNodes = _getNodesForBounds(expandedBounds);
|
||||
} else {
|
||||
allNodes = <OsmNode>[];
|
||||
}
|
||||
|
||||
|
||||
// Filter out invalid coordinates before applying limit
|
||||
final validNodes = allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).toList();
|
||||
|
||||
// Apply rendering limit to prevent UI lag
|
||||
if (validNodes.length > maxNodes) {
|
||||
validNodesCount = validNodes.length;
|
||||
|
||||
// Apply rendering limit to prevent UI lag.
|
||||
// Sort by distance from viewport center so the most visible nodes
|
||||
// always make the cut, preventing gaps that shift as you pan.
|
||||
if (validNodesCount > maxNodes) {
|
||||
final bounds = mapBounds!;
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
validNodes.sort((a, b) {
|
||||
final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) +
|
||||
(a.coord.longitude - centerLng) * (a.coord.longitude - centerLng);
|
||||
final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) +
|
||||
(b.coord.longitude - centerLng) * (b.coord.longitude - centerLng);
|
||||
final cmp = distA.compareTo(distB);
|
||||
return cmp != 0 ? cmp : a.id.compareTo(b.id);
|
||||
});
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
|
||||
} else {
|
||||
nodesToRender = validNodes;
|
||||
isLimitActive = false;
|
||||
@@ -87,6 +108,9 @@ class MapDataManager {
|
||||
// Notify parent if limit state changed (for button disabling)
|
||||
if (isLimitActive != _lastNodeLimitState) {
|
||||
_lastNodeLimitState = isLimitActive;
|
||||
if (isLimitActive) {
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of $validNodesCount valid devices');
|
||||
}
|
||||
// Schedule callback after build completes to avoid setState during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onNodeLimitChanged?.call(isLimitActive);
|
||||
@@ -97,11 +121,7 @@ class MapDataManager {
|
||||
allNodes: allNodes,
|
||||
nodesToRender: nodesToRender,
|
||||
isLimitActive: isLimitActive,
|
||||
validNodesCount: isLimitActive ? allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).length : 0,
|
||||
validNodesCount: isLimitActive ? validNodesCount : 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
@@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget {
|
||||
this.onSearchPressed,
|
||||
});
|
||||
|
||||
/// Show full attribution text in a dialog
|
||||
/// Show full attribution text in a dialog with license link.
|
||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Get the license URL from the current tile provider's service policy
|
||||
final appState = AppState.instance;
|
||||
final tileType = appState.selectedTileType;
|
||||
final attributionUrl = tileType?.servicePolicy.attributionUrl;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('mapTiles.attribution')),
|
||||
content: SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (attributionUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
link: true,
|
||||
label: locService.t('mapTiles.openLicense', params: [attributionUrl]),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
final uri = Uri.parse(attributionUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
attributionUrl,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget {
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,68 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/deflock_tile_provider.dart';
|
||||
import '../../services/provider_tile_cache_manager.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Uses DeFlock's custom tile provider for clean integration.
|
||||
/// Manages tile layer creation with per-provider caching and provider switching.
|
||||
///
|
||||
/// Each tile provider/type combination gets its own [DeflockTileProvider]
|
||||
/// instance with isolated caching (separate cache directory, configurable size
|
||||
/// limit, and policy-driven TTL enforcement). Providers are created lazily on
|
||||
/// first use and cached for instant switching.
|
||||
class TileLayerManager {
|
||||
DeflockTileProvider? _tileProvider;
|
||||
final Map<String, DeflockTileProvider> _providers = {};
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastProviderId;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
/// Stream that triggers flutter_map to drop all tiles and reload.
|
||||
/// Fired after a debounced delay when tile errors are detected.
|
||||
final StreamController<void> _resetController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
/// Debounce timer for scheduling a tile reset after errors.
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// Current retry delay — starts at [_minRetryDelay] and doubles on each
|
||||
/// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay]
|
||||
/// when a tile loads successfully.
|
||||
Duration _retryDelay = const Duration(seconds: 2);
|
||||
|
||||
static const _minRetryDelay = Duration(seconds: 2);
|
||||
static const _maxRetryDelay = Duration(seconds: 60);
|
||||
|
||||
/// Get the current map rebuild key for cache busting.
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
/// Current retry delay (exposed for testing).
|
||||
@visibleForTesting
|
||||
Duration get retryDelay => _retryDelay;
|
||||
|
||||
/// Stream of reset events (exposed for testing).
|
||||
@visibleForTesting
|
||||
Stream<void> get resetStream => _resetController.stream;
|
||||
|
||||
/// Initialize the tile layer manager.
|
||||
///
|
||||
/// [ProviderTileCacheManager.init] is called in main() before any widgets
|
||||
/// build, so this is a no-op retained for API compatibility.
|
||||
void initialize() {
|
||||
// Don't create tile provider here - create it fresh for each build
|
||||
// Cache directory is already resolved in main().
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
/// Dispose of all provider resources.
|
||||
///
|
||||
/// Synchronous to match Flutter's [State.dispose] contract. Calls
|
||||
/// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP
|
||||
/// client. (We don't call provider.dispose() here — flutter_map already
|
||||
/// called it when the TileLayer widget was removed, and it's safe to call
|
||||
/// again but unnecessary.)
|
||||
void dispose() {
|
||||
_tileProvider?.dispose();
|
||||
_retryTimer?.cancel();
|
||||
_resetController.close();
|
||||
for (final provider in _providers.values) {
|
||||
provider.shutdown();
|
||||
}
|
||||
_providers.clear();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentProviderId,
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
if (_lastProviderId != currentProviderId) {
|
||||
reason = 'provider ($currentProviderId)';
|
||||
shouldClear = true;
|
||||
} else if (_lastTileTypeId != currentTileTypeId) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
} else if (_lastOfflineMode != currentOfflineMode) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
// Force map rebuild with new key to bust flutter_map cache.
|
||||
// We don't dispose providers here — they're reusable across switches.
|
||||
_mapRebuildKey++;
|
||||
// Dispose old provider before creating a fresh one (closes HTTP client)
|
||||
_tileProvider?.dispose();
|
||||
_tileProvider = null;
|
||||
// Reset backoff so the new provider starts with a clean slate.
|
||||
// Cancel any pending retry timer — it belongs to the old provider's errors.
|
||||
_retryDelay = _minRetryDelay;
|
||||
_retryTimer?.cancel();
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastProviderId = currentProviderId;
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
/// Clear the tile request queue (call after cache clear).
|
||||
///
|
||||
/// In the old architecture this incremented [_mapRebuildKey] a second time
|
||||
/// to force a rebuild after the provider was disposed and recreated. With
|
||||
/// per-provider caching, [checkAndClearCacheIfNeeded] already increments the
|
||||
/// key, so this is now a no-op. Kept for API compatibility with map_view.
|
||||
void clearTileQueue() {
|
||||
// With NetworkTileProvider, clearing is handled by FlutterMap's internal cache
|
||||
// We just need to increment the rebuild key to bust the cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
|
||||
// No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey.
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
@@ -70,19 +126,85 @@ class TileLayerManager {
|
||||
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
/// Clear only tiles that are no longer visible in the current bounds.
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Called by flutter_map when a tile fails to load. Schedules a debounced
|
||||
/// reset so that all failed tiles get retried after the burst of errors
|
||||
/// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap.
|
||||
///
|
||||
/// Skips retry for [TileLoadCancelledException] (tile scrolled off screen)
|
||||
/// and [TileNotAvailableOfflineException] (no cached data, retrying won't
|
||||
/// help without network).
|
||||
@visibleForTesting
|
||||
void onTileLoadError(
|
||||
TileImage tile,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
) {
|
||||
// Cancelled tiles are already gone — no retry needed.
|
||||
if (error is TileLoadCancelledException) return;
|
||||
|
||||
// Offline misses won't resolve by retrying — tile isn't cached.
|
||||
if (error is TileNotAvailableOfflineException) return;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Tile error at '
|
||||
'${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, '
|
||||
'scheduling retry in ${_retryDelay.inSeconds}s',
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/// Schedule a debounced tile reset with exponential backoff.
|
||||
///
|
||||
/// Cancels any pending retry timer and starts a new one at the current
|
||||
/// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped
|
||||
/// at [_maxRetryDelay]).
|
||||
@visibleForTesting
|
||||
void scheduleRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(_retryDelay, () {
|
||||
if (!_resetController.isClosed) {
|
||||
debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles');
|
||||
_resetController.add(null);
|
||||
}
|
||||
// Back off for next failure cycle
|
||||
_retryDelay = Duration(
|
||||
milliseconds: min(
|
||||
_retryDelay.inMilliseconds * 2,
|
||||
_maxRetryDelay.inMilliseconds,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset backoff to minimum delay. Called when a tile loads successfully
|
||||
/// via the offline-first path, indicating connectivity has been restored.
|
||||
///
|
||||
/// Note: the common path (`NetworkTileImageProvider`) does not call this,
|
||||
/// so backoff resets only when the offline-first path succeeds over the
|
||||
/// network. In practice this is fine — the common path's `RetryClient`
|
||||
/// handles its own retries, and the reset stream only retries tiles that
|
||||
/// flutter_map has already marked as `loadError`.
|
||||
void onTileLoadSuccess() {
|
||||
_retryDelay = _minRetryDelay;
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
|
||||
///
|
||||
/// Gets or creates a [DeflockTileProvider] for the given provider/type
|
||||
/// combination, each with its own isolated cache.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Create a fresh tile provider instance if we don't have one or cache was cleared
|
||||
_tileProvider ??= DeflockTileProvider();
|
||||
final tileProvider = _getOrCreateProvider(
|
||||
selectedProvider: selectedProvider,
|
||||
selectedTileType: selectedTileType,
|
||||
);
|
||||
|
||||
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
|
||||
// override handles the real URL generation; flutter_map uses urlTemplate
|
||||
@@ -94,7 +216,89 @@ class TileLayerManager {
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
|
||||
tileProvider: _tileProvider!,
|
||||
tileProvider: tileProvider,
|
||||
// Wire the reset stream so failed tiles get retried after a delay.
|
||||
reset: _resetController.stream,
|
||||
errorTileCallback: onTileLoadError,
|
||||
// Clean up error tiles when they scroll off screen.
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a config fingerprint for drift detection.
|
||||
///
|
||||
/// If any of these fields change (e.g. user edits the URL template or
|
||||
/// rotates an API key) the cached [DeflockTileProvider] must be replaced.
|
||||
static String _configFingerprint(
|
||||
models.TileProvider provider,
|
||||
models.TileType tileType,
|
||||
) =>
|
||||
'${provider.id}/${tileType.id}'
|
||||
'|${tileType.urlTemplate}'
|
||||
'|${tileType.maxZoom}'
|
||||
'|${provider.apiKey ?? ''}';
|
||||
|
||||
/// Get or create a [DeflockTileProvider] for the given provider/type.
|
||||
///
|
||||
/// Providers are cached by `providerId/tileTypeId`. If the effective config
|
||||
/// (URL template, max zoom, API key) has changed since the provider was
|
||||
/// created, the stale instance is shut down and replaced.
|
||||
DeflockTileProvider _getOrCreateProvider({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
// No provider configured — return a fallback with default config.
|
||||
return _providers.putIfAbsent(
|
||||
'_fallback',
|
||||
() => DeflockTileProvider(
|
||||
providerId: 'unknown',
|
||||
tileType: models.TileType(
|
||||
id: 'unknown',
|
||||
name: 'Unknown',
|
||||
urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}',
|
||||
attribution: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final key = '${selectedProvider.id}/${selectedTileType.id}';
|
||||
final fingerprint = _configFingerprint(selectedProvider, selectedTileType);
|
||||
|
||||
// Check for config drift: if the provider exists but its config has
|
||||
// changed, shut down the stale instance so a fresh one is created below.
|
||||
final existing = _providers[key];
|
||||
if (existing != null && existing.configFingerprint != fingerprint) {
|
||||
debugPrint(
|
||||
'[TileLayerManager] Config changed for $key — replacing provider',
|
||||
);
|
||||
existing.shutdown();
|
||||
_providers.remove(key);
|
||||
}
|
||||
|
||||
return _providers.putIfAbsent(key, () {
|
||||
final cachingProvider = ProviderTileCacheManager.isInitialized
|
||||
? ProviderTileCacheManager.getOrCreate(
|
||||
providerId: selectedProvider.id,
|
||||
tileTypeId: selectedTileType.id,
|
||||
policy: selectedTileType.servicePolicy,
|
||||
)
|
||||
: null;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Creating provider for $key '
|
||||
'(cache: ${cachingProvider != null ? "enabled" : "disabled"})',
|
||||
);
|
||||
|
||||
return DeflockTileProvider(
|
||||
providerId: selectedProvider.id,
|
||||
tileType: selectedTileType,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onTileLoadSuccess,
|
||||
configFingerprint: fingerprint,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,17 +284,12 @@ class MapViewState extends State<MapView> {
|
||||
onProfilesChanged: _refreshNodesFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
|
||||
// Check if provider, tile type, or offline mode changed and clear cache if needed
|
||||
_tileManager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: appState.selectedTileProvider?.id,
|
||||
currentTileTypeId: appState.selectedTileType?.id,
|
||||
currentOfflineMode: appState.offlineMode,
|
||||
);
|
||||
|
||||
if (cacheCleared) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tileManager.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
@@ -396,7 +391,7 @@ class MapViewState extends State<MapView> {
|
||||
if (_activePointers > 0) _activePointers--;
|
||||
},
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
|
||||
238
pubspec.lock
238
pubspec.lock
@@ -13,10 +13,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
||||
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
version: "7.0.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -105,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -117,10 +125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -157,10 +165,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.12"
|
||||
desktop_webview_window:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -170,7 +178,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
@@ -181,10 +189,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -258,10 +266,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5
|
||||
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.1"
|
||||
version: "8.2.2"
|
||||
flutter_map_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -274,34 +282,34 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc"
|
||||
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.6"
|
||||
version: "2.4.7"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0-beta.4"
|
||||
version: "10.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c"
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -314,26 +322,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517"
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.1.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version: "2.2.3"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -343,18 +351,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182"
|
||||
sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0-alpha.3"
|
||||
version: "5.0.1"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_auth_2_platform_interface
|
||||
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
|
||||
sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0-alpha.0"
|
||||
version: "5.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -408,6 +416,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,6 +432,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -428,10 +452,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99"
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0-beta.2"
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -444,10 +468,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
version: "4.8.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -460,10 +484,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
version: "4.11.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -524,10 +548,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999"
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -568,6 +600,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -580,18 +620,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: oauth2_client
|
||||
sha256: d6a146049f36ef2da32bdc7a7a9e5671a0e66ea596d8f70a26de4cddfcab4d2e
|
||||
sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.2.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -628,18 +676,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.17"
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,10 +716,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -692,10 +740,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "6.5.0"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -708,10 +756,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
|
||||
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
random_string:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -724,26 +780,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.3"
|
||||
version: "2.5.4"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.10"
|
||||
version: "2.4.20"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -785,18 +841,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -809,10 +857,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -913,10 +961,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad"
|
||||
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
version: "2.3.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -929,34 +977,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.16"
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -969,26 +1017,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
version: "4.5.3"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1009,10 +1057,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
version: "1.2.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1041,10 +1089,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
version: "5.15.0"
|
||||
window_to_front:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1073,10 +1121,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1086,5 +1134,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
13
pubspec.yaml
13
pubspec.yaml
@@ -1,10 +1,10 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.7.2+48 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+)
|
||||
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -22,12 +22,12 @@ dependencies:
|
||||
flutter_local_notifications: ^17.2.2
|
||||
url_launcher: ^6.3.0
|
||||
flutter_linkify: ^6.0.0
|
||||
app_links: ^6.1.4
|
||||
app_links: ^7.0.0
|
||||
|
||||
# Auth, storage, prefs
|
||||
oauth2_client: ^4.2.0
|
||||
flutter_web_auth_2: 5.0.0-alpha.3
|
||||
flutter_secure_storage: 10.0.0-beta.4
|
||||
flutter_web_auth_2: ^5.0.1
|
||||
flutter_secure_storage: ^10.0.0
|
||||
|
||||
# Persistence
|
||||
shared_preferences: ^2.2.2
|
||||
@@ -35,7 +35,7 @@ dependencies:
|
||||
path: ^1.8.3
|
||||
path_provider: ^2.1.0
|
||||
uuid: ^4.0.0
|
||||
package_info_plus: ^8.0.0
|
||||
package_info_plus: ^9.0.0
|
||||
csv: ^6.0.0
|
||||
collection: ^1.18.0
|
||||
|
||||
@@ -43,6 +43,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.4
|
||||
fake_async: ^1.3.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_native_splash: ^2.4.6
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
import 'package:deflockapp/state/profile_state.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('NodeProfile', () {
|
||||
test('toJson/fromJson round-trip preserves all fields', () {
|
||||
final profile = NodeProfile(
|
||||
@@ -72,5 +79,180 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
expect(a, isNot(equals(c)));
|
||||
});
|
||||
|
||||
group('createExistingTagsProfile', () {
|
||||
test('should NOT assign FOV for nodes with single direction', () {
|
||||
// This is the core bug fix: nodes with just "direction=90" should not get a default FOV
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90',
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single direction nodes should not get default FOV');
|
||||
expect(profile.name, equals('<Existing tags>'));
|
||||
expect(profile.tags, isEmpty, reason: 'Existing tags profile should have empty tags');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with range notation', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125', // Range notation = explicit FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNotNull, reason: 'Range notation should preserve FOV');
|
||||
expect(profile.fov, equals(70.0), reason: 'Range 55-125 should calculate to 70 degree FOV');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with multiple consistent ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125;235-305', // Two ranges with same FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, equals(70.0), reason: 'Multiple consistent ranges should preserve FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for mixed single directions and ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180-360', // Mix of single direction and range
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Mixed notation should not assign FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for multiple single directions', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180;270', // Multiple single directions
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Multiple single directions should not get default FOV');
|
||||
});
|
||||
|
||||
test('should handle camera:direction tag', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'camera:direction': '180', // Using camera:direction instead of direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'camera',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single camera:direction should not get default FOV');
|
||||
});
|
||||
|
||||
test('should fix the specific bug: direction=90 should not become direction=55-125', () {
|
||||
// This tests the exact bug scenario mentioned in the issue
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90', // Single direction, should stay as single direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
// Key fix: profile should NOT have an FOV, so upload won't convert to range notation
|
||||
expect(profile.fov, isNull, reason: 'direction=90 should not get converted to direction=55-125');
|
||||
|
||||
// Verify the node does have directionFovPairs (for rendering), but profile ignores them
|
||||
expect(node.directionFovPairs, hasLength(1));
|
||||
expect(node.directionFovPairs.first.centerDegrees, equals(90.0));
|
||||
expect(node.directionFovPairs.first.fovDegrees, equals(70.0)); // Default FOV for rendering
|
||||
});
|
||||
});
|
||||
|
||||
group('ProfileState reordering', () {
|
||||
test('should reorder profiles correctly', () async {
|
||||
final profileState = ProfileState();
|
||||
|
||||
// Add some test profiles directly to avoid storage operations
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to the internal list to avoid storage
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Initial order should be A, B, C
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
|
||||
|
||||
// Move profile at index 0 (A) to index 2 (should become B, A, C due to Flutter's reorder logic)
|
||||
profileState.reorderProfiles(0, 2);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c']));
|
||||
|
||||
// Move profile at index 1 (A) to index 0 (should become A, B, C)
|
||||
profileState.reorderProfiles(1, 0);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
test('should maintain enabled status after reordering', () {
|
||||
final profileState = ProfileState();
|
||||
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to avoid storage operations
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Disable profile B
|
||||
profileState.internalEnabled.remove(profileB);
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
|
||||
// Reorder profiles
|
||||
profileState.reorderProfiles(0, 2);
|
||||
|
||||
// Profile B should still be disabled after reordering
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
expect(profileState.isEnabled(profileA), isTrue);
|
||||
expect(profileState.isEnabled(profileC), isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
class MockMapCachingProvider extends Mock implements MapCachingProvider {}
|
||||
|
||||
void main() {
|
||||
late DeflockTileProvider provider;
|
||||
late MockAppState mockAppState;
|
||||
|
||||
final osmTileType = models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final mapboxTileType = models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
|
||||
// Default stubs: online, OSM provider selected, no offline areas
|
||||
// Default stubs: online, no offline areas
|
||||
when(() => mockAppState.offlineMode).thenReturn(false);
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
),
|
||||
);
|
||||
|
||||
provider = DeflockTileProvider();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await provider.dispose();
|
||||
provider.shutdown();
|
||||
AppState.instance = MockAppState();
|
||||
});
|
||||
|
||||
@@ -49,7 +60,7 @@ void main() {
|
||||
expect(provider.supportsCancelLoading, isTrue);
|
||||
});
|
||||
|
||||
test('getTileUrl() delegates to TileType.getTileUrl()', () {
|
||||
test('getTileUrl() uses frozen tileType config', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
@@ -58,23 +69,12 @@ void main() {
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
|
||||
test('getTileUrl() includes API key when present', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
apiKey: 'test_key_123',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
),
|
||||
test('getTileUrl() includes API key when present', () async {
|
||||
provider.shutdown();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'mapbox',
|
||||
tileType: mapboxTileType,
|
||||
apiKey: 'test_key_123',
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 10);
|
||||
@@ -86,19 +86,6 @@ void main() {
|
||||
expect(url, contains('/10/1/2@2x'));
|
||||
});
|
||||
|
||||
test('getTileUrl() falls back to super when no provider selected', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(null);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(null);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
|
||||
// Super implementation uses the urlTemplate from TileLayer options
|
||||
expect(url, equals('https://example.com/3/1/2'));
|
||||
});
|
||||
|
||||
test('routes to network path when no offline areas exist', () {
|
||||
// offlineMode = false, OfflineAreaService not initialized → no offline areas
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
@@ -136,10 +123,19 @@ void main() {
|
||||
expect(offlineProvider.providerId, equals('openstreetmap'));
|
||||
expect(offlineProvider.tileTypeId, equals('osm_street'));
|
||||
});
|
||||
|
||||
test('frozen config is independent of AppState', () {
|
||||
// Provider was created with OSM config — changing AppState should not affect it
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider', () {
|
||||
test('equal for same coordinates and provider/type', () {
|
||||
test('equal for same coordinates, provider/type, and offlineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
@@ -161,7 +157,7 @@ void main() {
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true, // different — but not in ==
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
|
||||
@@ -171,6 +167,37 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('not equal for different isOfflineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final online = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final offline = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(online, isNot(equals(offline)));
|
||||
});
|
||||
|
||||
test('not equal for different coordinates', () {
|
||||
const coords1 = TileCoordinates(1, 2, 3);
|
||||
const coords2 = TileCoordinates(1, 2, 4);
|
||||
@@ -247,5 +274,298 @@ void main() {
|
||||
expect(base, isNot(equals(diffType)));
|
||||
expect(base.hashCode, isNot(equals(diffType.hashCode)));
|
||||
});
|
||||
|
||||
test('equality ignores cachingProvider and onNetworkSuccess', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final withCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
cachingProvider: MockMapCachingProvider(),
|
||||
onNetworkSuccess: () {},
|
||||
);
|
||||
final withoutCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(withCaching, equals(withoutCaching));
|
||||
expect(withCaching.hashCode, equals(withoutCaching.hashCode));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileProvider caching integration', () {
|
||||
test('passes cachingProvider through to offline path', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
final mockCaching = MockMapCachingProvider();
|
||||
var successCalled = false;
|
||||
|
||||
final cachingProvider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
cachingProvider: mockCaching,
|
||||
onNetworkSuccess: () => successCalled = true,
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = cachingProvider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, same(mockCaching));
|
||||
expect(offlineProvider.onNetworkSuccess, isNotNull);
|
||||
|
||||
// Invoke the callback to verify it's wired correctly
|
||||
offlineProvider.onNetworkSuccess!();
|
||||
expect(successCalled, isTrue);
|
||||
|
||||
cachingProvider.shutdown();
|
||||
});
|
||||
|
||||
test('offline provider has null caching when not provided', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, isNull);
|
||||
expect(offlineProvider.onNetworkSuccess, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider caching helpers', () {
|
||||
late Directory tempDir;
|
||||
late ProviderTileCacheStore cacheStore;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('disk cache integration: putTile then getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/3/1/2.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"tile-etag"',
|
||||
);
|
||||
|
||||
// Write to cache
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
// Read back
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(cached.metadata.etag, equals('"tile-etag"'));
|
||||
expect(cached.metadata.isStale, isFalse);
|
||||
});
|
||||
|
||||
test('disk cache: stale tiles are detectable', () async {
|
||||
const url = 'https://tile.example.com/stale.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.isStale, isTrue);
|
||||
// Bytes are still available even when stale (for conditional revalidation)
|
||||
expect(cached.bytes, equals(bytes));
|
||||
});
|
||||
|
||||
test('disk cache: metadata-only update preserves bytes', () async {
|
||||
const url = 'https://tile.example.com/revalidated.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
|
||||
// Initial write with bytes
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
),
|
||||
bytes: bytes,
|
||||
);
|
||||
|
||||
// Metadata-only update (simulating 304 Not Modified revalidation)
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
),
|
||||
// No bytes — metadata only
|
||||
);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // original bytes preserved
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
expect(cached.metadata.isStale, isFalse); // now fresh
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider load error paths', () {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
/// Load the tile via [loadImage] and return the first error from the
|
||||
/// image stream. The decode callback should never be reached on error
|
||||
/// paths, so we throw if it is.
|
||||
Future<Object> loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider provider) {
|
||||
final completer = Completer<Object>();
|
||||
final stream = provider.loadImage(
|
||||
provider,
|
||||
(buffer, {getTargetSize}) async =>
|
||||
throw StateError('decode should not be called'),
|
||||
);
|
||||
stream.addListener(ImageStreamListener(
|
||||
(_, _) {
|
||||
if (!completer.isCompleted) {
|
||||
completer
|
||||
.completeError(StateError('expected error but got image'));
|
||||
}
|
||||
},
|
||||
onError: (error, _) {
|
||||
if (!completer.isCompleted) completer.complete(error);
|
||||
},
|
||||
));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
test('offline both-miss throws TileNotAvailableOfflineException',
|
||||
() async {
|
||||
// No offline areas, no cache → both miss.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileNotAvailableOfflineException>());
|
||||
});
|
||||
|
||||
test('cancelled offline tile throws TileLoadCancelledException',
|
||||
() async {
|
||||
// cancelLoading already resolved → _loadAsync catch block detects
|
||||
// cancellation and throws TileLoadCancelledException instead of
|
||||
// the underlying TileNotAvailableOfflineException.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('online cancel before network throws TileLoadCancelledException',
|
||||
() async {
|
||||
// Online mode: cache miss, local miss, then cancelled check fires
|
||||
// before reaching the network fetch.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('network error throws HttpException', () async {
|
||||
// Online mode: cache miss, local miss, not cancelled, network
|
||||
// returns 500 → HttpException with tile coordinates and status.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(4, 5, 6),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: MockClient((_) async => http.Response('', 500)),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/6/4/5.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<HttpException>());
|
||||
expect((error as HttpException).message, contains('6/4/5'));
|
||||
expect(error.message, contains('500'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
93
test/services/offline_area_service_test.dart
Normal file
93
test/services/offline_area_service_test.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
import 'package:deflockapp/services/offline_area_service.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
||||
|
||||
OfflineArea _makeArea({
|
||||
String providerId = 'osm',
|
||||
String tileTypeId = 'standard',
|
||||
int minZoom = 5,
|
||||
int maxZoom = 12,
|
||||
OfflineAreaStatus status = OfflineAreaStatus.complete,
|
||||
}) {
|
||||
return OfflineArea(
|
||||
id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom',
|
||||
bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: '/tmp/test-area',
|
||||
status: status,
|
||||
tileProviderId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = OfflineAreaService();
|
||||
|
||||
setUp(() {
|
||||
service.setAreasForTesting([]);
|
||||
});
|
||||
|
||||
group('hasOfflineAreasForProviderAtZoom', () {
|
||||
test('returns true for zoom within range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue);
|
||||
});
|
||||
|
||||
test('returns false for zoom outside range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong provider', () {
|
||||
service.setAreasForTesting([_makeArea(providerId: 'osm')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong tile type', () {
|
||||
service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for non-complete areas', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(status: OfflineAreaStatus.downloading),
|
||||
_makeArea(status: OfflineAreaStatus.error),
|
||||
]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when initialized with no areas', () {
|
||||
service.setAreasForTesting([]);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('matches when any area covers the zoom level', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(minZoom: 5, maxZoom: 8),
|
||||
_makeArea(minZoom: 10, maxZoom: 14),
|
||||
]);
|
||||
|
||||
// In first area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue);
|
||||
// In gap between areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse);
|
||||
// In second area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue);
|
||||
// Beyond both areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/services/overpass_service.dart';
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
class MockHttpClient extends Mock implements http.Client {}
|
||||
|
||||
@@ -36,6 +37,7 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
mockClient = MockHttpClient();
|
||||
// Initialize OverpassService with a mock HTTP client for testing
|
||||
service = OverpassService(client: mockClient);
|
||||
});
|
||||
|
||||
@@ -246,9 +248,9 @@ void main() {
|
||||
stubErrorResponse(
|
||||
400, 'Error: too many nodes (limit is 50000) in query');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
@@ -256,9 +258,9 @@ void main() {
|
||||
test('response with "timeout" throws NodeLimitError', () async {
|
||||
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
@@ -267,9 +269,9 @@ void main() {
|
||||
() async {
|
||||
stubErrorResponse(400, 'runtime limit exceeded');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
});
|
||||
@@ -277,9 +279,9 @@ void main() {
|
||||
test('HTTP 429 throws RateLimitError', () async {
|
||||
stubErrorResponse(429, 'Too Many Requests');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<RateLimitError>()),
|
||||
);
|
||||
});
|
||||
@@ -287,9 +289,9 @@ void main() {
|
||||
test('response with "rate limited" throws RateLimitError', () async {
|
||||
stubErrorResponse(503, 'You are rate limited');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<RateLimitError>()),
|
||||
);
|
||||
});
|
||||
@@ -298,9 +300,9 @@ void main() {
|
||||
() async {
|
||||
stubErrorResponse(500, 'Internal Server Error');
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NetworkError>()),
|
||||
);
|
||||
});
|
||||
@@ -313,4 +315,178 @@ void main() {
|
||||
verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
|
||||
});
|
||||
});
|
||||
|
||||
group('fallback behavior', () {
|
||||
test('falls back to overpass-api.de on NetworkError after retries', () async {
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
|
||||
if (uri.host == 'overpass.deflock.org') {
|
||||
return http.Response('Internal Server Error', 500);
|
||||
}
|
||||
// Fallback succeeds
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'elements': [
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 1,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
]
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final nodes = await service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0));
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
// primary (1 attempt, 0 retries) + fallback (1 attempt) = 2
|
||||
expect(callCount, equals(2));
|
||||
});
|
||||
|
||||
test('does NOT fallback on NodeLimitError', () async {
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((_) async => http.Response(
|
||||
'Error: too many nodes (limit is 50000) in query',
|
||||
400,
|
||||
));
|
||||
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NodeLimitError>()),
|
||||
);
|
||||
|
||||
// Only one call — no fallback (abort disposition)
|
||||
verify(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.called(1);
|
||||
});
|
||||
|
||||
test('RateLimitError triggers fallback without retrying primary', () async {
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
|
||||
if (uri.host == 'overpass.deflock.org') {
|
||||
return http.Response('Too Many Requests', 429);
|
||||
}
|
||||
// Fallback succeeds
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'elements': [
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 1,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
]
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final nodes = await service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
// 1 primary (no retry on fallback disposition) + 1 fallback = 2
|
||||
expect(callCount, equals(2));
|
||||
});
|
||||
|
||||
test('primary fails then fallback also fails -> error propagated', () async {
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((_) async =>
|
||||
http.Response('Internal Server Error', 500));
|
||||
|
||||
await expectLater(
|
||||
() => service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NetworkError>()),
|
||||
);
|
||||
|
||||
// primary + fallback
|
||||
verify(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.called(2);
|
||||
});
|
||||
|
||||
test('does NOT fallback when using custom endpoint', () async {
|
||||
final customService = OverpassService(
|
||||
client: mockClient,
|
||||
endpoint: 'https://custom.example.com/api/interpreter',
|
||||
);
|
||||
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((_) async =>
|
||||
http.Response('Internal Server Error', 500));
|
||||
|
||||
await expectLater(
|
||||
() => customService.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||
throwsA(isA<NetworkError>()),
|
||||
);
|
||||
|
||||
// Only one call - no fallback with custom endpoint
|
||||
verify(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.called(1);
|
||||
});
|
||||
|
||||
test('retries exhaust before fallback kicks in', () async {
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(any(), body: any(named: 'body')))
|
||||
.thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
|
||||
if (uri.host == 'overpass.deflock.org') {
|
||||
return http.Response('Server Error', 500);
|
||||
}
|
||||
// Fallback succeeds
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'elements': [
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 1,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
]
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final nodes = await service.fetchNodes(
|
||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
|
||||
expect(callCount, equals(4));
|
||||
});
|
||||
});
|
||||
|
||||
group('default endpoints', () {
|
||||
test('default endpoint is overpass.deflock.org', () {
|
||||
expect(OverpassService.defaultEndpoint,
|
||||
equals('https://overpass.deflock.org/api/interpreter'));
|
||||
});
|
||||
|
||||
test('fallback endpoint is overpass-api.de', () {
|
||||
expect(OverpassService.fallbackEndpoint,
|
||||
equals('https://overpass-api.de/api/interpreter'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
517
test/services/provider_tile_cache_store_test.dart
Normal file
517
test/services/provider_tile_cache_store_test.dart
Normal file
@@ -0,0 +1,517 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_manager.dart';
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore', () {
|
||||
late ProviderTileCacheStore store;
|
||||
|
||||
setUp(() {
|
||||
store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSupported is true', () {
|
||||
expect(store.isSupported, isTrue);
|
||||
});
|
||||
|
||||
test('getTile returns null for uncached URL', () async {
|
||||
final result = await store.getTile('https://tile.example.com/1/2/3.png');
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('putTile and getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final staleAt = DateTime.utc(2026, 3, 1);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"abc123"',
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(
|
||||
cached.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(staleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
expect(cached.metadata.etag, equals('"abc123"'));
|
||||
expect(cached.metadata.lastModified, isNotNull);
|
||||
});
|
||||
|
||||
test('putTile without bytes updates metadata only', () async {
|
||||
const url = 'https://tile.example.com/1/2/3.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata1 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
);
|
||||
|
||||
// Write with bytes first
|
||||
await store.putTile(url: url, metadata: metadata1, bytes: bytes);
|
||||
|
||||
// Update metadata only
|
||||
final metadata2 = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 4, 1),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
);
|
||||
await store.putTile(url: url, metadata: metadata2);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // bytes unchanged
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
});
|
||||
|
||||
test('handles null lastModified and etag', () async {
|
||||
const url = 'https://tile.example.com/simple.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.lastModified, isNull);
|
||||
expect(cached.metadata.etag, isNull);
|
||||
});
|
||||
|
||||
test('creates cache directory lazily on first putTile', () async {
|
||||
final subDir = p.join(tempDir.path, 'lazy', 'nested');
|
||||
final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir);
|
||||
|
||||
// Directory should not exist yet
|
||||
expect(await Directory(subDir).exists(), isFalse);
|
||||
|
||||
await lazyStore.putTile(
|
||||
url: 'https://example.com/tile.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([1]),
|
||||
);
|
||||
|
||||
// Directory should now exist
|
||||
expect(await Directory(subDir).exists(), isTrue);
|
||||
});
|
||||
|
||||
test('clear deletes all cached tiles', () async {
|
||||
// Write some tiles
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://example.com/$i.png',
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
),
|
||||
bytes: Uint8List.fromList([i]),
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tiles exist
|
||||
expect(await store.getTile('https://example.com/0.png'), isNotNull);
|
||||
|
||||
// Clear
|
||||
await store.clear();
|
||||
|
||||
// Directory should be gone
|
||||
expect(await Directory(tempDir.path).exists(), isFalse);
|
||||
|
||||
// getTile should return null (directory gone)
|
||||
expect(await store.getTile('https://example.com/0.png'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore TTL override', () {
|
||||
test('overrideFreshAge bumps staleAt forward', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
overrideFreshAge: const Duration(days: 7),
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/osm.png';
|
||||
// Server says stale in 1 hour, but policy requires 7 days
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
|
||||
// staleAt should be ~7 days from now, not 1 hour
|
||||
final expectedMin = DateTime.timestamp().add(const Duration(days: 6));
|
||||
expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue);
|
||||
});
|
||||
|
||||
test('without overrideFreshAge, server staleAt is preserved', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
// No overrideFreshAge
|
||||
);
|
||||
|
||||
const url = 'https://tile.example.com/bing.png';
|
||||
final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0);
|
||||
final serverMetadata = CachedMapTileMetadata(
|
||||
staleAt: serverStaleAt,
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await store.putTile(
|
||||
url: url,
|
||||
metadata: serverMetadata,
|
||||
bytes: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
|
||||
final cached = await store.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(
|
||||
cached!.metadata.staleAt.millisecondsSinceEpoch,
|
||||
equals(serverStaleAt.millisecondsSinceEpoch),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore isolation', () {
|
||||
test('separate directories do not interfere', () async {
|
||||
final dirA = p.join(tempDir.path, 'provider_a', 'type_1');
|
||||
final dirB = p.join(tempDir.path, 'provider_b', 'type_1');
|
||||
|
||||
final storeA = ProviderTileCacheStore(cacheDirectory: dirA);
|
||||
final storeB = ProviderTileCacheStore(cacheDirectory: dirB);
|
||||
|
||||
const url = 'https://tile.example.com/shared-url.png';
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await storeA.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([1, 1, 1]),
|
||||
);
|
||||
await storeB.putTile(
|
||||
url: url,
|
||||
metadata: metadata,
|
||||
bytes: Uint8List.fromList([2, 2, 2]),
|
||||
);
|
||||
|
||||
final cachedA = await storeA.getTile(url);
|
||||
final cachedB = await storeB.getTile(url);
|
||||
|
||||
expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1])));
|
||||
expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2])));
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheManager', () {
|
||||
test('getOrCreate returns same instance for same key', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isTrue);
|
||||
});
|
||||
|
||||
test('getOrCreate returns different instances for different keys', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeA = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
final storeB = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(storeA, storeB), isFalse);
|
||||
});
|
||||
|
||||
test('passes overrideFreshAge from policy.minCacheTtl', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy.osmTileServer(),
|
||||
);
|
||||
|
||||
expect(store.overrideFreshAge, equals(const Duration(days: 7)));
|
||||
});
|
||||
|
||||
test('custom maxCacheBytes is applied', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'big',
|
||||
tileTypeId: 'tiles',
|
||||
policy: const ServicePolicy(),
|
||||
maxCacheBytes: 1024 * 1024 * 1024, // 1 GB
|
||||
);
|
||||
|
||||
expect(store.maxCacheBytes, equals(1024 * 1024 * 1024));
|
||||
});
|
||||
|
||||
test('resetAll clears all stores from registry', () async {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final storeBefore = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'bing',
|
||||
tileTypeId: 'satellite',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
await ProviderTileCacheManager.resetAll();
|
||||
|
||||
// After reset, must set base dir again before creating stores
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
final storeAfter = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
// New instance should be created (not the old cached one)
|
||||
expect(identical(storeBefore, storeAfter), isFalse);
|
||||
});
|
||||
|
||||
test('unregister removes store from registry', () {
|
||||
ProviderTileCacheManager.setBaseCacheDir(tempDir.path);
|
||||
|
||||
final store1 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
ProviderTileCacheManager.unregister('osm', 'street');
|
||||
|
||||
// Should create a new instance after unregistering
|
||||
final store2 = ProviderTileCacheManager.getOrCreate(
|
||||
providerId: 'osm',
|
||||
tileTypeId: 'street',
|
||||
policy: const ServicePolicy(),
|
||||
);
|
||||
|
||||
expect(identical(store1, store2), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ProviderTileCacheStore eviction', () {
|
||||
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
|
||||
/// Sets deterministic modification times (1 second apart) so eviction
|
||||
/// ordering is stable across platforms without relying on wall-clock delays.
|
||||
Future<void> fillCache(
|
||||
ProviderTileCacheStore store, {
|
||||
required int count,
|
||||
required int bytesPerTile,
|
||||
String prefix = '',
|
||||
}) async {
|
||||
final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42));
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.utc(2026, 3, 1),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
final baseTime = DateTime.utc(2026, 1, 1);
|
||||
for (var i = 0; i < count; i++) {
|
||||
await store.putTile(
|
||||
url: 'https://tile.example.com/$prefix$i.png',
|
||||
metadata: metadata,
|
||||
bytes: bytes,
|
||||
);
|
||||
// Set deterministic mtime so eviction order is stable across platforms.
|
||||
final key = ProviderTileCacheStore.keyFor(
|
||||
'https://tile.example.com/$prefix$i.png',
|
||||
);
|
||||
final tileFile = File(p.join(store.cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(store.cacheDirectory, '$key.meta'));
|
||||
final mtime = baseTime.add(Duration(seconds: i));
|
||||
await tileFile.setLastModified(mtime);
|
||||
await metaFile.setLastModified(mtime);
|
||||
}
|
||||
}
|
||||
|
||||
test('eviction reduces cache when exceeding maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write tiles that exceed the limit
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
|
||||
// Explicitly trigger eviction (bypasses throttle)
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
expect(sizeAfter, lessThanOrEqualTo(500),
|
||||
reason: 'Eviction should reduce cache to at or below limit');
|
||||
});
|
||||
|
||||
test('eviction targets 80% of maxCacheBytes', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 1000,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 200);
|
||||
await store.forceEviction();
|
||||
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
// Target is 80% of 1000 = 800 bytes
|
||||
expect(sizeAfter, lessThanOrEqualTo(800),
|
||||
reason: 'Eviction should target 80% of maxCacheBytes');
|
||||
});
|
||||
|
||||
test('oldest-modified tiles are evicted first', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write old tiles first (these should be evicted)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_');
|
||||
|
||||
// Write newer tiles (these should survive)
|
||||
await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_');
|
||||
|
||||
await store.forceEviction();
|
||||
|
||||
// Newest tile should still be present
|
||||
final newestTile = await store.getTile('https://tile.example.com/new_4.png');
|
||||
expect(newestTile, isNotNull,
|
||||
reason: 'Newest tiles should survive eviction');
|
||||
|
||||
// Oldest tile should have been evicted
|
||||
final oldestTile = await store.getTile('https://tile.example.com/old_0.png');
|
||||
expect(oldestTile, isNull,
|
||||
reason: 'Oldest tiles should be evicted first');
|
||||
});
|
||||
|
||||
test('orphan .meta files are cleaned up during eviction', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 500,
|
||||
);
|
||||
|
||||
// Write a tile to create the directory
|
||||
await fillCache(store, count: 1, bytesPerTile: 50);
|
||||
|
||||
// Manually create an orphan .meta file (no matching .tile)
|
||||
final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta'));
|
||||
await orphanMetaFile.writeAsString('{"staleAt":0}');
|
||||
expect(await orphanMetaFile.exists(), isTrue);
|
||||
|
||||
// Write enough tiles to exceed the limit, then force eviction
|
||||
await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_');
|
||||
await store.forceEviction();
|
||||
|
||||
// The orphan .meta file should have been cleaned up
|
||||
expect(await orphanMetaFile.exists(), isFalse,
|
||||
reason: 'Orphan .meta file should be cleaned up during eviction');
|
||||
});
|
||||
|
||||
test('evicted tiles have their .meta files removed too', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 300,
|
||||
);
|
||||
|
||||
await fillCache(store, count: 10, bytesPerTile: 100);
|
||||
await store.forceEviction();
|
||||
|
||||
// After eviction, count remaining .tile and .meta files
|
||||
final dir = Directory(tempDir.path);
|
||||
final files = await dir.list().toList();
|
||||
final tileFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.tile'))
|
||||
.length;
|
||||
final metaFiles = files
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.endsWith('.meta'))
|
||||
.length;
|
||||
|
||||
// Every remaining .tile should have a matching .meta (1:1)
|
||||
expect(metaFiles, equals(tileFiles),
|
||||
reason: '.meta count should match .tile count after eviction');
|
||||
});
|
||||
|
||||
test('no eviction when cache is under limit', () async {
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: tempDir.path,
|
||||
maxCacheBytes: 100000, // 100KB — way more than we'll write
|
||||
);
|
||||
|
||||
await fillCache(store, count: 3, bytesPerTile: 50);
|
||||
final sizeBefore = await store.estimatedSizeBytes;
|
||||
|
||||
await store.forceEviction();
|
||||
final sizeAfter = await store.estimatedSizeBytes;
|
||||
|
||||
expect(sizeAfter, equals(sizeBefore),
|
||||
reason: 'No eviction needed when under limit');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,30 @@ void main() {
|
||||
AppState.instance = MockAppState();
|
||||
});
|
||||
|
||||
/// Helper: stub a successful routing response
|
||||
void stubSuccessResponse() {
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [
|
||||
[-77.0, 38.9],
|
||||
[-77.1, 39.0],
|
||||
],
|
||||
'distance': 1000.0,
|
||||
'duration': 600.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
));
|
||||
}
|
||||
|
||||
group('RoutingService', () {
|
||||
test('empty tags are filtered from request body', () async {
|
||||
// Profile with empty tag values (like builtin-flock has camera:mount: '')
|
||||
@@ -57,29 +81,7 @@ void main() {
|
||||
];
|
||||
when(() => mockAppState.enabledProfiles).thenReturn(profiles);
|
||||
|
||||
// Capture the request body
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((invocation) async {
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [
|
||||
[-77.0, 38.9],
|
||||
[-77.1, 39.0],
|
||||
],
|
||||
'distance': 1000.0,
|
||||
'duration': 600.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
stubSuccessResponse();
|
||||
|
||||
await service.calculateRoute(start: start, end: end);
|
||||
|
||||
@@ -147,7 +149,7 @@ void main() {
|
||||
reasonPhrase: 'Bad Request',
|
||||
));
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.message,
|
||||
@@ -166,7 +168,7 @@ void main() {
|
||||
body: any(named: 'body'),
|
||||
)).thenThrow(http.ClientException('Connection refused'));
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.message,
|
||||
@@ -176,7 +178,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('API-level error surfaces alprwatch message', () async {
|
||||
test('API-level error surfaces message', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
@@ -191,7 +193,7 @@ void main() {
|
||||
200,
|
||||
));
|
||||
|
||||
expect(
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.message,
|
||||
@@ -201,4 +203,299 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('fallback behavior', () {
|
||||
test('falls back to secondary on server error (500) after retries', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
|
||||
if (uri.host == 'api.dontgetflocked.com') {
|
||||
return http.Response('Internal Server Error', 500,
|
||||
reasonPhrase: 'Internal Server Error');
|
||||
}
|
||||
// Fallback succeeds
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [
|
||||
[-77.0, 38.9],
|
||||
[-77.1, 39.0],
|
||||
],
|
||||
'distance': 5000.0,
|
||||
'duration': 300.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.calculateRoute(start: start, end: end);
|
||||
expect(result.distanceMeters, equals(5000.0));
|
||||
// 2 primary attempts (1 + 1 retry) + 1 fallback = 3
|
||||
expect(callCount, equals(3));
|
||||
});
|
||||
|
||||
test('falls back on 502 (GraphHopper unavailable) after retries', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
if (uri.host == 'api.dontgetflocked.com') {
|
||||
return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway');
|
||||
}
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [[-77.0, 38.9]],
|
||||
'distance': 100.0,
|
||||
'duration': 60.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.calculateRoute(start: start, end: end);
|
||||
expect(result.distanceMeters, equals(100.0));
|
||||
// 2 primary attempts + 1 fallback = 3
|
||||
expect(callCount, equals(3));
|
||||
});
|
||||
|
||||
test('falls back on network error after retries', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
if (uri.host == 'api.dontgetflocked.com') {
|
||||
throw http.ClientException('Connection refused');
|
||||
}
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [[-77.0, 38.9]],
|
||||
'distance': 100.0,
|
||||
'duration': 60.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.calculateRoute(start: start, end: end);
|
||||
expect(result.distanceMeters, equals(100.0));
|
||||
// 2 primary attempts + 1 fallback = 3
|
||||
expect(callCount, equals(3));
|
||||
});
|
||||
|
||||
test('429 triggers fallback without retrying primary', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
int callCount = 0;
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((invocation) async {
|
||||
callCount++;
|
||||
final uri = invocation.positionalArguments[0] as Uri;
|
||||
if (uri.host == 'api.dontgetflocked.com') {
|
||||
return http.Response('Too Many Requests', 429,
|
||||
reasonPhrase: 'Too Many Requests');
|
||||
}
|
||||
return http.Response(
|
||||
json.encode({
|
||||
'ok': true,
|
||||
'result': {
|
||||
'route': {
|
||||
'coordinates': [[-77.0, 38.9]],
|
||||
'distance': 200.0,
|
||||
'duration': 120.0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.calculateRoute(start: start, end: end);
|
||||
expect(result.distanceMeters, equals(200.0));
|
||||
// 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2
|
||||
expect(callCount, equals(2));
|
||||
});
|
||||
|
||||
test('does NOT fallback on 400 (validation error)', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
'Bad Request: missing start', 400,
|
||||
reasonPhrase: 'Bad Request'));
|
||||
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.statusCode, 'statusCode', 400)),
|
||||
);
|
||||
|
||||
// Only one call — no retry, no fallback (abort disposition)
|
||||
verify(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('does NOT fallback on 403 (all 4xx except 429 abort)', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
'Forbidden', 403,
|
||||
reasonPhrase: 'Forbidden'));
|
||||
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.statusCode, 'statusCode', 403)),
|
||||
);
|
||||
|
||||
// Only one call — no retry, no fallback (abort disposition)
|
||||
verify(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('does NOT fallback on API-level business logic errors', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
json.encode({
|
||||
'ok': false,
|
||||
'error': 'No route found',
|
||||
}),
|
||||
200,
|
||||
));
|
||||
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.isApiError, 'isApiError', true)),
|
||||
);
|
||||
|
||||
verify(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('primary fails then fallback also fails -> error propagated', () async {
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
'Internal Server Error', 500,
|
||||
reasonPhrase: 'Internal Server Error'));
|
||||
|
||||
await expectLater(
|
||||
() => service.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>().having(
|
||||
(e) => e.statusCode, 'statusCode', 500)),
|
||||
);
|
||||
|
||||
// 2 primary attempts + 2 fallback attempts = 4
|
||||
verify(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).called(4);
|
||||
});
|
||||
|
||||
test('does NOT fallback when using custom baseUrl', () async {
|
||||
final customService = RoutingService(
|
||||
client: mockClient,
|
||||
baseUrl: 'https://custom.example.com/route',
|
||||
);
|
||||
|
||||
when(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||
|
||||
when(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => http.Response(
|
||||
'Service Unavailable', 503,
|
||||
reasonPhrase: 'Service Unavailable'));
|
||||
|
||||
await expectLater(
|
||||
() => customService.calculateRoute(start: start, end: end),
|
||||
throwsA(isA<RoutingException>()),
|
||||
);
|
||||
|
||||
// 2 attempts (1 + 1 retry), no fallback with custom URL
|
||||
verify(() => mockClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
)).called(2);
|
||||
});
|
||||
});
|
||||
|
||||
group('RoutingException', () {
|
||||
test('statusCode is preserved', () {
|
||||
const e = RoutingException('test', statusCode: 502);
|
||||
expect(e.statusCode, 502);
|
||||
expect(e.isApiError, false);
|
||||
});
|
||||
|
||||
test('isApiError flag works', () {
|
||||
const e = RoutingException('test', isApiError: true);
|
||||
expect(e.isApiError, true);
|
||||
expect(e.statusCode, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
558
test/services/service_policy_test.dart
Normal file
558
test/services/service_policy_test.dart
Normal file
@@ -0,0 +1,558 @@
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:deflockapp/services/service_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('ServicePolicyResolver', () {
|
||||
group('resolveType', () {
|
||||
test('resolves OSM editing API from production URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from sandbox URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM editing API from dev URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'),
|
||||
ServiceType.osmEditingApi,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves OSM tile server from tile URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'),
|
||||
ServiceType.osmTileServer,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Nominatim from geocoding URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'),
|
||||
ServiceType.nominatim,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Overpass API', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'),
|
||||
ServiceType.overpass,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves TagInfo', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'),
|
||||
ServiceType.tagInfo,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Bing tiles from virtualearth URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'),
|
||||
ServiceType.bingTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Mapbox tiles', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'),
|
||||
ServiceType.mapboxTiles,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for unknown host', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for empty string', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType(''),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns custom for malformed URL', () {
|
||||
expect(
|
||||
ServicePolicyResolver.resolveType('not-a-url'),
|
||||
ServiceType.custom,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve', () {
|
||||
test('OSM tile server policy allows offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('OSM tile server policy requires 7-day min cache TTL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
});
|
||||
|
||||
test('OSM tile server has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('Nominatim policy enforces 1-second rate limit', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
});
|
||||
|
||||
test('Nominatim policy requires client caching', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
});
|
||||
|
||||
test('Nominatim has attribution URL', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://nominatim.openstreetmap.org/search?q=test',
|
||||
);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('OSM editing API allows max 2 concurrent requests', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
});
|
||||
|
||||
test('Bing tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('Mapbox tiles allow offline download', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('custom/unknown host gets permissive defaults', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolve with URL templates', () {
|
||||
test('handles {z}/{x}/{y} template variables', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('handles {quadkey} template variable', () {
|
||||
final policy = ServicePolicyResolver.resolve(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1',
|
||||
);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
});
|
||||
|
||||
test('handles {0_3} subdomain template', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg',
|
||||
);
|
||||
expect(type, ServiceType.bingTiles);
|
||||
});
|
||||
|
||||
test('handles {api_key} template variable', () {
|
||||
final type = ServicePolicyResolver.resolveType(
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
);
|
||||
expect(type, ServiceType.mapboxTiles);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
group('ServiceRateLimiter', () {
|
||||
setUp(() {
|
||||
ServiceRateLimiter.reset();
|
||||
});
|
||||
|
||||
test('acquire and release work for editing API (2 concurrent)', () async {
|
||||
// Should be able to acquire 2 slots without blocking
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Release both
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('third acquire blocks until a slot is released', () async {
|
||||
// Fill both slots (osmEditingApi maxConcurrentRequests = 2)
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
// Third acquire should block
|
||||
var thirdCompleted = false;
|
||||
final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) {
|
||||
thirdCompleted = true;
|
||||
});
|
||||
|
||||
// Give microtasks a chance to run — third should still be blocked
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(thirdCompleted, false);
|
||||
|
||||
// Release one slot — third should now complete
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
await thirdFuture;
|
||||
expect(thirdCompleted, true);
|
||||
|
||||
// Clean up
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
});
|
||||
|
||||
test('Nominatim rate limiting delays rapid requests', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// First request should be immediate
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
// Second request should be delayed by ~1 second
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1, reason: 'second acquire should be blocked');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(acquireCount, 2, reason: 'second acquire should have completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('services with no rate limit pass through immediately', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var acquireCount = 0;
|
||||
|
||||
// Overpass has maxConcurrentRequests: 0, so acquire should not apply
|
||||
// any artificial rate limiting delays.
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 1);
|
||||
|
||||
ServiceRateLimiter.acquire(ServiceType.overpass).then((_) {
|
||||
acquireCount++;
|
||||
ServiceRateLimiter.release(ServiceType.overpass);
|
||||
});
|
||||
async.flushMicrotasks();
|
||||
expect(acquireCount, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('Nominatim enforces min interval under concurrent callers', () {
|
||||
fakeAsync((async) {
|
||||
ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now();
|
||||
|
||||
var completedCount = 0;
|
||||
|
||||
// Start two concurrent callers; only one should run at a time and
|
||||
// the minRequestInterval of ~1s should still be enforced.
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) {
|
||||
completedCount++;
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
});
|
||||
|
||||
async.flushMicrotasks();
|
||||
expect(completedCount, 1, reason: 'only first caller should complete immediately');
|
||||
|
||||
// Advance past the 1-second rate limit
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(completedCount, 2, reason: 'second caller should complete after interval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicePolicy', () {
|
||||
test('osmTileServer policy has correct values', () {
|
||||
const policy = ServicePolicy.osmTileServer();
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minCacheTtl, const Duration(days: 7));
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
expect(policy.maxConcurrentRequests, 0); // managed by flutter_map
|
||||
});
|
||||
|
||||
test('nominatim policy has correct values', () {
|
||||
const policy = ServicePolicy.nominatim();
|
||||
expect(policy.minRequestInterval, const Duration(seconds: 1));
|
||||
expect(policy.maxConcurrentRequests, 1);
|
||||
expect(policy.requiresClientCaching, true);
|
||||
expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright');
|
||||
});
|
||||
|
||||
test('osmEditingApi policy has correct values', () {
|
||||
const policy = ServicePolicy.osmEditingApi();
|
||||
expect(policy.maxConcurrentRequests, 2);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
});
|
||||
|
||||
test('custom policy uses permissive defaults', () {
|
||||
const policy = ServicePolicy();
|
||||
expect(policy.maxConcurrentRequests, 8);
|
||||
expect(policy.allowsOfflineDownload, true);
|
||||
expect(policy.minRequestInterval, isNull);
|
||||
expect(policy.requiresClientCaching, false);
|
||||
expect(policy.minCacheTtl, isNull);
|
||||
expect(policy.attributionUrl, isNull);
|
||||
});
|
||||
|
||||
test('custom policy accepts overrides', () {
|
||||
const policy = ServicePolicy.custom(
|
||||
maxConcurrent: 20,
|
||||
allowsOffline: false,
|
||||
attribution: 'https://example.com/license',
|
||||
);
|
||||
expect(policy.maxConcurrentRequests, 20);
|
||||
expect(policy.allowsOfflineDownload, false);
|
||||
expect(policy.attributionUrl, 'https://example.com/license');
|
||||
});
|
||||
});
|
||||
|
||||
group('ResiliencePolicy', () {
|
||||
test('retryDelay uses exponential backoff', () {
|
||||
const policy = ResiliencePolicy(
|
||||
retryBackoffBase: Duration(milliseconds: 100),
|
||||
retryBackoffMaxMs: 2000,
|
||||
);
|
||||
expect(policy.retryDelay(0), const Duration(milliseconds: 100));
|
||||
expect(policy.retryDelay(1), const Duration(milliseconds: 200));
|
||||
expect(policy.retryDelay(2), const Duration(milliseconds: 400));
|
||||
});
|
||||
|
||||
test('retryDelay clamps to max', () {
|
||||
const policy = ResiliencePolicy(
|
||||
retryBackoffBase: Duration(milliseconds: 1000),
|
||||
retryBackoffMaxMs: 3000,
|
||||
);
|
||||
expect(policy.retryDelay(0), const Duration(milliseconds: 1000));
|
||||
expect(policy.retryDelay(1), const Duration(milliseconds: 2000));
|
||||
expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped
|
||||
expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped
|
||||
});
|
||||
});
|
||||
|
||||
group('executeWithFallback', () {
|
||||
const policy = ResiliencePolicy(
|
||||
maxRetries: 2,
|
||||
retryBackoffBase: Duration.zero, // no delay in tests
|
||||
);
|
||||
|
||||
test('abort error stops immediately, no fallback', () async {
|
||||
int callCount = 0;
|
||||
|
||||
await expectLater(
|
||||
() => executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
callCount++;
|
||||
throw Exception('bad request');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.abort,
|
||||
policy: policy,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
expect(callCount, 1); // no retries, no fallback
|
||||
});
|
||||
|
||||
test('fallback error skips retries, goes to fallback', () async {
|
||||
final urlsSeen = <String>[];
|
||||
|
||||
final result = await executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
urlsSeen.add(url);
|
||||
if (url.contains('primary')) {
|
||||
throw Exception('rate limited');
|
||||
}
|
||||
return Future.value('ok from fallback');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.fallback,
|
||||
policy: policy,
|
||||
);
|
||||
|
||||
expect(result, 'ok from fallback');
|
||||
// 1 primary (no retries) + 1 fallback = 2
|
||||
expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']);
|
||||
});
|
||||
|
||||
test('retry error retries N times then falls back', () async {
|
||||
final urlsSeen = <String>[];
|
||||
|
||||
final result = await executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
urlsSeen.add(url);
|
||||
if (url.contains('primary')) {
|
||||
throw Exception('server error');
|
||||
}
|
||||
return Future.value('ok from fallback');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.retry,
|
||||
policy: policy,
|
||||
);
|
||||
|
||||
expect(result, 'ok from fallback');
|
||||
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
|
||||
expect(urlsSeen.where((u) => u.contains('primary')).length, 3);
|
||||
expect(urlsSeen.where((u) => u.contains('fallback')).length, 1);
|
||||
});
|
||||
|
||||
test('no fallback URL rethrows after retries', () async {
|
||||
int callCount = 0;
|
||||
|
||||
await expectLater(
|
||||
() => executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: null,
|
||||
execute: (url) {
|
||||
callCount++;
|
||||
throw Exception('server error');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.retry,
|
||||
policy: policy,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
// 3 attempts (1 + 2 retries), then rethrow
|
||||
expect(callCount, 3);
|
||||
});
|
||||
|
||||
test('fallback disposition with no fallback URL rethrows immediately', () async {
|
||||
int callCount = 0;
|
||||
|
||||
await expectLater(
|
||||
() => executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: null,
|
||||
execute: (url) {
|
||||
callCount++;
|
||||
throw Exception('rate limited');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.fallback,
|
||||
policy: policy,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
// Only 1 attempt — fallback disposition skips retries, and no fallback URL
|
||||
expect(callCount, 1);
|
||||
});
|
||||
|
||||
test('both fail propagates last error', () async {
|
||||
await expectLater(
|
||||
() => executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
if (url.contains('fallback')) {
|
||||
throw Exception('fallback also failed');
|
||||
}
|
||||
throw Exception('primary failed');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.retry,
|
||||
policy: policy,
|
||||
),
|
||||
throwsA(isA<Exception>().having(
|
||||
(e) => e.toString(), 'message', contains('fallback also failed'))),
|
||||
);
|
||||
});
|
||||
|
||||
test('success on first try returns immediately', () async {
|
||||
int callCount = 0;
|
||||
|
||||
final result = await executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
callCount++;
|
||||
return Future.value('success');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.retry,
|
||||
policy: policy,
|
||||
);
|
||||
|
||||
expect(result, 'success');
|
||||
expect(callCount, 1);
|
||||
});
|
||||
|
||||
test('success after retry does not try fallback', () async {
|
||||
int callCount = 0;
|
||||
|
||||
final result = await executeWithFallback<String>(
|
||||
primaryUrl: 'https://primary.example.com',
|
||||
fallbackUrl: 'https://fallback.example.com',
|
||||
execute: (url) {
|
||||
callCount++;
|
||||
if (callCount == 1) throw Exception('transient');
|
||||
return Future.value('recovered');
|
||||
},
|
||||
classifyError: (_) => ErrorDisposition.retry,
|
||||
policy: policy,
|
||||
);
|
||||
|
||||
expect(result, 'recovered');
|
||||
expect(callCount, 2); // 1 fail + 1 success, no fallback
|
||||
});
|
||||
});
|
||||
}
|
||||
227
test/services/tiles_from_local_test.dart
Normal file
227
test/services/tiles_from_local_test.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeBounds', () {
|
||||
test('swapped corners are normalized', () {
|
||||
// NE as first arg, SW as second (swapped)
|
||||
final swapped = LatLngBounds(
|
||||
const LatLng(52.0, 1.0), // NE corner passed as SW
|
||||
const LatLng(51.0, -1.0), // SW corner passed as NE
|
||||
);
|
||||
final normalized = normalizeBounds(swapped);
|
||||
expect(normalized.south, closeTo(51.0, 1e-6));
|
||||
expect(normalized.north, closeTo(52.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-1.0, 1e-6));
|
||||
expect(normalized.east, closeTo(1.0, 1e-6));
|
||||
});
|
||||
|
||||
test('degenerate (zero-width) bounds are expanded', () {
|
||||
final point = LatLngBounds(
|
||||
const LatLng(51.5, -0.1),
|
||||
const LatLng(51.5, -0.1),
|
||||
);
|
||||
final normalized = normalizeBounds(point);
|
||||
expect(normalized.south, lessThan(51.5));
|
||||
expect(normalized.north, greaterThan(51.5));
|
||||
expect(normalized.west, lessThan(-0.1));
|
||||
expect(normalized.east, greaterThan(-0.1));
|
||||
});
|
||||
|
||||
test('already-normalized bounds are unchanged', () {
|
||||
final normal = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
final normalized = normalizeBounds(normal);
|
||||
expect(normalized.south, closeTo(40.0, 1e-6));
|
||||
expect(normalized.north, closeTo(60.0, 1e-6));
|
||||
expect(normalized.west, closeTo(-10.0, 1e-6));
|
||||
expect(normalized.east, closeTo(30.0, 1e-6));
|
||||
});
|
||||
});
|
||||
|
||||
group('tileInBounds', () {
|
||||
/// Helper: compute expected tile range for [bounds] at [z] using the same
|
||||
/// Mercator projection math and return whether (x, y) is within range.
|
||||
bool referenceTileInBounds(
|
||||
LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final minX = ((bounds.west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((bounds.east + 180.0) / 360.0 * n).floor();
|
||||
final minY = ((1.0 -
|
||||
log(tan(bounds.north * pi / 180.0) +
|
||||
1.0 / cos(bounds.north * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
final maxY = ((1.0 -
|
||||
log(tan(bounds.south * pi / 180.0) +
|
||||
1.0 / cos(bounds.south * pi / 180.0)) /
|
||||
pi) /
|
||||
2.0 *
|
||||
n)
|
||||
.floor();
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
test('zoom 0: single tile covers the whole world', () {
|
||||
final world = LatLngBounds(
|
||||
const LatLng(-85, -180),
|
||||
const LatLng(85, 180),
|
||||
);
|
||||
expect(tileInBounds(world, 0, 0, 0), isTrue);
|
||||
});
|
||||
|
||||
test('zoom 1: London area covers NW and NE quadrants', () {
|
||||
// Bounds straddling the prime meridian in the northern hemisphere
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// NW quadrant (x=0, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 0), isTrue);
|
||||
// NE quadrant (x=1, y=0) — should be in bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 0), isTrue);
|
||||
// SW quadrant (x=0, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 0, 1), isFalse);
|
||||
// SE quadrant (x=1, y=1) — southern hemisphere, out of bounds
|
||||
expect(tileInBounds(londonArea, 1, 1, 1), isFalse);
|
||||
});
|
||||
|
||||
test('zoom 2: London area covers specific tiles', () {
|
||||
final londonArea = LatLngBounds(
|
||||
const LatLng(51.0, -1.0),
|
||||
const LatLng(52.0, 1.0),
|
||||
);
|
||||
|
||||
// Expected: X 1-2, Y 1
|
||||
expect(tileInBounds(londonArea, 2, 1, 1), isTrue);
|
||||
expect(tileInBounds(londonArea, 2, 2, 1), isTrue);
|
||||
// Outside X range
|
||||
expect(tileInBounds(londonArea, 2, 0, 1), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 3, 1), isFalse);
|
||||
// Outside Y range
|
||||
expect(tileInBounds(londonArea, 2, 1, 0), isFalse);
|
||||
expect(tileInBounds(londonArea, 2, 1, 2), isFalse);
|
||||
});
|
||||
|
||||
test('southern hemisphere: Sydney area', () {
|
||||
final sydneyArea = LatLngBounds(
|
||||
const LatLng(-34.0, 151.0),
|
||||
const LatLng(-33.5, 151.5),
|
||||
);
|
||||
|
||||
// At zoom 1, Sydney is in the SE quadrant (x=1, y=1)
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse);
|
||||
expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse);
|
||||
});
|
||||
|
||||
test('western hemisphere: NYC area at zoom 4', () {
|
||||
final nycArea = LatLngBounds(
|
||||
const LatLng(40.5, -74.5),
|
||||
const LatLng(41.0, -73.5),
|
||||
);
|
||||
|
||||
// At zoom 4 (16x16), NYC should be around x=4-5, y=6
|
||||
// x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4
|
||||
// x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4
|
||||
// So x range is just 4
|
||||
expect(tileInBounds(nycArea, 4, 4, 6), isTrue);
|
||||
expect(tileInBounds(nycArea, 4, 5, 6), isFalse);
|
||||
expect(tileInBounds(nycArea, 4, 3, 6), isFalse);
|
||||
});
|
||||
|
||||
test('higher zoom: smaller area at zoom 10', () {
|
||||
// Small area around central London
|
||||
final centralLondon = LatLngBounds(
|
||||
const LatLng(51.49, -0.13),
|
||||
const LatLng(51.52, -0.08),
|
||||
);
|
||||
|
||||
// Compute expected tile range at zoom 10 using reference
|
||||
const z = 10;
|
||||
final n = pow(2.0, z);
|
||||
final expectedMinX =
|
||||
((-0.13 + 180.0) / 360.0 * n).floor();
|
||||
final expectedMaxX =
|
||||
((-0.08 + 180.0) / 360.0 * n).floor();
|
||||
|
||||
// Tiles inside the computed range should be in bounds
|
||||
for (var x = expectedMinX; x <= expectedMaxX; x++) {
|
||||
expect(
|
||||
referenceTileInBounds(centralLondon, z, x, 340),
|
||||
equals(tileInBounds(centralLondon, z, x, 340)),
|
||||
reason: 'Mismatch at tile ($x, 340, $z)',
|
||||
);
|
||||
}
|
||||
|
||||
// Tiles outside X range should not be in bounds
|
||||
expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse);
|
||||
expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse);
|
||||
});
|
||||
|
||||
test('tile exactly at boundary is included', () {
|
||||
// Bounds whose edges align exactly with tile boundaries at zoom 1
|
||||
// At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180
|
||||
final halfWorld = LatLngBounds(
|
||||
const LatLng(0.0, 0.0),
|
||||
const LatLng(60.0, 180.0),
|
||||
);
|
||||
|
||||
// Tile (1, 0, 1) should be in bounds (NE quadrant)
|
||||
expect(tileInBounds(halfWorld, 1, 1, 0), isTrue);
|
||||
});
|
||||
|
||||
test('anti-meridian: bounds crossing 180° longitude', () {
|
||||
// Bounds from eastern Russia (170°E) to Alaska (170°W = -170°)
|
||||
// After normalization, west=170 east=-170 which is swapped —
|
||||
// normalizeBounds will swap to west=-170 east=170, which covers
|
||||
// nearly the whole world. This is the expected behavior since
|
||||
// LatLngBounds doesn't support anti-meridian wrapping.
|
||||
final antiMeridian = normalizeBounds(LatLngBounds(
|
||||
const LatLng(50.0, 170.0),
|
||||
const LatLng(70.0, -170.0),
|
||||
));
|
||||
|
||||
// After normalization, west=-170 east=170 (covers most longitudes)
|
||||
// At zoom 2, tiles 0-3 along X axis
|
||||
// Since the normalized bounds cover lon -170 to 170 (340° of 360°),
|
||||
// almost all tiles should be in bounds
|
||||
expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue);
|
||||
expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue);
|
||||
});
|
||||
|
||||
test('exhaustive check at zoom 3 matches reference', () {
|
||||
final bounds = LatLngBounds(
|
||||
const LatLng(40.0, -10.0),
|
||||
const LatLng(60.0, 30.0),
|
||||
);
|
||||
|
||||
// Check all 64 tiles at zoom 3 against reference implementation
|
||||
const z = 3;
|
||||
final tilesPerSide = pow(2, z).toInt();
|
||||
for (var x = 0; x < tilesPerSide; x++) {
|
||||
for (var y = 0; y < tilesPerSide; y++) {
|
||||
expect(
|
||||
tileInBounds(bounds, z, x, y),
|
||||
equals(referenceTileInBounds(bounds, z, x, y)),
|
||||
reason: 'Mismatch at tile ($x, $y, $z)',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
73
test/state/settings_state_test.dart
Normal file
73
test/state/settings_state_test.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:deflockapp/state/settings_state.dart';
|
||||
import 'package:deflockapp/keys.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('kHasOsmSecrets (no --dart-define)', () {
|
||||
test('is false when built without secrets', () {
|
||||
expect(kHasOsmSecrets, isFalse);
|
||||
});
|
||||
|
||||
test('client ID getters return empty strings instead of throwing', () {
|
||||
expect(kOsmProdClientId, isEmpty);
|
||||
expect(kOsmSandboxClientId, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('SettingsState without secrets', () {
|
||||
test('defaults to simulate mode', () {
|
||||
final state = SettingsState();
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('init() forces simulate even if prefs has production stored', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.production.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
|
||||
// Verify it persisted the override
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getInt('upload_mode'), UploadMode.simulate.index);
|
||||
});
|
||||
|
||||
test('init() forces simulate even if prefs has sandbox stored', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.sandbox.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('init() keeps simulate if already simulate', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'upload_mode': UploadMode.simulate.index,
|
||||
});
|
||||
|
||||
final state = SettingsState();
|
||||
await state.init();
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
|
||||
test('setUploadMode() allows simulate', () async {
|
||||
final state = SettingsState();
|
||||
await state.setUploadMode(UploadMode.simulate);
|
||||
|
||||
expect(state.uploadMode, UploadMode.simulate);
|
||||
});
|
||||
});
|
||||
}
|
||||
618
test/widgets/map/tile_layer_manager_test.dart
Normal file
618
test/widgets/map/tile_layer_manager_test.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/widgets/map/tile_layer_manager.dart';
|
||||
|
||||
class MockTileImage extends Mock implements TileImage {}
|
||||
|
||||
void main() {
|
||||
group('TileLayerManager exponential backoff', () {
|
||||
test('initial retry delay is 2 seconds', () {
|
||||
final manager = TileLayerManager();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('scheduleRetry fires reset stream after delay', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay doubles after each retry fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// First retry: 2s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
// Second retry: 4s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Third retry: 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 8));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 16)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay caps at 60 seconds', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60
|
||||
var currentDelay = manager.retryDelay;
|
||||
while (currentDelay < const Duration(seconds: 60)) {
|
||||
manager.scheduleRetry();
|
||||
async.elapse(currentDelay);
|
||||
currentDelay = manager.retryDelay;
|
||||
}
|
||||
|
||||
// Should be capped at 60s
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
// Another cycle stays at 60s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 60));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 60)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('onTileLoadSuccess resets delay to minimum', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
// Drive up the delay
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 4)));
|
||||
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Reset on success
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('rapid errors debounce: only last timer fires', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Fire 3 errors in quick succession (each cancels the previous timer)
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(milliseconds: 500));
|
||||
manager.scheduleRetry();
|
||||
|
||||
// 1s elapsed total since first error, but last timer started 0ms ago
|
||||
// Need to wait 2s from *last* scheduleRetry call
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, isEmpty, reason: 'Timer should not fire yet');
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(resets, hasLength(1), reason: 'Only one reset should fire');
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('delay stays at minimum if no retries have fired', () {
|
||||
final manager = TileLayerManager();
|
||||
// Just calling onTileLoadSuccess without any errors
|
||||
manager.onTileLoadSuccess();
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
manager.resetStream.listen((_) {});
|
||||
|
||||
final expectedDelays = [
|
||||
const Duration(seconds: 2),
|
||||
const Duration(seconds: 4),
|
||||
const Duration(seconds: 8),
|
||||
const Duration(seconds: 16),
|
||||
const Duration(seconds: 32),
|
||||
const Duration(seconds: 60),
|
||||
const Duration(seconds: 60), // capped
|
||||
];
|
||||
|
||||
for (var i = 0; i < expectedDelays.length; i++) {
|
||||
expect(manager.retryDelay, equals(expectedDelays[i]),
|
||||
reason: 'Step $i');
|
||||
manager.scheduleRetry();
|
||||
async.elapse(expectedDelays[i]);
|
||||
}
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('dispose cancels pending retry timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final manager = TileLayerManager();
|
||||
final resets = <void>[];
|
||||
late StreamSubscription<void> sub;
|
||||
sub = manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.scheduleRetry();
|
||||
// Dispose before timer fires
|
||||
sub.cancel();
|
||||
manager.dispose();
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager checkAndClearCacheIfNeeded', () {
|
||||
late TileLayerManager manager;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('first call triggers clear (initial null differs from provided values)', () {
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call: internal state is (null, null, false) → (osm, street, false)
|
||||
// provider null→osm triggers clear. Harmless: no tiles to clear yet.
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('same values on second call returns false', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('different provider triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different tile type triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('different offline mode triggers cache clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: true,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('cache clear increments mapRebuildKey', () {
|
||||
final initialKey = manager.mapRebuildKey;
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// First call increments (null → osm)
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 1));
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// Type change should increment again
|
||||
expect(manager.mapRebuildKey, equals(initialKey + 2));
|
||||
});
|
||||
|
||||
test('no cache clear does not increment mapRebuildKey', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final keyAfterFirst = manager.mapRebuildKey;
|
||||
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(manager.mapRebuildKey, equals(keyAfterFirst));
|
||||
});
|
||||
|
||||
test('null to non-null transition triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
// null → osm is a change — triggers clear so stale tiles are flushed
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('non-null to null to non-null triggers clear both times', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
// Provider goes null (e.g., during reload)
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: null,
|
||||
currentTileTypeId: null,
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// Provider returns — should still trigger clear
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching back and forth triggers clear each time', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'satellite',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching providers with same tile type triggers clear', () {
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
final result = manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'standard',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('provider switch resets retry delay and cancels pending timer', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
// Escalate backoff: 2s → 4s → 8s
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
manager.scheduleRetry();
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 8)));
|
||||
|
||||
// Start another retry timer (hasn't fired yet)
|
||||
manager.scheduleRetry();
|
||||
|
||||
// Switch provider — should reset delay and cancel pending timer
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'osm',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
manager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: 'bing',
|
||||
currentTileTypeId: 'street',
|
||||
currentOfflineMode: false,
|
||||
);
|
||||
|
||||
expect(manager.retryDelay, equals(const Duration(seconds: 2)));
|
||||
|
||||
// The pending 8s timer should have been cancelled
|
||||
final resetsBefore = resets.length;
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets.length, equals(resetsBefore),
|
||||
reason: 'Old retry timer should be cancelled on provider switch');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager config drift detection', () {
|
||||
late TileLayerManager manager;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
models.TileProvider makeProvider({String? apiKey}) => models.TileProvider(
|
||||
id: 'test_provider',
|
||||
name: 'Test',
|
||||
apiKey: apiKey,
|
||||
tileTypes: [],
|
||||
);
|
||||
|
||||
models.TileType makeTileType({
|
||||
String urlTemplate = 'https://example.com/{z}/{x}/{y}.png',
|
||||
int maxZoom = 18,
|
||||
}) =>
|
||||
models.TileType(
|
||||
id: 'test_tile',
|
||||
name: 'Test',
|
||||
urlTemplate: urlTemplate,
|
||||
attribution: 'Test',
|
||||
maxZoom: maxZoom,
|
||||
);
|
||||
|
||||
test('returns same provider for identical config', () {
|
||||
final provider = makeProvider();
|
||||
final tileType = makeTileType();
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isTrue,
|
||||
reason: 'Same config should return the cached provider instance',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when urlTemplate changes', () {
|
||||
final provider = makeProvider();
|
||||
final tileTypeV1 = makeTileType(
|
||||
urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
final tileTypeV2 = makeTileType(
|
||||
urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV1,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV2,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed urlTemplate should create a new provider',
|
||||
);
|
||||
expect(
|
||||
(layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate,
|
||||
'https://new.example.com/{z}/{x}/{y}.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when apiKey changes', () {
|
||||
final providerV1 = makeProvider(apiKey: 'old_key');
|
||||
final providerV2 = makeProvider(apiKey: 'new_key');
|
||||
final tileType = makeTileType();
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: providerV1,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: providerV2,
|
||||
selectedTileType: tileType,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed apiKey should create a new provider',
|
||||
);
|
||||
expect(
|
||||
(layer2.tileProvider as DeflockTileProvider).apiKey,
|
||||
'new_key',
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces provider when maxZoom changes', () {
|
||||
final provider = makeProvider();
|
||||
final tileTypeV1 = makeTileType(maxZoom: 18);
|
||||
final tileTypeV2 = makeTileType(maxZoom: 20);
|
||||
|
||||
final layer1 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV1,
|
||||
) as TileLayer;
|
||||
|
||||
final layer2 = manager.buildTileLayer(
|
||||
selectedProvider: provider,
|
||||
selectedTileType: tileTypeV2,
|
||||
) as TileLayer;
|
||||
|
||||
expect(
|
||||
identical(layer1.tileProvider, layer2.tileProvider),
|
||||
isFalse,
|
||||
reason: 'Changed maxZoom should create a new provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('TileLayerManager error-type filtering', () {
|
||||
late TileLayerManager manager;
|
||||
late MockTileImage mockTile;
|
||||
|
||||
setUp(() {
|
||||
manager = TileLayerManager();
|
||||
mockTile = MockTileImage();
|
||||
when(() => mockTile.coordinates)
|
||||
.thenReturn(const TileCoordinates(1, 2, 3));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('skips retry for TileLoadCancelledException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileLoadCancelledException(),
|
||||
null,
|
||||
);
|
||||
|
||||
// Even after waiting well past the retry delay, no reset should fire.
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('skips retry for TileNotAvailableOfflineException', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const TileNotAvailableOfflineException(),
|
||||
null,
|
||||
);
|
||||
|
||||
async.elapse(const Duration(seconds: 10));
|
||||
expect(resets, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('schedules retry for other errors (e.g. HttpException)', () {
|
||||
FakeAsync().run((async) {
|
||||
final resets = <void>[];
|
||||
manager.resetStream.listen((_) => resets.add(null));
|
||||
|
||||
manager.onTileLoadError(
|
||||
mockTile,
|
||||
const HttpException('tile fetch failed'),
|
||||
null,
|
||||
);
|
||||
|
||||
// Should fire after the initial 2s retry delay.
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
expect(resets, hasLength(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
164
test/widgets/map_data_manager_test.dart
Normal file
164
test/widgets/map_data_manager_test.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/widgets/map/map_data_manager.dart';
|
||||
|
||||
void main() {
|
||||
OsmNode nodeAt(int id, double lat, double lng) {
|
||||
return OsmNode(id: id, coord: LatLng(lat, lng), tags: {'surveillance': 'outdoor'});
|
||||
}
|
||||
|
||||
group('Node render prioritization', () {
|
||||
late MapDataManager dataManager;
|
||||
late List<OsmNode> testNodes;
|
||||
|
||||
setUp(() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
testNodes = [];
|
||||
dataManager = MapDataManager(
|
||||
getNodesForBounds: (_) => testNodes,
|
||||
);
|
||||
});
|
||||
|
||||
test('closest nodes to viewport center are kept', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
// Center is (38.5, -77.5)
|
||||
testNodes = [
|
||||
nodeAt(1, 38.9, -77.9), // far from center
|
||||
nodeAt(2, 38.5, -77.5), // at center
|
||||
nodeAt(3, 38.1, -77.1), // far from center
|
||||
nodeAt(4, 38.51, -77.49), // very close to center
|
||||
nodeAt(5, 38.0, -78.0), // corner — farthest
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 3,
|
||||
);
|
||||
|
||||
expect(result.isLimitActive, isTrue);
|
||||
expect(result.nodesToRender.length, 3);
|
||||
final ids = result.nodesToRender.map((n) => n.id).toSet();
|
||||
expect(ids.contains(2), isTrue, reason: 'Node at center should be kept');
|
||||
expect(ids.contains(4), isTrue, reason: 'Node near center should be kept');
|
||||
expect(ids.contains(5), isFalse, reason: 'Node at corner should be dropped');
|
||||
});
|
||||
|
||||
test('returns all nodes when under the limit', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [
|
||||
nodeAt(1, 38.5, -77.5),
|
||||
nodeAt(2, 38.6, -77.6),
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.isLimitActive, isFalse);
|
||||
expect(result.nodesToRender.length, 2);
|
||||
});
|
||||
|
||||
test('returns empty when below minimum zoom', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [nodeAt(1, 38.5, -77.5)];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 5,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.nodesToRender, isEmpty);
|
||||
});
|
||||
|
||||
test('panning viewport changes which nodes are prioritized', () {
|
||||
final nodes = [
|
||||
nodeAt(1, 38.0, -78.0), // SW
|
||||
nodeAt(2, 38.5, -77.5), // middle
|
||||
nodeAt(3, 39.0, -77.0), // NE
|
||||
];
|
||||
|
||||
// Viewport centered near SW
|
||||
testNodes = List.from(nodes);
|
||||
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
|
||||
final swResult = dataManager.getNodesForRendering(
|
||||
currentZoom: 14,
|
||||
mapBounds: swBounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 1,
|
||||
);
|
||||
expect(swResult.nodesToRender.first.id, 1,
|
||||
reason: 'SW node closest to SW-centered viewport');
|
||||
|
||||
// Viewport centered near NE
|
||||
testNodes = List.from(nodes);
|
||||
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
|
||||
final neResult = dataManager.getNodesForRendering(
|
||||
currentZoom: 14,
|
||||
mapBounds: neBounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 1,
|
||||
);
|
||||
expect(neResult.nodesToRender.first.id, 3,
|
||||
reason: 'NE node closest to NE-centered viewport');
|
||||
});
|
||||
|
||||
test('order is stable for repeated calls with same viewport', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
makeNodes() => [
|
||||
nodeAt(1, 38.9, -77.9),
|
||||
nodeAt(2, 38.5, -77.5),
|
||||
nodeAt(3, 38.1, -77.1),
|
||||
nodeAt(4, 38.51, -77.49),
|
||||
nodeAt(5, 38.0, -78.0),
|
||||
];
|
||||
|
||||
testNodes = makeNodes();
|
||||
final result1 = dataManager.getNodesForRendering(
|
||||
currentZoom: 14, mapBounds: bounds,
|
||||
uploadMode: UploadMode.production, maxNodes: 3,
|
||||
);
|
||||
|
||||
testNodes = makeNodes();
|
||||
final result2 = dataManager.getNodesForRendering(
|
||||
currentZoom: 14, mapBounds: bounds,
|
||||
uploadMode: UploadMode.production, maxNodes: 3,
|
||||
);
|
||||
|
||||
expect(
|
||||
result1.nodesToRender.map((n) => n.id).toList(),
|
||||
result2.nodesToRender.map((n) => n.id).toList(),
|
||||
);
|
||||
});
|
||||
|
||||
test('filters out invalid coordinates before prioritizing', () {
|
||||
final bounds = LatLngBounds(LatLng(38.0, -78.0), LatLng(39.0, -77.0));
|
||||
testNodes = [
|
||||
nodeAt(1, 0, 0), // invalid (0,0)
|
||||
nodeAt(2, 38.5, -77.5), // valid, at center
|
||||
nodeAt(3, 200, -77.5), // invalid lat
|
||||
];
|
||||
|
||||
final result = dataManager.getNodesForRendering(
|
||||
currentZoom: 14,
|
||||
mapBounds: bounds,
|
||||
uploadMode: UploadMode.production,
|
||||
maxNodes: 10,
|
||||
);
|
||||
|
||||
expect(result.nodesToRender.length, 1);
|
||||
expect(result.nodesToRender.first.id, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user