Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
8e4c963df1 De-emphasize Visit OpenStreetMap button and add prominent login FilledButton
Co-authored-by: dougborg <1261222+dougborg@users.noreply.github.com>
2026-03-12 22:01:04 +00:00
copilot-swe-agent[bot]
2281c39b09 Initial plan 2026-03-12 21:57:46 +00:00
27 changed files with 191 additions and 443 deletions

View File

@@ -1,12 +1,4 @@
{
"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.",

View File

@@ -20,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -2,15 +2,12 @@ import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
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)
}
}

View File

@@ -1,91 +1,72 @@
<?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>UIApplicationSupportsMultipleScenes</key>
<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>
<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>

View File

@@ -581,8 +581,8 @@ class AppState extends ChangeNotifier {
}
}
void deleteNode(OsmNode node, {String? changesetComment}) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
void deleteNode(OsmNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
@@ -717,11 +717,6 @@ 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);

View File

@@ -90,7 +90,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 = 16; // Minimum zoom to open add/edit node sheets
const int kMinZoomForNodeEditingSheets = 15; // 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 +128,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 = 50.0; // meters - distance threshold to show warning
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
// Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
@@ -136,7 +136,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 = 300000.0; // meters - distance threshold for timeout warning (30km)
const double kNavigationDistanceWarningThreshold = 20000.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

View File

@@ -95,9 +95,7 @@
"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.",
"deleteReasonLabel": "Grund für Löschung (Optional)",
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
},
"addNode": {
"profile": "Profil",
@@ -249,9 +247,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Reason for Deletion (Optional)",
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
},
"addNode": {
"profile": "Profile",
@@ -284,11 +282,8 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Razón para Eliminación (Opcional)",
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
},
"addNode": {
"profile": "Perfil",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Raison de Suppression (Optionnel)",
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
},
"addNode": {
"profile": "Profil",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Motivo per Eliminazione (Opzionale)",
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
},
"addNode": {
"profile": "Profilo",
@@ -286,9 +284,6 @@
"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",

View File

@@ -131,10 +131,8 @@
"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.",
"deleteReasonLabel": "Reden voor Verwijdering (Optioneel)",
"deleteReasonHint": "bijv. apparaat verwijderd, onjuiste locatie, duplicaat..."
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."
},
"addNode": {
"profile": "Profiel",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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ąć.",
"deleteReasonLabel": "Powód Usunięcia (Opcjonalny)",
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć."
},
"addNode": {
"profile": "Profil",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Motivo para Exclusão (Opcional)",
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
},
"addNode": {
"profile": "Perfil",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"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.",
"deleteReasonLabel": "Silme Nedeni (İsteğe Bağlı)",
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"addNode": {
"profile": "Profil",
@@ -286,9 +284,6 @@
"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",

View File

@@ -132,9 +132,7 @@
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
"confirmDeleteTitle": "Видалити Вузол",
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
"deleteReasonLabel": "Причина Видалення (Опціонально)",
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
},
"addNode": {
"profile": "Профіль",
@@ -286,9 +284,6 @@
"addProvider": "Додати Постачальника",
"deleteProvider": "Видалити Постачальника",
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
"clearCaches": "Очистити Кеш",
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
"cachesCleared": "Кеш очищено для \"{}\"",
"providerName": "Назва Постачальника",
"providerNameHint": "напр., Кастомні Карти ТОВ",
"providerNameRequired": "Назва постачальника обов'язкова",

View File

@@ -132,9 +132,7 @@
"editQueuedForUpload": "节点编辑已排队上传",
"deleteQueuedForUpload": "节点删除已排队上传",
"confirmDeleteTitle": "删除节点",
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
"deleteReasonLabel": "删除原因(可选)",
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
},
"addNode": {
"profile": "配置文件",
@@ -286,9 +284,6 @@
"addProvider": "添加提供商",
"deleteProvider": "删除提供商",
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
"clearCaches": "清除缓存",
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
"cachesCleared": "已清除 \"{}\" 的缓存",
"providerName": "提供商名称",
"providerNameHint": "例如,自定义地图公司",
"providerNameRequired": "提供商名称为必填项",

View File

@@ -76,9 +76,6 @@ 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),
@@ -99,8 +96,6 @@ 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'),
@@ -126,39 +121,6 @@ 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;

View File

@@ -82,6 +82,22 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
},
),
if (!appState.isLoggedIn) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
await appState.forceLogin();
},
icon: const Icon(Icons.login),
label: Text(locService.t('auth.loginToOSM')),
),
),
),
],
if (appState.isLoggedIn) ...[
const Divider(),
ListTile(
@@ -195,9 +211,50 @@ 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,
),
TextButton.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, size: 16),
label: Text(locService.t('auth.visitOSM')),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),
// Account deletion section - only show when logged in and not in simulate mode
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
const SizedBox(height: 16),

View File

@@ -104,9 +104,6 @@ class TileProviderSection extends StatelessWidget {
case 'edit':
_editProvider(context, provider);
break;
case 'clear_cache':
_clearProviderCaches(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
@@ -123,16 +120,6 @@ 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(
@@ -167,37 +154,6 @@ 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(

View File

@@ -61,13 +61,10 @@ 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[key];
final store = _stores.remove(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);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
@@ -28,10 +27,6 @@ class ProviderTileCacheStore implements MapCachingProvider {
/// [putTile] call to avoid blocking construction.
int? _estimatedSize;
/// Semaphore to limit concurrent file I/O operations and prevent
/// "too many open files" errors during heavy tile loading.
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
/// Throttle: don't re-scan more than once per minute.
DateTime? _lastPruneCheck;
@@ -57,16 +52,9 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try {
// Use semaphore to limit concurrent file I/O operations
final result = await _ioSemaphore.execute(() async {
final bytes = await tileFile.readAsBytes();
final metaJson = json.decode(await metaFile.readAsString())
as Map<String, dynamic>;
return (bytes: bytes, metaJson: metaJson);
});
final bytes = result.bytes;
final metaJson = result.metaJson;
final bytes = await tileFile.readAsBytes();
final metaJson = json.decode(await metaFile.readAsString())
as Map<String, dynamic>;
final metadata = CachedMapTileMetadata(
staleAt: DateTime.fromMillisecondsSinceEpoch(
@@ -132,13 +120,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
// Write .tile before .meta: if we crash between the two writes, the
// read path's both-must-exist check sees a miss rather than an orphan .meta.
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
await _ioSemaphore.execute(() async {
if (bytes != null) {
await tileFile.writeAsBytes(bytes);
}
await metaFile.writeAsString(metaJson);
});
if (bytes != null) {
await tileFile.writeAsBytes(bytes);
}
await metaFile.writeAsString(metaJson);
// Reset size estimate so it resyncs from disk on next check.
// This avoids drift from overwrites where the old size isn't subtracted.
@@ -265,19 +250,14 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try {
final deletedBytes = await _ioSemaphore.execute(() async {
await entry.file.delete();
var bytes = entry.stat.size;
if (await metaFile.exists()) {
final metaStat = await metaFile.stat();
await metaFile.delete();
bytes += metaStat.size;
}
return bytes;
});
freedBytes += deletedBytes;
await entry.file.delete();
freedBytes += entry.stat.size;
evictedKeys.add(key);
if (await metaFile.exists()) {
final metaStat = await metaFile.stat();
await metaFile.delete();
freedBytes += metaStat.size;
}
} catch (e) {
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
}
@@ -333,44 +313,3 @@ 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();
}
}
}

View File

@@ -4,7 +4,6 @@ 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';
@@ -333,17 +332,6 @@ 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) {

View File

@@ -253,12 +253,12 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a node deletion to the upload queue
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
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: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
changesetComment: 'Delete a surveillance node', // Default comment for deletions
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,

View File

@@ -65,17 +65,30 @@ class NodeTagSheet extends StatelessWidget {
}
void deleteNode() async {
final result = await showDialog<({bool confirmed, String comment})>(
final shouldDelete = await showDialog<bool>(
context: context,
builder: (BuildContext context) => _DeleteNodeDialog(
nodeId: node.id.toString(),
locService: locService,
),
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')),
),
],
);
},
);
if ((result?.confirmed ?? false) && context.mounted) {
if (shouldDelete == true && context.mounted) {
Navigator.pop(context); // Close this sheet first
appState.deleteNode(node, changesetComment: result!.comment);
appState.deleteNode(node);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
);
@@ -247,80 +260,4 @@ 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')),
),
],
);
}
}

View File

@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -564,18 +564,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -929,10 +929,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.7"
timezone:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.9.2+53 # The thing after the + is the version code, incremented with each release
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)