mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-04-01 09:40:32 +02:00
Compare commits
12 Commits
v2.9.1-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
157e72011f | ||
|
|
bd71f88452 | ||
|
|
6e2fa3a04c | ||
|
|
843d377b87 | ||
|
|
3857023d43 | ||
|
|
ddf7f543ff | ||
|
|
9f72ec9a76 | ||
|
|
16e34b614f | ||
|
|
2d00d7a8fb | ||
|
|
f85db3ca11 | ||
|
|
447f358727 | ||
|
|
08b395214b |
11
COMMENT
11
COMMENT
@@ -1,11 +0,0 @@
|
||||
---
|
||||
An alternative approach to addressing this issue could be adjusting the `optionsBuilder` logic to avoid returning any suggestions when the input text field is empty, rather than guarding `onFieldSubmitted`. For instance:
|
||||
|
||||
```dart
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text.isEmpty) return <String>[];
|
||||
return suggestions.where((s) => s.contains(textEditingValue.text));
|
||||
}
|
||||
```
|
||||
|
||||
This ensures that the `RawAutocomplete` widget doesn't offer any options to auto-select on submission when the field is cleared, potentially simplifying the implementation and avoiding the need for additional boolean flags (`guardOnSubmitted`). This pattern can be seen in some implementations "in the wild."
|
||||
@@ -1,4 +1,24 @@
|
||||
{
|
||||
"2.10.1": {
|
||||
"content": [
|
||||
"• Operator profiles are now reorderable",
|
||||
"• Support operator profile import via deflockapp:// links",
|
||||
"• Makes operator profile UI consistent with device profiles"
|
||||
]
|
||||
},
|
||||
"2.10.0": {
|
||||
"content": [
|
||||
"• Simplified profile FOVs; there is now only a checkbox for '360'",
|
||||
]
|
||||
},
|
||||
"2.9.2": {
|
||||
"content": [
|
||||
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
|
||||
"• Added 'Clear Caches' option to tile provider menus - easily free up storage space by clearing cached tiles for specific providers",
|
||||
"• Enhanced node deletion workflow - users can now provide an informative reason when deleting surveillance devices",
|
||||
"• Added 'Report issue with OSM base map' link in About screen help section - easily report issues with the underlying OpenStreetMap data"
|
||||
]
|
||||
},
|
||||
"2.9.1": {
|
||||
"content": [
|
||||
"• When hitting node render limit, only render nodes closest to center of viewport.",
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,12 +2,15 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<!-- OAuth2 redirect handler -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- (Optional) allow opening the system browser and returning -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -414,6 +414,11 @@ class AppState extends ChangeNotifier {
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
/// Reload all profiles from storage (useful after migrations modify stored profiles)
|
||||
Future<void> reloadProfiles() async {
|
||||
await _profileState.reloadFromStorage();
|
||||
}
|
||||
|
||||
// Callback when a profile is deleted - clear any stale session references
|
||||
void _onProfileDeleted(NodeProfile deletedProfile) {
|
||||
@@ -437,6 +442,10 @@ class AppState extends ChangeNotifier {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
void reorderOperatorProfiles(int oldIndex, int newIndex) {
|
||||
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
@@ -581,8 +590,8 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode(OsmNode node) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||
void deleteNode(OsmNode node, {String? changesetComment}) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
||||
_startUploader();
|
||||
}
|
||||
|
||||
@@ -717,6 +726,11 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
/// Clear all tile caches for a specific provider
|
||||
Future<void> clearTileProviderCaches(String providerId) async {
|
||||
await _settingsState.clearTileProviderCaches(providerId);
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
|
||||
@@ -82,6 +82,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)
|
||||
|
||||
// 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
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
return kEnableNavigationFeatures && !offlineMode;
|
||||
@@ -90,7 +93,7 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
@@ -128,7 +131,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
|
||||
|
||||
// Positioning tutorial configuration
|
||||
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
|
||||
@@ -136,7 +139,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
|
||||
|
||||
// Navigation route planning configuration
|
||||
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
||||
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
|
||||
const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
|
||||
|
||||
// Node display configuration
|
||||
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
||||
|
||||
@@ -95,7 +95,9 @@
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
||||
"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."
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteReasonLabel": "Grund für Löschung (Optional)",
|
||||
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -247,6 +249,9 @@
|
||||
"addProvider": "Anbieter Hinzufügen",
|
||||
"deleteProvider": "Anbieter Löschen",
|
||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"clearCaches": "Caches Leeren",
|
||||
"clearCachesConfirm": "Alle Kachel-Caches für \"{}\" löschen? Dies wird Speicherplatz freigeben, aber die Kacheln müssen beim Betrachten dieser Bereiche erneut heruntergeladen werden.",
|
||||
"cachesCleared": "Caches für \"{}\" geleert",
|
||||
"providerName": "Anbieter-Name",
|
||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||
@@ -319,6 +324,8 @@
|
||||
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
|
||||
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
|
||||
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
|
||||
"fov360": "360° Sichtfeld",
|
||||
"fov360Subtitle": "Kamera hat 360-Grad omnidirektionales Sichtfeld",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
|
||||
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
|
||||
"minimumDistance": "Mindestabstand zu echten Geräten",
|
||||
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
|
||||
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {} vorhandener Überwachungsgeräte ausblenden",
|
||||
"updating": "Verdächtige Standorte werden aktualisiert",
|
||||
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
|
||||
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Node edit queued for upload",
|
||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||
"confirmDeleteTitle": "Delete Node",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
|
||||
"deleteReasonLabel": "Reason for Deletion (Optional)",
|
||||
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
@@ -282,8 +284,11 @@
|
||||
"needsApiKey": "Needs API key",
|
||||
"editProvider": "Edit Provider",
|
||||
"addProvider": "Add Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"clearCaches": "Clear Caches",
|
||||
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
|
||||
"cachesCleared": "Caches cleared for \"{}\"",
|
||||
"providerName": "Provider Name",
|
||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||
"providerNameRequired": "Provider name is required",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "FOV in degrees (leave empty for default)",
|
||||
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
|
||||
"fovInvalid": "FOV must be between 1 and 360 degrees",
|
||||
"fov360": "360 FOV",
|
||||
"fov360Subtitle": "Camera has 360-degree omnidirectional field of view",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
|
||||
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
|
||||
"minimumDistance": "Minimum Distance from Real Nodes",
|
||||
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
|
||||
"minimumDistanceSubtitle": "Hide suspected locations within {} of existing surveillance devices",
|
||||
"updating": "Updating Suspected Locations",
|
||||
"downloadingAndProcessing": "Downloading and processing data...",
|
||||
"updateSuccess": "Suspected locations updated successfully",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
||||
"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."
|
||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
|
||||
"deleteReasonLabel": "Razón para Eliminación (Opcional)",
|
||||
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Agregar Proveedor",
|
||||
"deleteProvider": "Eliminar Proveedor",
|
||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"clearCaches": "Limpiar Caché",
|
||||
"clearCachesConfirm": "¿Limpiar todo el caché de mosaicos para \"{}\"? Esto liberará espacio de almacenamiento pero los mosaicos deberán descargarse nuevamente al ver esas áreas.",
|
||||
"cachesCleared": "Caché limpiado para \"{}\"",
|
||||
"providerName": "Nombre del Proveedor",
|
||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
|
||||
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
|
||||
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
|
||||
"fov360": "Campo de Visión 360°",
|
||||
"fov360Subtitle": "La cámara tiene un campo de visión omnidireccional de 360 grados",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
|
||||
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
|
||||
"minimumDistance": "Distancia Mínima de Nodos Reales",
|
||||
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
|
||||
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {} de dispositivos de vigilancia existentes",
|
||||
"updating": "Actualizando Ubicaciones Sospechosas",
|
||||
"downloadingAndProcessing": "Descargando y procesando datos...",
|
||||
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
||||
"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."
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
|
||||
"deleteReasonLabel": "Raison de Suppression (Optionnel)",
|
||||
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Ajouter Fournisseur",
|
||||
"deleteProvider": "Supprimer Fournisseur",
|
||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"clearCaches": "Vider les Caches",
|
||||
"clearCachesConfirm": "Vider tous les caches de tuiles pour \"{}\"? Cela libérera de l'espace de stockage mais les tuiles devront être téléchargées à nouveau lors de la consultation de ces zones.",
|
||||
"cachesCleared": "Caches vidés pour \"{}\"",
|
||||
"providerName": "Nom du Fournisseur",
|
||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
|
||||
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
|
||||
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
|
||||
"fov360": "Champ de Vision 360°",
|
||||
"fov360Subtitle": "La caméra a un champ de vision omnidirectionnel de 360 degrés",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
|
||||
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
|
||||
"minimumDistance": "Distance Minimale des Nœuds Réels",
|
||||
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants",
|
||||
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {} des dispositifs de surveillance existants",
|
||||
"updating": "Mise à Jour des Emplacements Suspects",
|
||||
"downloadingAndProcessing": "Téléchargement et traitement des données...",
|
||||
"updateSuccess": "Emplacements suspects mis à jour avec succès",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||
"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."
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
|
||||
"deleteReasonLabel": "Motivo per Eliminazione (Opzionale)",
|
||||
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profilo",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Aggiungi Fornitore",
|
||||
"deleteProvider": "Elimina Fornitore",
|
||||
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"clearCaches": "Svuota Cache",
|
||||
"clearCachesConfirm": "Svuotare tutte le cache delle tessere per \"{}\"? Questo libererà spazio di archiviazione ma le tessere dovranno essere scaricate nuovamente quando si visualizzano quelle aree.",
|
||||
"cachesCleared": "Cache svuotate per \"{}\"",
|
||||
"providerName": "Nome Fornitore",
|
||||
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
|
||||
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
|
||||
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
|
||||
"fov360": "Campo Visivo 360°",
|
||||
"fov360Subtitle": "La telecamera ha un campo visivo omnidirezionale di 360 gradi",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
|
||||
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
|
||||
"minimumDistance": "Distanza Minima dai Nodi Reali",
|
||||
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
|
||||
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {} dai dispositivi di sorveglianza esistenti",
|
||||
"updating": "Aggiornamento Posizioni Sospette",
|
||||
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
|
||||
"updateSuccess": "Posizioni sospette aggiornate con successo",
|
||||
|
||||
@@ -131,8 +131,10 @@
|
||||
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
|
||||
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
|
||||
"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."
|
||||
"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..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profiel",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Voeg Provider Toe",
|
||||
"deleteProvider": "Verwijder Provider",
|
||||
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"clearCaches": "Cache Wissen",
|
||||
"clearCachesConfirm": "Alle tegelcaches wissen voor \"{}\"? Dit zal opslagruimte vrijmaken maar tegels moeten opnieuw worden gedownload bij het bekijken van die gebieden.",
|
||||
"cachesCleared": "Caches gewist voor \"{}\"",
|
||||
"providerName": "Provider Naam",
|
||||
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
|
||||
"providerNameRequired": "Provider naam is vereist",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "FOV in graden (laat leeg voor standaard)",
|
||||
"fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat",
|
||||
"fovInvalid": "FOV moet tussen 1 en 360 graden zijn",
|
||||
"fov360": "360° Gezichtsveld",
|
||||
"fov360Subtitle": "Camera heeft een 360-graden omnidirectioneel gezichtsveld",
|
||||
"submittable": "Indienbaar",
|
||||
"submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
|
||||
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
|
||||
"minimumDistance": "Minimum Afstand van Echte Nodes",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
|
||||
"updating": "Verdachte Locaties Bijwerken",
|
||||
"downloadingAndProcessing": "Data downloaden en verwerken...",
|
||||
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
||||
"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ąć."
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
|
||||
"deleteReasonLabel": "Powód Usunięcia (Opcjonalny)",
|
||||
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Dodaj Dostawcę",
|
||||
"deleteProvider": "Usuń Dostawcę",
|
||||
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"clearCaches": "Wyczyść Cache",
|
||||
"clearCachesConfirm": "Wyczyścić wszystkie cache kafelków dla \"{}\"? To zwolni miejsce na dysku, ale kafelki będą musiały zostać ponownie pobrane podczas przeglądania tych obszarów.",
|
||||
"cachesCleared": "Cache wyczyszczone dla \"{}\"",
|
||||
"providerName": "Nazwa Dostawcy",
|
||||
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
|
||||
"providerNameRequired": "Nazwa dostawcy jest wymagana",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "FOV w stopniach (zostaw puste dla domyślnego)",
|
||||
"fovSubtitle": "Pole widzenia kamery - używane dla szerokości stożka i formatu zgłaszania zasięgu",
|
||||
"fovInvalid": "FOV musi być między 1 a 360 stopniami",
|
||||
"fov360": "Pole Widzenia 360°",
|
||||
"fov360Subtitle": "Kamera ma wielokierunkowe pole widzenia o zasięgu 360 stopni",
|
||||
"submittable": "Możliwy do Zgłoszenia",
|
||||
"submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer",
|
||||
"osmTags": "Tagi OSM",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
|
||||
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
|
||||
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
|
||||
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
|
||||
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
|
||||
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||
"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."
|
||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
|
||||
"deleteReasonLabel": "Motivo para Exclusão (Opcional)",
|
||||
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Adicionar Provedor",
|
||||
"deleteProvider": "Excluir Provedor",
|
||||
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"clearCaches": "Limpar Caches",
|
||||
"clearCachesConfirm": "Limpar todos os caches de mapas para \"{}\"? Isso liberará espaço de armazenamento, mas os mapas precisarão ser baixados novamente ao visualizar essas áreas.",
|
||||
"cachesCleared": "Caches limpos para \"{}\"",
|
||||
"providerName": "Nome do Provedor",
|
||||
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
|
||||
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
|
||||
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
|
||||
"fov360": "Campo de Visão 360°",
|
||||
"fov360Subtitle": "A câmera tem um campo de visão omnidirecional de 360 graus",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
|
||||
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
|
||||
"minimumDistance": "Distância Mínima de Nós Reais",
|
||||
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes",
|
||||
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {} de dispositivos de vigilância existentes",
|
||||
"updating": "Atualizando Localizações Suspeitas",
|
||||
"downloadingAndProcessing": "Baixando e processando dados...",
|
||||
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||
"confirmDeleteTitle": "Düğümü Sil",
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleteReasonLabel": "Silme Nedeni (İsteğe Bağlı)",
|
||||
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Sağlayıcı Ekle",
|
||||
"deleteProvider": "Sağlayıcıyı Sil",
|
||||
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"clearCaches": "Önbellekleri Temizle",
|
||||
"clearCachesConfirm": "\"{}\" için tüm döşeme önbelleklerini temizle? Bu depolama alanını boşaltacak ancak bu alanları görüntülerken döşemelerin tekrar indirilmesi gerekecek.",
|
||||
"cachesCleared": "\"{}\" için önbellekler temizlendi",
|
||||
"providerName": "Sağlayıcı Adı",
|
||||
"providerNameHint": "örn., Özel Haritalar A.Ş.",
|
||||
"providerNameRequired": "Sağlayıcı adı gerekli",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)",
|
||||
"fovSubtitle": "Kamera görüş alanı - koni genişliği ve aralık gönderim formatı için kullanılır",
|
||||
"fovInvalid": "FOV 1 ile 360 derece arasında olmalıdır",
|
||||
"fov360": "360° Görüş Alanı",
|
||||
"fov360Subtitle": "Kamera 360 derece çok yönlü görüş alanına sahiptir",
|
||||
"submittable": "Gönderilebilir",
|
||||
"submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı",
|
||||
"osmTags": "OSM Etiketleri",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
|
||||
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
|
||||
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
|
||||
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}m yakınındaki şüpheli konumları gizle",
|
||||
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {} yakınındaki şüpheli konumları gizle",
|
||||
"updating": "Şüpheli Konumlar Güncelleniyor",
|
||||
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
|
||||
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||
"confirmDeleteTitle": "Видалити Вузол",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||
"deleteReasonLabel": "Причина Видалення (Опціонально)",
|
||||
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Профіль",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "Додати Постачальника",
|
||||
"deleteProvider": "Видалити Постачальника",
|
||||
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"clearCaches": "Очистити Кеш",
|
||||
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
|
||||
"cachesCleared": "Кеш очищено для \"{}\"",
|
||||
"providerName": "Назва Постачальника",
|
||||
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
||||
"providerNameRequired": "Назва постачальника обов'язкова",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
|
||||
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
|
||||
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
|
||||
"fov360": "Поле Зору 360°",
|
||||
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
|
||||
"submittable": "Можна Подавати",
|
||||
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
|
||||
"osmTags": "OSM Теги",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
||||
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
||||
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
|
||||
"updating": "Оновлення Підозрілих Локацій",
|
||||
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
||||
"updateSuccess": "Підозрілі локації успішно оновлено",
|
||||
|
||||
@@ -132,7 +132,9 @@
|
||||
"editQueuedForUpload": "节点编辑已排队上传",
|
||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||
"confirmDeleteTitle": "删除节点",
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
||||
"deleteReasonLabel": "删除原因(可选)",
|
||||
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
@@ -284,6 +286,9 @@
|
||||
"addProvider": "添加提供商",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"clearCaches": "清除缓存",
|
||||
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
|
||||
"cachesCleared": "已清除 \"{}\" 的缓存",
|
||||
"providerName": "提供商名称",
|
||||
"providerNameHint": "例如,自定义地图公司",
|
||||
"providerNameRequired": "提供商名称为必填项",
|
||||
@@ -356,6 +361,8 @@
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"fov360": "360°视场角",
|
||||
"fov360Subtitle": "摄像头具有360度全方向视场角",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
@@ -519,7 +526,7 @@
|
||||
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
||||
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
||||
"minimumDistance": "与真实节点的最小距离",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
|
||||
"updating": "正在更新疑似位置",
|
||||
"downloadingAndProcessing": "正在下载和处理数据...",
|
||||
"updateSuccess": "疑似位置更新成功",
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'app_state.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.
|
||||
@@ -142,6 +143,52 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear non-360 FOV values from all profiles (v2.10.0)
|
||||
static Future<void> migrate_2_10_0(AppState appState) async {
|
||||
try {
|
||||
// Only perform this migration if non-360 FOVs are disabled
|
||||
if (kEnableNon360FOVs) {
|
||||
debugPrint('[Migration] 2.10.0: Non-360 FOVs enabled, skipping FOV cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all profiles from storage
|
||||
final profiles = await ProfileService().load();
|
||||
bool anyProfileChanged = false;
|
||||
int profilesCleared = 0;
|
||||
|
||||
// Clear non-360 FOV values from all profiles
|
||||
final updatedProfiles = profiles.map((profile) {
|
||||
if (profile.fov != null) {
|
||||
// Use approximation to handle floating point precision issues
|
||||
final fovValue = profile.fov!;
|
||||
final is360 = (fovValue - 360.0).abs() < 0.01; // Within 0.01 degrees of 360
|
||||
|
||||
if (!is360) {
|
||||
debugPrint('[Migration] 2.10.0: Clearing FOV $fovValue from profile: ${profile.name}');
|
||||
anyProfileChanged = true;
|
||||
profilesCleared++;
|
||||
return profile.copyWith(fov: null);
|
||||
}
|
||||
}
|
||||
return profile;
|
||||
}).toList();
|
||||
|
||||
// Save updated profiles back to storage if any changes were made
|
||||
if (anyProfileChanged) {
|
||||
await ProfileService().save(updatedProfiles);
|
||||
await appState.reloadProfiles();
|
||||
debugPrint('[Migration] 2.10.0: Cleared FOV from $profilesCleared profiles');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.10.0 completed: cleared non-360 FOV values from profiles');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[Migration] 2.10.0 ERROR: Failed to clear non-360 FOV values: $e');
|
||||
debugPrint('[Migration] 2.10.0 ERROR: Stack trace: $stackTrace');
|
||||
// Don't rethrow - this is non-critical, FOV restrictions will still apply going forward
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -157,6 +204,8 @@ class OneTimeMigrations {
|
||||
return migrate_2_1_0;
|
||||
case '2.7.3':
|
||||
return migrate_2_7_3;
|
||||
case '2.10.0':
|
||||
return migrate_2_10_0;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/nuclear_reset_service.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
import '../widgets/submission_guide_dialog.dart';
|
||||
|
||||
@@ -76,10 +79,20 @@ class AboutScreen extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// About OpenStreetMap section
|
||||
_buildAboutOSMSection(context),
|
||||
const SizedBox(height: 24),
|
||||
// Information dialogs section
|
||||
_buildDialogButtons(context),
|
||||
const SizedBox(height: 24),
|
||||
_buildHelpLinks(context),
|
||||
|
||||
// Dev-only nuclear reset button at very bottom
|
||||
if (kDebugMode || kEnableDevelopmentModes) ...[
|
||||
const SizedBox(height: 32),
|
||||
_buildDevNuclearResetButton(context),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -96,6 +109,8 @@ class AboutScreen extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Report issue with OSM base map', 'https://www.openstreetmap.org/fixthemap'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||
@@ -121,6 +136,39 @@ class AboutScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutOSMSection(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.aboutOSM'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.aboutOSMDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _launchUrl('https://openstreetmap.org', context),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(locService.t('auth.visitOSM')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogButtons(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
@@ -165,5 +213,231 @@ class AboutScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dev-only nuclear reset button (only visible in debug mode)
|
||||
Widget _buildDevNuclearResetButton(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.developer_mode,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Developer Tools',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'These tools are only available in debug mode for development and troubleshooting.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showNuclearResetConfirmation(context),
|
||||
icon: const Icon(Icons.delete_forever, color: Colors.red),
|
||||
label: const Text(
|
||||
'Nuclear Reset (Clear All Data)',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show confirmation dialog for nuclear reset
|
||||
Future<void> _showNuclearResetConfirmation(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Nuclear Reset'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'This will completely clear ALL app data:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text('• All settings and preferences'),
|
||||
Text('• OAuth login credentials'),
|
||||
Text('• Custom profiles and operators'),
|
||||
Text('• Upload queue and cached data'),
|
||||
Text('• Downloaded offline areas'),
|
||||
Text('• Everything else'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'The app will behave exactly like a fresh install after this operation.',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'This action cannot be undone.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Nuclear Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
await _performNuclearReset(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the nuclear reset operation
|
||||
Future<void> _performNuclearReset(BuildContext context) async {
|
||||
// Show progress dialog
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Clearing all app data...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Perform the nuclear reset
|
||||
await NuclearResetService.clearEverything();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Close progress dialog
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Show completion dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset Complete'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'All app data has been cleared successfully.',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Please close and restart the app to continue with a fresh state.',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Close progress dialog if it's still open
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Show error dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset Failed'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'An error occurred during the nuclear reset:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
e.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Some data may have been partially cleared. You may want to manually clear app data through device settings.',
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
final key = e.key.trim();
|
||||
final value = e.value.trim();
|
||||
|
||||
// Skip if key is empty OR value is empty (no empty values for operator profiles)
|
||||
if (key.isEmpty || value.isEmpty) continue;
|
||||
|
||||
tagMap[key] = value;
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
|
||||
@@ -195,50 +195,9 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
child: UploadModeSection(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Information Section
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.aboutOSM'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.aboutOSMDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(locService.t('auth.visitOSM')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Account deletion section - only show when logged in and not in simulate mode
|
||||
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
@@ -22,6 +23,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
late TextEditingController _fovCtrl;
|
||||
late bool _is360Fov;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -41,6 +43,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||
_is360Fov = widget.profile.fov == 360.0;
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -94,28 +97,68 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
// Direction and FOV configuration - show different UI based on dev config
|
||||
if (kEnableNon360FOVs) ...[
|
||||
// Old UI: direction required + optional custom FOV text field (development mode)
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// New UI: mutually exclusive direction vs 360° FOV checkboxes (production mode)
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: widget.profile.editable
|
||||
? (value) {
|
||||
setState(() {
|
||||
_requiresDirection = value ?? false;
|
||||
// Make mutually exclusive with 360° FOV
|
||||
if (_requiresDirection) {
|
||||
_is360Fov = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.fov360')),
|
||||
subtitle: Text(locService.t('profileEditor.fov360Subtitle')),
|
||||
value: _is360Fov,
|
||||
onChanged: widget.profile.editable
|
||||
? (value) {
|
||||
setState(() {
|
||||
_is360Fov = value ?? false;
|
||||
// Make mutually exclusive with direction requirement
|
||||
if (_is360Fov) {
|
||||
_requiresDirection = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
@@ -199,6 +242,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
}
|
||||
|
||||
String? _validateFov() {
|
||||
// Only validate when using text field mode
|
||||
if (!kEnableNon360FOVs) return null;
|
||||
|
||||
final text = _fovCtrl.text.trim();
|
||||
if (text.isEmpty) return null; // Optional field
|
||||
|
||||
@@ -219,14 +265,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate FOV if provided
|
||||
if (_validateFov() != null) {
|
||||
// Validate FOV if using text field mode
|
||||
if (kEnableNon360FOVs && _validateFov() != null) {
|
||||
return; // Don't save if FOV validation fails
|
||||
}
|
||||
|
||||
// Parse FOV
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
// Parse FOV based on dev config mode
|
||||
double? fov;
|
||||
if (kEnableNon360FOVs) {
|
||||
// Old mode: parse from text field
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
} else {
|
||||
// New mode: use checkbox state
|
||||
fov = _is360Fov ? 360.0 : null;
|
||||
}
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
|
||||
@@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: appState.operatorProfiles.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
appState.reorderOperatorProfiles(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final p = appState.operatorProfiles[index];
|
||||
return ListTile(
|
||||
key: ValueKey(p.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.grey,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -104,6 +104,9 @@ class TileProviderSection extends StatelessWidget {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'clear_cache':
|
||||
_clearProviderCaches(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
@@ -120,6 +123,16 @@ class TileProviderSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'clear_cache',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.clear_all),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.clearCaches')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
@@ -154,6 +167,37 @@ class TileProviderSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _clearProviderCaches(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('tileProviders.clearCaches')),
|
||||
content: Text(locService.t('tileProviders.clearCachesConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await context.read<AppState>().clearTileProviderCaches(provider.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('tileProviders.cachesCleared', params: [provider.name])),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(locService.t('tileProviders.clearCaches')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
|
||||
@@ -225,10 +225,22 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.6.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.8.0')) {
|
||||
versionsNeedingMigration.add('1.8.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.1.0')) {
|
||||
versionsNeedingMigration.add('2.1.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
|
||||
versionsNeedingMigration.add('2.7.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) {
|
||||
versionsNeedingMigration.add('2.10.0');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
|
||||
@@ -3,8 +3,11 @@ import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import 'profile_import_service.dart';
|
||||
import 'operator_profile_import_service.dart';
|
||||
import '../screens/profile_editor.dart';
|
||||
import '../screens/operator_profile_editor.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
static final DeepLinkService _instance = DeepLinkService._internal();
|
||||
@@ -86,8 +89,16 @@ class DeepLinkService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
|
||||
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
|
||||
void _handleAddProfileLink(Uri uri) {
|
||||
// Check for operator profile parameter first
|
||||
final operatorBase64Data = uri.queryParameters['op'];
|
||||
if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) {
|
||||
_handleOperatorProfileImport(operatorBase64Data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise check for device profile parameter
|
||||
final base64Data = uri.queryParameters['p'];
|
||||
|
||||
if (base64Data == null || base64Data.isEmpty) {
|
||||
@@ -107,6 +118,20 @@ class DeepLinkService {
|
||||
_navigateToProfileEditor(profile);
|
||||
}
|
||||
|
||||
/// Handle operator profile import from deep link
|
||||
void _handleOperatorProfileImport(String base64Data) {
|
||||
// Parse operator profile from base64
|
||||
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (operatorProfile == null) {
|
||||
_showError('Invalid operator profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to operator profile editor with the imported profile
|
||||
_navigateToOperatorProfileEditor(operatorProfile);
|
||||
}
|
||||
|
||||
/// Navigate to profile editor with pre-filled profile data
|
||||
void _navigateToProfileEditor(NodeProfile profile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
@@ -124,6 +149,23 @@ class DeepLinkService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to operator profile editor with pre-filled operator profile data
|
||||
void _navigateToOperatorProfileEditor(OperatorProfile operatorProfile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] No navigator context available');
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: operatorProfile),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error message to user
|
||||
void _showError(String message) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
100
lib/services/operator_profile_import_service.dart
Normal file
100
lib/services/operator_profile_import_service.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
|
||||
class OperatorProfileImportService {
|
||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||
static const int maxBase64Length = 70000;
|
||||
|
||||
/// Parse and validate an operator profile from a base64-encoded JSON string
|
||||
/// Returns null if parsing/validation fails
|
||||
static OperatorProfile? parseProfileFromBase64(String base64Data) {
|
||||
try {
|
||||
// Basic size validation before expensive decode
|
||||
if (base64Data.length > maxBase64Length) {
|
||||
debugPrint('[OperatorProfileImportService] Base64 data too large: ${base64Data.length} characters');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
final jsonBytes = base64Decode(base64Data);
|
||||
final jsonString = utf8.decode(jsonBytes);
|
||||
|
||||
// Parse JSON
|
||||
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
// Validate and sanitize the profile data
|
||||
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
|
||||
return sanitizedProfile;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[OperatorProfileImportService] Failed to parse profile from base64: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate operator profile structure and sanitize all string values
|
||||
static OperatorProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Extract and sanitize required fields
|
||||
final name = _sanitizeString(data['name']);
|
||||
if (name == null || name.isEmpty) {
|
||||
debugPrint('[OperatorProfileImportService] Operator profile name is required');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract and sanitize tags
|
||||
final tagsData = data['tags'];
|
||||
if (tagsData is! Map<String, dynamic>) {
|
||||
debugPrint('[OperatorProfileImportService] Operator profile tags must be a map');
|
||||
return null;
|
||||
}
|
||||
|
||||
final sanitizedTags = <String, String>{};
|
||||
for (final entry in tagsData.entries) {
|
||||
final key = _sanitizeString(entry.key);
|
||||
final value = _sanitizeString(entry.value);
|
||||
|
||||
if (key != null && key.isNotEmpty) {
|
||||
// Allow empty values for refinement purposes
|
||||
sanitizedTags[key] = value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedTags.isEmpty) {
|
||||
debugPrint('[OperatorProfileImportService] Operator profile must have at least one valid tag');
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperatorProfile(
|
||||
id: const Uuid().v4(), // Always generate new ID for imported profiles
|
||||
name: name,
|
||||
tags: sanitizedTags,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[OperatorProfileImportService] Failed to validate operator profile: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a string value by trimming and removing potentially harmful characters
|
||||
static String? _sanitizeString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
|
||||
final str = value.toString().trim();
|
||||
|
||||
// Remove control characters and limit length
|
||||
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
|
||||
// Limit length to prevent abuse
|
||||
const maxLength = 500;
|
||||
if (sanitized.length > maxLength) {
|
||||
return sanitized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class ProfileImportService {
|
||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||
@@ -72,13 +73,17 @@ class ProfileImportService {
|
||||
final requiresDirection = data['requiresDirection'] ?? true;
|
||||
final submittable = data['submittable'] ?? true;
|
||||
|
||||
// Parse FOV if provided
|
||||
// Parse FOV if provided, applying restrictions based on dev config
|
||||
double? fov;
|
||||
if (data['fov'] != null) {
|
||||
if (data['fov'] is num) {
|
||||
final fovValue = (data['fov'] as num).toDouble();
|
||||
if (fovValue > 0 && fovValue <= 360) {
|
||||
fov = fovValue;
|
||||
// If non-360 FOVs are disabled, only allow 360° FOV
|
||||
if (kEnableNon360FOVs || fovValue == 360.0) {
|
||||
fov = fovValue;
|
||||
}
|
||||
// Otherwise, clear non-360 FOV values (set fov to null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,13 @@ class ProviderTileCacheManager {
|
||||
/// Delete a specific provider's cache directory and remove the store.
|
||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||
final key = '$providerId/$tileTypeId';
|
||||
final store = _stores.remove(key);
|
||||
final store = _stores[key];
|
||||
if (store != null) {
|
||||
// Use the store's clear method to properly reset its internal state
|
||||
await store.clear();
|
||||
// Don't remove from registry - let it be reused with clean state
|
||||
} else if (_baseCacheDir != null) {
|
||||
// Fallback for stores not in registry
|
||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||
if (await cacheDir.exists()) {
|
||||
await cacheDir.delete(recursive: true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -27,6 +28,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
||||
/// [putTile] call to avoid blocking construction.
|
||||
int? _estimatedSize;
|
||||
|
||||
/// Semaphore to limit concurrent file I/O operations and prevent
|
||||
/// "too many open files" errors during heavy tile loading.
|
||||
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
|
||||
|
||||
/// Throttle: don't re-scan more than once per minute.
|
||||
DateTime? _lastPruneCheck;
|
||||
|
||||
@@ -52,9 +57,16 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
// Use semaphore to limit concurrent file I/O operations
|
||||
final result = await _ioSemaphore.execute(() async {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
return (bytes: bytes, metaJson: metaJson);
|
||||
});
|
||||
|
||||
final bytes = result.bytes;
|
||||
final metaJson = result.metaJson;
|
||||
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
@@ -120,10 +132,13 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
||||
|
||||
// Write .tile before .meta: if we crash between the two writes, the
|
||||
// read path's both-must-exist check sees a miss rather than an orphan .meta.
|
||||
if (bytes != null) {
|
||||
await tileFile.writeAsBytes(bytes);
|
||||
}
|
||||
await metaFile.writeAsString(metaJson);
|
||||
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
|
||||
await _ioSemaphore.execute(() async {
|
||||
if (bytes != null) {
|
||||
await tileFile.writeAsBytes(bytes);
|
||||
}
|
||||
await metaFile.writeAsString(metaJson);
|
||||
});
|
||||
|
||||
// Reset size estimate so it resyncs from disk on next check.
|
||||
// This avoids drift from overwrites where the old size isn't subtracted.
|
||||
@@ -250,14 +265,19 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
await entry.file.delete();
|
||||
freedBytes += entry.stat.size;
|
||||
final deletedBytes = await _ioSemaphore.execute(() async {
|
||||
await entry.file.delete();
|
||||
var bytes = entry.stat.size;
|
||||
if (await metaFile.exists()) {
|
||||
final metaStat = await metaFile.stat();
|
||||
await metaFile.delete();
|
||||
bytes += metaStat.size;
|
||||
}
|
||||
return bytes;
|
||||
});
|
||||
|
||||
freedBytes += deletedBytes;
|
||||
evictedKeys.add(key);
|
||||
if (await metaFile.exists()) {
|
||||
final metaStat = await metaFile.stat();
|
||||
await metaFile.delete();
|
||||
freedBytes += metaStat.size;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
||||
}
|
||||
@@ -313,3 +333,44 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
||||
@visibleForTesting
|
||||
Future<void> forceEviction() => _evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Simple semaphore to limit concurrent operations and prevent resource exhaustion.
|
||||
class _Semaphore {
|
||||
final int maxCount;
|
||||
int _currentCount;
|
||||
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
|
||||
|
||||
_Semaphore(this.maxCount) : _currentCount = maxCount;
|
||||
|
||||
/// Acquire a permit. Returns a Future that completes when a permit is available.
|
||||
Future<void> acquire() {
|
||||
if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
return Future.value();
|
||||
} else {
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a permit, potentially unblocking a waiting operation.
|
||||
void release() {
|
||||
if (_waitQueue.isNotEmpty) {
|
||||
final completer = _waitQueue.removeFirst();
|
||||
completer.complete();
|
||||
} else {
|
||||
_currentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a function while holding a permit.
|
||||
Future<T> execute<T>(Future<T> Function() operation) async {
|
||||
await acquire();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/operator_profile_service.dart';
|
||||
|
||||
class OperatorProfileState extends ChangeNotifier {
|
||||
static const String _profileOrderPrefsKey = 'operator_profile_order';
|
||||
|
||||
final List<OperatorProfile> _profiles = [];
|
||||
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
_profiles.addAll(await OperatorProfileService().load());
|
||||
@@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier {
|
||||
_profiles.addAll(OperatorProfile.getDefaults());
|
||||
await OperatorProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load custom order from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(OperatorProfile p) {
|
||||
@@ -34,4 +42,56 @@ class OperatorProfileState extends ChangeNotifier {
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Reorder profiles (for drag-and-drop in settings)
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
final orderedProfiles = _getOrderedProfiles();
|
||||
|
||||
// Standard Flutter reordering logic
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = orderedProfiles.removeAt(oldIndex);
|
||||
orderedProfiles.insert(newIndex, item);
|
||||
|
||||
// Update custom order with new sequence
|
||||
_customOrder = orderedProfiles.map((p) => p.id).toList();
|
||||
_saveCustomOrder();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Get profiles in custom order, with unordered profiles at the end
|
||||
List<OperatorProfile> _getOrderedProfiles() {
|
||||
if (_customOrder.isEmpty) {
|
||||
return List.from(_profiles);
|
||||
}
|
||||
|
||||
final ordered = <OperatorProfile>[];
|
||||
final profilesById = {for (final p in _profiles) p.id: p};
|
||||
|
||||
// Add profiles in custom order
|
||||
for (final id in _customOrder) {
|
||||
final profile = profilesById[id];
|
||||
if (profile != null) {
|
||||
ordered.add(profile);
|
||||
profilesById.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining profiles that weren't in the custom order
|
||||
ordered.addAll(profilesById.values);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Save custom order to disk
|
||||
Future<void> _saveCustomOrder() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[OperatorProfileState] Failed to save custom order: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ class ProfileState extends ChangeNotifier {
|
||||
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||
}
|
||||
|
||||
/// Reload all profiles from storage (useful after migrations modify stored profiles)
|
||||
Future<void> reloadFromStorage() async {
|
||||
// Preserve enabled state by ID
|
||||
final enabledIds = _enabled.map((p) => p.id).toSet();
|
||||
|
||||
// Clear and reload profiles
|
||||
_profiles.clear();
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Restore enabled state for profiles that still exist
|
||||
_enabled.clear();
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty && _profiles.isNotEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/provider_tile_cache_manager.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../keys.dart';
|
||||
|
||||
@@ -332,6 +333,17 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all tile caches for a specific provider
|
||||
Future<void> clearTileProviderCaches(String providerId) async {
|
||||
final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||
if (provider == null) return;
|
||||
|
||||
// Clear cache for each tile type in this provider
|
||||
for (final tileType in provider.tileTypes) {
|
||||
await ProviderTileCacheManager.deleteCache(providerId, tileType.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
if (_followMeMode != mode) {
|
||||
|
||||
@@ -253,12 +253,12 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Add a node deletion to the upload queue
|
||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
|
||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
||||
final upload = PendingUpload(
|
||||
coord: node.coord,
|
||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||
profile: null, // No profile needed for deletions - just delete by node ID
|
||||
changesetComment: 'Delete a surveillance node', // Default comment for deletions
|
||||
changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.delete,
|
||||
originalNodeId: node.id,
|
||||
|
||||
@@ -65,30 +65,17 @@ class NodeTagSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
void deleteNode() async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
final result = await showDialog<({bool confirmed, String comment})>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(locService.t('node.confirmDeleteTitle')),
|
||||
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(locService.t('actions.delete')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
builder: (BuildContext context) => _DeleteNodeDialog(
|
||||
nodeId: node.id.toString(),
|
||||
locService: locService,
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldDelete == true && context.mounted) {
|
||||
if ((result?.confirmed ?? false) && context.mounted) {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.deleteNode(node);
|
||||
appState.deleteNode(node, changesetComment: result!.comment);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
|
||||
);
|
||||
@@ -260,4 +247,80 @@ class NodeTagSheet extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteNodeDialog extends StatefulWidget {
|
||||
final String nodeId;
|
||||
final LocalizationService locService;
|
||||
|
||||
const _DeleteNodeDialog({
|
||||
required this.nodeId,
|
||||
required this.locService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState();
|
||||
}
|
||||
|
||||
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
late final TextEditingController _commentController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_commentController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.locService.t('node.confirmDeleteTitle')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.locService.t('node.deleteReasonLabel'),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _commentController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.locService.t('node.deleteReasonHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
|
||||
child: Text(widget.locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final comment = _commentController.text.trim();
|
||||
final finalComment = comment.isEmpty
|
||||
? 'Delete a surveillance node'
|
||||
: 'Delete a surveillance node: $comment';
|
||||
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(widget.locService.t('actions.delete')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -564,18 +564,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -929,10 +929,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.10"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.10.1+55 # 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+)
|
||||
|
||||
Reference in New Issue
Block a user