Compare commits

..
26 changed files with 638 additions and 88 deletions

No files matched your search

+13
View File
@@ -30,6 +30,19 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Disable Flutter's implicit deep-link routing so that
app_links/DeepLinkService is the sole handler of incoming
deflockapp:// URIs. Without this, Flutter's engine also
tries to auto-convert the URI into a Navigator route
(e.g. "/add?p=...") which conflicts with the navigation
DeepLinkService performs, causing screens pushed by
DeepLinkService to be immediately buried by a duplicate
HomeScreen route. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<!-- Launcher intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
+8
View File
@@ -1,4 +1,12 @@
{
"2.11.0": {
"content": [
"• Further improve node loading performance",
"• Fix node constrainment when part of a way/relation",
"• Show submission status in details sheet",
"• Fix profile import links not opening"
]
},
"2.10.5": {
"content": [
"• Make node tags selectable / copyable",
+11
View File
@@ -2,8 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Disable Flutter's implicit deep-link routing so that
app_links/DeepLinkService is the sole handler of incoming
deflockapp:// URIs. Without this, Flutter's engine also tries
to auto-convert the URI into a Navigator route (e.g.
"/add?p=...") which conflicts with the navigation
DeepLinkService performs, causing screens pushed by
DeepLinkService to be immediately buried by a duplicate
HomeScreen route. -->
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
+7
View File
@@ -188,6 +188,13 @@ class AppState extends ChangeNotifier {
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
/// True if any queue item currently has an open changeset that hasn't
/// been closed yet (creating changeset, uploading node, or closing
/// changeset). Used to gate enabling offline mode / pausing the upload
/// queue so in-flight submissions can finish first.
bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing);
// Suspected location state
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
+9 -2
View File
@@ -64,6 +64,13 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0;
// How long to keep the "Submission Complete" confirmation visible in the
// in-flight upload wait dialog (shown when the user enables offline mode or
// pauses the upload queue while a submission is actively in progress) before
// automatically dismissing and applying the requested setting change.
const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2);
// Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
@@ -71,7 +78,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
@@ -83,7 +90,7 @@ const bool kEnableNodeEdits = true; // Set to false to temporarily disable node
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
// Auto-open tags sheet after add/edit/delete, so the user can watch upload status update live.
const bool kAutoOpenNodeSheetAfterSubmit = true; // Set to true to enable auto-focus behavior
const bool kAutoOpenNodeSheetAfterSubmit = false; // Set to true to enable auto-focus behavior
// Profile FOV features - set to false to restrict profiles to 360° FOV only
const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles
+6 -1
View File
@@ -270,8 +270,13 @@
"closingChangeset": " (Changeset schließen...)",
"processingPaused": "Warteschlangenverarbeitung pausiert",
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert.",
"inFlightWaitTitle": "Übermittlung wird abgeschlossen",
"inFlightWaitMessage": "Eine Übermittlung läuft gerade und muss abgeschlossen werden, bevor diese Einstellung angewendet werden kann. Dies sollte nur einen Moment dauern.",
"inFlightCompleteTitle": "Übermittlung Abgeschlossen",
"inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet."
},
"tileProviders": {
"title": "Kachel-Anbieter",
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
+6 -1
View File
@@ -306,8 +306,13 @@
"closingChangeset": " (Closing changeset...)",
"processingPaused": "Queue Processing Paused",
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
"pausedByUser": "Upload processing is manually paused."
"pausedByUser": "Upload processing is manually paused.",
"inFlightWaitTitle": "Finishing Submission",
"inFlightWaitMessage": "A submission is currently in progress and needs to finish before this setting can be applied. This should only take a moment.",
"inFlightCompleteTitle": "Submission Complete",
"inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "No tile providers configured",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Cerrando changeset...)",
"processingPaused": "Procesamiento de Cola Pausado",
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
"pausedByUser": "El procesamiento de subida está pausado manualmente."
"pausedByUser": "El procesamiento de subida está pausado manualmente.",
"inFlightWaitTitle": "Finalizando Envío",
"inFlightWaitMessage": "Actualmente hay un envío en curso que debe finalizar antes de que se pueda aplicar esta configuración. Esto solo debería tomar un momento.",
"inFlightCompleteTitle": "Envío Completado",
"inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora."
},
"tileProviders": {
"title": "Proveedores de Tiles",
"noProvidersConfigured": "No hay proveedores de tiles configurados",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Fermeture du changeset...)",
"processingPaused": "Traitement de la File d'Attente Interrompu",
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
"pausedByUser": "Le traitement des téléversements est interrompu manuellement.",
"inFlightWaitTitle": "Finalisation de la Soumission",
"inFlightWaitMessage": "Une soumission est actuellement en cours et doit se terminer avant que ce paramètre puisse être appliqué. Cela ne devrait prendre qu'un instant.",
"inFlightCompleteTitle": "Soumission Terminée",
"inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant."
},
"tileProviders": {
"title": "Fournisseurs de Tuiles",
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Chiusura changeset...)",
"processingPaused": "Elaborazione Coda Sospesa",
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.",
"inFlightWaitTitle": "Completamento Invio",
"inFlightWaitMessage": "Un invio è attualmente in corso e deve terminare prima che questa impostazione possa essere applicata. Questo dovrebbe richiedere solo un momento.",
"inFlightCompleteTitle": "Invio Completato",
"inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso."
},
"tileProviders": {
"title": "Fornitori di Tile",
"noProvidersConfigured": "Nessun fornitore di tile configurato",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Changeset sluiten...)",
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
"pausedByUser": "Upload verwerking is handmatig gepauzeerd."
"pausedByUser": "Upload verwerking is handmatig gepauzeerd.",
"inFlightWaitTitle": "Indiening Wordt Afgerond",
"inFlightWaitMessage": "Er is momenteel een indiening bezig die moet worden afgerond voordat deze instelling kan worden toegepast. Dit zou maar een moment moeten duren.",
"inFlightCompleteTitle": "Indiening Voltooid",
"inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "Geen tile providers geconfigureerd",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Zamykanie zestawu zmian...)",
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane."
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.",
"inFlightWaitTitle": "Kończenie Przesyłania",
"inFlightWaitMessage": "Przesyłanie jest obecnie w toku i musi zostać zakończone, zanim to ustawienie będzie mogło zostać zastosowane. To powinno zająć tylko chwilę.",
"inFlightCompleteTitle": "Przesyłanie Zakończone",
"inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz."
},
"tileProviders": {
"title": "Dostawcy Kafelków",
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Fechando changeset...)",
"processingPaused": "Processamento da Fila Pausado",
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
"pausedByUser": "O processamento de upload está pausado manualmente."
"pausedByUser": "O processamento de upload está pausado manualmente.",
"inFlightWaitTitle": "Finalizando Envio",
"inFlightWaitMessage": "Atualmente há um envio em andamento que precisa terminar antes que esta configuração possa ser aplicada. Isso deve levar apenas um momento.",
"inFlightCompleteTitle": "Envio Concluído",
"inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora."
},
"tileProviders": {
"title": "Provedores de Tiles",
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Değişiklik seti kapatılıyor...)",
"processingPaused": "Kuyruk İşleme Duraklatıldı",
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı."
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.",
"inFlightWaitTitle": "Gönderim Tamamlanıyor",
"inFlightWaitMessage": "Şu anda devam eden bir gönderim var ve bu ayar uygulanmadan önce tamamlanması gerekiyor. Bu sadece bir an sürmelidir.",
"inFlightCompleteTitle": "Gönderim Tamamlandı",
"inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor."
},
"tileProviders": {
"title": "Döşeme Sağlayıcıları",
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (Закриття набору змін...)",
"processingPaused": "Обробка Черги Призупинена",
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
"pausedByUser": "Обробка завантаження призупинена вручну."
"pausedByUser": "Обробка завантаження призупинена вручну.",
"inFlightWaitTitle": "Завершення Подання",
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
"inFlightCompleteTitle": "Подання Завершено",
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
},
"tileProviders": {
"title": "Постачальники Плиток",
"noProvidersConfigured": "Постачальників плиток не налаштовано",
+6 -1
View File
@@ -307,8 +307,13 @@
"closingChangeset": " (关闭变更集...)",
"processingPaused": "队列处理已暂停",
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
"pausedByUser": "上传处理已手动暂停。"
"pausedByUser": "上传处理已手动暂停。",
"inFlightWaitTitle": "正在完成提交",
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
"inFlightCompleteTitle": "提交完成",
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
},
"tileProviders": {
"title": "瓦片提供商",
"noProvidersConfigured": "未配置瓦片提供商",
+9 -4
View File
@@ -125,16 +125,21 @@ class DeFlockApp extends StatelessWidget {
},
initialRoute: '/',
onUnknownRoute: (settings) {
// Handle deep link routes that Flutter tries to process before app_links
// This catches routes like "/?id=13939398601" from "deflockapp://node?id=13939398601"
debugPrint('[Navigation] Unknown route intercepted: ${settings.name} (likely deep link, will be handled by DeepLinkService)');
// Fallback for any genuinely unknown named route (e.g. a stale/bad
// link). Deep links (deflockapp://...) are handled exclusively by
// DeepLinkService via the app_links package - Flutter's own
// implicit deep-link routing is disabled at the platform level
// (see AndroidManifest.xml's flutter_deeplinking_enabled and
// Info.plist's FlutterDeepLinkingEnabled) to avoid this handler
// double-navigating and burying screens pushed by DeepLinkService.
debugPrint('[Navigation] Unknown route intercepted: ${settings.name}');
return MaterialPageRoute(
settings: settings,
builder: (_) => const HomeScreen(),
);
},
);
}
}
@@ -25,6 +25,7 @@ class EnableOfflineFeaturesSection extends StatelessWidget {
// no "cancel" path once this dialog is shown.
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
if (!context.mounted) return;
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
if (!hasExistingAreas) {
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/in_flight_upload_wait_dialog.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
@@ -49,10 +51,17 @@ class OfflineModeSection extends StatelessWidget {
}
}
// Proceed with the change
await appState.setOfflineMode(value);
// Proceed with the change, respecting any in-flight submission that
// needs to finish before offline mode actually takes effect.
if (!context.mounted) return;
await applyQueueSettingChangeRespectingInFlightUploads(
context: context,
appState: appState,
applyChange: () => appState.setOfflineMode(value),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
+20 -1
View File
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/pending_upload.dart';
import '../services/localization_service.dart';
import '../widgets/in_flight_upload_wait_dialog.dart';
class UploadQueueScreen extends StatelessWidget {
const UploadQueueScreen({super.key});
@@ -84,6 +86,22 @@ class UploadQueueScreen extends StatelessWidget {
}
}
Future<void> _handlePauseQueueChange(BuildContext context, AppState appState, bool value) async {
// Only need to wait for in-flight uploads when turning pause ON.
// Resuming (turning it off) can happen immediately.
if (!value) {
await appState.setPauseQueueProcessing(value);
return;
}
await applyQueueSettingChangeRespectingInFlightUploads(
context: context,
appState: appState,
applyChange: () => appState.setPauseQueueProcessing(value),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -166,9 +184,10 @@ class UploadQueueScreen extends StatelessWidget {
value: appState.pauseQueueProcessing,
onChanged: appState.offlineMode
? null // Disable when offline mode is on
: (value) => appState.setPauseQueueProcessing(value),
: (value) => _handlePauseQueueChange(context, appState, value),
),
),
const SizedBox(height: 16),
+53 -20
View File
@@ -143,27 +143,55 @@ class OverpassService {
'[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency');
}
final nodeClauses = profilesToQuery.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
// Per-profile tag filter strings (e.g. ["man_made"="surveillance"]["surveillance:type"="ALPR"]),
// excluding empty tag values. Reused twice below: once to select our nodes
// in the bbox, and again to re-select just our nodes out of the pool of
// way/relation-connected nodes (which may include unrelated node types).
final tagFilterStrings = profilesToQuery.map((profile) {
return profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
}).toList();
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
final bboxStr = '${bounds.southWest.latitude},${bounds.southWest.longitude},'
'${bounds.northEast.latitude},${bounds.northEast.longitude}';
final allNodesClauses =
tagFilterStrings.map((filters) => 'node$filters($bboxStr);').join('\n ');
final resNodesClauses =
tagFilterStrings.map((filters) => 'node.resNodes$filters;').join('\n ');
// Query strategy:
// 1. Find all matching nodes in bbox -> .allNodes, output them with tags ("out body").
// 2. Find any way/relation that references one of .allNodes ("bn." = "by node"),
// then pull the node IDs those ways/relations reference -> .wayNodes/.relNodes.
// (`node(w)`/`node(r)` on a way/relation set returns only node IDs, not
// full tags/coords, keeping the query/response cheap.)
// 3. Re-filter that combined node pool down to just our own matching
// surveillance nodes (.resNodes may contain unrelated node types too).
// 4. `convert restriction ::id = id();` emits each of those node IDs as a
// lightweight synthetic `{"type":"restriction","id":<nodeId>}` element —
// a flat, unambiguous list of which node IDs are "constrained" (part of
// some way/relation), without needing the full way/relation node lists.
return '''
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$nodeClauses
$allNodesClauses
)->.allNodes;
.allNodes out body;
way(bn.allNodes); node(w)->.wayNodes;
relation(bn.allNodes); node(r)->.relNodes;
(.wayNodes;.relNodes;)->.resNodes;
(
$resNodesClauses
);
out body;
<;
out ids;
convert restriction ::id = id();
out;
''';
}
/// Deduplicate profiles for Overpass queries by removing profiles that are
/// subsumed by others. A profile A subsumes profile B if all of A's
/// non-empty tags exist in B with identical values — meaning every node
@@ -232,7 +260,16 @@ out ids;
}
/// Parse Overpass JSON response into OsmNode objects
/// Parse Overpass JSON response into OsmNode objects.
///
/// The query (see [_buildQuery]) produces two kinds of elements:
/// - `type: "node"` — our matched surveillance nodes, with full tags/coords
/// (from `.allNodes out body;`).
/// - `type: "restriction"` — synthetic elements (from
/// `convert restriction ::id = id();`) whose `id` is the OSM node ID of
/// one of our nodes that Overpass determined is referenced by some way
/// or relation. These carry no lat/lon/tags — just the id — and exist
/// purely to tell us which node IDs are "constrained".
List<OsmNode> _parseResponse(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
@@ -240,21 +277,16 @@ out ids;
final nodeElements = <Map<String, dynamic>>[];
final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes and identify constrained nodes
// First pass: collect surveillance nodes and constrained-node markers.
for (final element in elements.whereType<Map<String, dynamic>>()) {
final type = element['type'] as String?;
if (type == 'node') {
nodeElements.add(element);
} else if (type == 'way' || type == 'relation') {
// Mark referenced nodes as constrained
final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
for (final ref in refs) {
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
} else if (type == 'restriction') {
final rawId = element['id'];
final nodeId = rawId is int ? rawId : int.tryParse(rawId.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
}
@@ -274,6 +306,7 @@ out ids;
}
}
/// Error thrown when a query would exceed Overpass's 50k node limit.
/// The caller should split the query area into smaller regions to resolve this
/// — it's a deterministic function of how much data lives within the bounds.
@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
/// Applies a queue-pausing settings change (enabling offline mode or pausing
/// the upload queue), but if there's an in-flight submission (a queue item
/// that has already opened a changeset and hasn't closed it yet), first
/// shows a non-dismissible-by-tap-outside dialog explaining that the
/// submission needs to finish. Once it completes, a brief confirmation is
/// shown before the dialog auto-closes and the setting takes effect.
///
/// If nothing is in-flight, [applyChange] is invoked immediately with no
/// dialog shown at all.
///
/// The dialog can also be dismissed early by the user tapping "View in
/// Queue", in case something is stuck (e.g. a failed/erroring item) — this
/// lets the user navigate to the queue screen to investigate/clear it out,
/// rather than getting stuck waiting indefinitely.
Future<void> applyQueueSettingChangeRespectingInFlightUploads({
required BuildContext context,
required AppState appState,
required Future<void> Function() applyChange,
}) async {
if (!appState.hasInFlightUploads) {
await applyChange();
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => InFlightUploadWaitDialog(
onAllComplete: () {
// Fire and forget - the dialog doesn't need to await this.
applyChange();
},
),
);
}
class InFlightUploadWaitDialog extends StatefulWidget {
/// Called exactly once, as soon as no queue items are actively processing.
final VoidCallback onAllComplete;
const InFlightUploadWaitDialog({super.key, required this.onAllComplete});
@override
State<InFlightUploadWaitDialog> createState() => _InFlightUploadWaitDialogState();
}
class _InFlightUploadWaitDialogState extends State<InFlightUploadWaitDialog> {
bool _completed = false;
bool _appliedChange = false;
Timer? _autoCloseTimer;
late final AppState _appState;
@override
void initState() {
super.initState();
_appState = AppState.instance;
_appState.addListener(_onAppStateChanged);
// Handle the (unlikely) race where the in-flight upload finishes between
// the initial check and this dialog actually mounting.
WidgetsBinding.instance.addPostFrameCallback((_) => _onAppStateChanged());
}
void _onAppStateChanged() {
if (!mounted || _completed) return;
if (_appState.hasInFlightUploads) return;
setState(() {
_completed = true;
});
if (!_appliedChange) {
_appliedChange = true;
widget.onAllComplete();
}
_autoCloseTimer = Timer(kInFlightUploadCompleteDisplayDuration, () {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
});
}
@override
void dispose() {
_appState.removeListener(_onAppStateChanged);
_autoCloseTimer?.cancel();
super.dispose();
}
void _viewInQueue() {
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context).pushNamed('/settings/queue');
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return PopScope(
canPop: false,
child: AlertDialog(
title: Row(
children: [
Icon(
_completed ? Icons.check_circle : Icons.cloud_upload,
color: _completed ? Colors.green : Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_completed
? locService.t('queue.inFlightCompleteTitle')
: locService.t('queue.inFlightWaitTitle'),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_completed
? locService.t('queue.inFlightCompleteMessage')
: locService.t('queue.inFlightWaitMessage'),
),
const SizedBox(height: 20),
Center(
child: _completed
? const Icon(Icons.check_circle, size: 40, color: Colors.green)
: const CircularProgressIndicator(),
),
],
),
actions: [
if (!_completed)
TextButton(
onPressed: _viewInQueue,
child: Text(locService.t('node.viewInQueue')),
),
],
),
);
},
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.10.5+60 # The thing after the + is the version code, incremented with each release
version: 2.11.0+61 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
localize.py — small helper for managing lib/localizations/*.json translation files.
Usage (normally invoked via scripts/localize.sh):
localize.sh --check
Validate that every locale file is valid JSON, has a language.name key,
and has exactly the same set of leaf keys as en.json (the reference).
Exits non-zero (and prints details) if anything doesn't match.
localize.sh --list [--lang xx]
Print all dot-notation leaf keys. Defaults to en.json if --lang is
omitted. Useful for seeing the full key structure at a glance.
localize.sh --get --lang xx --key some.nested.key
Print the value of a single key from lib/localizations/xx.json.
localize.sh --write --lang xx --key some.nested.key "New value"
Set (or add) the value of a key in lib/localizations/xx.json. Creates
intermediate objects as needed. Preserves key order/formatting via
Python's json module (2-space indent, matching existing files).
Examples:
./scripts/localize.sh --check
./scripts/localize.sh --list --lang de
./scripts/localize.sh --get --lang en --key node.title
./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
"""
import argparse
import json
import sys
from pathlib import Path
LOCALIZATIONS_DIR = Path(__file__).resolve().parent.parent / "lib" / "localizations"
REFERENCE_LOCALE = "en"
def locale_path(lang: str) -> Path:
return LOCALIZATIONS_DIR / f"{lang}.json"
def load_locale(lang: str) -> dict:
path = locale_path(lang)
if not path.exists():
print(f"error: no such locale file: {path}", file=sys.stderr)
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
print(f"error: {path.name} is not valid JSON: {e}", file=sys.stderr)
sys.exit(1)
def save_locale(lang: str, data: dict) -> None:
path = locale_path(lang)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
def all_locale_files():
return sorted(LOCALIZATIONS_DIR.glob("*.json"))
def extract_leaf_keys(data, prefix=""):
keys = set()
for k, v in data.items():
full_key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
keys |= extract_leaf_keys(v, full_key)
else:
keys.add(full_key)
return keys
def get_nested(data: dict, dotted_key: str):
parts = dotted_key.split(".")
node = data
for part in parts:
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
def set_nested(data: dict, dotted_key: str, value: str) -> None:
parts = dotted_key.split(".")
node = data
for part in parts[:-1]:
if part not in node or not isinstance(node[part], dict):
node[part] = {}
node = node[part]
node[parts[-1]] = value
def cmd_check(_args) -> int:
files = all_locale_files()
if not files:
print(f"error: no locale files found in {LOCALIZATIONS_DIR}", file=sys.stderr)
return 1
ref_path = locale_path(REFERENCE_LOCALE)
if not ref_path.exists():
print(f"error: reference locale '{REFERENCE_LOCALE}.json' not found", file=sys.stderr)
return 1
ok = True
parsed = {}
for path in files:
try:
with open(path, "r", encoding="utf-8") as f:
parsed[path.stem] = json.load(f)
except json.JSONDecodeError as e:
print(f"FAIL {path.name}: invalid JSON: {e}")
ok = False
for lang, data in parsed.items():
lang_name = data.get("language", {}).get("name")
if not isinstance(lang_name, str) or not lang_name:
print(f"FAIL {lang}.json: missing 'language.name' string")
ok = False
if REFERENCE_LOCALE not in parsed:
print(f"error: {REFERENCE_LOCALE}.json failed to parse, cannot compare keys")
return 1
reference_keys = extract_leaf_keys(parsed[REFERENCE_LOCALE])
for lang, data in parsed.items():
if lang == REFERENCE_LOCALE:
continue
keys = extract_leaf_keys(data)
missing = reference_keys - keys
extra = keys - reference_keys
if missing:
print(f"FAIL {lang}.json: missing {len(missing)} key(s):")
for k in sorted(missing):
print(f" - {k}")
ok = False
if extra:
print(f"FAIL {lang}.json: has {len(extra)} extra key(s) not in {REFERENCE_LOCALE}.json:")
for k in sorted(extra):
print(f" + {k}")
ok = False
if ok:
print(f"OK {len(parsed)} locale file(s) valid, all keys match {REFERENCE_LOCALE}.json")
return 0
return 1
def cmd_list(args) -> int:
lang = args.lang or REFERENCE_LOCALE
data = load_locale(lang)
keys = sorted(extract_leaf_keys(data))
for k in keys:
print(k)
return 0
def cmd_get(args) -> int:
if not args.lang or not args.key:
print("error: --get requires --lang and --key", file=sys.stderr)
return 1
data = load_locale(args.lang)
value = get_nested(data, args.key)
if value is None:
print(f"error: key '{args.key}' not found in {args.lang}.json", file=sys.stderr)
return 1
if isinstance(value, dict):
print(f"error: '{args.key}' is not a leaf key (it's an object) in {args.lang}.json", file=sys.stderr)
return 1
print(value)
return 0
def cmd_write(args) -> int:
if not args.lang or not args.key or args.value is None:
print("error: --write requires --lang, --key, and a value argument", file=sys.stderr)
return 1
data = load_locale(args.lang)
set_nested(data, args.key, args.value)
save_locale(args.lang, data)
print(f"Wrote {args.lang}.json: {args.key} = {args.value!r}")
return 0
def main():
parser = argparse.ArgumentParser(
description="Manage DeFlock localization JSON files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--check", action="store_true", help="Validate all locale files match en.json's key structure")
group.add_argument("--list", action="store_true", help="List all dot-notation leaf keys for a locale (default: en)")
group.add_argument("--get", action="store_true", help="Print the value of a single key")
group.add_argument("--write", action="store_true", help="Set the value of a single key")
parser.add_argument("--lang", help="Language code, e.g. 'en', 'de', 'es' (filename without .json)")
parser.add_argument("--key", help="Dot-notation key, e.g. 'node.title'")
parser.add_argument("value", nargs="?", help="New value (only used with --write)")
args = parser.parse_args()
if args.check:
sys.exit(cmd_check(args))
elif args.list:
sys.exit(cmd_list(args))
elif args.get:
sys.exit(cmd_get(args))
elif args.write:
sys.exit(cmd_write(args))
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Thin wrapper around localize.py — see that file's docstring for full usage.
#
# Examples:
# ./scripts/localize.sh --check
# ./scripts/localize.sh --list --lang de
# ./scripts/localize.sh --get --lang en --key node.title
# ./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
exec "$PYTHON_BIN" "$SCRIPT_DIR/localize.py" "$@"
+36 -47
View File
@@ -57,7 +57,7 @@ void main() {
}
group('query building', () {
test('uses out ids for way/relation pass, out body for node pass',
test('uses the way/relation membership pass with restriction conversion',
() async {
stubOverpassResponse([]);
@@ -68,11 +68,16 @@ void main() {
).captured;
final query = (captured.last as Map<String, String>)['data']!;
expect(query, contains('out body;'));
expect(query, contains('out ids;'));
expect(query, contains('.allNodes out body;'));
expect(query, contains('way(bn.allNodes);'));
expect(query, contains('relation(bn.allNodes);'));
expect(query, contains('.resNodes'));
expect(query, contains('convert restriction ::id = id();'));
expect(query, isNot(contains('out meta;')));
expect(query, isNot(contains('out ids;')));
});
test('empty tag values are excluded from filters', () async {
final profileWithEmpty = [
NodeProfile(
@@ -137,12 +142,14 @@ void main() {
expect(query, isNot(contains('manufacturer')));
expect(query, isNot(contains('Flock Safety')));
// Only one node clause total.
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
expect(nodeClauseCount, equals(1));
// Only one distinct tag-filter clause, appearing twice (once for
// .allNodes, once for the .resNodes re-filter).
final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length;
expect(nodeClauseCount, equals(2));
});
test('unrelated profiles are all kept', () async {
final alprGeneric = NodeProfile(
id: 'generic-alpr',
@@ -186,11 +193,14 @@ void main() {
).captured;
final query = (captured.last as Map<String, String>)['data']!;
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
expect(nodeClauseCount, equals(2));
// Each of the 2 generic clauses appears twice (once in .allNodes,
// once in the .resNodes re-filter) = 4 total tag-filter clauses.
final nodeClauseCount = RegExp(r'node[.\[]').allMatches(query).length;
expect(nodeClauseCount, equals(4));
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]'));
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]'));
// Brand-specific tags should not appear anywhere in the reduced query.
expect(query, isNot(contains('manufacturer')));
expect(query, isNot(contains('ShotSpotter')));
@@ -223,7 +233,13 @@ void main() {
group('response parsing — constraint detection', () {
test('nodes referenced by a way are constrained', () async {
test('a node referenced as a "restriction" element is constrained',
() async {
// The Overpass query converts each way/relation-referenced node ID
// into a synthetic {"type":"restriction","id":<nodeId>} element
// (via `convert restriction ::id = id();`) — this is how the server
// tells us a node is part of some way/relation, regardless of whether
// that way/relation came from a "way" or a "relation" in Overpass.
stubOverpassResponse([
{
'type': 'node',
@@ -240,9 +256,8 @@ void main() {
'tags': {'man_made': 'surveillance'},
},
{
'type': 'way',
'id': 100,
'nodes': [1],
'type': 'restriction',
'id': 1,
},
]);
@@ -256,32 +271,8 @@ void main() {
expect(node2.isConstrained, isFalse);
});
test('nodes referenced by a relation member are constrained', () async {
stubOverpassResponse([
{
'type': 'node',
'id': 3,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
{
'type': 'relation',
'id': 200,
'members': [
{'type': 'node', 'ref': 3, 'role': ''},
],
},
]);
final nodes =
await service.fetchNodes(bounds: bounds, profiles: profiles);
expect(nodes, hasLength(1));
expect(nodes.first.isConstrained, isTrue);
});
test('nodes not in any way or relation are unconstrained', () async {
test('nodes not marked with a "restriction" element are unconstrained',
() async {
stubOverpassResponse([
{
'type': 'node',
@@ -299,7 +290,8 @@ void main() {
expect(nodes.first.isConstrained, isFalse);
});
test('mixed response with nodes, ways, and relations', () async {
test('mixed response with multiple constrained and unconstrained nodes',
() async {
stubOverpassResponse([
{
'type': 'node',
@@ -323,16 +315,12 @@ void main() {
'tags': {'man_made': 'surveillance'},
},
{
'type': 'way',
'id': 300,
'nodes': [10],
'type': 'restriction',
'id': 10,
},
{
'type': 'relation',
'id': 400,
'members': [
{'type': 'node', 'ref': 11, 'role': ''},
],
'type': 'restriction',
'id': 11,
},
]);
@@ -346,6 +334,7 @@ void main() {
});
});
group('error handling', () {
test('HTTP 200 returns parsed nodes', () async {
stubOverpassResponse([