mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 07:18:22 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc5399c9f | ||
|
|
eb6a409b92 | ||
|
|
1909a3b5f1 | ||
|
|
7c5d066362 | ||
|
|
b231b48ac1 | ||
|
|
0e1e9cb2ff | ||
|
|
a808a4f37e | ||
|
|
0887d1ef44 | ||
|
|
0c6cf467fe | ||
|
|
c787589205 | ||
|
|
837f59bfde | ||
|
|
8148ad55c2 | ||
|
|
dfcc64e96e | ||
|
|
6f017fd6c1 | ||
|
|
78f1ef58de | ||
|
|
3e2c9c5b9e | ||
|
|
dd93ac64b6 | ||
|
|
096fb5868b | ||
|
|
974204bd91 | ||
|
|
6a0d4cd8f7 | ||
|
|
af2a08bf3d | ||
|
|
dd8c9b88cc | ||
|
|
1cfca663a6 | ||
|
|
b9110c1670 | ||
|
|
36675508e8 | ||
|
|
9a0928d22d | ||
|
|
b3001927c6 |
No files matched your search
@@ -30,6 +30,19 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<!-- Disable Flutter's implicit deep-link routing so that
|
||||
app_links/DeepLinkService is the sole handler of incoming
|
||||
deflockapp:// URIs. Without this, Flutter's engine also
|
||||
tries to auto-convert the URI into a Navigator route
|
||||
(e.g. "/add?p=...") which conflicts with the navigation
|
||||
DeepLinkService performs, causing screens pushed by
|
||||
DeepLinkService to be immediately buried by a duplicate
|
||||
HomeScreen route. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
|
||||
|
||||
<!-- Launcher intent -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
{
|
||||
"2.11.0": {
|
||||
"content": [
|
||||
"• Further improve node loading performance",
|
||||
"• Fix node constrainment when part of a way/relation",
|
||||
"• Show submission status in details sheet",
|
||||
"• Fix profile import links not opening"
|
||||
]
|
||||
},
|
||||
"2.10.5": {
|
||||
"content": [
|
||||
"• Make node tags selectable / copyable",
|
||||
"• Add status indicator to details sheet for pending nodes",
|
||||
"• Global toggle for offline features",
|
||||
"• Auto-open details sheet for new add/edit/deletes"
|
||||
]
|
||||
},
|
||||
"2.10.4": {
|
||||
"content": [
|
||||
"• Catch and correct a couple rare map bounds issues",
|
||||
"• Prevent queueing a deletion without being logged in",
|
||||
"• Require a reason for deletion, provide options in a dropdown",
|
||||
"• Add option to hide zoom controls in advanced settings"
|
||||
]
|
||||
},
|
||||
"2.10.3": {
|
||||
"content": [
|
||||
"• Data loading performance improvements",
|
||||
"• Node marker when submitting now green for visibility"
|
||||
]
|
||||
},
|
||||
"2.10.2": {
|
||||
"content": [
|
||||
"• Added tap and hold gesture on empty map area to add a node at that location",
|
||||
|
||||
@@ -2,8 +2,19 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Disable Flutter's implicit deep-link routing so that
|
||||
app_links/DeepLinkService is the sole handler of incoming
|
||||
deflockapp:// URIs. Without this, Flutter's engine also tries
|
||||
to auto-convert the URI into a Navigator route (e.g.
|
||||
"/add?p=...") which conflicts with the navigation
|
||||
DeepLinkService performs, causing screens pushed by
|
||||
DeepLinkService to be immediately buried by a duplicate
|
||||
HomeScreen route. -->
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
+60
-2
@@ -64,6 +64,12 @@ class AppState extends ChangeNotifier {
|
||||
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
|
||||
Timer? _messageCheckTimer;
|
||||
|
||||
// Node ID to auto-focus (open the details sheet for) after a submit/edit/delete
|
||||
// completes, when kAutoOpenNodeSheetAfterSubmit is enabled. Consumed (read once
|
||||
// and cleared) by HomeScreen after picking it up.
|
||||
int? _pendingFocusNodeId;
|
||||
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
@@ -157,6 +163,10 @@ class AppState extends ChangeNotifier {
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
bool get keepScreenAwake => _settingsState.keepScreenAwake;
|
||||
bool get hideZoomControls => _settingsState.hideZoomControls;
|
||||
bool get offlineFeaturesEnabled => _settingsState.offlineFeaturesEnabled;
|
||||
|
||||
|
||||
|
||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||
@@ -178,6 +188,13 @@ class AppState extends ChangeNotifier {
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
/// True if any queue item currently has an open changeset that hasn't
|
||||
/// been closed yet (creating changeset, uploading node, or closing
|
||||
/// changeset). Used to gate enabling offline mode / pausing the upload
|
||||
/// queue so in-flight submissions can finish first.
|
||||
bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing);
|
||||
|
||||
|
||||
// Suspected location state
|
||||
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
|
||||
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
|
||||
@@ -580,24 +597,44 @@ class AppState extends ChangeNotifier {
|
||||
void commitSession() {
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
final upload = _uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||
_pendingFocusNodeId = upload.tempNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void commitEditSession() {
|
||||
final session = _sessionState.commitEditSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
final upload = _uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||
_pendingFocusNodeId = upload.tempNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode(OsmNode node, {String? changesetComment}) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||
_pendingFocusNodeId = node.id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume (read once and clear) the node ID pending auto-focus, if any.
|
||||
/// Used by HomeScreen to reopen the node details sheet right after a
|
||||
/// submit/edit/delete completes, when [kAutoOpenNodeSheetAfterSubmit] is
|
||||
/// enabled. Returns null if no focus is pending.
|
||||
int? consumePendingFocusNodeId() {
|
||||
final id = _pendingFocusNodeId;
|
||||
_pendingFocusNodeId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Search Methods ----------
|
||||
Future<void> search(String query) async {
|
||||
await _searchState.search(query);
|
||||
@@ -685,6 +722,27 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setKeepScreenAwake(enabled);
|
||||
}
|
||||
|
||||
Future<void> setHideZoomControls(bool enabled) async {
|
||||
await _settingsState.setHideZoomControls(enabled);
|
||||
}
|
||||
|
||||
/// Set the master "offline features enabled" toggle. When disabling,
|
||||
/// also forces offlineMode off (if it was on) and cancels any active
|
||||
/// area downloads, so we never end up "in offline mode" with the
|
||||
/// feature disabled. Does NOT delete existing offline area data — that
|
||||
/// is a separate, explicit user choice handled by the UI.
|
||||
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
|
||||
await _settingsState.setOfflineFeaturesEnabled(enabled);
|
||||
if (!enabled) {
|
||||
if (offlineMode) {
|
||||
await setOfflineMode(false);
|
||||
}
|
||||
await OfflineAreaService().cancelActiveDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<void> setPauseQueueProcessing(bool enabled) async {
|
||||
await _settingsState.setPauseQueueProcessing(enabled);
|
||||
if (!enabled) {
|
||||
|
||||
@@ -64,6 +64,13 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
|
||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||
|
||||
// How long to keep the "Submission Complete" confirmation visible in the
|
||||
// in-flight upload wait dialog (shown when the user enables offline mode or
|
||||
// pauses the upload queue while a submission is actively in progress) before
|
||||
// automatically dismissing and applying the requested setting change.
|
||||
const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2);
|
||||
|
||||
|
||||
// Overpass API configuration
|
||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||
|
||||
@@ -82,6 +89,9 @@ const bool kEnableNodeEdits = true; // Set to false to temporarily disable node
|
||||
// Node extraction features - set to false to hide extract functionality for constrained nodes
|
||||
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
|
||||
|
||||
// Auto-open tags sheet after add/edit/delete, so the user can watch upload status update live.
|
||||
const bool kAutoOpenNodeSheetAfterSubmit = false; // Set to true to enable auto-focus behavior
|
||||
|
||||
// Profile FOV features - set to false to restrict profiles to 360° FOV only
|
||||
const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles
|
||||
|
||||
|
||||
@@ -75,12 +75,22 @@
|
||||
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
||||
"proximityAlerts": "Näherungswarnungen",
|
||||
"keepScreenAwake": "Bildschirm Aktiv Lassen",
|
||||
"networkStatusIndicator": "Netzwerkstatus-Anzeige"
|
||||
"networkStatusIndicator": "Netzwerkstatus-Anzeige",
|
||||
"hideZoomControls": "Zoom-Steuerung Ausblenden",
|
||||
"enableOfflineFeatures": "Offline-Funktionen Aktivieren",
|
||||
"enableOfflineFeaturesTitle": "Offline-Funktionen aktivieren",
|
||||
"enableOfflineFeaturesSubtitle": "Herunterladen und Nutzen von Offline-Kartenbereichen erlauben. Wenn deaktiviert, sind Offline-Modus und heruntergeladene Bereiche nicht verfügbar."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Bildschirm während der App-Nutzung aktiv lassen",
|
||||
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Zoom-Steuerung ausblenden",
|
||||
"subtitle": "Blendet die +/- Zoom-Schaltflächen und die Zoomstufen-Anzeige aus. Sie können weiterhin mit Fingergesten zoomen."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
|
||||
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
|
||||
@@ -101,9 +111,27 @@
|
||||
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
||||
"confirmDeleteTitle": "Knoten löschen",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Grund für Löschung",
|
||||
"deleteReasonSelectHint": "Auswählen...",
|
||||
"deleteReasonDeviceRemoved": "Gerät entfernt",
|
||||
"deleteReasonDuplicateNode": "Doppelter Knoten",
|
||||
"deleteReasonMisidentifiedInfra": "Falsch identifizierte Infrastruktur",
|
||||
"deleteReasonSpamVandalism": "Spam/Vandalismus",
|
||||
"deleteReasonCustom": "Benutzerdefiniert..",
|
||||
"deleteReasonHint": "Bitte Grund beschreiben...",
|
||||
"mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.",
|
||||
"uploadPending": "Ausstehender Upload",
|
||||
"uploadCreatingChangeset": "Changeset wird erstellt...",
|
||||
"uploadUploading": "Wird hochgeladen...",
|
||||
"uploadClosingChangeset": "Changeset wird geschlossen...",
|
||||
"uploadError": "Upload fehlgeschlagen",
|
||||
"uploadComplete": "Upload abgeschlossen",
|
||||
"viewInQueue": "In Warteschlange anzeigen",
|
||||
"deletedSuccessfully": "Dieser Knoten wurde erfolgreich von OpenStreetMap gelöscht."
|
||||
},
|
||||
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Profil auswählen...",
|
||||
@@ -242,8 +270,13 @@
|
||||
"closingChangeset": " (Changeset schließen...)",
|
||||
"processingPaused": "Warteschlangenverarbeitung pausiert",
|
||||
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert.",
|
||||
"inFlightWaitTitle": "Übermittlung wird abgeschlossen",
|
||||
"inFlightWaitMessage": "Eine Übermittlung läuft gerade und muss abgeschlossen werden, bevor diese Einstellung angewendet werden kann. Dies sollte nur einen Moment dauern.",
|
||||
"inFlightCompleteTitle": "Übermittlung Abgeschlossen",
|
||||
"inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
|
||||
@@ -389,8 +422,13 @@
|
||||
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
||||
"startRefresh": "Aktualisierung starten",
|
||||
"refreshStarted": "Aktualisierung gestartet!",
|
||||
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
|
||||
"refreshFailed": "Aktualisierung fehlgeschlagen: {}",
|
||||
"disableConfirmTitle": "Vorhandene Offline-Bereichsdaten löschen?",
|
||||
"disableConfirmMessage": "Sie haben bereits Offline-Bereichsdaten. Das Deaktivieren der Offline-Funktionen verhindert, dass die App diese verwendet. Möchten Sie diese Daten für den Fall behalten, dass Sie die Offline-Funktionen später wieder aktivieren, oder sie jetzt löschen, um Speicherplatz freizugeben?",
|
||||
"keepData": "Daten Behalten",
|
||||
"deleteData": "Daten Löschen"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Tags Verfeinern",
|
||||
"operatorProfile": "Betreiber-Profil",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
|
||||
"proximityAlerts": "Proximity Alerts",
|
||||
"networkStatusIndicator": "Network Status Indicator",
|
||||
"keepScreenAwake": "Keep Screen Awake"
|
||||
"keepScreenAwake": "Keep Screen Awake",
|
||||
"hideZoomControls": "Hide Zoom Controls",
|
||||
"enableOfflineFeatures": "Enable Offline Features",
|
||||
"enableOfflineFeaturesTitle": "Enable offline features",
|
||||
"enableOfflineFeaturesSubtitle": "Allow downloading and using offline map areas. When disabled, offline mode and downloaded areas are unavailable."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Keep screen awake while using app",
|
||||
"subtitle": "Prevents the screen from turning off automatically while using the app"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Hide zoom controls",
|
||||
"subtitle": "Hide the +/- zoom buttons and zoom level indicator. You can still zoom with pinch gestures."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Get notified when approaching surveillance devices",
|
||||
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
||||
@@ -138,9 +148,26 @@
|
||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||
"confirmDeleteTitle": "Delete Node",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Reason for Deletion",
|
||||
"deleteReasonSelectHint": "Select...",
|
||||
"deleteReasonDeviceRemoved": "Device removed",
|
||||
"deleteReasonDuplicateNode": "Duplicate node",
|
||||
"deleteReasonMisidentifiedInfra": "Misidentified infra",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalism",
|
||||
"deleteReasonCustom": "Custom..",
|
||||
"deleteReasonHint": "Please describe the reason...",
|
||||
"mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.",
|
||||
"uploadPending": "Pending upload",
|
||||
"uploadCreatingChangeset": "Creating changeset...",
|
||||
"uploadUploading": "Uploading...",
|
||||
"uploadClosingChangeset": "Closing changeset...",
|
||||
"uploadError": "Upload failed",
|
||||
"uploadComplete": "Upload complete",
|
||||
"viewInQueue": "View in Queue",
|
||||
"deletedSuccessfully": "This node was successfully deleted from OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
"selectProfile": "Select a profile...",
|
||||
@@ -279,8 +306,13 @@
|
||||
"closingChangeset": " (Closing changeset...)",
|
||||
"processingPaused": "Queue Processing Paused",
|
||||
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
|
||||
"pausedByUser": "Upload processing is manually paused."
|
||||
"pausedByUser": "Upload processing is manually paused.",
|
||||
"inFlightWaitTitle": "Finishing Submission",
|
||||
"inFlightWaitMessage": "A submission is currently in progress and needs to finish before this setting can be applied. This should only take a moment.",
|
||||
"inFlightCompleteTitle": "Submission Complete",
|
||||
"inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "No tile providers configured",
|
||||
@@ -426,8 +458,13 @@
|
||||
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
||||
"startRefresh": "Start Refresh",
|
||||
"refreshStarted": "Refresh started!",
|
||||
"refreshFailed": "Refresh failed: {}"
|
||||
"refreshFailed": "Refresh failed: {}",
|
||||
"disableConfirmTitle": "Delete Existing Offline Area Data?",
|
||||
"disableConfirmMessage": "You have existing offline area data. Disabling offline features will stop the app from using it. Would you like to keep this data on disk in case you re-enable offline features later, or delete it now to free up storage space?",
|
||||
"keepData": "Keep Data",
|
||||
"deleteData": "Delete Data"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Refine Tags",
|
||||
"operatorProfile": "Operator Profile",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
|
||||
"proximityAlerts": "Alertas de Proximidad",
|
||||
"keepScreenAwake": "Mantener la Pantalla Encendida",
|
||||
"networkStatusIndicator": "Indicador de Estado de Red"
|
||||
"networkStatusIndicator": "Indicador de Estado de Red",
|
||||
"hideZoomControls": "Ocultar Controles de Zoom",
|
||||
"enableOfflineFeatures": "Activar Funciones sin Conexión",
|
||||
"enableOfflineFeaturesTitle": "Activar funciones sin conexión",
|
||||
"enableOfflineFeaturesSubtitle": "Permitir descargar y usar áreas de mapa sin conexión. Si se desactiva, el modo sin conexión y las áreas descargadas no estarán disponibles."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Mantener la pantalla encendida mientras se usa la app",
|
||||
"subtitle": "Evita que la pantalla se apague automáticamente mientras se usa la app"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Ocultar controles de zoom",
|
||||
"subtitle": "Oculta los botones de zoom +/- y el indicador de nivel de zoom. Aún puedes hacer zoom con gestos de pellizco."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
|
||||
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
||||
"confirmDeleteTitle": "Eliminar Nodo",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Razón para Eliminación",
|
||||
"deleteReasonSelectHint": "Seleccionar...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||
"deleteReasonDuplicateNode": "Nodo duplicado",
|
||||
"deleteReasonMisidentifiedInfra": "Infraestructura mal identificada",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizado..",
|
||||
"deleteReasonHint": "Por favor describa la razón...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.",
|
||||
"uploadPending": "Subida pendiente",
|
||||
"uploadCreatingChangeset": "Creando changeset...",
|
||||
"uploadUploading": "Subiendo...",
|
||||
"uploadClosingChangeset": "Cerrando changeset...",
|
||||
"uploadError": "Error al subir",
|
||||
"uploadComplete": "Subida completada",
|
||||
"viewInQueue": "Ver en la Cola",
|
||||
"deletedSuccessfully": "Este nodo fue eliminado con éxito de OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Seleccionar un perfil...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Cerrando changeset...)",
|
||||
"processingPaused": "Procesamiento de Cola Pausado",
|
||||
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente."
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente.",
|
||||
"inFlightWaitTitle": "Finalizando Envío",
|
||||
"inFlightWaitMessage": "Actualmente hay un envío en curso que debe finalizar antes de que se pueda aplicar esta configuración. Esto solo debería tomar un momento.",
|
||||
"inFlightCompleteTitle": "Envío Completado",
|
||||
"inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
"noProvidersConfigured": "No hay proveedores de tiles configurados",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
||||
"startRefresh": "Iniciar Actualización",
|
||||
"refreshStarted": "¡Actualización iniciada!",
|
||||
"refreshFailed": "Actualización falló: {}"
|
||||
"refreshFailed": "Actualización falló: {}",
|
||||
"disableConfirmTitle": "¿Eliminar Datos de Áreas sin Conexión Existentes?",
|
||||
"disableConfirmMessage": "Ya tiene datos de áreas sin conexión existentes. Desactivar las funciones sin conexión evitará que la app los use. ¿Desea conservar estos datos en el dispositivo por si reactiva las funciones sin conexión más tarde, o eliminarlos ahora para liberar espacio de almacenamiento?",
|
||||
"keepData": "Conservar Datos",
|
||||
"deleteData": "Eliminar Datos"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Etiquetas",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
|
||||
"proximityAlerts": "Alertes de Proximité",
|
||||
"keepScreenAwake": "Maintenir l'écran Allumé",
|
||||
"networkStatusIndicator": "Indicateur de Statut Réseau"
|
||||
"networkStatusIndicator": "Indicateur de Statut Réseau",
|
||||
"hideZoomControls": "Masquer les Contrôles de Zoom",
|
||||
"enableOfflineFeatures": "Activer les Fonctionnalités Hors Ligne",
|
||||
"enableOfflineFeaturesTitle": "Activer les fonctionnalités hors ligne",
|
||||
"enableOfflineFeaturesSubtitle": "Autoriser le téléchargement et l'utilisation de zones de carte hors ligne. Si désactivé, le mode hors ligne et les zones téléchargées ne seront pas disponibles."
|
||||
},
|
||||
"keepScreenAwake": {
|
||||
|
||||
"title": "Maintenir l'écran allumé lors de l'utilisation de l'application",
|
||||
"subtitle": "Empêche l'écran de s'éteindre automatiquement pendant l'utilisation de l'application"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Masquer les contrôles de zoom",
|
||||
"subtitle": "Masque les boutons de zoom +/- et l'indicateur de niveau de zoom. Vous pouvez toujours zoomer avec des gestes de pincement."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
|
||||
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
||||
"confirmDeleteTitle": "Supprimer le Nœud",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Raison de Suppression",
|
||||
"deleteReasonSelectHint": "Sélectionner...",
|
||||
"deleteReasonDeviceRemoved": "Appareil retiré",
|
||||
"deleteReasonDuplicateNode": "Nœud dupliqué",
|
||||
"deleteReasonMisidentifiedInfra": "Infrastructure mal identifiée",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||
"deleteReasonCustom": "Personnalisé..",
|
||||
"deleteReasonHint": "Veuillez décrire la raison...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.",
|
||||
"uploadPending": "Téléchargement en attente",
|
||||
"uploadCreatingChangeset": "Création du changeset...",
|
||||
"uploadUploading": "Téléchargement en cours...",
|
||||
"uploadClosingChangeset": "Fermeture du changeset...",
|
||||
"uploadError": "Échec du téléchargement",
|
||||
"uploadComplete": "Téléchargement terminé",
|
||||
"viewInQueue": "Voir dans la File d'Attente",
|
||||
"deletedSuccessfully": "Ce nœud a été supprimé avec succès d'OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Sélectionner un profil...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Fermeture du changeset...)",
|
||||
"processingPaused": "Traitement de la File d'Attente Interrompu",
|
||||
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement.",
|
||||
"inFlightWaitTitle": "Finalisation de la Soumission",
|
||||
"inFlightWaitMessage": "Une soumission est actuellement en cours et doit se terminer avant que ce paramètre puisse être appliqué. Cela ne devrait prendre qu'un instant.",
|
||||
"inFlightCompleteTitle": "Soumission Terminée",
|
||||
"inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
||||
"startRefresh": "Démarrer l'Actualisation",
|
||||
"refreshStarted": "Actualisation démarrée !",
|
||||
"refreshFailed": "Actualisation échouée : {}"
|
||||
"refreshFailed": "Actualisation échouée : {}",
|
||||
"disableConfirmTitle": "Supprimer les Données de Zones Hors Ligne Existantes ?",
|
||||
"disableConfirmMessage": "Vous avez déjà des données de zones hors ligne. Désactiver les fonctionnalités hors ligne empêchera l'application de les utiliser. Souhaitez-vous conserver ces données sur l'appareil au cas où vous réactiveriez les fonctionnalités hors ligne plus tard, ou les supprimer maintenant pour libérer de l'espace de stockage ?",
|
||||
"keepData": "Conserver les Données",
|
||||
"deleteData": "Supprimer les Données"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Affiner les Étiquettes",
|
||||
"operatorProfile": "Profil d'Opérateur",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
|
||||
"proximityAlerts": "Avvisi di Prossimità",
|
||||
"keepScreenAwake": "Mantieni lo Schermo Attivo",
|
||||
"networkStatusIndicator": "Indicatore di Stato di Rete"
|
||||
"networkStatusIndicator": "Indicatore di Stato di Rete",
|
||||
"hideZoomControls": "Nascondi Controlli Zoom",
|
||||
"enableOfflineFeatures": "Abilita Funzionalità Offline",
|
||||
"enableOfflineFeaturesTitle": "Abilita funzionalità offline",
|
||||
"enableOfflineFeaturesSubtitle": "Consenti il download e l'uso di aree mappa offline. Se disabilitato, la modalità offline e le aree scaricate non saranno disponibili."
|
||||
},
|
||||
"keepScreenAwake": {
|
||||
|
||||
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
|
||||
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Nascondi i controlli dello zoom",
|
||||
"subtitle": "Nasconde i pulsanti di zoom +/- e l'indicatore del livello di zoom. Puoi ancora usare i gesti di pizzico per lo zoom."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
|
||||
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||
"confirmDeleteTitle": "Elimina Nodo",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Motivo per Eliminazione",
|
||||
"deleteReasonSelectHint": "Seleziona...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo rimosso",
|
||||
"deleteReasonDuplicateNode": "Nodo duplicato",
|
||||
"deleteReasonMisidentifiedInfra": "Infrastruttura identificata erroneamente",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizzato..",
|
||||
"deleteReasonHint": "Descrivi il motivo...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.",
|
||||
"uploadPending": "Caricamento in sospeso",
|
||||
"uploadCreatingChangeset": "Creazione changeset...",
|
||||
"uploadUploading": "Caricamento...",
|
||||
"uploadClosingChangeset": "Chiusura changeset...",
|
||||
"uploadError": "Caricamento fallito",
|
||||
"uploadComplete": "Caricamento completato",
|
||||
"viewInQueue": "Visualizza in Coda",
|
||||
"deletedSuccessfully": "Questo nodo è stato eliminato con successo da OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profilo",
|
||||
"selectProfile": "Seleziona un profilo...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Chiusura changeset...)",
|
||||
"processingPaused": "Elaborazione Coda Sospesa",
|
||||
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.",
|
||||
"inFlightWaitTitle": "Completamento Invio",
|
||||
"inFlightWaitMessage": "Un invio è attualmente in corso e deve terminare prima che questa impostazione possa essere applicata. Questo dovrebbe richiedere solo un momento.",
|
||||
"inFlightCompleteTitle": "Invio Completato",
|
||||
"inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
"noProvidersConfigured": "Nessun fornitore di tile configurato",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
||||
"startRefresh": "Avvia Aggiornamento",
|
||||
"refreshStarted": "Aggiornamento avviato!",
|
||||
"refreshFailed": "Aggiornamento fallito: {}"
|
||||
"refreshFailed": "Aggiornamento fallito: {}",
|
||||
"disableConfirmTitle": "Eliminare i Dati delle Aree Offline Esistenti?",
|
||||
"disableConfirmMessage": "Hai già dati di aree offline esistenti. Disabilitare le funzionalità offline impedirà all'app di usarli. Vuoi conservare questi dati sul dispositivo nel caso in cui riattivi le funzionalità offline in seguito, oppure eliminarli ora per liberare spazio di archiviazione?",
|
||||
"keepData": "Conserva Dati",
|
||||
"deleteData": "Elimina Dati"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Affina Tag",
|
||||
"operatorProfile": "Profilo Operatore",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
|
||||
"proximityAlerts": "Nabijheids Waarschuwingen",
|
||||
"keepScreenAwake": "Scherm Actief Houden",
|
||||
"networkStatusIndicator": "Netwerk Status Indicator"
|
||||
"networkStatusIndicator": "Netwerk Status Indicator",
|
||||
"hideZoomControls": "Zoomknoppen Verbergen",
|
||||
"enableOfflineFeatures": "Offline Functies Inschakelen",
|
||||
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
|
||||
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Houd het scherm actief tijdens het gebruik van de app",
|
||||
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Zoomknoppen verbergen",
|
||||
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
|
||||
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
|
||||
"confirmDeleteTitle": "Verwijder Node",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Reden voor Verwijdering",
|
||||
"deleteReasonSelectHint": "Selecteer...",
|
||||
"deleteReasonDeviceRemoved": "Apparaat verwijderd",
|
||||
"deleteReasonDuplicateNode": "Dubbele node",
|
||||
"deleteReasonMisidentifiedInfra": "Verkeerd geïdentificeerde infrastructuur",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||
"deleteReasonCustom": "Aangepast..",
|
||||
"deleteReasonHint": "Beschrijf de reden...",
|
||||
|
||||
"mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.",
|
||||
"uploadPending": "Upload in behandeling",
|
||||
"uploadCreatingChangeset": "Changeset maken...",
|
||||
"uploadUploading": "Uploaden...",
|
||||
"uploadClosingChangeset": "Changeset sluiten...",
|
||||
"uploadError": "Upload mislukt",
|
||||
"uploadComplete": "Upload voltooid",
|
||||
"viewInQueue": "Bekijk in Wachtrij",
|
||||
"deletedSuccessfully": "Deze node is succesvol verwijderd van OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profiel",
|
||||
"selectProfile": "Selecteer een profiel...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Changeset sluiten...)",
|
||||
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
|
||||
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
|
||||
"pausedByUser": "Upload verwerking is handmatig gepauzeerd."
|
||||
"pausedByUser": "Upload verwerking is handmatig gepauzeerd.",
|
||||
"inFlightWaitTitle": "Indiening Wordt Afgerond",
|
||||
"inFlightWaitMessage": "Er is momenteel een indiening bezig die moet worden afgerond voordat deze instelling kan worden toegepast. Dit zou maar een moment moeten duren.",
|
||||
"inFlightCompleteTitle": "Indiening Voltooid",
|
||||
"inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "Geen tile providers geconfigureerd",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
||||
"startRefresh": "Start Verversen",
|
||||
"refreshStarted": "Verversen gestart!",
|
||||
"refreshFailed": "Verversen mislukt: {}"
|
||||
"refreshFailed": "Verversen mislukt: {}",
|
||||
"disableConfirmTitle": "Bestaande Offline Gebiedsgegevens Verwijderen?",
|
||||
"disableConfirmMessage": "U heeft al bestaande offline gebiedsgegevens. Het uitschakelen van offline functies zal voorkomen dat de app deze gebruikt. Wilt u deze gegevens op het apparaat bewaren voor het geval u offline functies later weer inschakelt, of ze nu verwijderen om opslagruimte vrij te maken?",
|
||||
"keepData": "Gegevens Behouden",
|
||||
"deleteData": "Gegevens Verwijderen"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Verfijn Tags",
|
||||
"operatorProfile": "Operator Profiel",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
|
||||
"proximityAlerts": "Alerty Bliskości",
|
||||
"keepScreenAwake": "Nie Wygaszaj Ekranu",
|
||||
"networkStatusIndicator": "Wskaźnik Stanu Sieci"
|
||||
"networkStatusIndicator": "Wskaźnik Stanu Sieci",
|
||||
"hideZoomControls": "Ukryj Kontrolki Zoomu",
|
||||
"enableOfflineFeatures": "Włącz Funkcje Offline",
|
||||
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
|
||||
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
|
||||
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Ukryj kontrolki zoomu",
|
||||
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
|
||||
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
||||
"confirmDeleteTitle": "Usuń Węzeł",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Powód Usunięcia",
|
||||
"deleteReasonSelectHint": "Wybierz...",
|
||||
"deleteReasonDeviceRemoved": "Urządzenie usunięte",
|
||||
"deleteReasonDuplicateNode": "Zduplikowany węzeł",
|
||||
"deleteReasonMisidentifiedInfra": "Błędnie zidentyfikowana infrastruktura",
|
||||
"deleteReasonSpamVandalism": "Spam/wandalizm",
|
||||
"deleteReasonCustom": "Niestandardowy..",
|
||||
"deleteReasonHint": "Opisz powód...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
|
||||
"uploadPending": "Przesyłanie oczekujące",
|
||||
"uploadCreatingChangeset": "Tworzenie zestawu zmian...",
|
||||
"uploadUploading": "Przesyłanie...",
|
||||
"uploadClosingChangeset": "Zamykanie zestawu zmian...",
|
||||
"uploadError": "Przesyłanie nie powiodło się",
|
||||
"uploadComplete": "Przesyłanie zakończone",
|
||||
"viewInQueue": "Zobacz w Kolejce",
|
||||
"deletedSuccessfully": "Ten węzeł został pomyślnie usunięty z OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Wybierz profil...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Zamykanie zestawu zmian...)",
|
||||
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
|
||||
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
|
||||
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane."
|
||||
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.",
|
||||
"inFlightWaitTitle": "Kończenie Przesyłania",
|
||||
"inFlightWaitMessage": "Przesyłanie jest obecnie w toku i musi zostać zakończone, zanim to ustawienie będzie mogło zostać zastosowane. To powinno zająć tylko chwilę.",
|
||||
"inFlightCompleteTitle": "Przesyłanie Zakończone",
|
||||
"inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Dostawcy Kafelków",
|
||||
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
||||
"startRefresh": "Rozpocznij Odświeżanie",
|
||||
"refreshStarted": "Odświeżanie rozpoczęte!",
|
||||
"refreshFailed": "Odświeżanie nie powiodło się: {}"
|
||||
"refreshFailed": "Odświeżanie nie powiodło się: {}",
|
||||
"disableConfirmTitle": "Usunąć Istniejące Dane Obszarów Offline?",
|
||||
"disableConfirmMessage": "Masz już istniejące dane obszarów offline. Wyłączenie funkcji offline uniemożliwi aplikacji ich używanie. Czy chcesz zachować te dane na urządzeniu na wypadek ponownego włączenia funkcji offline później, czy usunąć je teraz, aby zwolnić miejsce na dysku?",
|
||||
"keepData": "Zachowaj Dane",
|
||||
"deleteData": "Usuń Dane"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Doprecyzuj Tagi",
|
||||
"operatorProfile": "Profil Operatora",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
|
||||
"proximityAlerts": "Alertas de Proximidade",
|
||||
"keepScreenAwake": "Manter Ecrã Ativa",
|
||||
"networkStatusIndicator": "Indicador de Status de Rede"
|
||||
"networkStatusIndicator": "Indicador de Status de Rede",
|
||||
"hideZoomControls": "Ocultar Controlos de Zoom",
|
||||
"enableOfflineFeatures": "Ativar Funcionalidades Offline",
|
||||
"enableOfflineFeaturesTitle": "Ativar funcionalidades offline",
|
||||
"enableOfflineFeaturesSubtitle": "Permitir descarregar e usar áreas de mapa offline. Se desativado, o modo offline e as áreas descarregadas não estarão disponíveis."
|
||||
},
|
||||
"keepScreenAwake": {
|
||||
|
||||
"title": "Manter o ecrã ativo durante o uso do app",
|
||||
"subtitle": "Impede que o ecrã se desligue automaticamente enquanto a app estiver em uso"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Ocultar controlos de zoom",
|
||||
"subtitle": "Oculta os botões de zoom +/- e o indicador de nível de zoom. Ainda pode ampliar com gestos de pinça."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
|
||||
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||
"confirmDeleteTitle": "Excluir Nó",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Motivo para Exclusão",
|
||||
"deleteReasonSelectHint": "Selecionar...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||
"deleteReasonDuplicateNode": "Nó duplicado",
|
||||
"deleteReasonMisidentifiedInfra": "Infraestrutura mal identificada",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizado..",
|
||||
"deleteReasonHint": "Descreva o motivo...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.",
|
||||
"uploadPending": "Envio pendente",
|
||||
"uploadCreatingChangeset": "Criando changeset...",
|
||||
"uploadUploading": "Enviando...",
|
||||
"uploadClosingChangeset": "Fechando changeset...",
|
||||
"uploadError": "Falha no envio",
|
||||
"uploadComplete": "Envio concluído",
|
||||
"viewInQueue": "Ver na Fila",
|
||||
"deletedSuccessfully": "Este nó foi excluído com sucesso do OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Selecionar um perfil...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Fechando changeset...)",
|
||||
"processingPaused": "Processamento da Fila Pausado",
|
||||
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente."
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente.",
|
||||
"inFlightWaitTitle": "Finalizando Envio",
|
||||
"inFlightWaitMessage": "Atualmente há um envio em andamento que precisa terminar antes que esta configuração possa ser aplicada. Isso deve levar apenas um momento.",
|
||||
"inFlightCompleteTitle": "Envio Concluído",
|
||||
"inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
||||
"startRefresh": "Iniciar Atualização",
|
||||
"refreshStarted": "Atualização iniciada!",
|
||||
"refreshFailed": "Atualização falhou: {}"
|
||||
"refreshFailed": "Atualização falhou: {}",
|
||||
"disableConfirmTitle": "Eliminar Dados de Áreas Offline Existentes?",
|
||||
"disableConfirmMessage": "Já tem dados de áreas offline existentes. Desativar as funcionalidades offline impedirá que a app os utilize. Deseja manter estes dados no dispositivo caso reative as funcionalidades offline mais tarde, ou eliminá-los agora para libertar espaço de armazenamento?",
|
||||
"keepData": "Manter Dados",
|
||||
"deleteData": "Eliminar Dados"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Tags",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
|
||||
"proximityAlerts": "Yakınlık Uyarıları",
|
||||
"keepScreenAwake": "Ekranı Açık Tut",
|
||||
"networkStatusIndicator": "Ağ Durumu Göstergesi"
|
||||
"networkStatusIndicator": "Ağ Durumu Göstergesi",
|
||||
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle",
|
||||
"enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir",
|
||||
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
|
||||
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Uygulamayı kullanırken ekranı açık tut",
|
||||
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Yakınlaştırma kontrollerini gizle",
|
||||
"subtitle": "+/- yakınlaştırma düğmelerini ve yakınlaştırma seviyesi göstergesini gizler. Yine de kıstırma hareketleriyle yakınlaştırabilirsiniz."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
|
||||
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||
"confirmDeleteTitle": "Düğümü Sil",
|
||||
"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..."
|
||||
"deleteReasonLabel": "Silme Nedeni",
|
||||
"deleteReasonSelectHint": "Seçin...",
|
||||
"deleteReasonDeviceRemoved": "Cihaz kaldırıldı",
|
||||
"deleteReasonDuplicateNode": "Yinelenen düğüm",
|
||||
"deleteReasonMisidentifiedInfra": "Yanlış tanımlanmış altyapı",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalizm",
|
||||
"deleteReasonCustom": "Özel..",
|
||||
"deleteReasonHint": "Lütfen nedeni açıklayın...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.",
|
||||
"uploadPending": "Yükleme bekliyor",
|
||||
"uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...",
|
||||
"uploadUploading": "Yükleniyor...",
|
||||
"uploadClosingChangeset": "Değişiklik seti kapatılıyor...",
|
||||
"uploadError": "Yükleme başarısız oldu",
|
||||
"uploadComplete": "Yükleme tamamlandı",
|
||||
"viewInQueue": "Sırada Görüntüle",
|
||||
"deletedSuccessfully": "Bu düğüm OpenStreetMap'ten başarıyla silindi."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Bir profil seçin...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Değişiklik seti kapatılıyor...)",
|
||||
"processingPaused": "Kuyruk İşleme Duraklatıldı",
|
||||
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
|
||||
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı."
|
||||
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.",
|
||||
"inFlightWaitTitle": "Gönderim Tamamlanıyor",
|
||||
"inFlightWaitMessage": "Şu anda devam eden bir gönderim var ve bu ayar uygulanmadan önce tamamlanması gerekiyor. Bu sadece bir an sürmelidir.",
|
||||
"inFlightCompleteTitle": "Gönderim Tamamlandı",
|
||||
"inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Döşeme Sağlayıcıları",
|
||||
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
||||
"startRefresh": "Yenilemeyi Başlat",
|
||||
"refreshStarted": "Yenileme başladı!",
|
||||
"refreshFailed": "Yenileme başarısız: {}"
|
||||
"refreshFailed": "Yenileme başarısız: {}",
|
||||
"disableConfirmTitle": "Mevcut Çevrimdışı Alan Verileri Silinsin mi?",
|
||||
"disableConfirmMessage": "Zaten mevcut çevrimdışı alan verileriniz var. Çevrimdışı özellikleri devre dışı bırakmak, uygulamanın bunları kullanmasını engelleyecektir. Daha sonra çevrimdışı özellikleri tekrar etkinleştirme ihtimaline karşı bu verileri cihazda saklamak mı istersiniz, yoksa depolama alanını boşaltmak için şimdi silmek mi istersiniz?",
|
||||
"keepData": "Verileri Sakla",
|
||||
"deleteData": "Verileri Sil"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Etiketleri Düzenle",
|
||||
"operatorProfile": "Operatör Profili",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
||||
"proximityAlerts": "Сповіщення Про Близькість",
|
||||
"keepScreenAwake": "Тримати екран активним",
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі"
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі",
|
||||
"hideZoomControls": "Приховати Елементи Керування Масштабом",
|
||||
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
|
||||
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
|
||||
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Тримати екран активним під час використання програми",
|
||||
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Приховати елементи керування масштабом",
|
||||
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
||||
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||
"confirmDeleteTitle": "Видалити Вузол",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||
"deleteReasonLabel": "Причина Видалення (Опціонально)",
|
||||
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
|
||||
"deleteReasonLabel": "Причина Видалення",
|
||||
"deleteReasonSelectHint": "Вибрати...",
|
||||
"deleteReasonDeviceRemoved": "Пристрій видалено",
|
||||
"deleteReasonDuplicateNode": "Дубльований вузол",
|
||||
"deleteReasonMisidentifiedInfra": "Неправильно ідентифікована інфраструктура",
|
||||
"deleteReasonSpamVandalism": "Спам/вандалізм",
|
||||
"deleteReasonCustom": "Власний..",
|
||||
"deleteReasonHint": "Опишіть причину...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
|
||||
"uploadPending": "Завантаження очікує",
|
||||
"uploadCreatingChangeset": "Створення набору змін...",
|
||||
"uploadUploading": "Завантаження...",
|
||||
"uploadClosingChangeset": "Закриття набору змін...",
|
||||
"uploadError": "Помилка завантаження",
|
||||
"uploadComplete": "Завантаження завершено",
|
||||
"viewInQueue": "Переглянути в Черзі",
|
||||
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (Закриття набору змін...)",
|
||||
"processingPaused": "Обробка Черги Призупинена",
|
||||
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну."
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну.",
|
||||
"inFlightWaitTitle": "Завершення Подання",
|
||||
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
|
||||
"inFlightCompleteTitle": "Подання Завершено",
|
||||
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Постачальники Плиток",
|
||||
"noProvidersConfigured": "Постачальників плиток не налаштовано",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
||||
"startRefresh": "Почати Оновлення",
|
||||
"refreshStarted": "Оновлення розпочато!",
|
||||
"refreshFailed": "Оновлення не вдалося: {}"
|
||||
"refreshFailed": "Оновлення не вдалося: {}",
|
||||
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
|
||||
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
|
||||
"keepData": "Зберегти Дані",
|
||||
"deleteData": "Видалити Дані"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Уточнити Теги",
|
||||
"operatorProfile": "Профіль Оператора",
|
||||
|
||||
@@ -112,12 +112,22 @@
|
||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||
"proximityAlerts": "邻近警报",
|
||||
"keepScreenAwake": "保持屏幕常亮",
|
||||
"networkStatusIndicator": "网络状态指示器"
|
||||
"networkStatusIndicator": "网络状态指示器",
|
||||
"hideZoomControls": "隐藏缩放控件",
|
||||
"enableOfflineFeatures": "启用离线功能",
|
||||
"enableOfflineFeaturesTitle": "启用离线功能",
|
||||
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "使用应用时保持屏幕常亮",
|
||||
"subtitle": "防止在使用应用期间屏幕自动熄灭"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "隐藏缩放控件",
|
||||
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "接近监控设备时接收通知",
|
||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||
@@ -138,9 +148,27 @@
|
||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||
"confirmDeleteTitle": "删除节点",
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
||||
"deleteReasonLabel": "删除原因(可选)",
|
||||
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
|
||||
"deleteReasonLabel": "删除原因",
|
||||
"deleteReasonSelectHint": "请选择...",
|
||||
"deleteReasonDeviceRemoved": "设备已移除",
|
||||
"deleteReasonDuplicateNode": "重复节点",
|
||||
"deleteReasonMisidentifiedInfra": "识别错误的基础设施",
|
||||
"deleteReasonSpamVandalism": "垃圾信息/破坏",
|
||||
"deleteReasonCustom": "自定义..",
|
||||
"deleteReasonHint": "请描述原因...",
|
||||
|
||||
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
|
||||
"uploadPending": "上传待处理",
|
||||
"uploadCreatingChangeset": "正在创建变更集...",
|
||||
"uploadUploading": "正在上传...",
|
||||
"uploadClosingChangeset": "正在关闭变更集...",
|
||||
"uploadError": "上传失败",
|
||||
"uploadComplete": "上传完成",
|
||||
"viewInQueue": "在队列中查看",
|
||||
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
"selectProfile": "选择配置文件...",
|
||||
@@ -279,8 +307,13 @@
|
||||
"closingChangeset": " (关闭变更集...)",
|
||||
"processingPaused": "队列处理已暂停",
|
||||
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
|
||||
"pausedByUser": "上传处理已手动暂停。"
|
||||
"pausedByUser": "上传处理已手动暂停。",
|
||||
"inFlightWaitTitle": "正在完成提交",
|
||||
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
|
||||
"inFlightCompleteTitle": "提交完成",
|
||||
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
"noProvidersConfigured": "未配置瓦片提供商",
|
||||
@@ -426,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||
"startRefresh": "开始刷新",
|
||||
"refreshStarted": "刷新已开始!",
|
||||
"refreshFailed": "刷新失败:{}"
|
||||
"refreshFailed": "刷新失败:{}",
|
||||
"disableConfirmTitle": "删除现有的离线区域数据?",
|
||||
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
|
||||
"keepData": "保留数据",
|
||||
"deleteData": "删除数据"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "细化标签",
|
||||
"operatorProfile": "运营商配置文件",
|
||||
|
||||
+42
-5
@@ -17,12 +17,44 @@ import 'services/localization_service.dart';
|
||||
import 'services/provider_tile_cache_manager.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
import 'services/deflock_tile_provider.dart' show TileLoadCancelledException, TileNotAvailableOfflineException;
|
||||
|
||||
|
||||
/// Suppress console noise for expected, already-handled tile load errors.
|
||||
///
|
||||
/// [TileLoadCancelledException] (tile scrolled off screen) and
|
||||
/// [TileNotAvailableOfflineException] (no cached data available) are thrown
|
||||
/// deliberately by DeflockOfflineTileImageProvider and are already handled —
|
||||
/// TileLayerManager.onTileLoadError() specifically skips retry logic for
|
||||
/// both. Flutter's image pipeline reports them via
|
||||
/// MultiFrameImageStreamCompleter with `silent: true`, but
|
||||
/// FlutterError.dumpErrorToConsole has a debug-mode assert() that ignores
|
||||
/// the silent flag and always prints to console anyway. This filters those
|
||||
/// two specific, expected exception types back out while leaving all other
|
||||
/// errors (including genuinely silent-but-unexpected ones) reported as
|
||||
/// normal.
|
||||
void _installTileErrorFilter() {
|
||||
final defaultOnError = FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
final exception = details.exception;
|
||||
if (details.silent &&
|
||||
(exception is TileLoadCancelledException ||
|
||||
exception is TileNotAvailableOfflineException)) {
|
||||
return; // Already handled — don't spam the console.
|
||||
}
|
||||
if (defaultOnError != null) {
|
||||
defaultOnError(details);
|
||||
} else {
|
||||
FlutterError.presentError(details);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
_installTileErrorFilter();
|
||||
|
||||
|
||||
// Initialize version service
|
||||
await VersionService().init();
|
||||
|
||||
@@ -93,16 +125,21 @@ class DeFlockApp extends StatelessWidget {
|
||||
},
|
||||
initialRoute: '/',
|
||||
onUnknownRoute: (settings) {
|
||||
// Handle deep link routes that Flutter tries to process before app_links
|
||||
// This catches routes like "/?id=13939398601" from "deflockapp://node?id=13939398601"
|
||||
debugPrint('[Navigation] Unknown route intercepted: ${settings.name} (likely deep link, will be handled by DeepLinkService)');
|
||||
// Fallback for any genuinely unknown named route (e.g. a stale/bad
|
||||
// link). Deep links (deflockapp://...) are handled exclusively by
|
||||
// DeepLinkService via the app_links package - Flutter's own
|
||||
// implicit deep-link routing is disabled at the platform level
|
||||
// (see AndroidManifest.xml's flutter_deeplinking_enabled and
|
||||
// Info.plist's FlutterDeepLinkingEnabled) to avoid this handler
|
||||
// double-navigating and burying screens pushed by DeepLinkService.
|
||||
debugPrint('[Navigation] Unknown route intercepted: ${settings.name}');
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (_) => const HomeScreen(),
|
||||
);
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app_state.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/suspected_location_cache.dart';
|
||||
import 'widgets/nuclear_reset_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
|
||||
|
||||
/// One-time migrations that run when users upgrade to specific versions.
|
||||
/// Each migration function is named after the version where it should run.
|
||||
class OneTimeMigrations {
|
||||
@@ -189,6 +191,30 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable offline features for existing users who already have offline
|
||||
/// area data (v2.10.5). New installs and existing users who never used
|
||||
/// offline areas will keep the new "offline features enabled" setting at
|
||||
/// its default of false.
|
||||
static Future<void> migrate_2_10_5(AppState appState) async {
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
await offlineAreaService.ensureInitialized();
|
||||
|
||||
if (offlineAreaService.offlineAreas.isNotEmpty) {
|
||||
await appState.setOfflineFeaturesEnabled(true);
|
||||
debugPrint('[Migration] 2.10.5: Existing offline area data found - enabled offline features');
|
||||
} else {
|
||||
debugPrint('[Migration] 2.10.5: No existing offline area data - leaving offline features disabled');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.10.5 completed: offline features migration done');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.10.5 ERROR: Failed to migrate offline features setting: $e');
|
||||
// Don't rethrow - this is non-critical, worst case is the user needs
|
||||
// to manually re-enable the toggle to see their existing offline areas.
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -206,11 +232,14 @@ class OneTimeMigrations {
|
||||
return migrate_2_7_3;
|
||||
case '2.10.0':
|
||||
return migrate_2_10_0;
|
||||
case '2.10.5':
|
||||
return migrate_2_10_5;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Run migration for a specific version with nuclear reset on failure
|
||||
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'settings/sections/proximity_alerts_section.dart';
|
||||
import 'settings/sections/suspected_locations_section.dart';
|
||||
import 'settings/sections/tile_provider_section.dart';
|
||||
import 'settings/sections/keep_screen_awake_section.dart';
|
||||
import 'settings/sections/hide_zoom_controls_section.dart';
|
||||
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AdvancedSettingsScreen extends StatelessWidget {
|
||||
@@ -35,6 +37,9 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
||||
Divider(),
|
||||
KeepScreenAwakeSection(),
|
||||
Divider(),
|
||||
HideZoomControlsSection(),
|
||||
Divider(),
|
||||
|
||||
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
|
||||
// Divider(),
|
||||
TileProviderSection(),
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
|
||||
|
||||
import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
@@ -455,6 +457,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-focus a node's details sheet right after it was submitted/edited/deleted,
|
||||
// behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback).
|
||||
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||
final focusNodeId = appState.consumePendingFocusNodeId();
|
||||
if (focusNodeId != null && !_sheetCoordinator.hasActiveNodeSheet) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final node = MapDataProvider().getNodeById(focusNodeId);
|
||||
if (node != null) {
|
||||
openNodeTagSheet(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Pass the active sheet height directly to the map
|
||||
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
|
||||
|
||||
@@ -595,67 +613,70 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7, // 70% for primary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final showDownloadButton = appState.offlineFeaturesEnabled;
|
||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
// Take full width when the download button is hidden
|
||||
flex: showDownloadButton ? 7 : 10,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: canDownload ? () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
if (showDownloadButton) ...[
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: canDownload ? () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import 'settings/sections/enable_offline_features_section.dart';
|
||||
import 'settings/sections/offline_mode_section.dart';
|
||||
import 'settings/sections/offline_areas_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
@@ -16,20 +19,42 @@ class OfflineSettingsScreen extends StatelessWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.offlineSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: const [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
body: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final offlineFeaturesEnabled = appState.offlineFeaturesEnabled;
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
const EnableOfflineFeaturesSection(),
|
||||
const Divider(),
|
||||
// Grey out and disable interaction with the rest of the
|
||||
// offline settings when the master toggle is off, while
|
||||
// keeping them visible/discoverable.
|
||||
Opacity(
|
||||
opacity: offlineFeaturesEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !offlineFeaturesEnabled,
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
/// Master toggle for all offline-area functionality (downloading, browsing,
|
||||
/// and using cached tiles/nodes offline). When disabled, the rest of the
|
||||
/// Offline Settings screen (offline mode + offline areas list) is greyed out
|
||||
/// and the download button on the map screen is hidden. Functionally, the
|
||||
/// app will behave as if no offline area data exists at all while this is
|
||||
/// off, regardless of whether area files still exist on disk.
|
||||
class EnableOfflineFeaturesSection extends StatelessWidget {
|
||||
const EnableOfflineFeaturesSection({super.key});
|
||||
|
||||
Future<void> _handleToggle(BuildContext context, AppState appState, bool value) async {
|
||||
if (value) {
|
||||
// Enabling requires no confirmation.
|
||||
await appState.setOfflineFeaturesEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabling: if offline area data already exists, ask whether to keep
|
||||
// or delete it. Either choice proceeds to disable the toggle - there is
|
||||
// no "cancel" path once this dialog is shown.
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
if (!context.mounted) return;
|
||||
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
|
||||
|
||||
if (!hasExistingAreas) {
|
||||
await appState.setOfflineFeaturesEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('offlineAreas.disableConfirmTitle')),
|
||||
content: Text(locService.t('offlineAreas.disableConfirmMessage')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(locService.t('offlineAreas.keepData')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(locService.t('offlineAreas.deleteData')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldDelete == true) {
|
||||
for (final area in offlineService.offlineAreas.toList()) {
|
||||
offlineService.deleteArea(area.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable regardless of the user's data choice.
|
||||
await appState.setOfflineFeaturesEnabled(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('settings.enableOfflineFeatures'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(locService.t('settings.enableOfflineFeaturesTitle')),
|
||||
subtitle: Text(
|
||||
locService.t('settings.enableOfflineFeaturesSubtitle'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
value: appState.offlineFeaturesEnabled,
|
||||
onChanged: (value) => _handleToggle(context, appState, value),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class HideZoomControlsSection extends StatelessWidget {
|
||||
const HideZoomControlsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Standard Pattern: Use AnimatedBuilder to respond to language/localization changes
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Bold Section Header (matches Keep Screen Awake, Proximity, etc.)
|
||||
Text(
|
||||
locService.t('settings.hideZoomControls'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// The Functional Toggle
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero, // Aligns switch text with the bold header
|
||||
title: Text(locService.t('hideZoomControls.title')),
|
||||
subtitle: Text(
|
||||
locService.t('hideZoomControls.subtitle'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
value: appState.hideZoomControls,
|
||||
onChanged: (bool value) {
|
||||
appState.setHideZoomControls(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/in_flight_upload_wait_dialog.dart';
|
||||
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
@@ -49,10 +51,17 @@ class OfflineModeSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the change
|
||||
await appState.setOfflineMode(value);
|
||||
// Proceed with the change, respecting any in-flight submission that
|
||||
// needs to finish before offline mode actually takes effect.
|
||||
if (!context.mounted) return;
|
||||
await applyQueueSettingChangeRespectingInFlightUploads(
|
||||
context: context,
|
||||
appState: appState,
|
||||
applyChange: () => appState.setOfflineMode(value),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/in_flight_upload_wait_dialog.dart';
|
||||
|
||||
|
||||
class UploadQueueScreen extends StatelessWidget {
|
||||
const UploadQueueScreen({super.key});
|
||||
@@ -84,6 +86,22 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePauseQueueChange(BuildContext context, AppState appState, bool value) async {
|
||||
// Only need to wait for in-flight uploads when turning pause ON.
|
||||
// Resuming (turning it off) can happen immediately.
|
||||
if (!value) {
|
||||
await appState.setPauseQueueProcessing(value);
|
||||
return;
|
||||
}
|
||||
|
||||
await applyQueueSettingChangeRespectingInFlightUploads(
|
||||
context: context,
|
||||
appState: appState,
|
||||
applyChange: () => appState.setPauseQueueProcessing(value),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -166,9 +184,10 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
: (value) => _handlePauseQueueChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
@@ -241,7 +241,12 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('2.10.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
|
||||
versionsNeedingMigration.add('2.10.5');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/// Shared validation helpers for geographic coordinates and zoom levels.
|
||||
///
|
||||
/// Used wherever a coordinate or zoom level comes from an external source
|
||||
/// (GPS fixes, persisted preferences, live map camera state) that isn't
|
||||
/// guaranteed to be finite/in-range.
|
||||
library;
|
||||
|
||||
import '../dev_config.dart' show kAbsoluteMaxZoom;
|
||||
|
||||
/// Validate that a latitude value is finite and within -90 to 90.
|
||||
bool isValidLatitude(double value) {
|
||||
return !value.isNaN && !value.isInfinite && value >= -90.0 && value <= 90.0;
|
||||
}
|
||||
|
||||
/// Validate that a longitude value is finite and within -180 to 180.
|
||||
bool isValidLongitude(double value) {
|
||||
return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0;
|
||||
}
|
||||
|
||||
/// Validate that a zoom level is finite and within a sane range.
|
||||
///
|
||||
/// Defaults to 1.0 to [kAbsoluteMaxZoom]. `num` is used (rather than
|
||||
/// `double`) so the `int` constant [kAbsoluteMaxZoom] can be used directly
|
||||
/// as a default parameter value.
|
||||
bool isValidZoom(double zoom, {num min = 1.0, num max = kAbsoluteMaxZoom}) {
|
||||
return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max;
|
||||
}
|
||||
@@ -390,8 +390,13 @@ class NodeDataManager extends ChangeNotifier {
|
||||
|
||||
/// Load all offline nodes into cache (call at app startup)
|
||||
Future<void> preloadOfflineNodes() async {
|
||||
// Skip entirely when the user has disabled offline features — this call
|
||||
// isn't gated by OfflineAreaService.hasOfflineAreasForProvider*() since
|
||||
// it fetches directly via fetchLocalNodes, so we guard it explicitly.
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return;
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
|
||||
|
||||
for (final area in offlineAreaService.offlineAreas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
|
||||
@@ -29,7 +29,13 @@ class OfflineAreaService {
|
||||
/// Fast check: do we have any completed offline areas for a specific provider/type?
|
||||
/// This allows smart cache routing without expensive filesystem searches.
|
||||
/// Safe to call before initialization - returns false if not yet initialized.
|
||||
///
|
||||
/// Returns false immediately if the user has disabled offline features via
|
||||
/// the master "Enable Offline Features" toggle, regardless of whether area
|
||||
/// data still exists on disk — this fully gates the offline-first tile
|
||||
/// pipeline without needing to touch fetchLocalTile/fetchLocalNodes.
|
||||
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
@@ -44,7 +50,11 @@ class OfflineAreaService {
|
||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||
/// offline-first path for tiles that will never be found locally.
|
||||
///
|
||||
/// Also returns false immediately when offline features are disabled (see
|
||||
/// [hasOfflineAreasForProvider] for rationale).
|
||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||
if (!_initialized) return false;
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
@@ -54,6 +64,7 @@ class OfflineAreaService {
|
||||
zoom <= area.maxZoom
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Reset service state and inject areas for unit tests.
|
||||
@visibleForTesting
|
||||
|
||||
@@ -143,27 +143,55 @@ class OverpassService {
|
||||
'[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency');
|
||||
}
|
||||
|
||||
final nodeClauses = profilesToQuery.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
// Per-profile tag filter strings (e.g. ["man_made"="surveillance"]["surveillance:type"="ALPR"]),
|
||||
// excluding empty tag values. Reused twice below: once to select our nodes
|
||||
// in the bbox, and again to re-select just our nodes out of the pool of
|
||||
// way/relation-connected nodes (which may include unrelated node types).
|
||||
final tagFilterStrings = profilesToQuery.map((profile) {
|
||||
return profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
}).toList();
|
||||
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
final bboxStr = '${bounds.southWest.latitude},${bounds.southWest.longitude},'
|
||||
'${bounds.northEast.latitude},${bounds.northEast.longitude}';
|
||||
|
||||
final allNodesClauses =
|
||||
tagFilterStrings.map((filters) => 'node$filters($bboxStr);').join('\n ');
|
||||
final resNodesClauses =
|
||||
tagFilterStrings.map((filters) => 'node.resNodes$filters;').join('\n ');
|
||||
|
||||
// Query strategy:
|
||||
// 1. Find all matching nodes in bbox -> .allNodes, output them with tags ("out body").
|
||||
// 2. Find any way/relation that references one of .allNodes ("bn." = "by node"),
|
||||
// then pull the node IDs those ways/relations reference -> .wayNodes/.relNodes.
|
||||
// (`node(w)`/`node(r)` on a way/relation set returns only node IDs, not
|
||||
// full tags/coords, keeping the query/response cheap.)
|
||||
// 3. Re-filter that combined node pool down to just our own matching
|
||||
// surveillance nodes (.resNodes may contain unrelated node types too).
|
||||
// 4. `convert restriction ::id = id();` emits each of those node IDs as a
|
||||
// lightweight synthetic `{"type":"restriction","id":<nodeId>}` element —
|
||||
// a flat, unambiguous list of which node IDs are "constrained" (part of
|
||||
// some way/relation), without needing the full way/relation node lists.
|
||||
return '''
|
||||
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
|
||||
(
|
||||
$nodeClauses
|
||||
$allNodesClauses
|
||||
)->.allNodes;
|
||||
.allNodes out body;
|
||||
way(bn.allNodes); node(w)->.wayNodes;
|
||||
relation(bn.allNodes); node(r)->.relNodes;
|
||||
(.wayNodes;.relNodes;)->.resNodes;
|
||||
(
|
||||
$resNodesClauses
|
||||
);
|
||||
out body;
|
||||
<;
|
||||
out ids;
|
||||
convert restriction ::id = id();
|
||||
out;
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
/// Deduplicate profiles for Overpass queries by removing profiles that are
|
||||
/// subsumed by others. A profile A subsumes profile B if all of A's
|
||||
/// non-empty tags exist in B with identical values — meaning every node
|
||||
@@ -232,7 +260,16 @@ out ids;
|
||||
}
|
||||
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
/// Parse Overpass JSON response into OsmNode objects.
|
||||
///
|
||||
/// The query (see [_buildQuery]) produces two kinds of elements:
|
||||
/// - `type: "node"` — our matched surveillance nodes, with full tags/coords
|
||||
/// (from `.allNodes out body;`).
|
||||
/// - `type: "restriction"` — synthetic elements (from
|
||||
/// `convert restriction ::id = id();`) whose `id` is the OSM node ID of
|
||||
/// one of our nodes that Overpass determined is referenced by some way
|
||||
/// or relation. These carry no lat/lon/tags — just the id — and exist
|
||||
/// purely to tell us which node IDs are "constrained".
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
@@ -240,21 +277,16 @@ out ids;
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
// First pass: collect surveillance nodes and constrained-node markers.
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// Mark referenced nodes as constrained
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
for (final ref in refs) {
|
||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
} else if (type == 'restriction') {
|
||||
final rawId = element['id'];
|
||||
final nodeId = rawId is int ? rawId : int.tryParse(rawId.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +306,7 @@ out ids;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Error thrown when a query would exceed Overpass's 50k node limit.
|
||||
/// The caller should split the query area into smaller regions to resolve this
|
||||
/// — it's a deterministic function of how much data lives within the bounds.
|
||||
|
||||
@@ -104,7 +104,7 @@ class Uploader {
|
||||
// Create new node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
@@ -141,7 +141,7 @@ class Uploader {
|
||||
// Update existing node with version
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
@@ -190,7 +190,7 @@ class Uploader {
|
||||
// Extract creates a new node with tags from the original node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
|
||||
@@ -40,10 +40,15 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
static const String _distanceUnitPrefsKey = 'distance_unit';
|
||||
static const String _keepScreenAwakePrefsKey = 'keep_screen_awake';
|
||||
static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls';
|
||||
static const String _offlineFeaturesEnabledPrefsKey = 'offline_features_enabled';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
bool _keepScreenAwake = false;
|
||||
bool _hideZoomControls = false;
|
||||
bool _offlineFeaturesEnabled = false;
|
||||
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
// Default must account for missing secrets (preview builds) even before init() runs
|
||||
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
|
||||
@@ -68,7 +73,11 @@ class SettingsState extends ChangeNotifier {
|
||||
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
bool get keepScreenAwake => _keepScreenAwake;
|
||||
bool get hideZoomControls => _hideZoomControls;
|
||||
bool get offlineFeaturesEnabled => _offlineFeaturesEnabled;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
|
||||
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _distanceUnit;
|
||||
@@ -144,7 +153,17 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
// Load keep screen awake setting
|
||||
_keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false;
|
||||
|
||||
// Load hide zoom controls setting
|
||||
_hideZoomControls = prefs.getBool(_hideZoomControlsPrefsKey) ?? false;
|
||||
|
||||
// Load offline features enabled setting (defaults to false; migration
|
||||
// for existing users with pre-existing offline area data lives in
|
||||
// migrations.dart / migrate_2_10_5)
|
||||
_offlineFeaturesEnabled = prefs.getBool(_offlineFeaturesEnabledPrefsKey) ?? false;
|
||||
|
||||
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
@@ -413,6 +432,33 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set hide zoom controls enabled/disabled
|
||||
Future<void> setHideZoomControls(bool enabled) async {
|
||||
if (_hideZoomControls != enabled) {
|
||||
_hideZoomControls = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hideZoomControlsPrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the master "offline features enabled" toggle. When this is off,
|
||||
/// offline areas (downloading, browsing, and using cached tiles/nodes) are
|
||||
/// disabled entirely. Note: any cascading side effects (forcing offline
|
||||
/// mode off, cancelling active downloads, prompting to delete existing
|
||||
/// area data) are handled by the caller (see AppState.setOfflineFeaturesEnabled)
|
||||
/// since those require access to services not owned by SettingsState.
|
||||
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
|
||||
if (_offlineFeaturesEnabled != enabled) {
|
||||
_offlineFeaturesEnabled = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineFeaturesEnabledPrefsKey, enabled);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Set distance for avoidance of nodes during navigation
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
if (_navigationAvoidanceDistance != distance) {
|
||||
|
||||
@@ -121,7 +121,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Add a completed session to the upload queue
|
||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
PendingUpload addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||
final upload = PendingUpload(
|
||||
coord: session.target!,
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
@@ -159,10 +159,11 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
// Add a completed edit session to the upload queue
|
||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
PendingUpload addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||
// Determine operation type and coordinates
|
||||
final UploadOperation operation;
|
||||
final LatLng coordToUse;
|
||||
@@ -250,10 +251,11 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
// Add a node deletion to the upload queue
|
||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
||||
PendingUpload addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
||||
final upload = PendingUpload(
|
||||
coord: node.coord,
|
||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||
@@ -282,6 +284,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
/// Applies a queue-pausing settings change (enabling offline mode or pausing
|
||||
/// the upload queue), but if there's an in-flight submission (a queue item
|
||||
/// that has already opened a changeset and hasn't closed it yet), first
|
||||
/// shows a non-dismissible-by-tap-outside dialog explaining that the
|
||||
/// submission needs to finish. Once it completes, a brief confirmation is
|
||||
/// shown before the dialog auto-closes and the setting takes effect.
|
||||
///
|
||||
/// If nothing is in-flight, [applyChange] is invoked immediately with no
|
||||
/// dialog shown at all.
|
||||
///
|
||||
/// The dialog can also be dismissed early by the user tapping "View in
|
||||
/// Queue", in case something is stuck (e.g. a failed/erroring item) — this
|
||||
/// lets the user navigate to the queue screen to investigate/clear it out,
|
||||
/// rather than getting stuck waiting indefinitely.
|
||||
Future<void> applyQueueSettingChangeRespectingInFlightUploads({
|
||||
required BuildContext context,
|
||||
required AppState appState,
|
||||
required Future<void> Function() applyChange,
|
||||
}) async {
|
||||
if (!appState.hasInFlightUploads) {
|
||||
await applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => InFlightUploadWaitDialog(
|
||||
onAllComplete: () {
|
||||
// Fire and forget - the dialog doesn't need to await this.
|
||||
applyChange();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class InFlightUploadWaitDialog extends StatefulWidget {
|
||||
/// Called exactly once, as soon as no queue items are actively processing.
|
||||
final VoidCallback onAllComplete;
|
||||
|
||||
const InFlightUploadWaitDialog({super.key, required this.onAllComplete});
|
||||
|
||||
@override
|
||||
State<InFlightUploadWaitDialog> createState() => _InFlightUploadWaitDialogState();
|
||||
}
|
||||
|
||||
class _InFlightUploadWaitDialogState extends State<InFlightUploadWaitDialog> {
|
||||
bool _completed = false;
|
||||
bool _appliedChange = false;
|
||||
Timer? _autoCloseTimer;
|
||||
late final AppState _appState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appState = AppState.instance;
|
||||
_appState.addListener(_onAppStateChanged);
|
||||
// Handle the (unlikely) race where the in-flight upload finishes between
|
||||
// the initial check and this dialog actually mounting.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _onAppStateChanged());
|
||||
}
|
||||
|
||||
void _onAppStateChanged() {
|
||||
if (!mounted || _completed) return;
|
||||
if (_appState.hasInFlightUploads) return;
|
||||
|
||||
setState(() {
|
||||
_completed = true;
|
||||
});
|
||||
|
||||
if (!_appliedChange) {
|
||||
_appliedChange = true;
|
||||
widget.onAllComplete();
|
||||
}
|
||||
|
||||
_autoCloseTimer = Timer(kInFlightUploadCompleteDisplayDuration, () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appState.removeListener(_onAppStateChanged);
|
||||
_autoCloseTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _viewInQueue() {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
Navigator.of(context).pushNamed('/settings/queue');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_completed ? Icons.check_circle : Icons.cloud_upload,
|
||||
color: _completed ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_completed
|
||||
? locService.t('queue.inFlightCompleteTitle')
|
||||
: locService.t('queue.inFlightWaitTitle'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_completed
|
||||
? locService.t('queue.inFlightCompleteMessage')
|
||||
: locService.t('queue.inFlightWaitMessage'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: _completed
|
||||
? const Icon(Icons.check_circle, size: 40, color: Colors.green)
|
||||
: const CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!_completed)
|
||||
TextButton(
|
||||
onPressed: _viewInQueue,
|
||||
child: Text(locService.t('node.viewInQueue')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatf
|
||||
import '../../dev_config.dart';
|
||||
import '../../app_state.dart' show FollowMeMode;
|
||||
import '../../services/proximity_alert_service.dart';
|
||||
import '../../services/coordinate_validation.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
|
||||
|
||||
/// Simple GPS controller that handles precise location permissions only.
|
||||
/// Key principles:
|
||||
/// - Respect "denied forever" - stop trying
|
||||
@@ -187,6 +189,18 @@ class GpsController {
|
||||
|
||||
/// Handle incoming GPS position
|
||||
void _onPositionReceived(Position position) {
|
||||
// Reject malformed fixes (occasionally NaN/Infinite from the platform
|
||||
// location stack) before they reach map state.
|
||||
if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) {
|
||||
|
||||
debugPrint(
|
||||
'[GpsController] Ignoring invalid GPS position: '
|
||||
'lat=${position.latitude}, lng=${position.longitude}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final newLocation = LatLng(position.latitude, position.longitude);
|
||||
_currentLocation = newLocation;
|
||||
|
||||
@@ -209,6 +223,7 @@ class GpsController {
|
||||
}
|
||||
|
||||
/// Handle GPS stream errors
|
||||
|
||||
void _onPositionError(dynamic error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class MapOverlays extends StatelessWidget {
|
||||
final EditNodeSession? editSession;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
final VoidCallback? onSearchPressed; // Callback for search button
|
||||
final bool hideZoomControls; // Hide zoom indicator + zoom in/out buttons
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
@@ -25,8 +26,10 @@ class MapOverlays extends StatelessWidget {
|
||||
this.editSession,
|
||||
this.attribution,
|
||||
this.onSearchPressed,
|
||||
this.hideZoomControls = false,
|
||||
});
|
||||
|
||||
|
||||
/// Show full attribution text in a dialog with license link.
|
||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||
final locService = LocalizationService.instance;
|
||||
@@ -138,35 +141,37 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned relative to button bar with left safe area
|
||||
Positioned(
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
double zoom = 15.0; // fallback
|
||||
try {
|
||||
zoom = mapController.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
if (!hideZoomControls)
|
||||
Positioned(
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
double zoom = 15.0; // fallback
|
||||
try {
|
||||
zoom = mapController.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Attribution overlay, positioned relative to button bar with left safe area
|
||||
if (attribution != null)
|
||||
@@ -228,37 +233,40 @@ class MapOverlays extends StatelessWidget {
|
||||
|
||||
// Layer selector button
|
||||
const LayerSelectorButton(),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
if (!hideZoomControls) ...[
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../services/coordinate_validation.dart';
|
||||
|
||||
/// Manages map position persistence and initial positioning.
|
||||
/// Handles saving/loading last map position and moving to initial locations.
|
||||
@@ -29,9 +30,9 @@ class MapPositionManager {
|
||||
final lng = prefs.getDouble('last_map_longitude');
|
||||
final zoom = prefs.getDouble('last_map_zoom');
|
||||
|
||||
if (lat != null && lng != null &&
|
||||
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
|
||||
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
|
||||
if (lat != null && lng != null &&
|
||||
isValidLatitude(lat) && isValidLongitude(lng)) {
|
||||
final validZoom = zoom != null && isValidZoom(zoom) ? zoom : 15.0;
|
||||
_initialLocation = LatLng(lat, lng);
|
||||
_initialZoom = validZoom;
|
||||
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
||||
@@ -50,9 +51,9 @@ class MapPositionManager {
|
||||
try {
|
||||
final zoom = _initialZoom ?? 15.0;
|
||||
// Double-check coordinates are valid before moving
|
||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
||||
_isValidCoordinate(_initialLocation!.longitude) &&
|
||||
_isValidZoom(zoom)) {
|
||||
if (isValidLatitude(_initialLocation!.latitude) &&
|
||||
isValidLongitude(_initialLocation!.longitude) &&
|
||||
isValidZoom(zoom)) {
|
||||
controller.mapController.move(_initialLocation!, zoom);
|
||||
_hasMovedToInitialLocation = true;
|
||||
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
||||
@@ -70,9 +71,9 @@ class MapPositionManager {
|
||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||
try {
|
||||
// Validate coordinates and zoom before saving
|
||||
if (!_isValidCoordinate(location.latitude) ||
|
||||
!_isValidCoordinate(location.longitude) ||
|
||||
!_isValidZoom(zoom)) {
|
||||
if (!isValidLatitude(location.latitude) ||
|
||||
!isValidLongitude(location.longitude) ||
|
||||
!isValidZoom(zoom)) {
|
||||
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
||||
return;
|
||||
}
|
||||
@@ -87,8 +88,6 @@ class MapPositionManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Clear any stored map position (useful for recovery from invalid data)
|
||||
static Future<void> clearStoredMapPosition() async {
|
||||
try {
|
||||
@@ -101,20 +100,4 @@ class MapPositionManager {
|
||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
|
||||
bool _isValidCoordinate(double value) {
|
||||
return !value.isNaN &&
|
||||
!value.isInfinite &&
|
||||
value >= -180.0 &&
|
||||
value <= 180.0;
|
||||
}
|
||||
|
||||
/// Validate that a zoom level is valid
|
||||
bool _isValidZoom(double zoom) {
|
||||
return !zoom.isNaN &&
|
||||
!zoom.isInfinite &&
|
||||
zoom >= 1.0 &&
|
||||
zoom <= 25.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,12 @@ import 'node_limit_indicator.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/proximity_alert_service.dart';
|
||||
import '../services/coordinate_validation.dart';
|
||||
import 'sheet_aware_map.dart';
|
||||
|
||||
import 'custom_scale_bar.dart';
|
||||
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final AnimatedMapController controller;
|
||||
const MapView({
|
||||
@@ -80,6 +83,13 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
// Last known-good camera center/zoom, used to self-heal if the camera
|
||||
// ever gets pushed into an invalid state.
|
||||
LatLng? _lastValidCenter;
|
||||
double? _lastValidZoom;
|
||||
|
||||
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
@@ -275,6 +285,27 @@ class MapViewState extends State<MapView> {
|
||||
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
}
|
||||
|
||||
/// Known-good fallback location for [initialCenter]: validated GPS
|
||||
/// location, then validated persisted position, then a hardcoded default.
|
||||
LatLng get _safeInitialCenter {
|
||||
final gpsLocation = _gpsController.currentLocation;
|
||||
if (gpsLocation != null &&
|
||||
isValidLatitude(gpsLocation.latitude) &&
|
||||
isValidLongitude(gpsLocation.longitude)) {
|
||||
return gpsLocation;
|
||||
}
|
||||
final persisted = _positionManager.initialLocation;
|
||||
if (persisted != null &&
|
||||
isValidLatitude(persisted.latitude) &&
|
||||
isValidLongitude(persisted.longitude)) {
|
||||
return persisted;
|
||||
}
|
||||
return LatLng(37.7749, -122.4194);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
@@ -414,18 +445,47 @@ class MapViewState extends State<MapView> {
|
||||
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialCenter: _safeInitialCenter,
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
|
||||
minZoom: 1.0,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
// Self-heal if the camera center/zoom ever becomes invalid
|
||||
// (observed to happen silently during ordinary panning,
|
||||
// leaving a blank grey map with no tiles/markers). Snap back
|
||||
// to the last known-good position immediately.
|
||||
if (!isValidLatitude(pos.center.latitude) ||
|
||||
!isValidLongitude(pos.center.longitude) ||
|
||||
!isValidZoom(pos.zoom)) {
|
||||
debugPrint(
|
||||
|
||||
'[MapView] Detected invalid camera position '
|
||||
'(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) '
|
||||
'- snapping back to last known-good position',
|
||||
);
|
||||
final recoveryCenter = _lastValidCenter ?? _safeInitialCenter;
|
||||
final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
_controller.mapController.move(recoveryCenter, recoveryZoom);
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Failed to recover camera position: $e');
|
||||
}
|
||||
});
|
||||
return; // Don't process this invalid position further
|
||||
}
|
||||
_lastValidCenter = pos.center;
|
||||
_lastValidZoom = pos.zoom;
|
||||
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||
|
||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||
_controller.animateTo(
|
||||
@@ -536,17 +596,24 @@ class MapViewState extends State<MapView> {
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
// When zoom controls (and the zoom level indicator) are hidden,
|
||||
// shift the scale bar down into the indicator's old slot so we
|
||||
// don't leave a weird empty gap above the attribution text.
|
||||
final scaleBarSpacing = appState.hideZoomControls
|
||||
? kZoomIndicatorSpacingAboveButtonBar
|
||||
: kScaleBarSpacingAboveButtonBar;
|
||||
return CustomScaleBar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
|
||||
bottom: bottomPositionFromButtonBar(scaleBarSpacing, safeArea.bottom),
|
||||
),
|
||||
maxWidthPx: 120,
|
||||
barHeight: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -560,8 +627,10 @@ class MapViewState extends State<MapView> {
|
||||
editSession: editSession,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
onSearchPressed: widget.onSearchPressed,
|
||||
hideZoomControls: appState.hideZoomControls,
|
||||
),
|
||||
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
builder: (context) {
|
||||
|
||||
+484
-80
@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
|
||||
class NodeTagSheet extends StatefulWidget {
|
||||
final OsmNode node;
|
||||
final VoidCallback? onEditPressed;
|
||||
final bool isNodeLimitActive;
|
||||
@@ -20,6 +24,88 @@ class NodeTagSheet extends StatelessWidget {
|
||||
this.isNodeLimitActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NodeTagSheet> createState() => _NodeTagSheetState();
|
||||
}
|
||||
|
||||
class _NodeTagSheetState extends State<NodeTagSheet> {
|
||||
// The PendingUpload backing this node (if it was in a pending state when the
|
||||
// sheet was opened). We resolve this once and hold onto the object reference
|
||||
// itself (not just its id) so we can keep observing its live status via its
|
||||
// mutable fields even after it's eventually removed from the queue list
|
||||
// (which happens ~1s after completion). Without this, once the item left
|
||||
// the queue we'd have no way to tell "finished successfully" apart from
|
||||
// "still pending" and would show stale/misleading status forever.
|
||||
PendingUpload? _upload;
|
||||
bool _uploadResolved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolveTrackedUpload();
|
||||
}
|
||||
|
||||
void _resolveTrackedUpload() {
|
||||
if (_uploadResolved) return;
|
||||
_uploadResolved = true;
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
if (widget.node.tags['_pending_upload'] == 'true') {
|
||||
_upload = appState.pendingUploads
|
||||
.firstWhereOrNull((u) => u.tempNodeId == widget.node.id);
|
||||
} else if (widget.node.tags['_pending_edit'] == 'true' ||
|
||||
widget.node.tags['_pending_deletion'] == 'true') {
|
||||
_upload = appState.pendingUploads
|
||||
.firstWhereOrNull((u) => u.originalNodeId == widget.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the node data that should currently be displayed.
|
||||
///
|
||||
/// While an upload/edit/deletion is still in progress (or has failed), this
|
||||
/// is just the snapshot that was passed in when the sheet was opened -
|
||||
/// nothing has changed in the cache yet. Once the tracked upload completes,
|
||||
/// we switch to the live node from the cache using the *real* submitted ID
|
||||
/// (which can differ from the temporary negative "-epoch" ID for creates
|
||||
/// and extracts, but matches the original ID for edits) so that tags, id,
|
||||
/// and editability reflect reality instead of the stale pre-submission
|
||||
/// snapshot. Returns null if the node has been deleted.
|
||||
OsmNode? _resolveDisplayNode() {
|
||||
final upload = _upload;
|
||||
if (upload == null) {
|
||||
// No tracked upload - either this node was never pending, or we
|
||||
// couldn't find a matching queue entry (shouldn't normally happen).
|
||||
// Try a live cache lookup so we still pick up any external updates,
|
||||
// but fall back to the passed-in node if it's not (or no longer) cached.
|
||||
return MapDataProvider().getNodeById(widget.node.id) ?? widget.node;
|
||||
}
|
||||
|
||||
if (!upload.isComplete) {
|
||||
// Still pending/in-progress/failed - nothing has changed yet.
|
||||
return widget.node;
|
||||
}
|
||||
|
||||
if (upload.isDeletion) {
|
||||
return null; // Node has been deleted
|
||||
}
|
||||
|
||||
// Upload completed successfully (create/modify/extract). Look up the
|
||||
// live node using the real submitted ID (same as originalNodeId for
|
||||
// modify operations, a brand-new positive ID for create/extract).
|
||||
final realId = upload.submittedNodeId ?? widget.node.id;
|
||||
final cached = MapDataProvider().getNodeById(realId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
// Fallback: cache might have already been cleared/replaced; reconstruct
|
||||
// from the upload data directly so we don't fall back to stale pending
|
||||
// tags/id.
|
||||
return OsmNode(
|
||||
id: realId,
|
||||
coord: upload.coord,
|
||||
tags: upload.getCombinedTags(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -27,22 +113,30 @@ class NodeTagSheet extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
|
||||
final upload = _upload;
|
||||
final isDeleted = upload != null && upload.isComplete && upload.isDeletion;
|
||||
final displayNode = isDeleted ? null : _resolveDisplayNode();
|
||||
|
||||
if (displayNode == null) {
|
||||
return _buildDeletedSheet(context, locService);
|
||||
}
|
||||
|
||||
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_deletion') ||
|
||||
node.tags['_pending_deletion'] != 'true');
|
||||
final isEditable = (!displayNode.tags.containsKey('_pending_upload') ||
|
||||
displayNode.tags['_pending_upload'] != 'true') &&
|
||||
(!displayNode.tags.containsKey('_pending_edit') ||
|
||||
displayNode.tags['_pending_edit'] != 'true') &&
|
||||
(!displayNode.tags.containsKey('_pending_deletion') ||
|
||||
displayNode.tags['_pending_deletion'] != 'true');
|
||||
|
||||
// Check if this is a real OSM node (not pending) - for "View on OSM" button
|
||||
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
|
||||
node.id > 0; // Real OSM nodes have positive IDs
|
||||
final isRealOSMNode = !displayNode.tags.containsKey('_pending_upload') &&
|
||||
displayNode.id > 0; // Real OSM nodes have positive IDs
|
||||
|
||||
void openEditSheet() {
|
||||
// Check if node limit is active and warn user
|
||||
if (isNodeLimitActive) {
|
||||
if (widget.isNodeLimitActive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@@ -55,27 +149,52 @@ class NodeTagSheet extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onEditPressed != null) {
|
||||
onEditPressed!(); // Use callback if provided
|
||||
if (widget.onEditPressed != null) {
|
||||
widget.onEditPressed!(); // Use callback if provided
|
||||
} else {
|
||||
// Fallback behavior
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
||||
appState.startEditSession(displayNode); // HomeScreen will auto-show the edit sheet
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode() async {
|
||||
if (!appState.isLoggedIn) {
|
||||
final shouldLogIn = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(locService.t('node.confirmDeleteTitle')),
|
||||
content: Text(locService.t('node.mustBeLoggedInToDelete')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(locService.t('actions.logIn')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if ((shouldLogIn ?? false) && context.mounted) {
|
||||
Navigator.pushNamed(context, '/settings/osm-account');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await showDialog<({bool confirmed, String comment})>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => _DeleteNodeDialog(
|
||||
nodeId: node.id.toString(),
|
||||
nodeId: displayNode.id.toString(),
|
||||
locService: locService,
|
||||
),
|
||||
);
|
||||
|
||||
if ((result?.confirmed ?? false) && context.mounted) {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.deleteNode(node, changesetComment: result!.comment);
|
||||
appState.deleteNode(displayNode, changesetComment: result!.comment);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
|
||||
);
|
||||
@@ -83,7 +202,7 @@ class NodeTagSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
void viewOnOSM() async {
|
||||
final url = 'https://www.openstreetmap.org/node/${node.id}';
|
||||
final url = 'https://www.openstreetmap.org/node/${displayNode.id}';
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
@@ -108,10 +227,148 @@ class NodeTagSheet extends StatelessWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AdvancedEditOptionsSheet(node: node),
|
||||
builder: (context) => AdvancedEditOptionsSheet(node: displayNode),
|
||||
);
|
||||
}
|
||||
|
||||
void viewInQueue() {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
Navigator.pushNamed(context, '/settings/queue');
|
||||
}
|
||||
|
||||
Widget? uploadStatusBanner;
|
||||
if (!isEditable) {
|
||||
final IconData statusIcon;
|
||||
final Color statusColor;
|
||||
final String statusLabel;
|
||||
final bool showSpinner;
|
||||
final bool showQueueButton;
|
||||
|
||||
final state = upload?.uploadState;
|
||||
|
||||
if (state == null) {
|
||||
// Fallback: tag says pending but we couldn't find a matching
|
||||
// queue entry (shouldn't normally happen).
|
||||
statusIcon = Icons.hourglass_empty;
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = locService.t('node.uploadPending');
|
||||
showSpinner = false;
|
||||
showQueueButton = true;
|
||||
} else {
|
||||
switch (state) {
|
||||
case UploadState.pending:
|
||||
statusIcon = Icons.hourglass_empty;
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = locService.t('node.uploadPending');
|
||||
showSpinner = false;
|
||||
showQueueButton = true;
|
||||
break;
|
||||
case UploadState.creatingChangeset:
|
||||
statusIcon = Icons.cloud_upload_outlined;
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = locService.t('node.uploadCreatingChangeset');
|
||||
showSpinner = true;
|
||||
showQueueButton = true;
|
||||
break;
|
||||
case UploadState.uploading:
|
||||
statusIcon = Icons.cloud_upload_outlined;
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = locService.t('node.uploadUploading');
|
||||
showSpinner = true;
|
||||
showQueueButton = true;
|
||||
break;
|
||||
case UploadState.closingChangeset:
|
||||
statusIcon = Icons.cloud_upload_outlined;
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = locService.t('node.uploadClosingChangeset');
|
||||
showSpinner = true;
|
||||
showQueueButton = true;
|
||||
break;
|
||||
case UploadState.error:
|
||||
statusIcon = Icons.error_outline;
|
||||
statusColor = Colors.red;
|
||||
statusLabel = locService.t('node.uploadError');
|
||||
showSpinner = false;
|
||||
showQueueButton = true;
|
||||
break;
|
||||
case UploadState.complete:
|
||||
statusIcon = Icons.check_circle_outline;
|
||||
statusColor = Colors.green;
|
||||
statusLabel = locService.t('node.uploadComplete');
|
||||
showSpinner = false;
|
||||
showQueueButton = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
uploadStatusBanner = Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (showSpinner)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: statusColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(statusIcon, color: statusColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
statusLabel,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state == UploadState.error &&
|
||||
(upload?.errorMessage?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
upload!.errorMessage!,
|
||||
style: TextStyle(
|
||||
color: statusColor.withValues(alpha: 0.9),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showQueueButton) ...[
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: viewInQueue,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
minimumSize: const Size(0, 28),
|
||||
foregroundColor: statusColor,
|
||||
),
|
||||
child: Text(locService.t('node.viewInQueue')),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SafeArea(
|
||||
@@ -122,78 +379,92 @@ class NodeTagSheet extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
locService.t('node.title').replaceAll('{}', displayNode.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
?uploadStatusBanner,
|
||||
|
||||
// Tag list with flexible height constraint
|
||||
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Linkify(
|
||||
onOpen: (link) async {
|
||||
final uri = Uri.parse(link.url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
text: e.value,
|
||||
child: SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...displayNode.tags.entries
|
||||
.where((e) => !e.key.startsWith('_')) // Hide internal bookkeeping tags
|
||||
.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Linkify(
|
||||
onOpen: (link) async {
|
||||
final uri = Uri.parse(link.url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
text: e.value,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// First row: View and Advanced buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isRealOSMNode) ...[
|
||||
TextButton.icon(
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => viewOnOSM(),
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(locService.t('actions.viewOnOSM')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
if (isEditable) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: openAdvancedEdit,
|
||||
@@ -222,12 +493,12 @@ class NodeTagSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: node.isConstrained ? null : deleteNode,
|
||||
onPressed: displayNode.isConstrained ? null : deleteNode,
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: Text(locService.t('actions.delete')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
foregroundColor: node.isConstrained ? null : Colors.red,
|
||||
foregroundColor: displayNode.isConstrained ? null : Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -247,6 +518,103 @@ class NodeTagSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shown when the tracked upload was a deletion that has now completed -
|
||||
/// the underlying node no longer exists, so we can't show tags or offer
|
||||
/// edit/delete actions on it anymore.
|
||||
Widget _buildDeletedSheet(BuildContext context, LocalizationService locService) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', widget.node.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: Colors.green, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
locService.t('node.deletedSuccessfully'),
|
||||
style: const TextStyle(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.close')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _DeleteReason {
|
||||
deviceRemoved,
|
||||
duplicateNode,
|
||||
misidentifiedInfra,
|
||||
spamVandalism,
|
||||
custom,
|
||||
}
|
||||
|
||||
extension _DeleteReasonLabel on _DeleteReason {
|
||||
String label(LocalizationService locService) {
|
||||
switch (this) {
|
||||
case _DeleteReason.deviceRemoved:
|
||||
return locService.t('node.deleteReasonDeviceRemoved');
|
||||
case _DeleteReason.duplicateNode:
|
||||
return locService.t('node.deleteReasonDuplicateNode');
|
||||
case _DeleteReason.misidentifiedInfra:
|
||||
return locService.t('node.deleteReasonMisidentifiedInfra');
|
||||
case _DeleteReason.spamVandalism:
|
||||
return locService.t('node.deleteReasonSpamVandalism');
|
||||
case _DeleteReason.custom:
|
||||
return locService.t('node.deleteReasonCustom');
|
||||
}
|
||||
}
|
||||
|
||||
/// The text used to build the changeset comment (kept in English/consistent
|
||||
/// regardless of UI language, matching prior app behavior for uploads).
|
||||
String get commentText {
|
||||
switch (this) {
|
||||
case _DeleteReason.deviceRemoved:
|
||||
return 'device removed';
|
||||
case _DeleteReason.duplicateNode:
|
||||
return 'duplicate node';
|
||||
case _DeleteReason.misidentifiedInfra:
|
||||
return 'misidentified infra';
|
||||
case _DeleteReason.spamVandalism:
|
||||
return 'spam/vandalism';
|
||||
case _DeleteReason.custom:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteNodeDialog extends StatefulWidget {
|
||||
@@ -264,6 +632,7 @@ class _DeleteNodeDialog extends StatefulWidget {
|
||||
|
||||
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
late final TextEditingController _commentController;
|
||||
_DeleteReason? _selectedReason;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -277,6 +646,14 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canConfirm {
|
||||
if (_selectedReason == null) return false;
|
||||
if (_selectedReason == _DeleteReason.custom) {
|
||||
return _commentController.text.trim().isNotEmpty;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
@@ -292,16 +669,39 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
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(),
|
||||
DropdownButtonFormField<_DeleteReason>(
|
||||
initialValue: _selectedReason,
|
||||
hint: Text(widget.locService.t('node.deleteReasonSelectHint')),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
items: _DeleteReason.values
|
||||
.map((reason) => DropdownMenuItem(
|
||||
value: reason,
|
||||
child: Text(reason.label(widget.locService)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (reason) {
|
||||
setState(() {
|
||||
_selectedReason = reason;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_selectedReason == _DeleteReason.custom) ...[
|
||||
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,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -310,17 +710,21 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
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));
|
||||
},
|
||||
onPressed: _canConfirm
|
||||
? () {
|
||||
final reasonText = _selectedReason == _DeleteReason.custom
|
||||
? _commentController.text.trim()
|
||||
: _selectedReason!.commentText;
|
||||
final finalComment = reasonText.isEmpty
|
||||
? 'Delete a surveillance node'
|
||||
: 'Delete a surveillance node: $reasonText';
|
||||
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
||||
}
|
||||
: null,
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(widget.locService.t('actions.delete')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.10.2+57 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.11.0+61 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
localize.py — small helper for managing lib/localizations/*.json translation files.
|
||||
|
||||
Usage (normally invoked via scripts/localize.sh):
|
||||
|
||||
localize.sh --check
|
||||
Validate that every locale file is valid JSON, has a language.name key,
|
||||
and has exactly the same set of leaf keys as en.json (the reference).
|
||||
Exits non-zero (and prints details) if anything doesn't match.
|
||||
|
||||
localize.sh --list [--lang xx]
|
||||
Print all dot-notation leaf keys. Defaults to en.json if --lang is
|
||||
omitted. Useful for seeing the full key structure at a glance.
|
||||
|
||||
localize.sh --get --lang xx --key some.nested.key
|
||||
Print the value of a single key from lib/localizations/xx.json.
|
||||
|
||||
localize.sh --write --lang xx --key some.nested.key "New value"
|
||||
Set (or add) the value of a key in lib/localizations/xx.json. Creates
|
||||
intermediate objects as needed. Preserves key order/formatting via
|
||||
Python's json module (2-space indent, matching existing files).
|
||||
|
||||
Examples:
|
||||
./scripts/localize.sh --check
|
||||
./scripts/localize.sh --list --lang de
|
||||
./scripts/localize.sh --get --lang en --key node.title
|
||||
./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LOCALIZATIONS_DIR = Path(__file__).resolve().parent.parent / "lib" / "localizations"
|
||||
REFERENCE_LOCALE = "en"
|
||||
|
||||
|
||||
def locale_path(lang: str) -> Path:
|
||||
return LOCALIZATIONS_DIR / f"{lang}.json"
|
||||
|
||||
|
||||
def load_locale(lang: str) -> dict:
|
||||
path = locale_path(lang)
|
||||
if not path.exists():
|
||||
print(f"error: no such locale file: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"error: {path.name} is not valid JSON: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def save_locale(lang: str, data: dict) -> None:
|
||||
path = locale_path(lang)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def all_locale_files():
|
||||
return sorted(LOCALIZATIONS_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def extract_leaf_keys(data, prefix=""):
|
||||
keys = set()
|
||||
for k, v in data.items():
|
||||
full_key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
keys |= extract_leaf_keys(v, full_key)
|
||||
else:
|
||||
keys.add(full_key)
|
||||
return keys
|
||||
|
||||
|
||||
def get_nested(data: dict, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
node = data
|
||||
for part in parts:
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return None
|
||||
node = node[part]
|
||||
return node
|
||||
|
||||
|
||||
def set_nested(data: dict, dotted_key: str, value: str) -> None:
|
||||
parts = dotted_key.split(".")
|
||||
node = data
|
||||
for part in parts[:-1]:
|
||||
if part not in node or not isinstance(node[part], dict):
|
||||
node[part] = {}
|
||||
node = node[part]
|
||||
node[parts[-1]] = value
|
||||
|
||||
|
||||
def cmd_check(_args) -> int:
|
||||
files = all_locale_files()
|
||||
if not files:
|
||||
print(f"error: no locale files found in {LOCALIZATIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ref_path = locale_path(REFERENCE_LOCALE)
|
||||
if not ref_path.exists():
|
||||
print(f"error: reference locale '{REFERENCE_LOCALE}.json' not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ok = True
|
||||
|
||||
parsed = {}
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
parsed[path.stem] = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"FAIL {path.name}: invalid JSON: {e}")
|
||||
ok = False
|
||||
|
||||
for lang, data in parsed.items():
|
||||
lang_name = data.get("language", {}).get("name")
|
||||
if not isinstance(lang_name, str) or not lang_name:
|
||||
print(f"FAIL {lang}.json: missing 'language.name' string")
|
||||
ok = False
|
||||
|
||||
if REFERENCE_LOCALE not in parsed:
|
||||
print(f"error: {REFERENCE_LOCALE}.json failed to parse, cannot compare keys")
|
||||
return 1
|
||||
|
||||
reference_keys = extract_leaf_keys(parsed[REFERENCE_LOCALE])
|
||||
|
||||
for lang, data in parsed.items():
|
||||
if lang == REFERENCE_LOCALE:
|
||||
continue
|
||||
keys = extract_leaf_keys(data)
|
||||
missing = reference_keys - keys
|
||||
extra = keys - reference_keys
|
||||
if missing:
|
||||
print(f"FAIL {lang}.json: missing {len(missing)} key(s):")
|
||||
for k in sorted(missing):
|
||||
print(f" - {k}")
|
||||
ok = False
|
||||
if extra:
|
||||
print(f"FAIL {lang}.json: has {len(extra)} extra key(s) not in {REFERENCE_LOCALE}.json:")
|
||||
for k in sorted(extra):
|
||||
print(f" + {k}")
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
print(f"OK {len(parsed)} locale file(s) valid, all keys match {REFERENCE_LOCALE}.json")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_list(args) -> int:
|
||||
lang = args.lang or REFERENCE_LOCALE
|
||||
data = load_locale(lang)
|
||||
keys = sorted(extract_leaf_keys(data))
|
||||
for k in keys:
|
||||
print(k)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_get(args) -> int:
|
||||
if not args.lang or not args.key:
|
||||
print("error: --get requires --lang and --key", file=sys.stderr)
|
||||
return 1
|
||||
data = load_locale(args.lang)
|
||||
value = get_nested(data, args.key)
|
||||
if value is None:
|
||||
print(f"error: key '{args.key}' not found in {args.lang}.json", file=sys.stderr)
|
||||
return 1
|
||||
if isinstance(value, dict):
|
||||
print(f"error: '{args.key}' is not a leaf key (it's an object) in {args.lang}.json", file=sys.stderr)
|
||||
return 1
|
||||
print(value)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_write(args) -> int:
|
||||
if not args.lang or not args.key or args.value is None:
|
||||
print("error: --write requires --lang, --key, and a value argument", file=sys.stderr)
|
||||
return 1
|
||||
data = load_locale(args.lang)
|
||||
set_nested(data, args.key, args.value)
|
||||
save_locale(args.lang, data)
|
||||
print(f"Wrote {args.lang}.json: {args.key} = {args.value!r}")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage DeFlock localization JSON files.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="Validate all locale files match en.json's key structure")
|
||||
group.add_argument("--list", action="store_true", help="List all dot-notation leaf keys for a locale (default: en)")
|
||||
group.add_argument("--get", action="store_true", help="Print the value of a single key")
|
||||
group.add_argument("--write", action="store_true", help="Set the value of a single key")
|
||||
|
||||
parser.add_argument("--lang", help="Language code, e.g. 'en', 'de', 'es' (filename without .json)")
|
||||
parser.add_argument("--key", help="Dot-notation key, e.g. 'node.title'")
|
||||
parser.add_argument("value", nargs="?", help="New value (only used with --write)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
sys.exit(cmd_check(args))
|
||||
elif args.list:
|
||||
sys.exit(cmd_list(args))
|
||||
elif args.get:
|
||||
sys.exit(cmd_get(args))
|
||||
elif args.write:
|
||||
sys.exit(cmd_write(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper around localize.py — see that file's docstring for full usage.
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/localize.sh --check
|
||||
# ./scripts/localize.sh --list --lang de
|
||||
# ./scripts/localize.sh --get --lang en --key node.title
|
||||
# ./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/localize.py" "$@"
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:deflockapp/services/coordinate_validation.dart';
|
||||
|
||||
void main() {
|
||||
group('isValidLatitude', () {
|
||||
test('accepts in-range values', () {
|
||||
expect(isValidLatitude(0), isTrue);
|
||||
expect(isValidLatitude(37.7749), isTrue);
|
||||
expect(isValidLatitude(90), isTrue);
|
||||
expect(isValidLatitude(-90), isTrue);
|
||||
});
|
||||
|
||||
test('rejects NaN and Infinite', () {
|
||||
expect(isValidLatitude(double.nan), isFalse);
|
||||
expect(isValidLatitude(double.infinity), isFalse);
|
||||
expect(isValidLatitude(double.negativeInfinity), isFalse);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values', () {
|
||||
expect(isValidLatitude(90.1), isFalse);
|
||||
expect(isValidLatitude(-90.1), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isValidLongitude', () {
|
||||
test('accepts in-range values', () {
|
||||
expect(isValidLongitude(0), isTrue);
|
||||
expect(isValidLongitude(-122.4194), isTrue);
|
||||
expect(isValidLongitude(180), isTrue);
|
||||
expect(isValidLongitude(-180), isTrue);
|
||||
});
|
||||
|
||||
test('rejects NaN and Infinite', () {
|
||||
expect(isValidLongitude(double.nan), isFalse);
|
||||
expect(isValidLongitude(double.infinity), isFalse);
|
||||
expect(isValidLongitude(double.negativeInfinity), isFalse);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values', () {
|
||||
expect(isValidLongitude(180.1), isFalse);
|
||||
expect(isValidLongitude(-180.1), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isValidZoom', () {
|
||||
test('accepts in-range values (default bounds: 1.0 to kAbsoluteMaxZoom)', () {
|
||||
expect(isValidZoom(1), isTrue);
|
||||
expect(isValidZoom(15), isTrue);
|
||||
expect(isValidZoom(23), isTrue);
|
||||
});
|
||||
|
||||
test('rejects NaN and Infinite', () {
|
||||
expect(isValidZoom(double.nan), isFalse);
|
||||
expect(isValidZoom(double.infinity), isFalse);
|
||||
expect(isValidZoom(double.negativeInfinity), isFalse);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values with default bounds', () {
|
||||
expect(isValidZoom(0), isFalse);
|
||||
expect(isValidZoom(0.5), isFalse);
|
||||
expect(isValidZoom(24), isFalse);
|
||||
});
|
||||
|
||||
test('respects custom min/max bounds', () {
|
||||
expect(isValidZoom(0.5, min: 1.0, max: 20.0), isFalse);
|
||||
expect(isValidZoom(1.0, min: 1.0, max: 20.0), isTrue);
|
||||
expect(isValidZoom(20.0, min: 1.0, max: 20.0), isTrue);
|
||||
expect(isValidZoom(20.1, min: 1.0, max: 20.0), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -41,8 +41,11 @@ void main() {
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
|
||||
// Default stubs: online, no offline areas
|
||||
// Default stubs: online, no offline areas, offline features enabled
|
||||
// (so tests exercise the same behavior as before this setting existed)
|
||||
when(() => mockAppState.offlineMode).thenReturn(false);
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||
|
||||
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/services/offline_area_service.dart';
|
||||
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
|
||||
|
||||
OfflineArea _makeArea({
|
||||
String providerId = 'osm',
|
||||
String tileTypeId = 'standard',
|
||||
@@ -26,11 +31,19 @@ OfflineArea _makeArea({
|
||||
|
||||
void main() {
|
||||
final service = OfflineAreaService();
|
||||
late MockAppState mockAppState;
|
||||
|
||||
setUp(() {
|
||||
service.setAreasForTesting([]);
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
// These tests exercise the underlying zoom/provider matching logic, so
|
||||
// offline features are enabled by default here — the "disabled" gating
|
||||
// itself is covered separately below.
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||
});
|
||||
|
||||
|
||||
group('hasOfflineAreasForProviderAtZoom', () {
|
||||
test('returns true for zoom within range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
@@ -90,4 +103,30 @@ void main() {
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('offlineFeaturesEnabled gating', () {
|
||||
test('hasOfflineAreasForProvider returns false when feature disabled, even with matching areas', () {
|
||||
service.setAreasForTesting([_makeArea(providerId: 'osm', tileTypeId: 'standard')]);
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||
|
||||
expect(service.hasOfflineAreasForProvider('osm', 'standard'), isFalse);
|
||||
});
|
||||
|
||||
test('hasOfflineAreasForProviderAtZoom returns false when feature disabled, even within zoom range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('re-enabling offlineFeaturesEnabled restores normal matching', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ void main() {
|
||||
}
|
||||
|
||||
group('query building', () {
|
||||
test('uses out ids for way/relation pass, out body for node pass',
|
||||
test('uses the way/relation membership pass with restriction conversion',
|
||||
() async {
|
||||
stubOverpassResponse([]);
|
||||
|
||||
@@ -68,11 +68,16 @@ void main() {
|
||||
).captured;
|
||||
|
||||
final query = (captured.last as Map<String, String>)['data']!;
|
||||
expect(query, contains('out body;'));
|
||||
expect(query, contains('out ids;'));
|
||||
expect(query, contains('.allNodes out body;'));
|
||||
expect(query, contains('way(bn.allNodes);'));
|
||||
expect(query, contains('relation(bn.allNodes);'));
|
||||
expect(query, contains('.resNodes'));
|
||||
expect(query, contains('convert restriction ::id = id();'));
|
||||
expect(query, isNot(contains('out meta;')));
|
||||
expect(query, isNot(contains('out ids;')));
|
||||
});
|
||||
|
||||
|
||||
test('empty tag values are excluded from filters', () async {
|
||||
final profileWithEmpty = [
|
||||
NodeProfile(
|
||||
@@ -137,12 +142,14 @@ void main() {
|
||||
expect(query, isNot(contains('manufacturer')));
|
||||
expect(query, isNot(contains('Flock Safety')));
|
||||
|
||||
// Only one node clause total.
|
||||
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
|
||||
expect(nodeClauseCount, equals(1));
|
||||
// Only one distinct tag-filter clause, appearing twice (once for
|
||||
// .allNodes, once for the .resNodes re-filter).
|
||||
final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length;
|
||||
expect(nodeClauseCount, equals(2));
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('unrelated profiles are all kept', () async {
|
||||
final alprGeneric = NodeProfile(
|
||||
id: 'generic-alpr',
|
||||
@@ -186,11 +193,14 @@ void main() {
|
||||
).captured;
|
||||
final query = (captured.last as Map<String, String>)['data']!;
|
||||
|
||||
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
|
||||
expect(nodeClauseCount, equals(2));
|
||||
// Each of the 2 generic clauses appears twice (once in .allNodes,
|
||||
// once in the .resNodes re-filter) = 4 total tag-filter clauses.
|
||||
final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length;
|
||||
expect(nodeClauseCount, equals(4));
|
||||
|
||||
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]'));
|
||||
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]'));
|
||||
|
||||
// Brand-specific tags should not appear anywhere in the reduced query.
|
||||
expect(query, isNot(contains('manufacturer')));
|
||||
expect(query, isNot(contains('ShotSpotter')));
|
||||
@@ -223,7 +233,13 @@ void main() {
|
||||
|
||||
|
||||
group('response parsing — constraint detection', () {
|
||||
test('nodes referenced by a way are constrained', () async {
|
||||
test('a node referenced as a "restriction" element is constrained',
|
||||
() async {
|
||||
// The Overpass query converts each way/relation-referenced node ID
|
||||
// into a synthetic {"type":"restriction","id":<nodeId>} element
|
||||
// (via `convert restriction ::id = id();`) — this is how the server
|
||||
// tells us a node is part of some way/relation, regardless of whether
|
||||
// that way/relation came from a "way" or a "relation" in Overpass.
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
@@ -240,9 +256,8 @@ void main() {
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'id': 100,
|
||||
'nodes': [1],
|
||||
'type': 'restriction',
|
||||
'id': 1,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -256,32 +271,8 @@ void main() {
|
||||
expect(node2.isConstrained, isFalse);
|
||||
});
|
||||
|
||||
test('nodes referenced by a relation member are constrained', () async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
'id': 3,
|
||||
'lat': 38.9,
|
||||
'lon': -77.0,
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'relation',
|
||||
'id': 200,
|
||||
'members': [
|
||||
{'type': 'node', 'ref': 3, 'role': ''},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
final nodes =
|
||||
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
expect(nodes, hasLength(1));
|
||||
expect(nodes.first.isConstrained, isTrue);
|
||||
});
|
||||
|
||||
test('nodes not in any way or relation are unconstrained', () async {
|
||||
test('nodes not marked with a "restriction" element are unconstrained',
|
||||
() async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
@@ -299,7 +290,8 @@ void main() {
|
||||
expect(nodes.first.isConstrained, isFalse);
|
||||
});
|
||||
|
||||
test('mixed response with nodes, ways, and relations', () async {
|
||||
test('mixed response with multiple constrained and unconstrained nodes',
|
||||
() async {
|
||||
stubOverpassResponse([
|
||||
{
|
||||
'type': 'node',
|
||||
@@ -323,16 +315,12 @@ void main() {
|
||||
'tags': {'man_made': 'surveillance'},
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'id': 300,
|
||||
'nodes': [10],
|
||||
'type': 'restriction',
|
||||
'id': 10,
|
||||
},
|
||||
{
|
||||
'type': 'relation',
|
||||
'id': 400,
|
||||
'members': [
|
||||
{'type': 'node', 'ref': 11, 'role': ''},
|
||||
],
|
||||
'type': 'restriction',
|
||||
'id': 11,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -346,6 +334,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group('error handling', () {
|
||||
test('HTTP 200 returns parsed nodes', () async {
|
||||
stubOverpassResponse([
|
||||
|
||||
Reference in new issue
Block a user