diff --git a/lib/app_state.dart b/lib/app_state.dart index 90fbb4f..317f4a7 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'; @@ -20,10 +21,14 @@ import 'services/tile_preview_service.dart'; import 'services/changelog_service.dart'; import 'services/operator_profile_service.dart'; import 'services/deep_link_service.dart'; +import 'services/auth_service.dart' show AccountBlockedException; +import 'services/localization_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 +51,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 +78,7 @@ class AppState extends ChangeNotifier { AppState() { instance = this; + _accountBlockState = AccountBlockState(); _authState = AuthState(); _messagesState = MessagesState(); _navigationState = NavigationState(); @@ -84,6 +91,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 +187,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 +207,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 +260,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 @@ -298,34 +315,131 @@ class AppState extends ChangeNotifier { } // ---------- Auth Methods ---------- - Future login() async { - await _authState.login(); - // Check for messages after successful login + Future login({BuildContext? context}) async { + try { + await _authState.login(); + } catch (e) { + await _recordLoginFailure(e); + if (context == null || !context.mounted) return; + _showLoginFailureUi(e, context); + return; + } + // 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 { await _authState.refreshAuthState(); } - Future forceLogin() async { - await _authState.forceLogin(); - // Check for messages after successful login + Future forceLogin({BuildContext? context}) async { + try { + await _authState.forceLogin(); + } catch (e) { + await _recordLoginFailure(e); + if (context == null || !context.mounted) return; + _showLoginFailureUi(e, context); + return; + } + // Check for messages and active blocks after successful login if (isLoggedIn) { checkMessages(); + checkActiveBlock(); } } - Future validateToken() async { - return await _authState.validateToken(); + /// Record any state changes resulting from a login/forceLogin failure. + /// Never touches the UI - safe to call before a BuildContext mounted check. + Future _recordLoginFailure(Object error) async { + debugPrint('AppState: Login failed: $error'); + if (error is AccountBlockedException) { + // We got a conclusive answer straight from the API during login, so + // record it like any other successful check. + await _accountBlockState.check( + accessToken: await _authState.getAccessToken(), + uploadMode: uploadMode, + ); + } + } + + /// Show the appropriate UI for a login/forceLogin failure. Callers must + /// check `context.mounted` immediately before calling this (no further + /// awaits in between) since it's used synchronously here. + void _showLoginFailureUi(Object error, BuildContext context) { + if (error is AccountBlockedException) { + _showActiveBlockDialog(context); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocalizationService.instance.t('auth.loginFailed')), + backgroundColor: Colors.red, + ), + ); + } + + // ---------- 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( + onViewDetails: () async { + final url = Uri.parse(getBlockDetailsUrl()); + 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 ---------- @@ -342,6 +456,18 @@ class AppState extends ChangeNotifier { String getMessagesUrl() { return _messagesState.getMessagesUrl(uploadMode); } + + /// URL to view the current user's block details on OSM's website, or - + /// if the username isn't known for some reason - the messages inbox, + /// since OSM also sends a message when a block is issued. + String getBlockDetailsUrl() { + if (username.isEmpty) return getMessagesUrl(); + final host = switch (uploadMode) { + UploadMode.sandbox => 'https://master.apis.dev.openstreetmap.org', + UploadMode.production || UploadMode.simulate => 'https://www.openstreetmap.org', + }; + return '$host/user/${Uri.encodeComponent(username)}/blocks'; + } /// URL of the specific changeset with unread comments, on OSM's website, /// or null if there are no unread changeset comments. @@ -783,9 +909,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 +1087,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/dev_config.dart b/lib/dev_config.dart index bf7f6ed..2b9eee9 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -49,6 +49,16 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) { return baseTop + safeArea.top; } +// Whether to proactively check blocks/active *before* attempting to fetch +// the username during login. Current OSM behavior: user/details returns 403 +// for blocked accounts, so we check blocks/active first (it's documented as +// accessible even while blocked) to detect this reliably instead of getting +// an unexplained 403. Flip to false once OSM allows user/details to succeed +// for blocked accounts too, so login can complete normally and rely on the +// regular checkActiveBlock() triggers (launch/resume/queue/test connection) +// to surface the block warning instead. +const bool kCheckBlockBeforeUsernameFetch = true; + // Client name for OSM uploads ("created_by" tag) const String kClientName = 'DeFlock'; // Note: Version is now dynamically retrieved from VersionService diff --git a/lib/localizations/de.json b/lib/localizations/de.json index b855799..9f27f6c 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.", @@ -210,6 +214,7 @@ "tapToLogout": "Zum Abmelden antippen", "requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen", "loggedOut": "Abgemeldet", + "loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", "testConnection": "Verbindung Testen", "testConnectionSubtitle": "OSM-Anmeldedaten überprüfen", "connectionOK": "Verbindung OK - Anmeldedaten sind gültig", @@ -241,7 +246,10 @@ "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": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Beiträge einreichen. Sie sollten eine Nachricht mit weiteren Details erhalten haben; bitte prüfen Sie Ihren OSM-Posteingang, um mehr zu erfahren.", + "viewBlockDetails": "Sperrdetails ansehen" }, "queue": { "title": "Upload-Warteschlange", @@ -277,6 +285,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..ce19b38 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Tap to logout", "requiredToSubmit": "Required to submit camera data", "loggedOut": "Logged out", + "loginFailed": "Login failed. Please try again.", "testConnection": "Test Connection", "testConnectionSubtitle": "Verify OSM credentials are working", "connectionOK": "Connection OK - credentials are valid", @@ -278,7 +283,10 @@ "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": "Your OpenStreetMap account currently has an active block and is restricted from making submissions. You should have received a message with more details; please check your OSM inbox to learn more.", + "viewBlockDetails": "View Block Details" }, "queue": { "title": "Upload Queue", @@ -314,6 +322,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..01072c7 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Toque para cerrar sesión", "requiredToSubmit": "Requerido para enviar datos de cámaras", "loggedOut": "Sesión cerrada", + "loginFailed": "Error al iniciar sesión. Inténtelo de nuevo.", "testConnection": "Probar Conexión", "testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen", "connectionOK": "Conexión OK - las credenciales son válidas", @@ -278,7 +283,10 @@ "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": "Su cuenta de OpenStreetMap tiene actualmente un bloqueo activo y no puede realizar envíos. Debería haber recibido un mensaje con más detalles; revise su bandeja de entrada de OSM para obtener más información.", + "viewBlockDetails": "Ver Detalles del Bloqueo" }, "queue": { "title": "Cola de Subida", @@ -314,6 +322,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..9bea050 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Appuyer pour se déconnecter", "requiredToSubmit": "Requis pour soumettre des données de caméras", "loggedOut": "Déconnecté", + "loginFailed": "Échec de la connexion. Veuillez réessayer.", "testConnection": "Tester Connexion", "testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent", "connectionOK": "Connexion OK - les identifiants sont valides", @@ -278,7 +283,10 @@ "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": "Votre compte OpenStreetMap a actuellement un blocage actif et ne peut pas effectuer de soumissions. Vous devriez avoir reçu un message avec plus de détails ; veuillez consulter votre boîte de réception OSM pour en savoir plus.", + "viewBlockDetails": "Voir les Détails du Blocage" }, "queue": { "title": "File de Téléchargement", @@ -314,6 +322,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..891ff7a 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Tocca per disconnetterti", "requiredToSubmit": "Richiesto per inviare dati delle telecamere", "loggedOut": "Disconnesso", + "loginFailed": "Accesso non riuscito. Riprova.", "testConnection": "Testa Connessione", "testConnectionSubtitle": "Verifica che le credenziali OSM funzionino", "connectionOK": "Connessione OK - le credenziali sono valide", @@ -278,7 +283,10 @@ "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": "Il tuo account OpenStreetMap ha attualmente un blocco attivo e non può effettuare invii. Dovresti aver ricevuto un messaggio con maggiori dettagli; controlla la tua casella di posta OSM per saperne di più.", + "viewBlockDetails": "Visualizza Dettagli Blocco" }, "queue": { "title": "Coda di Upload", @@ -314,6 +322,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..754c78d 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Tik om uit te loggen", "requiredToSubmit": "Vereist om camera gegevens in te dienen", "loggedOut": "Uitgelogd", + "loginFailed": "Inloggen mislukt. Probeer het opnieuw.", "testConnection": "Test Verbinding", "testConnectionSubtitle": "Verifieer dat OSM credentials werken", "connectionOK": "Verbinding OK - credentials zijn geldig", @@ -278,7 +283,10 @@ "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": "Uw OpenStreetMap-account heeft momenteel een actieve blokkade en kan geen inzendingen doen. U zou een bericht met meer details moeten hebben ontvangen; controleer uw OSM-inbox voor meer informatie.", + "viewBlockDetails": "Blokkadedetails Bekijken" }, "queue": { "title": "Upload Wachtrij", @@ -314,6 +322,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..bf0e7a8 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Stuknij aby się wylogować", "requiredToSubmit": "Wymagane do zgłaszania danych kamer", "loggedOut": "Wylogowany", + "loginFailed": "Logowanie nie powiodło się. Spróbuj ponownie.", "testConnection": "Testuj Połączenie", "testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają", "connectionOK": "Połączenie OK - dane logowania są ważne", @@ -278,7 +283,10 @@ "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": "Twoje konto OpenStreetMap ma obecnie aktywną blokadę i nie może dokonywać zgłoszeń. Powinieneś był otrzymać wiadomość z dalszymi szczegółami; sprawdź swoją skrzynkę odbiorczą OSM, aby dowiedzieć się więcej.", + "viewBlockDetails": "Zobacz Szczegóły Blokady" }, "queue": { "title": "Kolejka Przesyłania", @@ -314,6 +322,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..a934510 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Toque para sair", "requiredToSubmit": "Necessário para enviar dados de câmeras", "loggedOut": "Deslogado", + "loginFailed": "Falha no login. Tente novamente.", "testConnection": "Testar Conexão", "testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando", "connectionOK": "Conexão OK - credenciais são válidas", @@ -278,7 +283,10 @@ "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": "Sua conta OpenStreetMap atualmente tem um bloqueio ativo e está impedida de fazer envios. Você deve ter recebido uma mensagem com mais detalhes; verifique sua caixa de entrada do OSM para saber mais.", + "viewBlockDetails": "Ver Detalhes do Bloqueio" }, "queue": { "title": "Fila de Upload", @@ -314,6 +322,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..4f48680 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.", @@ -247,6 +251,7 @@ "tapToLogout": "Çıkış yapmak için dokun", "requiredToSubmit": "Kamera verisi göndermek için gerekli", "loggedOut": "Çıkış yapıldı", + "loginFailed": "Giriş başarısız oldu. Lütfen tekrar deneyin.", "testConnection": "Bağlantıyı Test Et", "testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın", "connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli", @@ -278,7 +283,10 @@ "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 şu anda aktif bir engelleme var ve gönderim yapmanız kısıtlanmış durumda. Daha fazla ayrıntı içeren bir mesaj almış olmalısınız; daha fazla bilgi için lütfen OSM gelen kutunuzu kontrol edin.", + "viewBlockDetails": "Engelleme Ayrıntılarını Görüntüle" }, "queue": { "title": "Yükleme Kuyruğu", @@ -314,6 +322,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..bb72834 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": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.", @@ -247,6 +251,7 @@ "tapToLogout": "Натисніть для виходу", "requiredToSubmit": "Потрібно для подання даних камер", "loggedOut": "Вихід здійснено", + "loginFailed": "Не вдалося увійти. Спробуйте ще раз.", "testConnection": "Тестувати З'єднання", "testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють", "connectionOK": "З'єднання в порядку - облікові дані дійсні", @@ -278,7 +283,10 @@ "reauthLater": "Пізніше", "unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення", "unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.", - "unreadNotificationsView": "Переглянути Зараз" + "unreadNotificationsView": "Переглянути Зараз", + "activeBlockTitle": "Ваш обліковий запис OSM має активне блокування", + "activeBlockMessage": "Ваш обліковий запис OpenStreetMap наразі має активне блокування і не може надсилати дані. Ви мали отримати повідомлення з додатковими деталями; перевірте свою поштову скриньку OSM, щоб дізнатися більше.", + "viewBlockDetails": "Переглянути Деталі Блокування" }, "queue": { "title": "Черга Завантаження", @@ -314,6 +322,7 @@ "processingPaused": "Обробка Черги Призупинена", "pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.", "pausedByUser": "Обробка завантаження призупинена вручну.", + "pausedDueToBlock": "Обробку черги завантаження призупинено, оскільки ваш обліковий запис OSM має активне блокування.", "inFlightWaitTitle": "Завершення Подання", "inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.", "inFlightCompleteTitle": "Подання Завершено", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 15bd4b4..052ffd8 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": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。", @@ -247,6 +251,7 @@ "tapToLogout": "点击登出", "requiredToSubmit": "提交摄像头数据所需", "loggedOut": "已登出", + "loginFailed": "登录失败,请重试。", "testConnection": "测试连接", "testConnectionSubtitle": "验证 OSM 凭据是否有效", "connectionOK": "连接正常 - 凭据有效", @@ -278,7 +283,10 @@ "reauthLater": "稍后", "unreadNotificationsTitle": "您有未读通知", "unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。", - "unreadNotificationsView": "立即查看" + "unreadNotificationsView": "立即查看", + "activeBlockTitle": "您的 OSM 账户有一个有效封禁", + "activeBlockMessage": "您的 OpenStreetMap 账户目前有有效封禁,无法提交内容。您应该已收到一条包含更多详情的消息;请查看您的 OSM 收件箱以了解更多信息。", + "viewBlockDetails": "查看封禁详情" }, "queue": { "title": "上传队列", @@ -314,6 +322,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..f54f7ca 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); } }); } @@ -74,22 +75,51 @@ class _OSMAccountScreenState extends State { } } else { // Start login flow - the user will be redirected to browser - await appState.forceLogin(); + await appState.forceLogin(context: context); - // Don't show immediate feedback - the UI will update automatically - // when the OAuth callback completes and notifyListeners() is called + // Don't show immediate feedback on success - the UI will update + // automatically when the OAuth callback completes and + // notifyListeners() is called. Failures are surfaced directly + // by forceLogin() via a snackbar/dialog. } }, ), 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.getBlockDetailsUrl()); + 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..bd3ae1b 100644 --- a/lib/screens/settings/sections/auth_section.dart +++ b/lib/screens/settings/sections/auth_section.dart @@ -40,10 +40,12 @@ class AuthSection extends StatelessWidget { } } else { // Start login flow - the user will be redirected to browser - await appState.forceLogin(); + await appState.forceLogin(context: context); - // Don't show immediate feedback - the UI will update automatically - // when the OAuth callback completes and notifyListeners() is called + // Don't show immediate feedback on success - the UI will update + // automatically when the OAuth callback completes and + // notifyListeners() is called. Failures are surfaced directly + // by forceLogin() via a snackbar/dialog. } }, ), @@ -53,7 +55,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/auth_service.dart b/lib/services/auth_service.dart index 4c7dcc4..c0c7aef 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -2,14 +2,42 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:oauth2_client/oauth2_client.dart'; import 'package:oauth2_client/oauth2_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Handles PKCE OAuth login with OpenStreetMap. import '../keys.dart'; +import '../dev_config.dart'; import '../app_state.dart' show UploadMode; import 'http_client.dart'; +import 'osm_block_service.dart'; + +/// Thrown when the OAuth token exchange succeeds but a subsequent +/// authenticated API call (e.g. fetching the username) fails. Carries the +/// HTTP status code so callers can distinguish e.g. a 403 (often indicating +/// an active account block) from other failures. +class AuthApiException implements Exception { + final String message; + final int? statusCode; + final String? body; + + AuthApiException(this.message, {this.statusCode, this.body}); + + @override + String toString() => 'AuthApiException: $message (status: $statusCode)'; +} + +/// Thrown when the OAuth token exchange succeeds but the account turns out +/// to have an active OSM DWG block. Detected by checking the +/// `user/blocks/active` endpoint (which is accessible even while blocked) +/// immediately after obtaining the token, before attempting any other +/// authenticated call that would otherwise fail with a 403. +class AccountBlockedException implements Exception { + @override + String toString() => 'AccountBlockedException: account has an active OSM block'; +} class AuthService { // Both client IDs from keys.dart @@ -18,8 +46,10 @@ class AuthService { late OAuth2Helper _helper; String? _displayName; UploadMode _mode = UploadMode.production; + final OSMBlockService _blockService; - AuthService({UploadMode mode = UploadMode.production}) { + AuthService({UploadMode mode = UploadMode.production, OSMBlockService? blockService}) + : _blockService = blockService ?? OSMBlockService() { setUploadMode(mode); } @@ -87,8 +117,12 @@ class AuthService { try { final token = await _helper.getToken(); if (token.accessToken == null) { + debugPrint('AuthService: OAuth error: token null or missing accessToken (status: ${token.httpStatusCode}, error: ${token.error})'); log('OAuth error: token null or missing accessToken'); - return null; + throw AuthApiException( + 'OAuth token exchange did not return an access token', + statusCode: token.httpStatusCode, + ); } final tokenMap = { 'accessToken': token.accessToken, @@ -97,8 +131,55 @@ class AuthService { final tokenJson = jsonEncode(tokenMap); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, tokenJson); // Save token for current mode - _displayName = await _fetchUsername(token.accessToken!); + + if (kCheckBlockBeforeUsernameFetch) { + // Check for an active block *before* fetching the username or + // anything else. As of writing, a blocked account's OAuth token + // exchange succeeds normally, but nearly every other authenticated + // endpoint (including user/details) returns 403. The blocks/active + // endpoint is explicitly documented as accessible even while + // blocked, so checking it first lets us detect this case reliably + // instead of getting a mysterious 403 from _fetchUsername with no + // way to tell why. + final isBlocked = await _blockService.checkActiveBlock( + accessToken: token.accessToken, + uploadMode: _mode, + ); + if (isBlocked == true) { + debugPrint('AuthService: Login succeeded but account has an active block'); + throw AccountBlockedException(); + } + } + + // Fetching the username can still fail even though the OAuth token + // exchange succeeded (e.g. if OSM ever allows this call to succeed for + // blocked accounts, kCheckBlockBeforeUsernameFetch would be false and + // we'd only find out here). Don't swallow that - the token is already + // saved, so the caller needs to know the login isn't actually usable + // rather than silently treating it as "not logged in". + try { + _displayName = await _fetchUsername(token.accessToken!); + } on AuthApiException { + // When we skipped the proactive check above, a 403 here could still + // mean the account is blocked - check blocks/active now so we can + // report that specifically instead of a generic auth failure. + if (!kCheckBlockBeforeUsernameFetch) { + final isBlocked = await _blockService.checkActiveBlock( + accessToken: token.accessToken, + uploadMode: _mode, + ); + if (isBlocked == true) { + debugPrint('AuthService: user/details failed and account has an active block'); + throw AccountBlockedException(); + } + } + rethrow; + } return _displayName; + } on AccountBlockedException { + rethrow; + } on AuthApiException { + rethrow; } catch (e) { debugPrint('AuthService: OAuth login failed: $e'); log('OAuth login failed: $e'); @@ -184,24 +265,37 @@ class AuthService { final _client = UserAgentClient(); Future _fetchUsername(String accessToken) async { + final http.Response resp; try { - final resp = await _client.get( + resp = await _client.get( Uri.parse('$_apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); - - if (resp.statusCode != 200) { - log('fetchUsername response ${resp.statusCode}: ${resp.body}'); - return null; - } - final userData = jsonDecode(resp.body); - final displayName = userData['user']?['display_name']; - return displayName; } catch (e) { + // Network-level failure (no connection, timeout, etc.) - not + // indicative of a block, just a connectivity problem. debugPrint('AuthService: Error fetching username: $e'); log('Error fetching username: $e'); - return null; + rethrow; } + + if (resp.statusCode != 200) { + // A non-200 here (very commonly 403) after a successful OAuth token + // exchange is a strong signal of an active account block. Surface it + // loudly via debugPrint (visible in a normal release/profile console, + // unlike dart:developer's log()) and throw instead of silently + // returning null, so the caller can show a real error to the user. + debugPrint('AuthService: fetchUsername failed - HTTP ${resp.statusCode}: ${resp.body}'); + log('fetchUsername response ${resp.statusCode}: ${resp.body}'); + throw AuthApiException( + 'Failed to fetch user details after login', + statusCode: resp.statusCode, + body: resp.body, + ); + } + final userData = jsonDecode(resp.body); + final displayName = userData['user']?['display_name']; + return displayName; } } 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/auth_state.dart b/lib/state/auth_state.dart index dcf163b..1a735e5 100644 --- a/lib/state/auth_state.dart +++ b/lib/state/auth_state.dart @@ -28,11 +28,16 @@ class AuthState extends ChangeNotifier { Future login() async { try { _username = await _auth.login(); + notifyListeners(); } catch (e) { debugPrint("AuthState: Login error: $e"); _username = null; + notifyListeners(); + // Don't swallow this - the caller (AppState) needs to know login + // failed so it can tell the user, rather than silently staying + // logged out with no explanation. + rethrow; } - notifyListeners(); } Future logout() async { @@ -58,11 +63,16 @@ class AuthState extends ChangeNotifier { Future forceLogin() async { try { _username = await _auth.forceLogin(); + notifyListeners(); } catch (e) { debugPrint("AuthState: Forced login error: $e"); _username = null; + notifyListeners(); + // Don't swallow this - the caller (AppState) needs to know login + // failed so it can tell the user, rather than silently staying + // logged out with no explanation. + rethrow; } - notifyListeners(); } Future validateToken() async { 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..5d0b3e5 --- /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 onViewDetails; + final VoidCallback onDismiss; + + const ActiveBlockDialog({ + super.key, + required this.onViewDetails, + 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(); + onViewDetails(); + }, + icon: const Icon(Icons.open_in_new, size: 18), + label: Text(locService.t('auth.viewBlockDetails')), + ), + ], + ); + } +} 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); + }); + }); +}