mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 07:18:22 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d00d7a8fb | ||
|
|
f85db3ca11 | ||
|
|
447f358727 | ||
|
|
08b395214b | ||
|
|
256dd1a43c | ||
|
|
ca7192d3ec | ||
|
|
2833906c68 |
No files matched your search
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
{
|
{
|
||||||
|
"2.9.2": {
|
||||||
|
"content": [
|
||||||
|
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
|
||||||
|
"• Added 'Clear Caches' option to tile provider menus - easily free up storage space by clearing cached tiles for specific providers",
|
||||||
|
"• Enhanced node deletion workflow - users can now provide an informative reason when deleting surveillance devices",
|
||||||
|
"• Added 'Report issue with OSM base map' link in About screen help section - easily report issues with the underlying OpenStreetMap data"
|
||||||
|
]
|
||||||
|
},
|
||||||
"2.9.1": {
|
"2.9.1": {
|
||||||
"content": [
|
"content": [
|
||||||
"• When hitting node render limit, only render nodes closest to center of viewport."
|
"• 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": {
|
"2.9.0": {
|
||||||
|
|||||||
@@ -20,7 +20,5 @@
|
|||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,12 +2,15 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+84
-65
@@ -1,72 +1,91 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DeFlock</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>deflockapp</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>None</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>deflockapp</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>https</string>
|
||||||
|
</array>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>DeFlock</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>deflockapp</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
|
||||||
<key>CFBundleSignature</key>
|
|
||||||
<string>????</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
|
||||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
|
||||||
<string>LaunchScreen</string>
|
|
||||||
<key>UIMainStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<!-- OAuth2 redirect handler -->
|
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleTypeRole</key>
|
|
||||||
<string>None</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>deflockapp</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<!-- (Optional) allow opening the system browser and returning -->
|
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>https</string>
|
|
||||||
</array>
|
|
||||||
<key>UIStatusBarHidden</key>
|
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
+7
-2
@@ -581,8 +581,8 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode(OsmNode node) {
|
void deleteNode(OsmNode node, {String? changesetComment}) {
|
||||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +717,11 @@ class AppState extends ChangeNotifier {
|
|||||||
await _settingsState.deleteTileProvider(providerId);
|
await _settingsState.deleteTileProvider(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all tile caches for a specific provider
|
||||||
|
Future<void> clearTileProviderCaches(String providerId) async {
|
||||||
|
await _settingsState.clearTileProviderCaches(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Set follow-me mode
|
/// Set follow-me mode
|
||||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||||
await _settingsState.setFollowMeMode(mode);
|
await _settingsState.setFollowMeMode(mode);
|
||||||
|
|||||||
+3
-6
@@ -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 Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||||
|
|
||||||
// Navigation routing configuration
|
|
||||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
|
|
||||||
|
|
||||||
// Overpass API configuration
|
// Overpass API configuration
|
||||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||||
|
|
||||||
@@ -93,7 +90,7 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
|||||||
// Marker/node interaction
|
// Marker/node interaction
|
||||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
|
||||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||||
@@ -131,7 +128,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
|
|||||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||||
|
|
||||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
|
||||||
|
|
||||||
// Positioning tutorial configuration
|
// Positioning tutorial configuration
|
||||||
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
|
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
|
||||||
@@ -139,7 +136,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
|
|||||||
|
|
||||||
// Navigation route planning configuration
|
// Navigation route planning configuration
|
||||||
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
||||||
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
|
const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
|
||||||
|
|
||||||
// Node display configuration
|
// Node display configuration
|
||||||
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
||||||
|
|||||||
@@ -95,7 +95,9 @@
|
|||||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
||||||
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
||||||
"confirmDeleteTitle": "Knoten löschen",
|
"confirmDeleteTitle": "Knoten löschen",
|
||||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleteReasonLabel": "Grund für Löschung (Optional)",
|
||||||
|
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
@@ -247,6 +249,9 @@
|
|||||||
"addProvider": "Anbieter Hinzufügen",
|
"addProvider": "Anbieter Hinzufügen",
|
||||||
"deleteProvider": "Anbieter Löschen",
|
"deleteProvider": "Anbieter Löschen",
|
||||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||||
|
"clearCaches": "Caches Leeren",
|
||||||
|
"clearCachesConfirm": "Alle Kachel-Caches für \"{}\" löschen? Dies wird Speicherplatz freigeben, aber die Kacheln müssen beim Betrachten dieser Bereiche erneut heruntergeladen werden.",
|
||||||
|
"cachesCleared": "Caches für \"{}\" geleert",
|
||||||
"providerName": "Anbieter-Name",
|
"providerName": "Anbieter-Name",
|
||||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Node edit queued for upload",
|
"editQueuedForUpload": "Node edit queued for upload",
|
||||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||||
"confirmDeleteTitle": "Delete Node",
|
"confirmDeleteTitle": "Delete Node",
|
||||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
|
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
|
||||||
|
"deleteReasonLabel": "Reason for Deletion (Optional)",
|
||||||
|
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
@@ -282,8 +284,11 @@
|
|||||||
"needsApiKey": "Needs API key",
|
"needsApiKey": "Needs API key",
|
||||||
"editProvider": "Edit Provider",
|
"editProvider": "Edit Provider",
|
||||||
"addProvider": "Add Provider",
|
"addProvider": "Add Provider",
|
||||||
"deleteProvider": "Delete Provider",
|
"deleteProvider": "Delete Provider",
|
||||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||||
|
"clearCaches": "Clear Caches",
|
||||||
|
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
|
||||||
|
"cachesCleared": "Caches cleared for \"{}\"",
|
||||||
"providerName": "Provider Name",
|
"providerName": "Provider Name",
|
||||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||||
"providerNameRequired": "Provider name is required",
|
"providerNameRequired": "Provider name is required",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
||||||
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
||||||
"confirmDeleteTitle": "Eliminar Nodo",
|
"confirmDeleteTitle": "Eliminar Nodo",
|
||||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
|
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
|
||||||
|
"deleteReasonLabel": "Razón para Eliminación (Opcional)",
|
||||||
|
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Agregar Proveedor",
|
"addProvider": "Agregar Proveedor",
|
||||||
"deleteProvider": "Eliminar Proveedor",
|
"deleteProvider": "Eliminar Proveedor",
|
||||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||||
|
"clearCaches": "Limpiar Caché",
|
||||||
|
"clearCachesConfirm": "¿Limpiar todo el caché de mosaicos para \"{}\"? Esto liberará espacio de almacenamiento pero los mosaicos deberán descargarse nuevamente al ver esas áreas.",
|
||||||
|
"cachesCleared": "Caché limpiado para \"{}\"",
|
||||||
"providerName": "Nombre del Proveedor",
|
"providerName": "Nombre del Proveedor",
|
||||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
||||||
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
||||||
"confirmDeleteTitle": "Supprimer le Nœud",
|
"confirmDeleteTitle": "Supprimer le Nœud",
|
||||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
|
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
|
||||||
|
"deleteReasonLabel": "Raison de Suppression (Optionnel)",
|
||||||
|
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Ajouter Fournisseur",
|
"addProvider": "Ajouter Fournisseur",
|
||||||
"deleteProvider": "Supprimer Fournisseur",
|
"deleteProvider": "Supprimer Fournisseur",
|
||||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||||
|
"clearCaches": "Vider les Caches",
|
||||||
|
"clearCachesConfirm": "Vider tous les caches de tuiles pour \"{}\"? Cela libérera de l'espace de stockage mais les tuiles devront être téléchargées à nouveau lors de la consultation de ces zones.",
|
||||||
|
"cachesCleared": "Caches vidés pour \"{}\"",
|
||||||
"providerName": "Nom du Fournisseur",
|
"providerName": "Nom du Fournisseur",
|
||||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||||
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||||
"confirmDeleteTitle": "Elimina Nodo",
|
"confirmDeleteTitle": "Elimina Nodo",
|
||||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
|
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
|
||||||
|
"deleteReasonLabel": "Motivo per Eliminazione (Opzionale)",
|
||||||
|
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profilo",
|
"profile": "Profilo",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Aggiungi Fornitore",
|
"addProvider": "Aggiungi Fornitore",
|
||||||
"deleteProvider": "Elimina Fornitore",
|
"deleteProvider": "Elimina Fornitore",
|
||||||
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||||
|
"clearCaches": "Svuota Cache",
|
||||||
|
"clearCachesConfirm": "Svuotare tutte le cache delle tessere per \"{}\"? Questo libererà spazio di archiviazione ma le tessere dovranno essere scaricate nuovamente quando si visualizzano quelle aree.",
|
||||||
|
"cachesCleared": "Cache svuotate per \"{}\"",
|
||||||
"providerName": "Nome Fornitore",
|
"providerName": "Nome Fornitore",
|
||||||
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||||
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||||
|
|||||||
@@ -131,8 +131,10 @@
|
|||||||
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
|
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
|
||||||
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
|
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
|
||||||
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
|
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
|
||||||
"confirmDeleteTitle": "Verwijder Node",
|
"confirmDeleteTitle": "Verwijder Node",
|
||||||
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."
|
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
|
"deleteReasonLabel": "Reden voor Verwijdering (Optioneel)",
|
||||||
|
"deleteReasonHint": "bijv. apparaat verwijderd, onjuiste locatie, duplicaat..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profiel",
|
"profile": "Profiel",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Voeg Provider Toe",
|
"addProvider": "Voeg Provider Toe",
|
||||||
"deleteProvider": "Verwijder Provider",
|
"deleteProvider": "Verwijder Provider",
|
||||||
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||||
|
"clearCaches": "Cache Wissen",
|
||||||
|
"clearCachesConfirm": "Alle tegelcaches wissen voor \"{}\"? Dit zal opslagruimte vrijmaken maar tegels moeten opnieuw worden gedownload bij het bekijken van die gebieden.",
|
||||||
|
"cachesCleared": "Caches gewist voor \"{}\"",
|
||||||
"providerName": "Provider Naam",
|
"providerName": "Provider Naam",
|
||||||
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
|
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
|
||||||
"providerNameRequired": "Provider naam is vereist",
|
"providerNameRequired": "Provider naam is vereist",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
||||||
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
||||||
"confirmDeleteTitle": "Usuń Węzeł",
|
"confirmDeleteTitle": "Usuń Węzeł",
|
||||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć."
|
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
|
||||||
|
"deleteReasonLabel": "Powód Usunięcia (Opcjonalny)",
|
||||||
|
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Dodaj Dostawcę",
|
"addProvider": "Dodaj Dostawcę",
|
||||||
"deleteProvider": "Usuń Dostawcę",
|
"deleteProvider": "Usuń Dostawcę",
|
||||||
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||||
|
"clearCaches": "Wyczyść Cache",
|
||||||
|
"clearCachesConfirm": "Wyczyścić wszystkie cache kafelków dla \"{}\"? To zwolni miejsce na dysku, ale kafelki będą musiały zostać ponownie pobrane podczas przeglądania tych obszarów.",
|
||||||
|
"cachesCleared": "Cache wyczyszczone dla \"{}\"",
|
||||||
"providerName": "Nazwa Dostawcy",
|
"providerName": "Nazwa Dostawcy",
|
||||||
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
|
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
|
||||||
"providerNameRequired": "Nazwa dostawcy jest wymagana",
|
"providerNameRequired": "Nazwa dostawcy jest wymagana",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Edição de nó na fila para envio",
|
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||||
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||||
"confirmDeleteTitle": "Excluir Nó",
|
"confirmDeleteTitle": "Excluir Nó",
|
||||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
|
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
|
||||||
|
"deleteReasonLabel": "Motivo para Exclusão (Opcional)",
|
||||||
|
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Adicionar Provedor",
|
"addProvider": "Adicionar Provedor",
|
||||||
"deleteProvider": "Excluir Provedor",
|
"deleteProvider": "Excluir Provedor",
|
||||||
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||||
|
"clearCaches": "Limpar Caches",
|
||||||
|
"clearCachesConfirm": "Limpar todos os caches de mapas para \"{}\"? Isso liberará espaço de armazenamento, mas os mapas precisarão ser baixados novamente ao visualizar essas áreas.",
|
||||||
|
"cachesCleared": "Caches limpos para \"{}\"",
|
||||||
"providerName": "Nome do Provedor",
|
"providerName": "Nome do Provedor",
|
||||||
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||||
"providerNameRequired": "Nome do provedor é obrigatório",
|
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
||||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||||
"confirmDeleteTitle": "Düğümü Sil",
|
"confirmDeleteTitle": "Düğümü Sil",
|
||||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||||
|
"deleteReasonLabel": "Silme Nedeni (İsteğe Bağlı)",
|
||||||
|
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Sağlayıcı Ekle",
|
"addProvider": "Sağlayıcı Ekle",
|
||||||
"deleteProvider": "Sağlayıcıyı Sil",
|
"deleteProvider": "Sağlayıcıyı Sil",
|
||||||
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||||
|
"clearCaches": "Önbellekleri Temizle",
|
||||||
|
"clearCachesConfirm": "\"{}\" için tüm döşeme önbelleklerini temizle? Bu depolama alanını boşaltacak ancak bu alanları görüntülerken döşemelerin tekrar indirilmesi gerekecek.",
|
||||||
|
"cachesCleared": "\"{}\" için önbellekler temizlendi",
|
||||||
"providerName": "Sağlayıcı Adı",
|
"providerName": "Sağlayıcı Adı",
|
||||||
"providerNameHint": "örn., Özel Haritalar A.Ş.",
|
"providerNameHint": "örn., Özel Haritalar A.Ş.",
|
||||||
"providerNameRequired": "Sağlayıcı adı gerekli",
|
"providerNameRequired": "Sağlayıcı adı gerekli",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
||||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||||
"confirmDeleteTitle": "Видалити Вузол",
|
"confirmDeleteTitle": "Видалити Вузол",
|
||||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
|
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||||
|
"deleteReasonLabel": "Причина Видалення (Опціонально)",
|
||||||
|
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Профіль",
|
"profile": "Профіль",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "Додати Постачальника",
|
"addProvider": "Додати Постачальника",
|
||||||
"deleteProvider": "Видалити Постачальника",
|
"deleteProvider": "Видалити Постачальника",
|
||||||
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||||
|
"clearCaches": "Очистити Кеш",
|
||||||
|
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
|
||||||
|
"cachesCleared": "Кеш очищено для \"{}\"",
|
||||||
"providerName": "Назва Постачальника",
|
"providerName": "Назва Постачальника",
|
||||||
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
||||||
"providerNameRequired": "Назва постачальника обов'язкова",
|
"providerNameRequired": "Назва постачальника обов'язкова",
|
||||||
|
|||||||
@@ -132,7 +132,9 @@
|
|||||||
"editQueuedForUpload": "节点编辑已排队上传",
|
"editQueuedForUpload": "节点编辑已排队上传",
|
||||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||||
"confirmDeleteTitle": "删除节点",
|
"confirmDeleteTitle": "删除节点",
|
||||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
||||||
|
"deleteReasonLabel": "删除原因(可选)",
|
||||||
|
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
|
||||||
},
|
},
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "配置文件",
|
"profile": "配置文件",
|
||||||
@@ -284,6 +286,9 @@
|
|||||||
"addProvider": "添加提供商",
|
"addProvider": "添加提供商",
|
||||||
"deleteProvider": "删除提供商",
|
"deleteProvider": "删除提供商",
|
||||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||||
|
"clearCaches": "清除缓存",
|
||||||
|
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
|
||||||
|
"cachesCleared": "已清除 \"{}\" 的缓存",
|
||||||
"providerName": "提供商名称",
|
"providerName": "提供商名称",
|
||||||
"providerNameHint": "例如,自定义地图公司",
|
"providerNameHint": "例如,自定义地图公司",
|
||||||
"providerNameRequired": "提供商名称为必填项",
|
"providerNameRequired": "提供商名称为必填项",
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class AboutScreen extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
// About OpenStreetMap section
|
||||||
|
_buildAboutOSMSection(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
// Information dialogs section
|
// Information dialogs section
|
||||||
_buildDialogButtons(context),
|
_buildDialogButtons(context),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -96,6 +99,8 @@ class AboutScreen extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'Report issue with OSM base map', 'https://www.openstreetmap.org/fixthemap'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||||
@@ -121,6 +126,39 @@ class AboutScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAboutOSMSection(BuildContext context) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('auth.aboutOSM'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
locService.t('auth.aboutOSMDescription'),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _launchUrl('https://openstreetmap.org', context),
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
label: Text(locService.t('auth.visitOSM')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDialogButtons(BuildContext context) {
|
Widget _buildDialogButtons(BuildContext context) {
|
||||||
final locService = LocalizationService.instance;
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
|
|||||||
@@ -195,50 +195,9 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
|||||||
child: UploadModeSection(),
|
child: UploadModeSection(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Information Section
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
locService.t('auth.aboutOSM'),
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
locService.t('auth.aboutOSMDescription'),
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
final url = Uri.parse('https://openstreetmap.org');
|
|
||||||
if (await canLaunchUrl(url)) {
|
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
||||||
} else {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.open_in_new),
|
|
||||||
label: Text(locService.t('auth.visitOSM')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Account deletion section - only show when logged in and not in simulate mode
|
// Account deletion section - only show when logged in and not in simulate mode
|
||||||
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ class TileProviderSection extends StatelessWidget {
|
|||||||
case 'edit':
|
case 'edit':
|
||||||
_editProvider(context, provider);
|
_editProvider(context, provider);
|
||||||
break;
|
break;
|
||||||
|
case 'clear_cache':
|
||||||
|
_clearProviderCaches(context, provider);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
_deleteProvider(context, provider);
|
_deleteProvider(context, provider);
|
||||||
break;
|
break;
|
||||||
@@ -120,6 +123,16 @@ class TileProviderSection extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'clear_cache',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.clear_all),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(locService.t('tileProviders.clearCaches')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -154,6 +167,37 @@ class TileProviderSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clearProviderCaches(BuildContext context, TileProvider provider) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(locService.t('tileProviders.clearCaches')),
|
||||||
|
content: Text(locService.t('tileProviders.clearCachesConfirm', params: [provider.name])),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(locService.t('actions.cancel')),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await context.read<AppState>().clearTileProviderCaches(provider.id);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(locService.t('tileProviders.cachesCleared', params: [provider.name])),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(locService.t('tileProviders.clearCaches')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||||
final locService = LocalizationService.instance;
|
final locService = LocalizationService.instance;
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|||||||
@@ -8,97 +8,106 @@ import '../models/node_profile.dart';
|
|||||||
import '../models/osm_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import 'http_client.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.
|
/// Single responsibility: Make requests, handle network errors, return data.
|
||||||
class OverpassService {
|
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;
|
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.
|
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||||
Future<List<OsmNode>> fetchNodes({
|
Future<List<OsmNode>> fetchNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
int maxRetries = 3,
|
ResiliencePolicy? policy,
|
||||||
}) async {
|
}) async {
|
||||||
if (profiles.isEmpty) return [];
|
if (profiles.isEmpty) return [];
|
||||||
|
|
||||||
final query = _buildQuery(bounds, profiles);
|
final query = _buildQuery(bounds, profiles);
|
||||||
|
final endpoint = _primaryEndpoint;
|
||||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
final canFallback = _endpointOverride == null;
|
||||||
try {
|
final effectivePolicy = policy ?? _policy;
|
||||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
|
||||||
|
return executeWithFallback<List<OsmNode>>(
|
||||||
final response = await _client.post(
|
primaryUrl: endpoint,
|
||||||
Uri.parse(_endpoint),
|
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||||
body: {'data': query},
|
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||||
).timeout(kOverpassQueryTimeout);
|
classifyError: _classifyError,
|
||||||
|
policy: effectivePolicy,
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Build Overpass QL query for given bounds and profiles
|
||||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||||
final nodeClauses = profiles.map((profile) {
|
final nodeClauses = profiles.map((profile) {
|
||||||
@@ -107,7 +116,7 @@ class OverpassService {
|
|||||||
.where((entry) => entry.value.trim().isNotEmpty)
|
.where((entry) => entry.value.trim().isNotEmpty)
|
||||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||||
.join();
|
.join();
|
||||||
|
|
||||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
@@ -119,38 +128,38 @@ class OverpassService {
|
|||||||
out body;
|
out body;
|
||||||
(
|
(
|
||||||
way(bn);
|
way(bn);
|
||||||
rel(bn);
|
rel(bn);
|
||||||
);
|
);
|
||||||
out skel;
|
out skel;
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse Overpass JSON response into OsmNode objects
|
/// Parse Overpass JSON response into OsmNode objects
|
||||||
List<OsmNode> _parseResponse(String responseBody) {
|
List<OsmNode> _parseResponse(String responseBody) {
|
||||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||||
final elements = data['elements'] as List<dynamic>;
|
final elements = data['elements'] as List<dynamic>;
|
||||||
|
|
||||||
final nodeElements = <Map<String, dynamic>>[];
|
final nodeElements = <Map<String, dynamic>>[];
|
||||||
final constrainedNodeIds = <int>{};
|
final constrainedNodeIds = <int>{};
|
||||||
|
|
||||||
// First pass: collect surveillance nodes and identify constrained nodes
|
// First pass: collect surveillance nodes and identify constrained nodes
|
||||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||||
final type = element['type'] as String?;
|
final type = element['type'] as String?;
|
||||||
|
|
||||||
if (type == 'node') {
|
if (type == 'node') {
|
||||||
nodeElements.add(element);
|
nodeElements.add(element);
|
||||||
} else if (type == 'way' || type == 'relation') {
|
} else if (type == 'way' || type == 'relation') {
|
||||||
// Mark referenced nodes as constrained
|
// 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']) ?? [];
|
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||||
|
|
||||||
for (final ref in refs) {
|
for (final ref in refs) {
|
||||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: create OsmNode objects
|
// Second pass: create OsmNode objects
|
||||||
final nodes = nodeElements.map((element) {
|
final nodes = nodeElements.map((element) {
|
||||||
final nodeId = element['id'] as int;
|
final nodeId = element['id'] as int;
|
||||||
@@ -161,7 +170,7 @@ out skel;
|
|||||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
@@ -189,4 +198,4 @@ class NetworkError extends Error {
|
|||||||
NetworkError(this.message);
|
NetworkError(this.message);
|
||||||
@override
|
@override
|
||||||
String toString() => 'NetworkError: $message';
|
String toString() => 'NetworkError: $message';
|
||||||
}
|
}
|
||||||
@@ -61,10 +61,13 @@ class ProviderTileCacheManager {
|
|||||||
/// Delete a specific provider's cache directory and remove the store.
|
/// Delete a specific provider's cache directory and remove the store.
|
||||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||||
final key = '$providerId/$tileTypeId';
|
final key = '$providerId/$tileTypeId';
|
||||||
final store = _stores.remove(key);
|
final store = _stores[key];
|
||||||
if (store != null) {
|
if (store != null) {
|
||||||
|
// Use the store's clear method to properly reset its internal state
|
||||||
await store.clear();
|
await store.clear();
|
||||||
|
// Don't remove from registry - let it be reused with clean state
|
||||||
} else if (_baseCacheDir != null) {
|
} else if (_baseCacheDir != null) {
|
||||||
|
// Fallback for stores not in registry
|
||||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||||
if (await cacheDir.exists()) {
|
if (await cacheDir.exists()) {
|
||||||
await cacheDir.delete(recursive: true);
|
await cacheDir.delete(recursive: true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
/// [putTile] call to avoid blocking construction.
|
/// [putTile] call to avoid blocking construction.
|
||||||
int? _estimatedSize;
|
int? _estimatedSize;
|
||||||
|
|
||||||
|
/// Semaphore to limit concurrent file I/O operations and prevent
|
||||||
|
/// "too many open files" errors during heavy tile loading.
|
||||||
|
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
|
||||||
|
|
||||||
/// Throttle: don't re-scan more than once per minute.
|
/// Throttle: don't re-scan more than once per minute.
|
||||||
DateTime? _lastPruneCheck;
|
DateTime? _lastPruneCheck;
|
||||||
|
|
||||||
@@ -52,9 +57,16 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final bytes = await tileFile.readAsBytes();
|
// Use semaphore to limit concurrent file I/O operations
|
||||||
final metaJson = json.decode(await metaFile.readAsString())
|
final result = await _ioSemaphore.execute(() async {
|
||||||
as Map<String, dynamic>;
|
final bytes = await tileFile.readAsBytes();
|
||||||
|
final metaJson = json.decode(await metaFile.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
return (bytes: bytes, metaJson: metaJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
final bytes = result.bytes;
|
||||||
|
final metaJson = result.metaJson;
|
||||||
|
|
||||||
final metadata = CachedMapTileMetadata(
|
final metadata = CachedMapTileMetadata(
|
||||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
@@ -120,10 +132,13 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
|
|
||||||
// Write .tile before .meta: if we crash between the two writes, the
|
// 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.
|
// read path's both-must-exist check sees a miss rather than an orphan .meta.
|
||||||
if (bytes != null) {
|
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
|
||||||
await tileFile.writeAsBytes(bytes);
|
await _ioSemaphore.execute(() async {
|
||||||
}
|
if (bytes != null) {
|
||||||
await metaFile.writeAsString(metaJson);
|
await tileFile.writeAsBytes(bytes);
|
||||||
|
}
|
||||||
|
await metaFile.writeAsString(metaJson);
|
||||||
|
});
|
||||||
|
|
||||||
// Reset size estimate so it resyncs from disk on next check.
|
// Reset size estimate so it resyncs from disk on next check.
|
||||||
// This avoids drift from overwrites where the old size isn't subtracted.
|
// This avoids drift from overwrites where the old size isn't subtracted.
|
||||||
@@ -250,14 +265,19 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await entry.file.delete();
|
final deletedBytes = await _ioSemaphore.execute(() async {
|
||||||
freedBytes += entry.stat.size;
|
await entry.file.delete();
|
||||||
|
var bytes = entry.stat.size;
|
||||||
|
if (await metaFile.exists()) {
|
||||||
|
final metaStat = await metaFile.stat();
|
||||||
|
await metaFile.delete();
|
||||||
|
bytes += metaStat.size;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
freedBytes += deletedBytes;
|
||||||
evictedKeys.add(key);
|
evictedKeys.add(key);
|
||||||
if (await metaFile.exists()) {
|
|
||||||
final metaStat = await metaFile.stat();
|
|
||||||
await metaFile.delete();
|
|
||||||
freedBytes += metaStat.size;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
||||||
}
|
}
|
||||||
@@ -313,3 +333,44 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<void> forceEviction() => _evictIfNeeded();
|
Future<void> forceEviction() => _evictIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple semaphore to limit concurrent operations and prevent resource exhaustion.
|
||||||
|
class _Semaphore {
|
||||||
|
final int maxCount;
|
||||||
|
int _currentCount;
|
||||||
|
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
|
||||||
|
|
||||||
|
_Semaphore(this.maxCount) : _currentCount = maxCount;
|
||||||
|
|
||||||
|
/// Acquire a permit. Returns a Future that completes when a permit is available.
|
||||||
|
Future<void> acquire() {
|
||||||
|
if (_currentCount > 0) {
|
||||||
|
_currentCount--;
|
||||||
|
return Future.value();
|
||||||
|
} else {
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_waitQueue.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release a permit, potentially unblocking a waiting operation.
|
||||||
|
void release() {
|
||||||
|
if (_waitQueue.isNotEmpty) {
|
||||||
|
final completer = _waitQueue.removeFirst();
|
||||||
|
completer.complete();
|
||||||
|
} else {
|
||||||
|
_currentCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a function while holding a permit.
|
||||||
|
Future<T> execute<T>(Future<T> Function() operation) async {
|
||||||
|
await acquire();
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../dev_config.dart';
|
|
||||||
import 'http_client.dart';
|
import 'http_client.dart';
|
||||||
|
import 'service_policy.dart';
|
||||||
|
|
||||||
class RouteResult {
|
class RouteResult {
|
||||||
final List<LatLng> waypoints;
|
final List<LatLng> waypoints;
|
||||||
final double distanceMeters;
|
final double distanceMeters;
|
||||||
final double durationSeconds;
|
final double durationSeconds;
|
||||||
|
|
||||||
const RouteResult({
|
const RouteResult({
|
||||||
required this.waypoints,
|
required this.waypoints,
|
||||||
required this.distanceMeters,
|
required this.distanceMeters,
|
||||||
required this.durationSeconds,
|
required this.durationSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
|
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 {
|
class RoutingService {
|
||||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
|
||||||
final http.Client _client;
|
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();
|
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({
|
Future<RouteResult> calculateRoute({
|
||||||
required LatLng start,
|
required LatLng start,
|
||||||
required LatLng end,
|
required LatLng end,
|
||||||
@@ -53,8 +66,7 @@ class RoutingService {
|
|||||||
'tags': tags,
|
'tags': tags,
|
||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final uri = Uri.parse(_baseUrl);
|
|
||||||
final params = {
|
final params = {
|
||||||
'start': {
|
'start': {
|
||||||
'longitude': start.longitude,
|
'longitude': start.longitude,
|
||||||
@@ -66,11 +78,25 @@ class RoutingService {
|
|||||||
},
|
},
|
||||||
'avoidance_distance': avoidanceDistance,
|
'avoidance_distance': avoidanceDistance,
|
||||||
'enabled_profiles': enabledProfiles,
|
'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 {
|
try {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
uri,
|
uri,
|
||||||
@@ -78,7 +104,7 @@ class RoutingService {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: json.encode(params)
|
body: json.encode(params)
|
||||||
).timeout(kNavigationRoutingTimeout);
|
).timeout(_policy.httpTimeout);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
@@ -91,24 +117,25 @@ class RoutingService {
|
|||||||
: body;
|
: body;
|
||||||
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
|
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>;
|
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
debugPrint('[RoutingService] response data: $data');
|
||||||
|
|
||||||
// Check alprwatch response status
|
// Check response status
|
||||||
final ok = data['ok'] as bool? ?? false;
|
final ok = data['ok'] as bool? ?? false;
|
||||||
if ( ! ok ) {
|
if ( ! ok ) {
|
||||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
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>?;
|
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||||
if (route == null) {
|
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>?)
|
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||||
?.map((inner) {
|
?.map((inner) {
|
||||||
final pair = inner as List<dynamic>;
|
final pair = inner as List<dynamic>;
|
||||||
@@ -116,19 +143,19 @@ class RoutingService {
|
|||||||
final lng = (pair[0] as num).toDouble();
|
final lng = (pair[0] as num).toDouble();
|
||||||
final lat = (pair[1] as num).toDouble();
|
final lat = (pair[1] as num).toDouble();
|
||||||
return LatLng(lat, lng);
|
return LatLng(lat, lng);
|
||||||
}).whereType<LatLng>().toList() ?? [];
|
}).whereType<LatLng>().toList() ?? [];
|
||||||
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
||||||
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
final result = RouteResult(
|
final result = RouteResult(
|
||||||
waypoints: waypoints,
|
waypoints: waypoints,
|
||||||
distanceMeters: distance,
|
distanceMeters: distance,
|
||||||
durationSeconds: duration,
|
durationSeconds: duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint('[RoutingService] Route calculated: $result');
|
debugPrint('[RoutingService] Route calculated: $result');
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[RoutingService] Route calculation failed: $e');
|
debugPrint('[RoutingService] Route calculation failed: $e');
|
||||||
if (e is RoutingException) {
|
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 {
|
class RoutingException implements Exception {
|
||||||
final String message;
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
const RoutingException(this.message);
|
final bool isApiError;
|
||||||
|
|
||||||
|
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'RoutingException: $message';
|
String toString() => 'RoutingException: $message';
|
||||||
}
|
}
|
||||||
@@ -152,11 +152,10 @@ class ServicePolicy {
|
|||||||
'attributionUrl: $attributionUrl)';
|
'attributionUrl: $attributionUrl)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves URLs and tile providers to their applicable [ServicePolicy].
|
/// Resolves service URLs to their applicable [ServicePolicy].
|
||||||
///
|
///
|
||||||
/// Built-in patterns cover all OSMF official services and common third-party
|
/// Built-in patterns cover all OSMF official services and common third-party
|
||||||
/// tile providers. Custom overrides can be registered for self-hosted endpoints
|
/// tile providers. Falls back to permissive defaults for unrecognized hosts.
|
||||||
/// via [registerCustomPolicy].
|
|
||||||
class ServicePolicyResolver {
|
class ServicePolicyResolver {
|
||||||
/// Host → ServiceType mapping for known services.
|
/// Host → ServiceType mapping for known services.
|
||||||
static final Map<String, ServiceType> _hostPatterns = {
|
static final Map<String, ServiceType> _hostPatterns = {
|
||||||
@@ -166,6 +165,7 @@ class ServicePolicyResolver {
|
|||||||
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
||||||
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
||||||
'overpass-api.de': ServiceType.overpass,
|
'overpass-api.de': ServiceType.overpass,
|
||||||
|
'overpass.deflock.org': ServiceType.overpass,
|
||||||
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
||||||
'tiles.virtualearth.net': ServiceType.bingTiles,
|
'tiles.virtualearth.net': ServiceType.bingTiles,
|
||||||
'api.mapbox.com': ServiceType.mapboxTiles,
|
'api.mapbox.com': ServiceType.mapboxTiles,
|
||||||
@@ -183,25 +183,14 @@ class ServicePolicyResolver {
|
|||||||
ServiceType.custom: const ServicePolicy(),
|
ServiceType.custom: const ServicePolicy(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Custom host overrides registered at runtime (for self-hosted services).
|
|
||||||
static final Map<String, ServicePolicy> _customOverrides = {};
|
|
||||||
|
|
||||||
/// Resolve a URL to its applicable [ServicePolicy].
|
/// Resolve a URL to its applicable [ServicePolicy].
|
||||||
///
|
///
|
||||||
/// Checks custom overrides first, then built-in host patterns. Falls back
|
/// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
|
||||||
/// to [ServicePolicy.custom] for unrecognized hosts.
|
/// for unrecognized hosts.
|
||||||
static ServicePolicy resolve(String url) {
|
static ServicePolicy resolve(String url) {
|
||||||
final host = _extractHost(url);
|
final host = _extractHost(url);
|
||||||
if (host == null) return const ServicePolicy();
|
if (host == null) return const ServicePolicy();
|
||||||
|
|
||||||
// Check custom overrides first (exact or subdomain matching)
|
|
||||||
for (final entry in _customOverrides.entries) {
|
|
||||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check built-in patterns (support subdomain matching)
|
|
||||||
for (final entry in _hostPatterns.entries) {
|
for (final entry in _hostPatterns.entries) {
|
||||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||||
return _policies[entry.value] ?? const ServicePolicy();
|
return _policies[entry.value] ?? const ServicePolicy();
|
||||||
@@ -218,14 +207,6 @@ class ServicePolicyResolver {
|
|||||||
final host = _extractHost(url);
|
final host = _extractHost(url);
|
||||||
if (host == null) return ServiceType.custom;
|
if (host == null) return ServiceType.custom;
|
||||||
|
|
||||||
// Check custom overrides first — a registered custom policy means
|
|
||||||
// the host is treated as ServiceType.custom with custom rules.
|
|
||||||
for (final entry in _customOverrides.entries) {
|
|
||||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
|
||||||
return ServiceType.custom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final entry in _hostPatterns.entries) {
|
for (final entry in _hostPatterns.entries) {
|
||||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||||
return entry.value;
|
return entry.value;
|
||||||
@@ -239,29 +220,6 @@ class ServicePolicyResolver {
|
|||||||
static ServicePolicy resolveByType(ServiceType type) =>
|
static ServicePolicy resolveByType(ServiceType type) =>
|
||||||
_policies[type] ?? const ServicePolicy();
|
_policies[type] ?? const ServicePolicy();
|
||||||
|
|
||||||
/// Register a custom policy override for a host pattern.
|
|
||||||
///
|
|
||||||
/// Use this to configure self-hosted services:
|
|
||||||
/// ```dart
|
|
||||||
/// ServicePolicyResolver.registerCustomPolicy(
|
|
||||||
/// 'tiles.myserver.com',
|
|
||||||
/// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20),
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
static void registerCustomPolicy(String hostPattern, ServicePolicy policy) {
|
|
||||||
_customOverrides[hostPattern] = policy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a custom policy override.
|
|
||||||
static void removeCustomPolicy(String hostPattern) {
|
|
||||||
_customOverrides.remove(hostPattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all custom policy overrides (useful for testing).
|
|
||||||
static void clearCustomPolicies() {
|
|
||||||
_customOverrides.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the host from a URL or URL template.
|
/// Extract the host from a URL or URL template.
|
||||||
static String? _extractHost(String url) {
|
static String? _extractHost(String url) {
|
||||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||||
@@ -283,6 +241,95 @@ class ServicePolicyResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Reusable per-service rate limiter and concurrency controller.
|
||||||
///
|
///
|
||||||
/// Enforces the rate limits and concurrency constraints defined in each
|
/// Enforces the rate limits and concurrency constraints defined in each
|
||||||
|
|||||||
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
|
|||||||
_calculateRoute();
|
_calculateRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate route using alprwatch
|
/// Calculate route via RoutingService (primary + fallback endpoints).
|
||||||
void _calculateRoute() {
|
void _calculateRoute() {
|
||||||
if (_routeStart == null || _routeEnd == null) return;
|
if (_routeStart == null || _routeEnd == null) return;
|
||||||
|
|
||||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
debugPrint('[NavigationState] Calculating route...');
|
||||||
_isCalculating = true;
|
_isCalculating = true;
|
||||||
_routingError = null;
|
_routingError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
|
|||||||
_showingOverview = true;
|
_showingOverview = true;
|
||||||
_provisionalPinLocation = null; // Hide provisional pin
|
_provisionalPinLocation = null; // Hide provisional pin
|
||||||
|
|
||||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import '../models/tile_provider.dart';
|
import '../models/tile_provider.dart';
|
||||||
|
import '../services/provider_tile_cache_manager.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import '../keys.dart';
|
import '../keys.dart';
|
||||||
|
|
||||||
@@ -332,6 +333,17 @@ class SettingsState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all tile caches for a specific provider
|
||||||
|
Future<void> clearTileProviderCaches(String providerId) async {
|
||||||
|
final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||||
|
if (provider == null) return;
|
||||||
|
|
||||||
|
// Clear cache for each tile type in this provider
|
||||||
|
for (final tileType in provider.tileTypes) {
|
||||||
|
await ProviderTileCacheManager.deleteCache(providerId, tileType.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set follow-me mode
|
/// Set follow-me mode
|
||||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||||
if (_followMeMode != mode) {
|
if (_followMeMode != mode) {
|
||||||
|
|||||||
@@ -253,12 +253,12 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a node deletion to the upload queue
|
// Add a node deletion to the upload queue
|
||||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
|
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
||||||
final upload = PendingUpload(
|
final upload = PendingUpload(
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||||
profile: null, // No profile needed for deletions - just delete by node ID
|
profile: null, // No profile needed for deletions - just delete by node ID
|
||||||
changesetComment: 'Delete a surveillance node', // Default comment for deletions
|
changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
|
||||||
uploadMode: uploadMode,
|
uploadMode: uploadMode,
|
||||||
operation: UploadOperation.delete,
|
operation: UploadOperation.delete,
|
||||||
originalNodeId: node.id,
|
originalNodeId: node.id,
|
||||||
|
|||||||
@@ -65,30 +65,17 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode() async {
|
void deleteNode() async {
|
||||||
final shouldDelete = await showDialog<bool>(
|
final result = await showDialog<({bool confirmed, String comment})>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) => _DeleteNodeDialog(
|
||||||
return AlertDialog(
|
nodeId: node.id.toString(),
|
||||||
title: Text(locService.t('node.confirmDeleteTitle')),
|
locService: locService,
|
||||||
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: Text(locService.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
||||||
child: Text(locService.t('actions.delete')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldDelete == true && context.mounted) {
|
if ((result?.confirmed ?? false) && context.mounted) {
|
||||||
Navigator.pop(context); // Close this sheet first
|
Navigator.pop(context); // Close this sheet first
|
||||||
appState.deleteNode(node);
|
appState.deleteNode(node, changesetComment: result!.comment);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
|
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
|
||||||
);
|
);
|
||||||
@@ -260,4 +247,80 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeleteNodeDialog extends StatefulWidget {
|
||||||
|
final String nodeId;
|
||||||
|
final LocalizationService locService;
|
||||||
|
|
||||||
|
const _DeleteNodeDialog({
|
||||||
|
required this.nodeId,
|
||||||
|
required this.locService,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||||
|
late final TextEditingController _commentController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_commentController = TextEditingController();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(widget.locService.t('node.confirmDeleteTitle')),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
widget.locService.t('node.deleteReasonLabel'),
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.locService.t('node.deleteReasonHint'),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
|
||||||
|
child: Text(widget.locService.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final comment = _commentController.text.trim();
|
||||||
|
final finalComment = comment.isEmpty
|
||||||
|
? 'Delete a surveillance node'
|
||||||
|
: 'Delete a surveillance node: $comment';
|
||||||
|
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(widget.locService.t('actions.delete')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+8
-8
@@ -77,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -564,18 +564,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -929,10 +929,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: deflockapp
|
name: deflockapp
|
||||||
description: Map public surveillance infrastructure with OpenStreetMap
|
description: Map public surveillance infrastructure with OpenStreetMap
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
|
version: 2.9.2+53 # The thing after the + is the version code, incremented with each release
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart';
|
|||||||
|
|
||||||
import 'package:deflockapp/models/node_profile.dart';
|
import 'package:deflockapp/models/node_profile.dart';
|
||||||
import 'package:deflockapp/services/overpass_service.dart';
|
import 'package:deflockapp/services/overpass_service.dart';
|
||||||
|
import 'package:deflockapp/services/service_policy.dart';
|
||||||
|
|
||||||
class MockHttpClient extends Mock implements http.Client {}
|
class MockHttpClient extends Mock implements http.Client {}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockClient = MockHttpClient();
|
mockClient = MockHttpClient();
|
||||||
|
// Initialize OverpassService with a mock HTTP client for testing
|
||||||
service = OverpassService(client: mockClient);
|
service = OverpassService(client: mockClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,9 +248,9 @@ void main() {
|
|||||||
stubErrorResponse(
|
stubErrorResponse(
|
||||||
400, 'Error: too many nodes (limit is 50000) in query');
|
400, 'Error: too many nodes (limit is 50000) in query');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NodeLimitError>()),
|
throwsA(isA<NodeLimitError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -256,9 +258,9 @@ void main() {
|
|||||||
test('response with "timeout" throws NodeLimitError', () async {
|
test('response with "timeout" throws NodeLimitError', () async {
|
||||||
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NodeLimitError>()),
|
throwsA(isA<NodeLimitError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -267,9 +269,9 @@ void main() {
|
|||||||
() async {
|
() async {
|
||||||
stubErrorResponse(400, 'runtime limit exceeded');
|
stubErrorResponse(400, 'runtime limit exceeded');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NodeLimitError>()),
|
throwsA(isA<NodeLimitError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -277,9 +279,9 @@ void main() {
|
|||||||
test('HTTP 429 throws RateLimitError', () async {
|
test('HTTP 429 throws RateLimitError', () async {
|
||||||
stubErrorResponse(429, 'Too Many Requests');
|
stubErrorResponse(429, 'Too Many Requests');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<RateLimitError>()),
|
throwsA(isA<RateLimitError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -287,9 +289,9 @@ void main() {
|
|||||||
test('response with "rate limited" throws RateLimitError', () async {
|
test('response with "rate limited" throws RateLimitError', () async {
|
||||||
stubErrorResponse(503, 'You are rate limited');
|
stubErrorResponse(503, 'You are rate limited');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<RateLimitError>()),
|
throwsA(isA<RateLimitError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -298,9 +300,9 @@ void main() {
|
|||||||
() async {
|
() async {
|
||||||
stubErrorResponse(500, 'Internal Server Error');
|
stubErrorResponse(500, 'Internal Server Error');
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, maxRetries: 0),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NetworkError>()),
|
throwsA(isA<NetworkError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -313,4 +315,178 @@ void main() {
|
|||||||
verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,30 @@ void main() {
|
|||||||
AppState.instance = MockAppState();
|
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', () {
|
group('RoutingService', () {
|
||||||
test('empty tags are filtered from request body', () async {
|
test('empty tags are filtered from request body', () async {
|
||||||
// Profile with empty tag values (like builtin-flock has camera:mount: '')
|
// Profile with empty tag values (like builtin-flock has camera:mount: '')
|
||||||
@@ -57,29 +81,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
when(() => mockAppState.enabledProfiles).thenReturn(profiles);
|
when(() => mockAppState.enabledProfiles).thenReturn(profiles);
|
||||||
|
|
||||||
// Capture the request body
|
stubSuccessResponse();
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.calculateRoute(start: start, end: end);
|
await service.calculateRoute(start: start, end: end);
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ void main() {
|
|||||||
reasonPhrase: 'Bad Request',
|
reasonPhrase: 'Bad Request',
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.calculateRoute(start: start, end: end),
|
() => service.calculateRoute(start: start, end: end),
|
||||||
throwsA(isA<RoutingException>().having(
|
throwsA(isA<RoutingException>().having(
|
||||||
(e) => e.message,
|
(e) => e.message,
|
||||||
@@ -166,7 +168,7 @@ void main() {
|
|||||||
body: any(named: 'body'),
|
body: any(named: 'body'),
|
||||||
)).thenThrow(http.ClientException('Connection refused'));
|
)).thenThrow(http.ClientException('Connection refused'));
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.calculateRoute(start: start, end: end),
|
() => service.calculateRoute(start: start, end: end),
|
||||||
throwsA(isA<RoutingException>().having(
|
throwsA(isA<RoutingException>().having(
|
||||||
(e) => e.message,
|
(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(() => mockAppState.enabledProfiles).thenReturn([]);
|
||||||
|
|
||||||
when(() => mockClient.post(
|
when(() => mockClient.post(
|
||||||
@@ -191,7 +193,7 @@ void main() {
|
|||||||
200,
|
200,
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(
|
await expectLater(
|
||||||
() => service.calculateRoute(start: start, end: end),
|
() => service.calculateRoute(start: start, end: end),
|
||||||
throwsA(isA<RoutingException>().having(
|
throwsA(isA<RoutingException>().having(
|
||||||
(e) => e.message,
|
(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,6 @@ import 'package:deflockapp/services/service_policy.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ServicePolicyResolver', () {
|
group('ServicePolicyResolver', () {
|
||||||
setUp(() {
|
|
||||||
ServicePolicyResolver.clearCustomPolicies();
|
|
||||||
});
|
|
||||||
|
|
||||||
group('resolveType', () {
|
group('resolveType', () {
|
||||||
test('resolves OSM editing API from production URL', () {
|
test('resolves OSM editing API from production URL', () {
|
||||||
expect(
|
expect(
|
||||||
@@ -200,63 +196,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('custom policy overrides', () {
|
|
||||||
test('custom override takes precedence over built-in', () {
|
|
||||||
ServicePolicyResolver.registerCustomPolicy(
|
|
||||||
'overpass-api.de',
|
|
||||||
const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true),
|
|
||||||
);
|
|
||||||
|
|
||||||
final policy = ServicePolicyResolver.resolve(
|
|
||||||
'https://overpass-api.de/api/interpreter',
|
|
||||||
);
|
|
||||||
expect(policy.maxConcurrentRequests, 20);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('custom policy for self-hosted tiles allows offline', () {
|
|
||||||
ServicePolicyResolver.registerCustomPolicy(
|
|
||||||
'tiles.myserver.com',
|
|
||||||
const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16),
|
|
||||||
);
|
|
||||||
|
|
||||||
final policy = ServicePolicyResolver.resolve(
|
|
||||||
'https://tiles.myserver.com/{z}/{x}/{y}.png',
|
|
||||||
);
|
|
||||||
expect(policy.allowsOfflineDownload, true);
|
|
||||||
expect(policy.maxConcurrentRequests, 16);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('removing custom override restores built-in policy', () {
|
|
||||||
ServicePolicyResolver.registerCustomPolicy(
|
|
||||||
'overpass-api.de',
|
|
||||||
const ServicePolicy.custom(maxConcurrent: 20),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
|
|
||||||
ServicePolicyResolver.removeCustomPolicy('overpass-api.de');
|
|
||||||
// Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere)
|
|
||||||
expect(
|
|
||||||
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clearCustomPolicies removes all overrides', () {
|
|
||||||
ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1));
|
|
||||||
ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2));
|
|
||||||
|
|
||||||
ServicePolicyResolver.clearCustomPolicies();
|
|
||||||
|
|
||||||
// Both should now return custom (default) policy
|
|
||||||
expect(
|
|
||||||
ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests,
|
|
||||||
8, // default custom maxConcurrent
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('ServiceRateLimiter', () {
|
group('ServiceRateLimiter', () {
|
||||||
@@ -423,4 +362,197 @@ void main() {
|
|||||||
expect(policy.attributionUrl, 'https://example.com/license');
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
final result = dataManager.getNodesForRendering(
|
final result = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0,
|
currentZoom: 14,
|
||||||
mapBounds: bounds,
|
mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 3,
|
maxNodes: 3,
|
||||||
@@ -58,7 +58,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
final result = dataManager.getNodesForRendering(
|
final result = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0,
|
currentZoom: 14,
|
||||||
mapBounds: bounds,
|
mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 10,
|
maxNodes: 10,
|
||||||
@@ -73,7 +73,7 @@ void main() {
|
|||||||
testNodes = [nodeAt(1, 38.5, -77.5)];
|
testNodes = [nodeAt(1, 38.5, -77.5)];
|
||||||
|
|
||||||
final result = dataManager.getNodesForRendering(
|
final result = dataManager.getNodesForRendering(
|
||||||
currentZoom: 5.0,
|
currentZoom: 5,
|
||||||
mapBounds: bounds,
|
mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 10,
|
maxNodes: 10,
|
||||||
@@ -93,7 +93,7 @@ void main() {
|
|||||||
testNodes = List.from(nodes);
|
testNodes = List.from(nodes);
|
||||||
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
|
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
|
||||||
final swResult = dataManager.getNodesForRendering(
|
final swResult = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0,
|
currentZoom: 14,
|
||||||
mapBounds: swBounds,
|
mapBounds: swBounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 1,
|
maxNodes: 1,
|
||||||
@@ -105,7 +105,7 @@ void main() {
|
|||||||
testNodes = List.from(nodes);
|
testNodes = List.from(nodes);
|
||||||
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
|
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
|
||||||
final neResult = dataManager.getNodesForRendering(
|
final neResult = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0,
|
currentZoom: 14,
|
||||||
mapBounds: neBounds,
|
mapBounds: neBounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 1,
|
maxNodes: 1,
|
||||||
@@ -126,13 +126,13 @@ void main() {
|
|||||||
|
|
||||||
testNodes = makeNodes();
|
testNodes = makeNodes();
|
||||||
final result1 = dataManager.getNodesForRendering(
|
final result1 = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0, mapBounds: bounds,
|
currentZoom: 14, mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production, maxNodes: 3,
|
uploadMode: UploadMode.production, maxNodes: 3,
|
||||||
);
|
);
|
||||||
|
|
||||||
testNodes = makeNodes();
|
testNodes = makeNodes();
|
||||||
final result2 = dataManager.getNodesForRendering(
|
final result2 = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0, mapBounds: bounds,
|
currentZoom: 14, mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production, maxNodes: 3,
|
uploadMode: UploadMode.production, maxNodes: 3,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ void main() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
final result = dataManager.getNodesForRendering(
|
final result = dataManager.getNodesForRendering(
|
||||||
currentZoom: 14.0,
|
currentZoom: 14,
|
||||||
mapBounds: bounds,
|
mapBounds: bounds,
|
||||||
uploadMode: UploadMode.production,
|
uploadMode: UploadMode.production,
|
||||||
maxNodes: 10,
|
maxNodes: 10,
|
||||||
|
|||||||
Reference in new issue
Block a user