From 54ef1b8afc19f5f85080a15459898650033c2cf5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 23 Sep 2026 14:39:11 -0500 Subject: [PATCH] first working version --- lib/app_state.dart | 85 ++++++++- lib/localizations/de.json | 9 +- lib/localizations/en.json | 9 +- lib/localizations/es.json | 9 +- lib/localizations/fr.json | 9 +- lib/localizations/it.json | 9 +- lib/localizations/nl.json | 9 +- lib/localizations/pl.json | 9 +- lib/localizations/pt.json | 9 +- lib/localizations/tr.json | 9 +- lib/localizations/uk.json | 9 +- lib/localizations/zh.json | 9 +- lib/screens/home_screen.dart | 24 ++- lib/screens/osm_account_screen.dart | 32 +++- .../settings/sections/auth_section.dart | 2 +- lib/screens/settings_screen.dart | 4 +- lib/screens/upload_queue_screen.dart | 10 +- lib/services/osm_block_service.dart | 55 ++++++ lib/state/account_block_state.dart | 60 +++++++ lib/state/upload_queue_state.dart | 14 ++ lib/widgets/active_block_dialog.dart | 54 ++++++ lib/widgets/add_node_sheet.dart | 17 ++ lib/widgets/edit_node_sheet.dart | 17 ++ lib/widgets/node_tag_sheet.dart | 17 ++ test/services/osm_block_service_test.dart | 164 ++++++++++++++++++ test/state/account_block_state_test.dart | 118 +++++++++++++ 26 files changed, 745 insertions(+), 27 deletions(-) create mode 100644 lib/services/osm_block_service.dart create mode 100644 lib/state/account_block_state.dart create mode 100644 lib/widgets/active_block_dialog.dart create mode 100644 test/services/osm_block_service_test.dart create mode 100644 test/state/account_block_state_test.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 90fbb4f..74b076a 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:url_launcher/url_launcher.dart'; import 'services/http_client.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,7 +24,9 @@ import 'services/deep_link_service.dart'; import 'widgets/node_provider_with_cache.dart'; import 'services/profile_service.dart'; import 'widgets/reauth_messages_dialog.dart'; +import 'widgets/active_block_dialog.dart'; import 'dev_config.dart'; +import 'state/account_block_state.dart'; import 'state/auth_state.dart'; import 'state/messages_state.dart'; import 'state/navigation_state.dart'; @@ -46,6 +49,7 @@ class AppState extends ChangeNotifier { static late AppState instance; // State modules + late final AccountBlockState _accountBlockState; late final AuthState _authState; late final MessagesState _messagesState; late final NavigationState _navigationState; @@ -72,6 +76,7 @@ class AppState extends ChangeNotifier { AppState() { instance = this; + _accountBlockState = AccountBlockState(); _authState = AuthState(); _messagesState = MessagesState(); _navigationState = NavigationState(); @@ -84,6 +89,7 @@ class AppState extends ChangeNotifier { _uploadQueueState = UploadQueueState(); // Set up state change listeners + _accountBlockState.addListener(_onStateChanged); _authState.addListener(_onStateChanged); _messagesState.addListener(_onStateChanged); _navigationState.addListener(_onStateChanged); @@ -179,6 +185,9 @@ class AppState extends ChangeNotifier { bool get hasUnreadChangesetComments => _messagesState.hasUnreadChangesetComments; bool get hasUnreadNotifications => _messagesState.hasUnreadNotifications; bool get isCheckingMessages => _messagesState.isChecking; + + // Account block state + bool get hasActiveBlock => _accountBlockState.isBlocked; // Tile provider state List get tileProviders => _settingsState.tileProviders; @@ -196,6 +205,10 @@ class AppState extends ChangeNotifier { /// queue so in-flight submissions can finish first. bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing); + /// True if queue processing is currently paused because an active-block + /// check found the user's account blocked. + bool get isQueuePausedDueToBlock => _uploadQueueState.isPausedDueToBlock; + // Suspected location state SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation; @@ -245,15 +258,17 @@ class AppState extends ChangeNotifier { await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode); await _uploadQueueState.init(); await _authState.init(_settingsState.uploadMode); + await _accountBlockState.init(_settingsState.uploadMode); // Set up callback to repopulate pending nodes after cache clears NodeProviderWithCache.instance.setOnCacheClearedCallback(() { _uploadQueueState.repopulateCacheFromQueue(); }); - // Check for messages on app launch if user is already logged in + // Check for messages and active blocks on app launch if user is already logged in if (isLoggedIn) { checkMessages(); + checkActiveBlock(); } // Note: Re-auth check will be triggered from home screen after init @@ -300,16 +315,18 @@ class AppState extends ChangeNotifier { // ---------- Auth Methods ---------- Future login() async { await _authState.login(); - // Check for messages after successful login + // Check for messages and active blocks after successful login if (isLoggedIn) { checkMessages(); + checkActiveBlock(); } } Future logout() async { await _authState.logout(); - // Clear message state when logging out + // Clear message and block state when logging out clearMessages(); + _accountBlockState.clear(); } Future refreshAuthState() async { @@ -318,14 +335,65 @@ class AppState extends ChangeNotifier { Future forceLogin() async { await _authState.forceLogin(); - // Check for messages after successful login + // Check for messages and active blocks after successful login if (isLoggedIn) { checkMessages(); + checkActiveBlock(); } } - Future validateToken() async { - return await _authState.validateToken(); + // ---------- Account Block Methods ---------- + + /// Silently check whether the user currently has an active OSM block. + /// Returns the conclusive result (true/false), or null if the check was + /// inconclusive (offline, error, not logged in, simulate mode). + Future checkActiveBlock() async { + final accessToken = await _authState.getAccessToken(); + final result = await _accountBlockState.check( + accessToken: accessToken, + uploadMode: uploadMode, + ); + // If the block was just lifted, resume queue processing. + if (result == false && isQueuePausedDueToBlock) { + _startUploader(); + } + return result; + } + + /// Check for an active block and, if found, show [ActiveBlockDialog]. + Future checkAndPromptForActiveBlock(BuildContext context) async { + final result = await checkActiveBlock(); + if (result == true) { + if (!context.mounted) return; + _showActiveBlockDialog(context); + } + } + + void _showActiveBlockDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => ActiveBlockDialog( + onViewMessages: () async { + final url = Uri.parse(getMessagesUrl()); + await launchUrl(url, mode: LaunchMode.externalApplication); + }, + onDismiss: () { + // Just dismiss - will show again on the next check that finds a block. + }, + ), + ); + } + + /// Test the OSM connection using the same endpoint as the active-block + /// check. A conclusive result (blocked or not) means the credentials are + /// valid; null means the connection/credentials failed. Also surfaces the + /// [ActiveBlockDialog] if the user turns out to be blocked. + Future testConnection(BuildContext context) async { + final result = await checkActiveBlock(); + if (result == true) { + if (context.mounted) _showActiveBlockDialog(context); + } + return result != null; } // ---------- Messages Methods ---------- @@ -783,9 +851,12 @@ class AppState extends ChangeNotifier { // Clear and re-check messages for new mode clearMessages(); + // Load the persisted block state for the new mode, then re-check it + await _accountBlockState.init(mode); if (isLoggedIn) { // Don't await - let it run in background checkMessages(); + checkActiveBlock(); // Note: Re-auth check will be triggered from the settings screen after mode change } @@ -958,12 +1029,14 @@ class AppState extends ChangeNotifier { pauseQueueProcessing: pauseQueueProcessing, uploadMode: uploadMode, getAccessToken: _authState.getAccessToken, + checkActiveBlock: checkActiveBlock, ); } @override void dispose() { _messageCheckTimer?.cancel(); + _accountBlockState.removeListener(_onStateChanged); _authState.removeListener(_onStateChanged); _messagesState.removeListener(_onStateChanged); _navigationState.removeListener(_onStateChanged); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index b855799..b89c21e 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -12,6 +12,7 @@ "edit": "Bearbeiten", "delete": "Löschen", "cancel": "Abbrechen", + "dismiss": "Verwerfen", "ok": "OK", "close": "Schließen", "submit": "Senden", @@ -124,6 +125,7 @@ "deleteReasonCustom": "Benutzerdefiniert..", "deleteReasonHint": "Bitte Grund beschreiben...", "mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.", + "accountBlockedCannotDelete": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Knoten löschen.", "uploadPending": "Ausstehender Upload", "uploadCreatingChangeset": "Changeset wird erstellt...", "uploadUploading": "Wird hochgeladen...", @@ -140,6 +142,7 @@ "direction": "Richtung {}°", "profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.", "mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.", + "accountBlocked": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine neuen Knoten einreichen.", "enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.", "profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.", "loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.", @@ -154,6 +157,7 @@ "profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.", "temporarilyDisabled": "Bearbeitungen wurden vorübergehend deaktiviert, während wir einen Fehler beheben - Entschuldigung - schauen Sie bald wieder vorbei.", "mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.", + "accountBlocked": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Knoten bearbeiten.", "sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.", "enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.", "profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.", @@ -241,7 +245,9 @@ "reauthLater": "Später", "unreadNotificationsTitle": "Sie haben ungelesene Benachrichtigungen", "unreadNotificationsMessage": "Sie haben ungelesene OSM-Nachrichten und/oder neue Kommentare zu Ihren Changesets.", - "unreadNotificationsView": "Jetzt ansehen" + "unreadNotificationsView": "Jetzt ansehen", + "activeBlockTitle": "Ihr OSM-Konto hat eine aktive Sperre", + "activeBlockMessage": "Es scheint, dass Ihr OpenStreetMap-Konto derzeit gesperrt ist und keine Beiträge einreichen kann. Bitte lesen Sie die Nachricht, die Sie erhalten haben sollten, um zu erfahren, warum und was Sie tun können, um das Problem zu beheben." }, "queue": { "title": "Upload-Warteschlange", @@ -277,6 +283,7 @@ "processingPaused": "Warteschlangenverarbeitung pausiert", "pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.", "pausedByUser": "Upload-Verarbeitung ist manuell pausiert.", + "pausedDueToBlock": "Die Upload-Verarbeitung ist pausiert, da Ihr OSM-Konto derzeit gesperrt ist.", "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", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 36e915c..36b821c 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -49,6 +49,7 @@ "edit": "Edit", "delete": "Delete", "cancel": "Cancel", + "dismiss": "Dismiss", "ok": "OK", "close": "Close", "submit": "Submit", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Custom..", "deleteReasonHint": "Please describe the reason...", "mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.", + "accountBlockedCannotDelete": "Your OpenStreetMap account has an active block and cannot delete nodes.", "uploadPending": "Pending upload", "uploadCreatingChangeset": "Creating changeset...", "uploadUploading": "Uploading...", @@ -177,6 +179,7 @@ "direction": "Direction {}°", "profileNoDirectionInfo": "This profile does not require a direction.", "mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.", + "accountBlocked": "Your OpenStreetMap account has an active block and cannot submit new nodes.", "enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.", "profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.", "loadingAreaData": "Loading area data... Please wait before submitting.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "This profile does not require a direction.", "temporarilyDisabled": "Edits have been temporarily disabled while we sort out a bug - apologies - check back soon.", "mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.", + "accountBlocked": "Your OpenStreetMap account has an active block and cannot edit nodes.", "sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.", "enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.", "profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.", @@ -278,7 +282,9 @@ "reauthLater": "Later", "unreadNotificationsTitle": "You Have Unread Notifications", "unreadNotificationsMessage": "You have unread OSM messages and/or new comments on your changesets.", - "unreadNotificationsView": "View Now" + "unreadNotificationsView": "View Now", + "activeBlockTitle": "Your OSM Account Has an Active Block", + "activeBlockMessage": "It seems your OpenStreetMap account has an active block, and is currently restricted from making submissions. Please read the message you should have received to learn why, and what you can do to resolve it." }, "queue": { "title": "Upload Queue", @@ -314,6 +320,7 @@ "processingPaused": "Queue Processing Paused", "pausedDueToOffline": "Upload processing is paused because offline mode is enabled.", "pausedByUser": "Upload processing is manually paused.", + "pausedDueToBlock": "Upload processing is paused because your OSM account has an active block.", "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", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index d4bc19d..c5a92a9 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -49,6 +49,7 @@ "edit": "Editar", "delete": "Eliminar", "cancel": "Cancelar", + "dismiss": "Descartar", "ok": "Aceptar", "close": "Cerrar", "submit": "Enviar", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Personalizado..", "deleteReasonHint": "Por favor describa la razón...", "mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.", + "accountBlockedCannotDelete": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede eliminar nodos.", "uploadPending": "Subida pendiente", "uploadCreatingChangeset": "Creando changeset...", "uploadUploading": "Subiendo...", @@ -177,6 +179,7 @@ "direction": "Dirección {}°", "profileNoDirectionInfo": "Este perfil no requiere una dirección.", "mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.", + "accountBlocked": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede enviar nodos nuevos.", "enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.", "profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.", "loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Este perfil no requiere una dirección.", "temporarilyDisabled": "Las ediciones han sido temporalmente deshabilitadas mientras solucionamos un error - disculpas - regrese pronto.", "mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.", + "accountBlocked": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede editar nodos.", "sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.", "enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.", "profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.", @@ -278,7 +282,9 @@ "reauthLater": "Más Tarde", "unreadNotificationsTitle": "Tienes Notificaciones Sin Leer", "unreadNotificationsMessage": "Tienes mensajes de OSM sin leer y/o nuevos comentarios en tus changesets.", - "unreadNotificationsView": "Ver Ahora" + "unreadNotificationsView": "Ver Ahora", + "activeBlockTitle": "Su cuenta de OSM tiene un bloqueo activo", + "activeBlockMessage": "Parece que su cuenta de OpenStreetMap tiene un bloqueo activo y actualmente no puede realizar envíos. Lea el mensaje que debería haber recibido para saber por qué y qué puede hacer para resolverlo." }, "queue": { "title": "Cola de Subida", @@ -314,6 +320,7 @@ "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.", + "pausedDueToBlock": "El procesamiento de subidas está pausado porque su cuenta de OSM tiene un bloqueo activo.", "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", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 47bd605..4465f4f 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -49,6 +49,7 @@ "edit": "Modifier", "delete": "Supprimer", "cancel": "Annuler", + "dismiss": "Ignorer", "ok": "OK", "close": "Fermer", "submit": "Soumettre", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Personnalisé..", "deleteReasonHint": "Veuillez décrire la raison...", "mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.", + "accountBlockedCannotDelete": "Votre compte OpenStreetMap a un blocage actif et ne peut pas supprimer de nœuds.", "uploadPending": "Téléchargement en attente", "uploadCreatingChangeset": "Création du changeset...", "uploadUploading": "Téléchargement en cours...", @@ -177,6 +179,7 @@ "direction": "Direction {}°", "profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.", "mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.", + "accountBlocked": "Votre compte OpenStreetMap a un blocage actif et ne peut pas soumettre de nouveaux nœuds.", "enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.", "profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.", "loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.", "temporarilyDisabled": "Les modifications ont été temporairement désactivées pendant que nous résolvons un bug - désolés - revenez bientôt.", "mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.", + "accountBlocked": "Votre compte OpenStreetMap a un blocage actif et ne peut pas modifier de nœuds.", "sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.", "enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.", "profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.", @@ -278,7 +282,9 @@ "reauthLater": "Plus Tard", "unreadNotificationsTitle": "Vous Avez des Notifications Non Lues", "unreadNotificationsMessage": "Vous avez des messages OSM non lus et/ou de nouveaux commentaires sur vos changesets.", - "unreadNotificationsView": "Voir Maintenant" + "unreadNotificationsView": "Voir Maintenant", + "activeBlockTitle": "Votre compte OSM a un blocage actif", + "activeBlockMessage": "Il semble que votre compte OpenStreetMap ait un blocage actif et ne puisse actuellement pas effectuer de soumissions. Veuillez lire le message que vous devriez avoir reçu pour savoir pourquoi et ce que vous pouvez faire pour y remédier." }, "queue": { "title": "File de Téléchargement", @@ -314,6 +320,7 @@ "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.", + "pausedDueToBlock": "Le traitement des envois est en pause car votre compte OSM a un blocage actif.", "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", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 1a57865..f38b114 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -49,6 +49,7 @@ "edit": "Modifica", "delete": "Elimina", "cancel": "Annulla", + "dismiss": "Ignora", "ok": "OK", "close": "Chiudi", "submit": "Invia", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Personalizzato..", "deleteReasonHint": "Descrivi il motivo...", "mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.", + "accountBlockedCannotDelete": "Il tuo account OpenStreetMap ha un blocco attivo e non può eliminare i nodi.", "uploadPending": "Caricamento in sospeso", "uploadCreatingChangeset": "Creazione changeset...", "uploadUploading": "Caricamento...", @@ -177,6 +179,7 @@ "direction": "Direzione {}°", "profileNoDirectionInfo": "Questo profilo non richiede una direzione.", "mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.", + "accountBlocked": "Il tuo account OpenStreetMap ha un blocco attivo e non può inviare nuovi nodi.", "enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.", "profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.", "loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Questo profilo non richiede una direzione.", "temporarilyDisabled": "Le modifiche sono state temporaneamente disabilitate mentre risolviamo un bug - scuse - torna presto.", "mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.", + "accountBlocked": "Il tuo account OpenStreetMap ha un blocco attivo e non può modificare i nodi.", "sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.", "enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.", "profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.", @@ -278,7 +282,9 @@ "reauthLater": "Più Tardi", "unreadNotificationsTitle": "Hai Notifiche Non Lette", "unreadNotificationsMessage": "Hai messaggi OSM non letti e/o nuovi commenti sui tuoi changeset.", - "unreadNotificationsView": "Visualizza Ora" + "unreadNotificationsView": "Visualizza Ora", + "activeBlockTitle": "Il tuo account OSM ha un blocco attivo", + "activeBlockMessage": "Sembra che il tuo account OpenStreetMap abbia un blocco attivo e sia attualmente impossibilitato a effettuare invii. Leggi il messaggio che dovresti aver ricevuto per sapere perché e cosa puoi fare per risolvere il problema." }, "queue": { "title": "Coda di Upload", @@ -314,6 +320,7 @@ "processingPaused": "Elaborazione Coda Sospesa", "pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.", "pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.", + "pausedDueToBlock": "L'elaborazione della coda di caricamento è in pausa perché il tuo account OSM ha un blocco attivo.", "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", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 4a379bd..1650db3 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -49,6 +49,7 @@ "edit": "Bewerken", "delete": "Verwijderen", "cancel": "Annuleren", + "dismiss": "Negeren", "ok": "OK", "close": "Sluiten", "submit": "Indienen", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Aangepast..", "deleteReasonHint": "Beschrijf de reden...", "mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.", + "accountBlockedCannotDelete": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen knooppunten verwijderen.", "uploadPending": "Upload in behandeling", "uploadCreatingChangeset": "Changeset maken...", "uploadUploading": "Uploaden...", @@ -177,6 +179,7 @@ "direction": "Richting {}°", "profileNoDirectionInfo": "Dit profiel vereist geen richting.", "mustBeLoggedIn": "U moet ingelogd zijn om nieuwe nodes in te dienen. Log in via Instellingen.", + "accountBlocked": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen nieuwe knooppunten indienen.", "enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nieuwe nodes in te dienen.", "profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nieuwe nodes in te dienen.", "loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Dit profiel vereist geen richting.", "temporarilyDisabled": "Bewerkingen zijn tijdelijk uitgeschakeld terwijl we een bug oplossen - excuses - controleer binnenkort opnieuw.", "mustBeLoggedIn": "U moet ingelogd zijn om nodes te bewerken. Log in via Instellingen.", + "accountBlocked": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen knooppunten bewerken.", "sandboxModeWarning": "Kan geen bewerkingen op productie nodes naar sandbox versturen. Schakel naar Productie modus in Instellingen om nodes te bewerken.", "enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nodes te bewerken.", "profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nodes te bewerken.", @@ -278,7 +282,9 @@ "reauthLater": "Later", "unreadNotificationsTitle": "U Heeft Ongelezen Meldingen", "unreadNotificationsMessage": "U heeft ongelezen OSM-berichten en/of nieuwe opmerkingen op uw changesets.", - "unreadNotificationsView": "Nu Bekijken" + "unreadNotificationsView": "Nu Bekijken", + "activeBlockTitle": "Uw OSM-account heeft een actieve blokkade", + "activeBlockMessage": "Het lijkt erop dat uw OpenStreetMap-account een actieve blokkade heeft en momenteel geen inzendingen kan doen. Lees het bericht dat u zou moeten hebben ontvangen om te weten waarom en wat u kunt doen om dit op te lossen." }, "queue": { "title": "Upload Wachtrij", @@ -314,6 +320,7 @@ "processingPaused": "Wachtrij Verwerking Gepauzeerd", "pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.", "pausedByUser": "Upload verwerking is handmatig gepauzeerd.", + "pausedDueToBlock": "Het uploadproces is gepauzeerd omdat uw OSM-account een actieve blokkade heeft.", "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", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index f7fb118..2c22347 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -49,6 +49,7 @@ "edit": "Edytuj", "delete": "Usuń", "cancel": "Anuluj", + "dismiss": "Odrzuć", "ok": "OK", "close": "Zamknij", "submit": "Zgłoś", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Niestandardowy..", "deleteReasonHint": "Opisz powód...", "mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.", + "accountBlockedCannotDelete": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może usuwać węzłów.", "uploadPending": "Przesyłanie oczekujące", "uploadCreatingChangeset": "Tworzenie zestawu zmian...", "uploadUploading": "Przesyłanie...", @@ -177,6 +179,7 @@ "direction": "Kierunek {}°", "profileNoDirectionInfo": "Ten profil nie wymaga kierunku.", "mustBeLoggedIn": "Musisz być zalogowany, aby zgłaszać nowe węzły. Zaloguj się przez Ustawienia.", + "accountBlocked": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może zgłaszać nowych węzłów.", "enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby zgłaszać nowe węzły.", "profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby zgłaszać nowe węzły.", "loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Ten profil nie wymaga kierunku.", "temporarilyDisabled": "Edycje zostały tymczasowo wyłączone, gdy rozwiązujemy błąd - przepraszamy - sprawdź wkrótce.", "mustBeLoggedIn": "Musisz być zalogowany, aby edytować węzły. Zaloguj się przez Ustawienia.", + "accountBlocked": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może edytować węzłów.", "sandboxModeWarning": "Nie można przesyłać edycji węzłów produkcyjnych do piaskownicy. Przełącz na tryb Produkcyjny w Ustawieniach, aby edytować węzły.", "enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby edytować węzły.", "profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby edytować węzły.", @@ -278,7 +282,9 @@ "reauthLater": "Później", "unreadNotificationsTitle": "Masz Nieprzeczytane Powiadomienia", "unreadNotificationsMessage": "Masz nieprzeczytane wiadomości OSM i/lub nowe komentarze do Twoich zestawów zmian.", - "unreadNotificationsView": "Zobacz Teraz" + "unreadNotificationsView": "Zobacz Teraz", + "activeBlockTitle": "Twoje konto OSM ma aktywną blokadę", + "activeBlockMessage": "Wygląda na to, że Twoje konto OpenStreetMap ma aktywną blokadę i obecnie nie może dokonywać zgłoszeń. Przeczytaj wiadomość, którą powinieneś był otrzymać, aby dowiedzieć się dlaczego i co możesz zrobić, aby to rozwiązać." }, "queue": { "title": "Kolejka Przesyłania", @@ -314,6 +320,7 @@ "processingPaused": "Przetwarzanie Kolejki Wstrzymane", "pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.", "pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.", + "pausedDueToBlock": "Przetwarzanie kolejki przesyłania jest wstrzymane, ponieważ Twoje konto OSM ma aktywną blokadę.", "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", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 2d0f48a..f26b1d0 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -49,6 +49,7 @@ "edit": "Editar", "delete": "Excluir", "cancel": "Cancelar", + "dismiss": "Dispensar", "ok": "OK", "close": "Fechar", "submit": "Enviar", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Personalizado..", "deleteReasonHint": "Descreva o motivo...", "mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.", + "accountBlockedCannotDelete": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode excluir nós.", "uploadPending": "Envio pendente", "uploadCreatingChangeset": "Criando changeset...", "uploadUploading": "Enviando...", @@ -177,6 +179,7 @@ "direction": "Direção {}°", "profileNoDirectionInfo": "Este perfil não requer uma direção.", "mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.", + "accountBlocked": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode enviar novos nós.", "enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.", "profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.", "loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Este perfil não requer uma direção.", "temporarilyDisabled": "As edições foram temporariamente desabilitadas enquanto resolvemos um bug - desculpe - volte em breve.", "mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.", + "accountBlocked": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode editar nós.", "sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.", "enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.", "profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.", @@ -278,7 +282,9 @@ "reauthLater": "Mais Tarde", "unreadNotificationsTitle": "Você Tem Notificações Não Lidas", "unreadNotificationsMessage": "Você tem mensagens OSM não lidas e/ou novos comentários nos seus changesets.", - "unreadNotificationsView": "Ver Agora" + "unreadNotificationsView": "Ver Agora", + "activeBlockTitle": "Sua conta OSM tem um bloqueio ativo", + "activeBlockMessage": "Parece que sua conta OpenStreetMap tem um bloqueio ativo e está impedida de fazer envios no momento. Leia a mensagem que você deve ter recebido para saber o motivo e o que pode fazer para resolver." }, "queue": { "title": "Fila de Upload", @@ -314,6 +320,7 @@ "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.", + "pausedDueToBlock": "O processamento da fila de upload está pausado porque sua conta OSM tem um bloqueio ativo.", "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", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 8fd429d..700c1a9 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -49,6 +49,7 @@ "edit": "Düzenle", "delete": "Sil", "cancel": "İptal", + "dismiss": "Kapat", "ok": "Tamam", "close": "Kapat", "submit": "Gönder", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Özel..", "deleteReasonHint": "Lütfen nedeni açıklayın...", "mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.", + "accountBlockedCannotDelete": "OpenStreetMap hesabınızda aktif bir engelleme var ve düğüm silemez.", "uploadPending": "Yükleme bekliyor", "uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...", "uploadUploading": "Yükleniyor...", @@ -177,6 +179,7 @@ "direction": "Yön {}°", "profileNoDirectionInfo": "Bu profil bir yön gerektirmez.", "mustBeLoggedIn": "Yeni düğümler göndermek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.", + "accountBlocked": "OpenStreetMap hesabınızda aktif bir engelleme var ve yeni düğüm gönderemez.", "enableSubmittableProfile": "Yeni düğümler göndermek için Ayarlarda gönderilebilir bir profili etkinleştirin.", "profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Yeni düğümler göndermek için lütfen gönderilebilir bir profil seçin.", "loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Bu profil bir yön gerektirmez.", "temporarilyDisabled": "Bir hatayı çözmemiz sırasında düzenlemeler geçici olarak devre dışı bırakıldı - özür dileriz - yakında tekrar kontrol edin.", "mustBeLoggedIn": "Düğümleri düzenlemek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.", + "accountBlocked": "OpenStreetMap hesabınızda aktif bir engelleme var ve düğüm düzenleyemez.", "sandboxModeWarning": "Üretim düğümlerinde düzenlemeleri sandbox'a gönderemezsiniz. Düğümleri düzenlemek için Ayarlar'da Üretim moduna geçin.", "enableSubmittableProfile": "Düğümleri düzenlemek için Ayarlarda gönderilebilir bir profili etkinleştirin.", "profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Düğümleri düzenlemek için lütfen gönderilebilir bir profil seçin.", @@ -278,7 +282,9 @@ "reauthLater": "Sonra", "unreadNotificationsTitle": "Okunmamış Bildirimleriniz Var", "unreadNotificationsMessage": "Okunmamış OSM mesajlarınız ve/veya değişiklik kümelerinize yapılan yeni yorumlar var.", - "unreadNotificationsView": "Şimdi Görüntüle" + "unreadNotificationsView": "Şimdi Görüntüle", + "activeBlockTitle": "OSM Hesabınızda Aktif Bir Engelleme Var", + "activeBlockMessage": "OpenStreetMap hesabınızda aktif bir engelleme olduğu ve şu anda gönderim yapmanızın kısıtlandığı görülüyor. Nedenini ve bunu nasıl çözebileceğinizi öğrenmek için almış olmanız gereken mesajı okuyun." }, "queue": { "title": "Yükleme Kuyruğu", @@ -314,6 +320,7 @@ "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ı.", + "pausedDueToBlock": "OSM hesabınızda aktif bir engelleme olduğu için yükleme işlemi 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ı", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index d756f84..766329e 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -49,6 +49,7 @@ "edit": "Редагувати", "delete": "Видалити", "cancel": "Скасувати", + "dismiss": "Відхилити", "ok": "ОК", "close": "Закрити", "submit": "Подати", @@ -161,6 +162,7 @@ "deleteReasonCustom": "Власний..", "deleteReasonHint": "Опишіть причину...", "mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.", + "accountBlockedCannotDelete": "Ваш обліковий запис OpenStreetMap має активне блокування і не може видаляти вузли.", "uploadPending": "Завантаження очікує", "uploadCreatingChangeset": "Створення набору змін...", "uploadUploading": "Завантаження...", @@ -177,6 +179,7 @@ "direction": "Напрямок {}°", "profileNoDirectionInfo": "Цей профіль не потребує напрямку.", "mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.", + "accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може подавати нові вузли.", "enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.", "profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.", "loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "Цей профіль не потребує напрямку.", "temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.", "mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.", + "accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може редагувати вузли.", "sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.", "enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.", "profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.", @@ -278,7 +282,9 @@ "reauthLater": "Пізніше", "unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення", "unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.", - "unreadNotificationsView": "Переглянути Зараз" + "unreadNotificationsView": "Переглянути Зараз", + "activeBlockTitle": "Ваш обліковий запис OSM має активне блокування", + "activeBlockMessage": "Схоже, що ваш обліковий запис OpenStreetMap має активне блокування і наразі не може надсилати дані. Будь ласка, прочитайте повідомлення, яке ви мали отримати, щоб дізнатися чому та що можна зробити для вирішення цієї проблеми." }, "queue": { "title": "Черга Завантаження", @@ -314,6 +320,7 @@ "processingPaused": "Обробка Черги Призупинена", "pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.", "pausedByUser": "Обробка завантаження призупинена вручну.", + "pausedDueToBlock": "Обробку черги завантаження призупинено, оскільки ваш обліковий запис OSM має активне блокування.", "inFlightWaitTitle": "Завершення Подання", "inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.", "inFlightCompleteTitle": "Подання Завершено", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 15bd4b4..0c7de61 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -49,6 +49,7 @@ "edit": "编辑", "delete": "删除", "cancel": "取消", + "dismiss": "关闭", "ok": "确定", "close": "关闭", "submit": "提交", @@ -161,6 +162,7 @@ "deleteReasonCustom": "自定义..", "deleteReasonHint": "请描述原因...", "mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。", + "accountBlockedCannotDelete": "您的 OpenStreetMap 账户有一个有效封禁,无法删除节点。", "uploadPending": "上传待处理", "uploadCreatingChangeset": "正在创建变更集...", "uploadUploading": "正在上传...", @@ -177,6 +179,7 @@ "direction": "方向 {}°", "profileNoDirectionInfo": "此配置文件不需要方向。", "mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。", + "accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法提交新节点。", "enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。", "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。", "loadingAreaData": "正在加载区域数据...提交前请稍候。", @@ -191,6 +194,7 @@ "profileNoDirectionInfo": "此配置文件不需要方向。", "temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。", "mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。", + "accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法编辑节点。", "sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。", "enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。", "profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。", @@ -278,7 +282,9 @@ "reauthLater": "稍后", "unreadNotificationsTitle": "您有未读通知", "unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。", - "unreadNotificationsView": "立即查看" + "unreadNotificationsView": "立即查看", + "activeBlockTitle": "您的 OSM 账户有一个有效封禁", + "activeBlockMessage": "您的 OpenStreetMap 账户似乎存在有效封禁,目前无法提交内容。请阅读您应该已收到的消息,了解原因以及如何解决。" }, "queue": { "title": "上传队列", @@ -314,6 +320,7 @@ "processingPaused": "队列处理已暂停", "pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。", "pausedByUser": "上传处理已手动暂停。", + "pausedDueToBlock": "由于您的 OSM 账户有一个有效封禁,上传处理已暂停。", "inFlightWaitTitle": "正在完成提交", "inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。", "inFlightCompleteTitle": "提交完成", diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4e5e7f2..81fc157 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -37,7 +37,7 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State with TickerProviderStateMixin { +class _HomeScreenState extends State with TickerProviderStateMixin, WidgetsBindingObserver { final GlobalKey _scaffoldKey = GlobalKey(); final GlobalKey _mapViewKey = GlobalKey(); late final AnimatedMapController _mapController; @@ -62,6 +62,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _mapController = AnimatedMapController(vsync: this); _sheetCoordinator = SheetCoordinator(); _navigationCoordinator = NavigationCoordinator(); @@ -71,11 +72,24 @@ class _HomeScreenState extends State with TickerProviderStateMixin { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); DeepLinkService().onNodeDeepLink = null; _mapController.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Re-check for an active OSM block when the user returns to the app, + // in case they resolved it (or got blocked) while away. + if (state == AppLifecycleState.resumed) { + final appState = context.read(); + if (appState.isLoggedIn) { + appState.checkAndPromptForActiveBlock(context); + } + } + } + String _getFollowMeTooltip(FollowMeMode mode) { final locService = LocalizationService.instance; switch (mode) { @@ -474,6 +488,10 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _checkForPopup(); // Check if re-authentication is needed for message notifications appState.checkAndPromptReauthForMessages(context); + // Check for an active OSM block on app launch + if (appState.isLoggedIn) { + appState.checkAndPromptForActiveBlock(context); + } }); } @@ -544,7 +562,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { return IconButton( tooltip: LocalizationService.instance.settings, icon: Badge( - isLabelVisible: appState.hasUnreadNotifications, + isLabelVisible: appState.hasActiveBlock || appState.hasUnreadNotifications, + backgroundColor: appState.hasActiveBlock ? Colors.red : null, + label: appState.hasActiveBlock ? const Icon(Icons.block, size: 10, color: Colors.white) : null, child: const Icon(Icons.settings), ), onPressed: () => Navigator.pushNamed(context, '/settings'), diff --git a/lib/screens/osm_account_screen.dart b/lib/screens/osm_account_screen.dart index 85cce6e..f5b5f9e 100644 --- a/lib/screens/osm_account_screen.dart +++ b/lib/screens/osm_account_screen.dart @@ -17,11 +17,12 @@ class _OSMAccountScreenState extends State { @override void initState() { super.initState(); - // Check for messages when screen loads + // Check for messages and active blocks when screen loads WidgetsBinding.instance.addPostFrameCallback((_) { final appState = context.read(); if (appState.isLoggedIn) { appState.checkMessages(); + appState.checkAndPromptForActiveBlock(context); } }); } @@ -83,13 +84,40 @@ class _OSMAccountScreenState extends State { ), if (appState.isLoggedIn) ...[ + // Active block warning - shown whenever a check has + // found the user's OSM account currently blocked. + if (appState.hasActiveBlock) ...[ + const Divider(), + ListTile( + leading: Badge( + isLabelVisible: true, + backgroundColor: Colors.red, + child: const Icon(Icons.block, color: Colors.red), + ), + title: Text(locService.t('auth.activeBlockTitle')), + subtitle: Text(locService.t('auth.activeBlockMessage')), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + final url = Uri.parse(appState.getMessagesUrl()); + 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'))), + ); + } + } + }, + ), + ], const Divider(), ListTile( leading: const Icon(Icons.wifi_protected_setup), title: Text(locService.t('auth.testConnection')), subtitle: Text(locService.t('auth.testConnectionSubtitle')), onTap: () async { - final isValid = await appState.validateToken(); + final isValid = await appState.testConnection(context); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/settings/sections/auth_section.dart b/lib/screens/settings/sections/auth_section.dart index c2b666a..d82bb8b 100644 --- a/lib/screens/settings/sections/auth_section.dart +++ b/lib/screens/settings/sections/auth_section.dart @@ -53,7 +53,7 @@ class AuthSection extends StatelessWidget { title: Text(locService.t('auth.testConnection')), subtitle: Text(locService.t('auth.testConnectionSubtitle')), onTap: () async { - final isValid = await appState.validateToken(); + final isValid = await appState.testConnection(context); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 63b055a..614a2bc 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -118,7 +118,9 @@ class SettingsScreen extends StatelessWidget { return ListTile( leading: Badge( - isLabelVisible: appState.hasUnreadNotifications, + isLabelVisible: appState.hasActiveBlock || appState.hasUnreadNotifications, + backgroundColor: appState.hasActiveBlock ? Colors.red : null, + label: appState.hasActiveBlock ? const Icon(Icons.block, size: 10, color: Colors.white) : null, child: const Icon(Icons.account_circle), ), title: Text(locService.t('auth.osmAccountTitle')), diff --git a/lib/screens/upload_queue_screen.dart b/lib/screens/upload_queue_screen.dart index 47efa6b..81f28c4 100644 --- a/lib/screens/upload_queue_screen.dart +++ b/lib/screens/upload_queue_screen.dart @@ -111,7 +111,7 @@ class UploadQueueScreen extends StatelessWidget { final appState = context.watch(); // Check if queue processing is paused - final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing; + final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing || appState.isQueuePausedDueToBlock; return Scaffold( appBar: AppBar( @@ -151,9 +151,11 @@ class UploadQueueScreen extends StatelessWidget { ), ), Text( - appState.offlineMode - ? locService.t('queue.pausedDueToOffline') - : locService.t('queue.pausedByUser'), + appState.isQueuePausedDueToBlock + ? locService.t('queue.pausedDueToBlock') + : appState.offlineMode + ? locService.t('queue.pausedDueToOffline') + : locService.t('queue.pausedByUser'), style: TextStyle( fontSize: 12, color: Colors.orange.shade700, diff --git a/lib/services/osm_block_service.dart b/lib/services/osm_block_service.dart new file mode 100644 index 0000000..49a9ceb --- /dev/null +++ b/lib/services/osm_block_service.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../state/settings_state.dart'; +import 'http_client.dart'; + +/// Service for checking whether the current user has an active OSM DWG block. +/// +/// See: https://wiki.openstreetmap.org/wiki/API_v0.6#List_active_blocks:_GET_/api/0.6/user/blocks/active +class OSMBlockService { + final http.Client _client; + + OSMBlockService({http.Client? client}) : _client = client ?? UserAgentClient(); + + /// Returns whether the user currently has an active block. + /// + /// Returns null if not logged in, in simulate mode, or on any error + /// (including no network connection) - callers should treat null as + /// "unknown, assume unchanged" rather than "not blocked". + Future checkActiveBlock({ + required String? accessToken, + required UploadMode uploadMode, + }) async { + if (uploadMode == UploadMode.simulate) return null; + if (accessToken == null || accessToken.isEmpty) return null; + + try { + final apiHost = _getApiHost(uploadMode); + final response = await _client.get( + Uri.parse('$apiHost/api/0.6/user/blocks/active.json'), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + + if (response.statusCode != 200) return null; + + final data = jsonDecode(response.body); + final userBlocks = data['user_blocks']; + if (userBlocks is! List) return null; + + return userBlocks.isNotEmpty; + } catch (e) { + return null; + } + } + + String _getApiHost(UploadMode uploadMode) { + switch (uploadMode) { + case UploadMode.production: + return 'https://api.openstreetmap.org'; + case UploadMode.sandbox: + return 'https://api06.dev.openstreetmap.org'; + case UploadMode.simulate: + return 'https://api.openstreetmap.org'; + } + } +} diff --git a/lib/state/account_block_state.dart b/lib/state/account_block_state.dart new file mode 100644 index 0000000..e7d8547 --- /dev/null +++ b/lib/state/account_block_state.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../services/osm_block_service.dart'; +import 'settings_state.dart'; + +/// Tracks whether the current user has an active OSM DWG block. +/// +/// Persisted per upload mode so the last known state survives app restarts +/// (e.g. launching offline) - a failed/inconclusive check leaves the +/// persisted value untouched rather than assuming "not blocked". +class AccountBlockState extends ChangeNotifier { + final OSMBlockService _blockService; + + AccountBlockState({OSMBlockService? blockService}) + : _blockService = blockService ?? OSMBlockService(); + + bool _isBlocked = false; + + bool get isBlocked => _isBlocked; + + /// Load the last known block state for the given upload mode. + Future init(UploadMode uploadMode) async { + final prefs = await SharedPreferences.getInstance(); + _isBlocked = prefs.getBool(_prefsKey(uploadMode)) ?? false; + notifyListeners(); + } + + /// Check for an active block. Returns the conclusive result (true/false), + /// or null if the check was inconclusive (offline, error, not logged in, + /// simulate mode). On null, the previously known state is left unchanged. + Future check({ + required String? accessToken, + required UploadMode uploadMode, + }) async { + final result = await _blockService.checkActiveBlock( + accessToken: accessToken, + uploadMode: uploadMode, + ); + + if (result != null && result != _isBlocked) { + _isBlocked = result; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKey(uploadMode), result); + notifyListeners(); + } + + return result; + } + + /// Clear block state (called on logout). + void clear() { + if (_isBlocked) { + _isBlocked = false; + notifyListeners(); + } + } + + String _prefsKey(UploadMode uploadMode) => 'account_blocked_${uploadMode.name}'; +} diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 15557f4..a2387c9 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -20,11 +20,13 @@ class UploadQueueState extends ChangeNotifier { final List _queue = []; Timer? _uploadTimer; int _activeUploadCount = 0; + bool _isPausedDueToBlock = false; // Getters int get pendingCount => _queue.length; List get pendingUploads => List.unmodifiable(_queue); int get activeUploadCount => _activeUploadCount; + bool get isPausedDueToBlock => _isPausedDueToBlock; // Initialize by loading queue from storage and repopulate cache with pending nodes Future init() async { @@ -326,8 +328,10 @@ class UploadQueueState extends ChangeNotifier { required bool pauseQueueProcessing, required UploadMode uploadMode, required Future Function() getAccessToken, + required Future Function() checkActiveBlock, }) { _uploadTimer?.cancel(); + _isPausedDueToBlock = false; // No uploads if queue is empty, offline mode is enabled, or queue processing is paused if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return; @@ -386,6 +390,16 @@ class UploadQueueState extends ChangeNotifier { final access = await getAccessToken(); if (access == null) return; // not logged in + // Don't start new uploads if the user has an active OSM block. + // Leave the item pending - it'll be retried next time the uploader starts. + if (await checkActiveBlock() == true) { + debugPrint('[UploadQueue] Active block detected, pausing queue processing'); + _isPausedDueToBlock = true; + _uploadTimer?.cancel(); + notifyListeners(); + return; + } + // Start processing the next pending upload final item = pendingItems.first; debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)'); diff --git a/lib/widgets/active_block_dialog.dart b/lib/widgets/active_block_dialog.dart new file mode 100644 index 0000000..2404e2c --- /dev/null +++ b/lib/widgets/active_block_dialog.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../services/localization_service.dart'; + +/// Dialog shown whenever a check finds that the user's OSM account has an +/// active block. Unlike the unread-notifications dialog, this has no +/// "don't show again" option and is shown every time a check finds a block. +class ActiveBlockDialog extends StatelessWidget { + final VoidCallback onViewMessages; + final VoidCallback onDismiss; + + const ActiveBlockDialog({ + super.key, + required this.onViewMessages, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final locService = LocalizationService.instance; + + return AlertDialog( + title: Row( + children: [ + Icon( + Icons.block, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Expanded( + child: Text(locService.t('auth.activeBlockTitle')), + ), + ], + ), + content: Text(locService.t('auth.activeBlockMessage')), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onDismiss(); + }, + child: Text(locService.t('actions.dismiss')), + ), + FilledButton.icon( + onPressed: () { + Navigator.of(context).pop(); + onViewMessages(); + }, + icon: const Icon(Icons.message, size: 18), + label: Text(locService.t('auth.viewMessages')), + ), + ], + ); + } +} diff --git a/lib/widgets/add_node_sheet.dart b/lib/widgets/add_node_sheet.dart index 38cdabb..daf9255 100644 --- a/lib/widgets/add_node_sheet.dart +++ b/lib/widgets/add_node_sheet.dart @@ -421,6 +421,7 @@ class _AddNodeSheetState extends State { } final allowSubmit = appState.isLoggedIn && + !appState.hasActiveBlock && submittableProfiles.isNotEmpty && session.profile != null && session.profile!.isSubmittable && @@ -493,6 +494,22 @@ class _AddNodeSheetState extends State { ], ), ) + else if (appState.hasActiveBlock) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.block, color: Colors.red, size: 20), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('addNode.accountBlocked'), + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ], + ), + ) else if (submittableProfiles.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), diff --git a/lib/widgets/edit_node_sheet.dart b/lib/widgets/edit_node_sheet.dart index 2131172..b3f8915 100644 --- a/lib/widgets/edit_node_sheet.dart +++ b/lib/widgets/edit_node_sheet.dart @@ -377,6 +377,7 @@ class _EditNodeSheetState extends State { final allowSubmit = kEnableNodeEdits && appState.isLoggedIn && + !appState.hasActiveBlock && submittableProfiles.isNotEmpty && session.profile != null && session.profile!.isSubmittable && @@ -526,6 +527,22 @@ class _EditNodeSheetState extends State { ], ), ) + else if (appState.hasActiveBlock) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.block, color: Colors.red, size: 20), + const SizedBox(width: 6), + Expanded( + child: Text( + locService.t('editNode.accountBlocked'), + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ], + ), + ) else if (submittableProfiles.isEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index c728af1..1c13341 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -184,6 +184,23 @@ class _NodeTagSheetState extends State { return; } + if (appState.hasActiveBlock) { + await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(locService.t('node.confirmDeleteTitle')), + content: Text(locService.t('node.accountBlockedCannotDelete')), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(locService.ok), + ), + ], + ), + ); + return; + } + final result = await showDialog<({bool confirmed, String comment})>( context: context, builder: (BuildContext context) => _DeleteNodeDialog( diff --git a/test/services/osm_block_service_test.dart b/test/services/osm_block_service_test.dart new file mode 100644 index 0000000..13c2de8 --- /dev/null +++ b/test/services/osm_block_service_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:deflockapp/services/osm_block_service.dart'; +import 'package:deflockapp/state/settings_state.dart'; + +void main() { + group('OSMBlockService', () { + test('returns null in simulate mode without making a request', () async { + var requested = false; + final client = MockClient((request) async { + requested = true; + return http.Response('{"user_blocks":[]}', 200); + }); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.simulate, + ); + + expect(result, isNull); + expect(requested, isFalse); + }); + + test('returns null when access token is null', () async { + final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: null, + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + + test('returns null when access token is empty', () async { + final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: '', + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + + test('returns false when user_blocks is empty', () async { + final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isFalse); + }); + + test('returns true when user_blocks is non-empty', () async { + final client = MockClient((request) async => http.Response( + '{"user_blocks":[{"id":101,"needs_view":true}]}', + 200, + )); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isTrue); + }); + + test('sends the request to the correct production host with auth header', () async { + Uri? capturedUri; + String? capturedAuth; + final client = MockClient((request) async { + capturedUri = request.url; + capturedAuth = request.headers['Authorization']; + return http.Response('{"user_blocks":[]}', 200); + }); + final service = OSMBlockService(client: client); + + await service.checkActiveBlock( + accessToken: 'my-token', + uploadMode: UploadMode.production, + ); + + expect(capturedUri?.host, 'api.openstreetmap.org'); + expect(capturedUri?.path, '/api/0.6/user/blocks/active.json'); + expect(capturedAuth, 'Bearer my-token'); + }); + + test('sends the request to the sandbox host in sandbox mode', () async { + Uri? capturedUri; + final client = MockClient((request) async { + capturedUri = request.url; + return http.Response('{"user_blocks":[]}', 200); + }); + final service = OSMBlockService(client: client); + + await service.checkActiveBlock( + accessToken: 'my-token', + uploadMode: UploadMode.sandbox, + ); + + expect(capturedUri?.host, 'api06.dev.openstreetmap.org'); + }); + + test('returns null on non-200 response', () async { + final client = MockClient((request) async => http.Response('Unauthorized', 401)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + + test('returns null on malformed JSON response', () async { + final client = MockClient((request) async => http.Response('not json', 200)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + + test('returns null when user_blocks field is missing', () async { + final client = MockClient((request) async => http.Response('{}', 200)); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + + test('returns null when the client throws (e.g. no network)', () async { + final client = MockClient((request) async { + throw Exception('network error'); + }); + final service = OSMBlockService(client: client); + + final result = await service.checkActiveBlock( + accessToken: 'token', + uploadMode: UploadMode.production, + ); + + expect(result, isNull); + }); + }); +} diff --git a/test/state/account_block_state_test.dart b/test/state/account_block_state_test.dart new file mode 100644 index 0000000..88504f1 --- /dev/null +++ b/test/state/account_block_state_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/services/osm_block_service.dart'; +import 'package:deflockapp/state/account_block_state.dart'; +import 'package:deflockapp/state/settings_state.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('AccountBlockState', () { + test('defaults to not blocked before init()', () { + final state = AccountBlockState(); + expect(state.isBlocked, isFalse); + }); + + test('init() loads persisted blocked state for the given upload mode', () async { + SharedPreferences.setMockInitialValues({ + 'account_blocked_production': true, + }); + + final state = AccountBlockState(); + await state.init(UploadMode.production); + + expect(state.isBlocked, isTrue); + }); + + test('init() defaults to false when nothing is persisted', () async { + final state = AccountBlockState(); + await state.init(UploadMode.production); + + expect(state.isBlocked, isFalse); + }); + + test('init() is scoped per upload mode', () async { + SharedPreferences.setMockInitialValues({ + 'account_blocked_production': true, + }); + + final state = AccountBlockState(); + await state.init(UploadMode.sandbox); + + expect(state.isBlocked, isFalse); + }); + + test('check() updates and persists state on a conclusive true result', () async { + final client = MockClient((request) async => http.Response( + '{"user_blocks":[{"id":1}]}', + 200, + )); + final state = AccountBlockState(blockService: OSMBlockService(client: client)); + await state.init(UploadMode.production); + + final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production); + + expect(result, isTrue); + expect(state.isBlocked, isTrue); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('account_blocked_production'), isTrue); + }); + + test('check() updates and persists state on a conclusive false result', () async { + SharedPreferences.setMockInitialValues({ + 'account_blocked_production': true, + }); + final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200)); + final state = AccountBlockState(blockService: OSMBlockService(client: client)); + await state.init(UploadMode.production); + expect(state.isBlocked, isTrue); + + final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production); + + expect(result, isFalse); + expect(state.isBlocked, isFalse); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('account_blocked_production'), isFalse); + }); + + test('check() leaves state unchanged on an inconclusive (null) result', () async { + SharedPreferences.setMockInitialValues({ + 'account_blocked_production': true, + }); + final client = MockClient((request) async { + throw Exception('offline'); + }); + final state = AccountBlockState(blockService: OSMBlockService(client: client)); + await state.init(UploadMode.production); + expect(state.isBlocked, isTrue); + + final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production); + + expect(result, isNull); + expect(state.isBlocked, isTrue); // unchanged + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('account_blocked_production'), isTrue); // unchanged + }); + + test('clear() resets in-memory blocked state', () async { + SharedPreferences.setMockInitialValues({ + 'account_blocked_production': true, + }); + final state = AccountBlockState(); + await state.init(UploadMode.production); + expect(state.isBlocked, isTrue); + + state.clear(); + + expect(state.isBlocked, isFalse); + }); + }); +}