Compare commits

..
20 Commits
Author SHA1 Message Date
stopflock 503556cac0 fix "test connection" to osm in simulate mode 2026-10-07 17:49:57 -05:00
stopflock 4429663a03 cleanup old node cache I forgot to delete in january 2026-10-04 23:33:37 -05:00
stopflock 6c9abba15b mildly silly way of fixing constraint inheritance for cached nodes following an edit 2026-10-04 23:24:55 -05:00
stopflock c317178117 Merge pull request #198 from FoggedLens/osm-user-block-detection
Osm user block detection
2026-10-04 20:57:59 -05:00
stopflock ccfce160a0 fix cross-boundary issue caught by linter 2026-10-04 20:48:42 -05:00
stopflock a627deab51 bool to handle potential future osm api behavior 2026-10-04 20:05:33 -05:00
stopflock 1d6cfd775f refine blocked user message, send users to blocks page instead of messages when username is available 2026-10-04 19:34:31 -05:00
stopflock 54ef1b8afc first working version 2026-09-23 14:39:11 -05:00
stopflock c95171a3ad Fix possible race condition that caused a rare crash 2026-09-04 20:01:14 -05:00
stopflock cbdefa82fb fix a rare crash around blurring in newer versions of flutter - bit of a workaround 2026-09-04 19:37:37 -05:00
stopflock 00b9348b8f version, changelog 2026-09-04 14:59:43 -05:00
stopflock 142389771e Merge pull request #195 from reb1995/add-notifications-android-and-background-fixes
Fix notifications and enable background data fetch while app is not focused.
2026-09-04 13:24:30 -05:00
Ryan Brown 8439d9bbb6 Fix late background alerts and cache starvation
- Increase background refresh threshold: Raised the edge distance check in GpsController from 1000m to 2500m to guarantee new Overpass data is fetched well before the user drives out of the cached bounding box.

- Fix bounding box race condition: Added await to the background fetch handler in MapView so tracking bounds only shift forward after the network request successfully finishes.

- Make debounced fetches awaitable: Refactored fetchAndUpdate in NodeProviderWithCache to return a Future<void> using a Completer, bridging the 400ms debounce timer.

- Prevent hanging futures: Added state checks for _activeCompleter to instantly resolve canceled network requests during rapid map panning, preventing fatal StateError crashes and infinite UI hangs.
2026-08-28 12:03:39 -04:00
Ryan Brown 3f118a77c4 Fix branch after android testing. Add background data refresh, not just location refresh.
Tested on a Pixel 10 Pro. These changes were needed to work on real hardware. Also, which background location updating was working, the data was not refreshing in the background. This also adds that. It will pull nodes in a 5km area around your location and update when you within 1km of the area. These values may need to be adjusted if there is increased load on the servers.
2026-08-27 10:55:11 -07:00
Joeseph Ferland 51037f3710 feat: Added Notifications to app 2026-08-27 10:55:11 -07:00
stopflock 2c64fe610e Merge pull request #187 from anthony-at-pieces/fix/issue-157-direction-notation
Fix direction notation edit detection
2026-08-25 12:40:06 -05:00
stopflock af2ff1e940 Merge pull request #193 from FoggedLens/changeset_tracking
add changeset comment tracking and message notification bubble on startup
2026-08-24 18:55:20 -05:00
stopflock 7722dcaabe fix unnecessary cast 2026-08-24 18:16:36 -05:00
stopflock 0e359f2aa9 add changeset comment tracking and message notification bubble on startup 2026-08-24 18:03:03 -05:00
Anthony Maio 60e7e59ae9 fix: detect direction notation edits 2026-08-16 18:33:57 -04:00
56 changed files with 2621 additions and 727 deletions

No files matched your search

+3 -2
View File
@@ -53,7 +53,7 @@ AppState (Coordinator)
↕️
State Modules (AuthState, ProfileState, etc.)
↕️
Services (MapDataProvider, NodeCache, Uploader)
Services (MapDataProvider, NodeDataManager, NodeSpatialCache, Uploader)
↕️
External APIs (OSM, Overpass, Tile providers)
```
@@ -926,7 +926,8 @@ lib/
├── services/ # Business logic
│ ├── map_data_provider.dart
│ ├── uploader.dart
│ └── node_cache.dart
│ ├── node_data_manager.dart
│ └── node_spatial_cache.dart
├── state/ # State management
│ ├── app_state.dart
│ ├── auth_state.dart
+20
View File
@@ -10,6 +10,26 @@
<!-- Notification permission for proximity alerts -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Background location for proximity alerts. Android stops delivering
location to a backgrounded app within a few minutes unless it is
running a foreground service, so geolocator is given a
ForegroundNotificationConfig while proximity alerts are enabled -
that starts its GeolocatorLocationService (already declared by the
geolocator_android plugin manifest) and keeps the stream alive.
FOREGROUND_SERVICE_LOCATION is required from Android 14 onward.
Deliberately NOT declaring ACCESS_BACKGROUND_LOCATION: the foreground
service covers our case without it, and merely listing it in the
manifest makes geolocator append it to the very first permission
request - which Android 11+ silently denies, because foreground and
background location cannot be asked for in one prompt. That would
degrade first-run permissions for every user, including those who
never turn proximity alerts on. It also drags in the Play Store
background-location declaration review. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:name="${applicationName}"
android:label="DeFlock"
+6
View File
@@ -1,4 +1,10 @@
{
"2.11.1": {
"content": [
"• Fix FOV-only edits not registering as a change",
"• Notify on changeset comments and show a popup bubble on launch"
]
},
"2.11.0": {
"content": [
"• Further improve node loading performance",
+11
View File
@@ -1,5 +1,6 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,6 +8,16 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Required by flutter_local_notifications: the plugin implements
// userNotificationCenter(_:willPresent:withCompletionHandler:), which is
// what allows a notification to appear while the app is in the foreground.
// iOS only calls it on the registered delegate, and the plugin does not
// register itself — without this line, foreground notifications are
// silently suppressed by the system.
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+12
View File
@@ -54,6 +54,8 @@
<true/>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>This app optionally uses your location in the background so proximity alerts can warn you about nearby surveillance devices while the app is not on screen. This feature is entirely optional.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>UIApplicationSceneManifest</key>
@@ -79,6 +81,16 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Required for proximity alerts. Without the "location" background mode
iOS suspends the app the moment it leaves the screen, the CoreLocation
stream stops delivering, and no alert can ever fire while backgrounded.
Only armed at runtime: GpsController sets
allowBackgroundLocationUpdates on the stream solely while the user has
proximity alerts turned on. -->
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+162 -10
View File
@@ -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);
@@ -176,7 +184,12 @@ class AppState extends ChangeNotifier {
// Messages state
int? get unreadMessageCount => _messagesState.unreadCount;
bool get hasUnreadMessages => _messagesState.hasUnreadMessages;
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<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -194,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;
@@ -243,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
@@ -296,34 +315,135 @@ class AppState extends ChangeNotifier {
}
// ---------- Auth Methods ----------
Future<void> login() async {
await _authState.login();
// Check for messages after successful login
Future<void> 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<void> logout() async {
await _authState.logout();
// Clear message state when logging out
// Clear message and block state when logging out
clearMessages();
_accountBlockState.clear();
}
Future<void> refreshAuthState() async {
await _authState.refreshAuthState();
}
Future<void> forceLogin() async {
await _authState.forceLogin();
// Check for messages after successful login
Future<void> 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<bool> 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<void> _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<bool?> 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<void> 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.
///
/// Simulate mode has no real account to check against, so there's nothing
/// to test - it always reports success.
Future<bool> testConnection(BuildContext context) async {
if (uploadMode == UploadMode.simulate) return true;
final result = await checkActiveBlock();
if (result == true) {
if (context.mounted) _showActiveBlockDialog(context);
}
return result != null;
}
// ---------- Messages Methods ----------
@@ -331,6 +451,7 @@ class AppState extends ChangeNotifier {
final accessToken = await _authState.getAccessToken();
await _messagesState.checkMessages(
accessToken: accessToken,
username: isLoggedIn ? username : null,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
);
@@ -339,11 +460,37 @@ 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.
String? getUnreadChangesetUrl() {
return _messagesState.getUnreadChangesetUrl(uploadMode);
}
void clearMessages() {
_messagesState.clearMessages();
}
/// Mark changeset comments as read (called when user views the changeset on OSM)
Future<void> markChangesetCommentsRead() async {
await _messagesState.markChangesetCommentsRead(
username: isLoggedIn ? username : null,
uploadMode: uploadMode,
);
}
/// Check if the current OAuth token has required scopes for message notifications
/// Returns true if re-authentication is needed
Future<bool> needsReauthForMessages() async {
@@ -766,9 +913,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
}
@@ -941,12 +1091,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);
+10
View File
@@ -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
+22 -9
View File
@@ -12,6 +12,7 @@
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"dismiss": "Verwerfen",
"ok": "OK",
"close": "Schließen",
"submit": "Senden",
@@ -81,7 +82,6 @@
"enableOfflineFeaturesTitle": "Offline-Funktionen aktivieren",
"enableOfflineFeaturesSubtitle": "Herunterladen und Nutzen von Offline-Kartenbereichen erlauben. Wenn deaktiviert, sind Offline-Modus und heruntergeladene Bereiche nicht verfügbar."
},
"keepScreenAwake": {
"title": "Bildschirm während der App-Nutzung aktiv lassen",
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
@@ -90,7 +90,6 @@
"title": "Zoom-Steuerung ausblenden",
"subtitle": "Blendet die +/- Zoom-Schaltflächen und die Zoomstufen-Anzeige aus. Sie können weiterhin mit Fingergesten zoomen."
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
@@ -101,7 +100,13 @@
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
"rangeInfo": "Bereich: {}-{} {} (Standard: {})",
"backgroundServiceTitle": "Näherungswarnungen aktiv",
"backgroundServiceText": "Sucht nach Überwachungsgeräten in der Nähe",
"backgroundServiceChannel": "Überwachung von Näherungswarnungen",
"openSettings": "Einstellungen öffnen",
"blockedTitle": "Benachrichtigungen sind blockiert",
"blockedMessage": "Benachrichtigungen für DeFlock sind deaktiviert. Aktiviere sie in den Systemeinstellungen, um Näherungswarnungen zu erhalten."
},
"node": {
"title": "Knoten #{}",
@@ -120,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...",
@@ -129,9 +135,6 @@
"viewInQueue": "In Warteschlange anzeigen",
"deletedSuccessfully": "Dieser Knoten wurde erfolgreich von OpenStreetMap gelöscht."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Profil auswählen...",
@@ -139,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.",
@@ -153,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.",
@@ -209,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",
@@ -235,7 +241,15 @@
"reauthExplanation": "Sie müssen Ihre Authentifizierung aktualisieren, um OSM-Nachrichtenbenachrichtigungen über die App zu erhalten.",
"reauthBenefit": "Dies ermöglicht Benachrichtigungspunkte, wenn Sie ungelesene Nachrichten auf OpenStreetMap haben.",
"reauthNow": "Jetzt machen",
"reauthLater": "Später"
"newChangesetComments": "Neuer Changeset-Kommentar",
"newChangesetCommentsSubtitle": "Jemand hat einen oder mehrere Ihrer Changesets kommentiert",
"reauthLater": "Später",
"unreadNotificationsTitle": "Sie haben ungelesene Benachrichtigungen",
"unreadNotificationsMessage": "Sie haben ungelesene OSM-Nachrichten und/oder neue Kommentare zu Ihren Changesets.",
"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",
@@ -271,12 +285,12 @@
"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",
"inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet."
},
"tileProviders": {
"title": "Kachel-Anbieter",
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
@@ -428,7 +442,6 @@
"keepData": "Daten Behalten",
"deleteData": "Daten Löschen"
},
"refineTagsSheet": {
"title": "Tags Verfeinern",
"operatorProfile": "Betreiber-Profil",
+23 -9
View File
@@ -49,6 +49,7 @@
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"dismiss": "Dismiss",
"ok": "OK",
"close": "Close",
"submit": "Submit",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Enable offline features",
"enableOfflineFeaturesSubtitle": "Allow downloading and using offline map areas. When disabled, offline mode and downloaded areas are unavailable."
},
"keepScreenAwake": {
"title": "Keep screen awake while using app",
"subtitle": "Prevents the screen from turning off automatically while using the app"
@@ -127,7 +127,6 @@
"title": "Hide zoom controls",
"subtitle": "Hide the +/- zoom buttons and zoom level indicator. You can still zoom with pinch gestures."
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
"batteryUsage": "Uses extra battery for continuous location monitoring",
@@ -138,7 +137,13 @@
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"rangeInfo": "Range: {}-{} {} (default: {})"
"rangeInfo": "Range: {}-{} {} (default: {})",
"backgroundServiceTitle": "Proximity alerts active",
"backgroundServiceText": "Watching for nearby surveillance devices",
"backgroundServiceChannel": "Proximity Alert Monitoring",
"openSettings": "Open Settings",
"blockedTitle": "Notifications are blocked",
"blockedMessage": "Notifications are turned off for DeFlock. Enable them in your system settings to receive proximity alerts."
},
"node": {
"title": "Node #{}",
@@ -157,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...",
@@ -166,8 +172,6 @@
"viewInQueue": "View in Queue",
"deletedSuccessfully": "This node was successfully deleted from OpenStreetMap."
},
"addNode": {
"profile": "Profile",
"selectProfile": "Select a profile...",
@@ -175,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.",
@@ -189,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.",
@@ -245,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",
@@ -267,11 +274,19 @@
"viewMessages": "View Messages on OSM",
"unreadMessagesCount": "You have {} unread messages",
"noUnreadMessages": "No unread messages",
"newChangesetComments": "New Changeset Comment",
"newChangesetCommentsSubtitle": "Someone commented on one or more of your changesets",
"reauthRequired": "Refresh Authentication",
"reauthExplanation": "You must refresh your authentication to receive OSM message notifications through the app.",
"reauthBenefit": "This will enable notification dots when you have unread messages on OpenStreetMap.",
"reauthNow": "Do That Now",
"reauthLater": "Later"
"reauthLater": "Later",
"unreadNotificationsTitle": "You Have Unread Notifications",
"unreadNotificationsMessage": "You have unread OSM messages and/or new comments on your changesets.",
"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",
@@ -307,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "No tile providers configured",
@@ -321,7 +336,7 @@
"needsApiKey": "Needs API key",
"editProvider": "Edit Provider",
"addProvider": "Add Provider",
"deleteProvider": "Delete Provider",
"deleteProvider": "Delete Provider",
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
"clearCaches": "Clear Caches",
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
@@ -464,7 +479,6 @@
"keepData": "Keep Data",
"deleteData": "Delete Data"
},
"refineTagsSheet": {
"title": "Refine Tags",
"operatorProfile": "Operator Profile",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Editar",
"delete": "Eliminar",
"cancel": "Cancelar",
"dismiss": "Descartar",
"ok": "Aceptar",
"close": "Cerrar",
"submit": "Enviar",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Activar funciones sin conexión",
"enableOfflineFeaturesSubtitle": "Permitir descargar y usar áreas de mapa sin conexión. Si se desactiva, el modo sin conexión y las áreas descargadas no estarán disponibles."
},
"keepScreenAwake": {
"title": "Mantener la pantalla encendida mientras se usa la app",
"subtitle": "Evita que la pantalla se apague automáticamente mientras se usa la app"
@@ -127,7 +127,6 @@
"title": "Ocultar controles de zoom",
"subtitle": "Oculta los botones de zoom +/- y el indicador de nivel de zoom. Aún puedes hacer zoom con gestos de pellizco."
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
@@ -138,7 +137,13 @@
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})",
"backgroundServiceTitle": "Alertas de proximidad activas",
"backgroundServiceText": "Buscando dispositivos de vigilancia cercanos",
"backgroundServiceChannel": "Monitoreo de alertas de proximidad",
"openSettings": "Abrir ajustes",
"blockedTitle": "Las notificaciones están bloqueadas",
"blockedMessage": "Las notificaciones están desactivadas para DeFlock. Actívalas en los ajustes del sistema para recibir alertas de proximidad."
},
"node": {
"title": "Nodo #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Ver en la Cola",
"deletedSuccessfully": "Este nodo fue eliminado con éxito de OpenStreetMap."
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Debes actualizar tu autenticación para recibir notificaciones de mensajes OSM a través de la aplicación.",
"reauthBenefit": "Esto habilitará puntos de notificación cuando tengas mensajes sin leer en OpenStreetMap.",
"reauthNow": "Hazlo Ahora",
"reauthLater": "Más Tarde"
"newChangesetComments": "Nuevo Comentario en Changeset",
"newChangesetCommentsSubtitle": "Alguien comentó en uno o más de tus changesets",
"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",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora."
},
"tileProviders": {
"title": "Proveedores de Tiles",
"noProvidersConfigured": "No hay proveedores de tiles configurados",
@@ -465,7 +479,6 @@
"keepData": "Conservar Datos",
"deleteData": "Eliminar Datos"
},
"refineTagsSheet": {
"title": "Refinar Etiquetas",
"operatorProfile": "Perfil de Operador",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Modifier",
"delete": "Supprimer",
"cancel": "Annuler",
"dismiss": "Ignorer",
"ok": "OK",
"close": "Fermer",
"submit": "Soumettre",
@@ -119,7 +120,6 @@
"enableOfflineFeaturesSubtitle": "Autoriser le téléchargement et l'utilisation de zones de carte hors ligne. Si désactivé, le mode hors ligne et les zones téléchargées ne seront pas disponibles."
},
"keepScreenAwake": {
"title": "Maintenir l'écran allumé lors de l'utilisation de l'application",
"subtitle": "Empêche l'écran de s'éteindre automatiquement pendant l'utilisation de l'application"
},
@@ -127,7 +127,6 @@
"title": "Masquer les contrôles de zoom",
"subtitle": "Masque les boutons de zoom +/- et l'indicateur de niveau de zoom. Vous pouvez toujours zoomer avec des gestes de pincement."
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
@@ -138,7 +137,13 @@
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
"rangeInfo": "Plage : {}-{} {} (par défaut : {})",
"backgroundServiceTitle": "Alertes de proximité actives",
"backgroundServiceText": "Recherche d'appareils de surveillance à proximité",
"backgroundServiceChannel": "Surveillance des alertes de proximité",
"openSettings": "Ouvrir les réglages",
"blockedTitle": "Les notifications sont bloquées",
"blockedMessage": "Les notifications sont désactivées pour DeFlock. Activez-les dans les réglages du système pour recevoir les alertes de proximité."
},
"node": {
"title": "Nœud #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalisme",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Voir dans la File d'Attente",
"deletedSuccessfully": "Ce nœud a été supprimé avec succès d'OpenStreetMap."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Vous devez actualiser votre authentification pour recevoir des notifications de messages OSM via l'application.",
"reauthBenefit": "Cela activera les points de notification lorsque vous avez des messages non lus sur OpenStreetMap.",
"reauthNow": "Le Faire Maintenant",
"reauthLater": "Plus Tard"
"newChangesetComments": "Nouveau Commentaire de Changeset",
"newChangesetCommentsSubtitle": "Quelqu'un a commenté un ou plusieurs de vos changesets",
"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",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant."
},
"tileProviders": {
"title": "Fournisseurs de Tuiles",
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
@@ -465,7 +479,6 @@
"keepData": "Conserver les Données",
"deleteData": "Supprimer les Données"
},
"refineTagsSheet": {
"title": "Affiner les Étiquettes",
"operatorProfile": "Profil d'Opérateur",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Modifica",
"delete": "Elimina",
"cancel": "Annulla",
"dismiss": "Ignora",
"ok": "OK",
"close": "Chiudi",
"submit": "Invia",
@@ -119,7 +120,6 @@
"enableOfflineFeaturesSubtitle": "Consenti il download e l'uso di aree mappa offline. Se disabilitato, la modalità offline e le aree scaricate non saranno disponibili."
},
"keepScreenAwake": {
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
},
@@ -127,7 +127,6 @@
"title": "Nascondi i controlli dello zoom",
"subtitle": "Nasconde i pulsanti di zoom +/- e l'indicatore del livello di zoom. Puoi ancora usare i gesti di pizzico per lo zoom."
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
@@ -138,7 +137,13 @@
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})",
"backgroundServiceTitle": "Avvisi di prossimità attivi",
"backgroundServiceText": "Ricerca di dispositivi di sorveglianza nelle vicinanze",
"backgroundServiceChannel": "Monitoraggio avvisi di prossimità",
"openSettings": "Apri impostazioni",
"blockedTitle": "Le notifiche sono bloccate",
"blockedMessage": "Le notifiche sono disattivate per DeFlock. Attivale nelle impostazioni di sistema per ricevere gli avvisi di prossimità."
},
"node": {
"title": "Nodo #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Visualizza in Coda",
"deletedSuccessfully": "Questo nodo è stato eliminato con successo da OpenStreetMap."
},
"addNode": {
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Devi aggiornare la tua autenticazione per ricevere notifiche di messaggi OSM tramite l'app.",
"reauthBenefit": "Questo abiliterà i punti di notifica quando hai messaggi non letti su OpenStreetMap.",
"reauthNow": "Fallo Ora",
"reauthLater": "Più Tardi"
"newChangesetComments": "Nuovo Commento al Changeset",
"newChangesetCommentsSubtitle": "Qualcuno ha commentato uno o più dei tuoi changeset",
"reauthLater": "Più Tardi",
"unreadNotificationsTitle": "Hai Notifiche Non Lette",
"unreadNotificationsMessage": "Hai messaggi OSM non letti e/o nuovi commenti sui tuoi changeset.",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso."
},
"tileProviders": {
"title": "Fornitori di Tile",
"noProvidersConfigured": "Nessun fornitore di tile configurato",
@@ -465,7 +479,6 @@
"keepData": "Conserva Dati",
"deleteData": "Elimina Dati"
},
"refineTagsSheet": {
"title": "Affina Tag",
"operatorProfile": "Profilo Operatore",
+23 -10
View File
@@ -49,6 +49,7 @@
"edit": "Bewerken",
"delete": "Verwijderen",
"cancel": "Annuleren",
"dismiss": "Negeren",
"ok": "OK",
"close": "Sluiten",
"submit": "Indienen",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
},
"keepScreenAwake": {
"title": "Houd het scherm actief tijdens het gebruik van de app",
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
@@ -127,7 +127,6 @@
"title": "Zoomknoppen verbergen",
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
},
"proximityAlerts": {
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
@@ -138,7 +137,13 @@
"enableNotifications": "Schakel Meldingen In",
"checkingPermissions": "Toestemmingen controleren...",
"alertDistance": "Waarschuwingsafstand: ",
"rangeInfo": "Bereik: {}-{} {} (standaard: {})"
"rangeInfo": "Bereik: {}-{} {} (standaard: {})",
"backgroundServiceTitle": "Nabijheidswaarschuwingen actief",
"backgroundServiceText": "Zoekt naar bewakingsapparatuur in de buurt",
"backgroundServiceChannel": "Monitoring van nabijheidswaarschuwingen",
"openSettings": "Instellingen openen",
"blockedTitle": "Meldingen zijn geblokkeerd",
"blockedMessage": "Meldingen zijn uitgeschakeld voor DeFlock. Schakel ze in via je systeeminstellingen om nabijheidswaarschuwingen te ontvangen."
},
"node": {
"title": "Node #{}",
@@ -146,7 +151,7 @@
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteReasonLabel": "Reden voor Verwijdering",
"deleteReasonSelectHint": "Selecteer...",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalisme",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Bekijk in Wachtrij",
"deletedSuccessfully": "Deze node is succesvol verwijderd van OpenStreetMap."
},
"addNode": {
"profile": "Profiel",
"selectProfile": "Selecteer een profiel...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "U moet uw authenticatie verversen om OSM bericht meldingen te ontvangen via de app.",
"reauthBenefit": "Dit zal melding stippen inschakelen wanneer u ongelezen berichten heeft op OpenStreetMap.",
"reauthNow": "Doe Dat Nu",
"reauthLater": "Later"
"newChangesetComments": "Nieuwe Changeset-opmerking",
"newChangesetCommentsSubtitle": "Iemand heeft commentaar gegeven op een of meer van uw changesets",
"reauthLater": "Later",
"unreadNotificationsTitle": "U Heeft Ongelezen Meldingen",
"unreadNotificationsMessage": "U heeft ongelezen OSM-berichten en/of nieuwe opmerkingen op uw changesets.",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "Geen tile providers geconfigureerd",
@@ -465,7 +479,6 @@
"keepData": "Gegevens Behouden",
"deleteData": "Gegevens Verwijderen"
},
"refineTagsSheet": {
"title": "Verfijn Tags",
"operatorProfile": "Operator Profiel",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Edytuj",
"delete": "Usuń",
"cancel": "Anuluj",
"dismiss": "Odrzuć",
"ok": "OK",
"close": "Zamknij",
"submit": "Zgłoś",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
},
"keepScreenAwake": {
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
@@ -127,7 +127,6 @@
"title": "Ukryj kontrolki zoomu",
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
},
"proximityAlerts": {
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
@@ -138,7 +137,13 @@
"enableNotifications": "Włącz Powiadomienia",
"checkingPermissions": "Sprawdzanie uprawnień...",
"alertDistance": "Odległość alertu: ",
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})"
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})",
"backgroundServiceTitle": "Alerty o bliskości aktywne",
"backgroundServiceText": "Wyszukiwanie pobliskich urządzeń monitorujących",
"backgroundServiceChannel": "Monitorowanie alertów o bliskości",
"openSettings": "Otwórz ustawienia",
"blockedTitle": "Powiadomienia są zablokowane",
"blockedMessage": "Powiadomienia dla DeFlock są wyłączone. Włącz je w ustawieniach systemowych, aby otrzymywać alerty o bliskości."
},
"node": {
"title": "Węzeł #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/wandalizm",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Zobacz w Kolejce",
"deletedSuccessfully": "Ten węzeł został pomyślnie usunięty z OpenStreetMap."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Wybierz profil...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Musisz odświeżyć uwierzytelnienie, aby otrzymywać powiadomienia o wiadomościach OSM przez aplikację.",
"reauthBenefit": "To włączy kropki powiadomień, gdy masz nieprzeczytane wiadomości w OpenStreetMap.",
"reauthNow": "Zrób To Teraz",
"reauthLater": "Później"
"newChangesetComments": "Nowy Komentarz do Zestawu Zmian",
"newChangesetCommentsSubtitle": "Ktoś skomentował jeden lub więcej Twoich zestawów zmian",
"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",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz."
},
"tileProviders": {
"title": "Dostawcy Kafelków",
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
@@ -465,7 +479,6 @@
"keepData": "Zachowaj Dane",
"deleteData": "Usuń Dane"
},
"refineTagsSheet": {
"title": "Doprecyzuj Tagi",
"operatorProfile": "Profil Operatora",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Editar",
"delete": "Excluir",
"cancel": "Cancelar",
"dismiss": "Dispensar",
"ok": "OK",
"close": "Fechar",
"submit": "Enviar",
@@ -119,7 +120,6 @@
"enableOfflineFeaturesSubtitle": "Permitir descarregar e usar áreas de mapa offline. Se desativado, o modo offline e as áreas descarregadas não estarão disponíveis."
},
"keepScreenAwake": {
"title": "Manter o ecrã ativo durante o uso do app",
"subtitle": "Impede que o ecrã se desligue automaticamente enquanto a app estiver em uso"
},
@@ -127,7 +127,6 @@
"title": "Ocultar controlos de zoom",
"subtitle": "Oculta os botões de zoom +/- e o indicador de nível de zoom. Ainda pode ampliar com gestos de pinça."
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
@@ -138,7 +137,13 @@
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
"rangeInfo": "Faixa: {}-{} {} (padrão: {})",
"backgroundServiceTitle": "Alertas de proximidade ativos",
"backgroundServiceText": "Procurando dispositivos de vigilância próximos",
"backgroundServiceChannel": "Monitoramento de alertas de proximidade",
"openSettings": "Abrir configurações",
"blockedTitle": "As notificações estão bloqueadas",
"blockedMessage": "As notificações estão desativadas para o DeFlock. Ative-as nas configurações do sistema para receber alertas de proximidade."
},
"node": {
"title": "Nó #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Ver na Fila",
"deletedSuccessfully": "Este nó foi excluído com sucesso do OpenStreetMap."
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Você deve atualizar sua autenticação para receber notificações de mensagens OSM através do aplicativo.",
"reauthBenefit": "Isso habilitará pontos de notificação quando você tiver mensagens não lidas no OpenStreetMap.",
"reauthNow": "Fazer Agora",
"reauthLater": "Mais Tarde"
"newChangesetComments": "Novo Comentário no Changeset",
"newChangesetCommentsSubtitle": "Alguém comentou em um ou mais dos seus changesets",
"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",
"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",
@@ -308,12 +322,12 @@
"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",
"inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora."
},
"tileProviders": {
"title": "Provedores de Tiles",
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
@@ -465,7 +479,6 @@
"keepData": "Manter Dados",
"deleteData": "Eliminar Dados"
},
"refineTagsSheet": {
"title": "Refinar Tags",
"operatorProfile": "Perfil de Operador",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Düzenle",
"delete": "Sil",
"cancel": "İptal",
"dismiss": "Kapat",
"ok": "Tamam",
"close": "Kapat",
"submit": "Gönder",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
},
"keepScreenAwake": {
"title": "Uygulamayı kullanırken ekranı açık tut",
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
@@ -127,7 +127,6 @@
"title": "Yakınlaştırma kontrollerini gizle",
"subtitle": "+/- yakınlaştırma düğmelerini ve yakınlaştırma seviyesi göstergesini gizler. Yine de kıstırma hareketleriyle yakınlaştırabilirsiniz."
},
"proximityAlerts": {
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
@@ -138,7 +137,13 @@
"enableNotifications": "Bildirimleri Etkinleştir",
"checkingPermissions": "İzinler kontrol ediliyor...",
"alertDistance": "Uyarı mesafesi: ",
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})"
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})",
"backgroundServiceTitle": "Yakınlık uyarıları etkin",
"backgroundServiceText": "Yakındaki gözetim cihazları aranıyor",
"backgroundServiceChannel": "Yakınlık Uyarısı İzleme",
"openSettings": "Ayarları aç",
"blockedTitle": "Bildirimler engellendi",
"blockedMessage": "DeFlock için bildirimler kapalı. Yakınlık uyarıları almak için sistem ayarlarından etkinleştirin."
},
"node": {
"title": "Düğüm #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Spam/vandalizm",
"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...",
@@ -167,8 +172,6 @@
"viewInQueue": "Sırada Görüntüle",
"deletedSuccessfully": "Bu düğüm OpenStreetMap'ten başarıyla silindi."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Bir profil seçin...",
@@ -176,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.",
@@ -190,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.",
@@ -246,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",
@@ -272,7 +278,15 @@
"reauthExplanation": "Uygulama üzerinden OSM mesaj bildirimlerini alabilmek için kimlik doğrulamanızı yenilemeniz gerekir.",
"reauthBenefit": "Bu, OpenStreetMap'te okunmamış mesajlarınız olduğunda bildirim noktalarını etkinleştirecek.",
"reauthNow": "Şimdi Yap",
"reauthLater": "Sonra"
"newChangesetComments": "Yeni Değişiklik Kümesi Yorumu",
"newChangesetCommentsSubtitle": "Biri değişiklik kümelerinizden birine veya birkaçına yorum yaptı",
"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",
"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",
@@ -308,12 +322,12 @@
"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ı",
"inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor."
},
"tileProviders": {
"title": "Döşeme Sağlayıcıları",
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
@@ -465,7 +479,6 @@
"keepData": "Verileri Sakla",
"deleteData": "Verileri Sil"
},
"refineTagsSheet": {
"title": "Etiketleri Düzenle",
"operatorProfile": "Operatör Profili",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "Редагувати",
"delete": "Видалити",
"cancel": "Скасувати",
"dismiss": "Відхилити",
"ok": "ОК",
"close": "Закрити",
"submit": "Подати",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
},
"keepScreenAwake": {
"title": "Тримати екран активним під час використання програми",
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
@@ -127,7 +127,6 @@
"title": "Приховати елементи керування масштабом",
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
},
"proximityAlerts": {
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
@@ -138,7 +137,13 @@
"enableNotifications": "Увімкнути Сповіщення",
"checkingPermissions": "Перевірка дозволів...",
"alertDistance": "Відстань сповіщення: ",
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})"
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})",
"backgroundServiceTitle": "Сповіщення про наближення активні",
"backgroundServiceText": "Пошук пристроїв стеження поблизу",
"backgroundServiceChannel": "Моніторинг сповіщень про наближення",
"openSettings": "Відкрити налаштування",
"blockedTitle": "Сповіщення заблоковано",
"blockedMessage": "Сповіщення для DeFlock вимкнено. Увімкніть їх у налаштуваннях системи, щоб отримувати сповіщення про наближення."
},
"node": {
"title": "Вузол #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "Спам/вандалізм",
"deleteReasonCustom": "Власний..",
"deleteReasonHint": "Опишіть причину...",
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
"accountBlockedCannotDelete": "Ваш обліковий запис OpenStreetMap має активне блокування і не може видаляти вузли.",
"uploadPending": "Завантаження очікує",
"uploadCreatingChangeset": "Створення набору змін...",
"uploadUploading": "Завантаження...",
@@ -167,8 +172,6 @@
"viewInQueue": "Переглянути в Черзі",
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
},
"addNode": {
"profile": "Профіль",
"selectProfile": "Оберіть профіль...",
@@ -176,6 +179,7 @@
"direction": "Напрямок {}°",
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може подавати нові вузли.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.",
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
@@ -190,6 +194,7 @@
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може редагувати вузли.",
"sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.",
@@ -246,6 +251,7 @@
"tapToLogout": "Натисніть для виходу",
"requiredToSubmit": "Потрібно для подання даних камер",
"loggedOut": "Вихід здійснено",
"loginFailed": "Не вдалося увійти. Спробуйте ще раз.",
"testConnection": "Тестувати З'єднання",
"testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють",
"connectionOK": "З'єднання в порядку - облікові дані дійсні",
@@ -272,7 +278,15 @@
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
"reauthNow": "Зробити Зараз",
"reauthLater": "Пізніше"
"newChangesetComments": "Новий Коментар до Набору Змін",
"newChangesetCommentsSubtitle": "Хтось прокоментував один або декілька ваших наборів змін",
"reauthLater": "Пізніше",
"unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення",
"unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.",
"unreadNotificationsView": "Переглянути Зараз",
"activeBlockTitle": "Ваш обліковий запис OSM має активне блокування",
"activeBlockMessage": "Ваш обліковий запис OpenStreetMap наразі має активне блокування і не може надсилати дані. Ви мали отримати повідомлення з додатковими деталями; перевірте свою поштову скриньку OSM, щоб дізнатися більше.",
"viewBlockDetails": "Переглянути Деталі Блокування"
},
"queue": {
"title": "Черга Завантаження",
@@ -308,12 +322,12 @@
"processingPaused": "Обробка Черги Призупинена",
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
"pausedByUser": "Обробка завантаження призупинена вручну.",
"pausedDueToBlock": "Обробку черги завантаження призупинено, оскільки ваш обліковий запис OSM має активне блокування.",
"inFlightWaitTitle": "Завершення Подання",
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
"inFlightCompleteTitle": "Подання Завершено",
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
},
"tileProviders": {
"title": "Постачальники Плиток",
"noProvidersConfigured": "Постачальників плиток не налаштовано",
@@ -465,7 +479,6 @@
"keepData": "Зберегти Дані",
"deleteData": "Видалити Дані"
},
"refineTagsSheet": {
"title": "Уточнити Теги",
"operatorProfile": "Профіль Оператора",
+22 -9
View File
@@ -49,6 +49,7 @@
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"dismiss": "关闭",
"ok": "确定",
"close": "关闭",
"submit": "提交",
@@ -118,7 +119,6 @@
"enableOfflineFeaturesTitle": "启用离线功能",
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
},
"keepScreenAwake": {
"title": "使用应用时保持屏幕常亮",
"subtitle": "防止在使用应用期间屏幕自动熄灭"
@@ -127,7 +127,6 @@
"title": "隐藏缩放控件",
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
"batteryUsage": "使用额外电量进行连续位置监控",
@@ -138,7 +137,13 @@
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"rangeInfo": "范围:{}-{} {}(默认:{})"
"rangeInfo": "范围:{}-{} {}(默认:{})",
"backgroundServiceTitle": "邻近提醒已启用",
"backgroundServiceText": "正在搜索附近的监控设备",
"backgroundServiceChannel": "邻近提醒监控",
"openSettings": "打开设置",
"blockedTitle": "通知已被阻止",
"blockedMessage": "DeFlock 的通知已关闭。请在系统设置中启用,以接收邻近提醒。"
},
"node": {
"title": "节点 #{}",
@@ -156,8 +161,8 @@
"deleteReasonSpamVandalism": "垃圾信息/破坏",
"deleteReasonCustom": "自定义..",
"deleteReasonHint": "请描述原因...",
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
"accountBlockedCannotDelete": "您的 OpenStreetMap 账户有一个有效封禁,无法删除节点。",
"uploadPending": "上传待处理",
"uploadCreatingChangeset": "正在创建变更集...",
"uploadUploading": "正在上传...",
@@ -167,8 +172,6 @@
"viewInQueue": "在队列中查看",
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
@@ -176,6 +179,7 @@
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法提交新节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
@@ -190,6 +194,7 @@
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法编辑节点。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
@@ -246,6 +251,7 @@
"tapToLogout": "点击登出",
"requiredToSubmit": "提交摄像头数据所需",
"loggedOut": "已登出",
"loginFailed": "登录失败,请重试。",
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
@@ -272,7 +278,15 @@
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
"reauthNow": "现在执行",
"reauthLater": "稍后"
"newChangesetComments": "新的变更集评论",
"newChangesetCommentsSubtitle": "有人评论了您的一个或多个变更集",
"reauthLater": "稍后",
"unreadNotificationsTitle": "您有未读通知",
"unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。",
"unreadNotificationsView": "立即查看",
"activeBlockTitle": "您的 OSM 账户有一个有效封禁",
"activeBlockMessage": "您的 OpenStreetMap 账户目前有有效封禁,无法提交内容。您应该已收到一条包含更多详情的消息;请查看您的 OSM 收件箱以了解更多信息。",
"viewBlockDetails": "查看封禁详情"
},
"queue": {
"title": "上传队列",
@@ -308,12 +322,12 @@
"processingPaused": "队列处理已暂停",
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
"pausedByUser": "上传处理已手动暂停。",
"pausedDueToBlock": "由于您的 OSM 账户有一个有效封禁,上传处理已暂停。",
"inFlightWaitTitle": "正在完成提交",
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
"inFlightCompleteTitle": "提交完成",
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
},
"tileProviders": {
"title": "瓦片提供商",
"noProvidersConfigured": "未配置瓦片提供商",
@@ -465,7 +479,6 @@
"keepData": "保留数据",
"deleteData": "删除数据"
},
"refineTagsSheet": {
"title": "细化标签",
"operatorProfile": "运营商配置文件",
+58 -19
View File
@@ -18,6 +18,7 @@ import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/changelog_dialog.dart';
import '../widgets/unread_notifications_dialog.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
@@ -27,6 +28,7 @@ import 'coordinators/sheet_coordinator.dart';
import 'coordinators/navigation_coordinator.dart';
import 'coordinators/map_interaction_handler.dart';
import 'package:geolocator/geolocator.dart';
import '../services/location_permission_gate.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -35,7 +37,7 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin, WidgetsBindingObserver {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
@@ -53,10 +55,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Track popup display to avoid showing multiple times
bool _hasCheckedForPopup = false;
// Track whether we've shown the unread notifications popup this session
bool _hasShownUnreadNotificationsPopup = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_mapController = AnimatedMapController(vsync: this);
_sheetCoordinator = SheetCoordinator();
_navigationCoordinator = NavigationCoordinator();
@@ -66,11 +72,24 @@ class _HomeScreenState extends State<HomeScreen> 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<AppState>();
if (appState.isLoggedIn) {
appState.checkAndPromptForActiveBlock(context);
}
}
}
String _getFollowMeTooltip(FollowMeMode mode) {
final locService = LocalizationService.instance;
switch (mode) {
@@ -173,7 +192,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
// Request location permission (this will show system dialog if needed)
final permission = await Geolocator.requestPermission();
final permission = await requestLocationPermissionOnce();
debugPrint('[HomeScreen] First launch location permission result: $permission');
}
} catch (e) {
@@ -247,6 +266,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
void _showUnreadNotificationsDialog() {
if (!mounted) return;
showDialog(
context: context,
builder: (context) => UnreadNotificationsDialog(
onView: () {
Navigator.of(context).pushNamed('/settings/osm-account');
},
onDismiss: () {
// Just dismiss - badges remain visible until the user views them.
},
),
);
}
void _onStartRoute() {
_navigationCoordinator.startRoute(
context: context,
@@ -454,6 +488,23 @@ class _HomeScreenState extends State<HomeScreen> 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);
}
});
}
// Show a one-time popup this session if the user has unread messages
// and/or unread changeset comments. Gated on _hasCheckedForPopup so it
// doesn't race with the welcome/changelog dialog above.
if (_hasCheckedForPopup &&
!_hasShownUnreadNotificationsPopup &&
appState.isLoggedIn &&
appState.hasUnreadNotifications) {
_hasShownUnreadNotificationsPopup = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showUnreadNotificationsDialog();
});
}
@@ -510,23 +561,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final appState = context.watch<AppState>();
return IconButton(
tooltip: LocalizationService.instance.settings,
icon: Stack(
children: [
const Icon(Icons.settings),
if (appState.hasUnreadMessages)
Positioned(
right: 0,
top: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
],
icon: Badge(
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'),
);
+72 -34
View File
@@ -17,11 +17,12 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
@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<AppState>();
if (appState.isLoggedIn) {
appState.checkMessages();
appState.checkAndPromptForActiveBlock(context);
}
});
}
@@ -74,22 +75,51 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
}
} 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(
@@ -128,38 +158,46 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
},
),
// Unread changeset comments - links directly to the
// specific changeset with the new comment, and only
// appears when there is one.
if (appState.hasUnreadChangesetComments) ...[
const Divider(),
ListTile(
leading: Badge(
isLabelVisible: true,
child: const Icon(Icons.comment),
),
title: Text(locService.t('auth.newChangesetComments')),
subtitle: Text(locService.t('auth.newChangesetCommentsSubtitle')),
trailing: const Icon(Icons.open_in_new),
onTap: () async {
final changesetUrl = appState.getUnreadChangesetUrl();
if (changesetUrl == null) return;
final url = Uri.parse(changesetUrl);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
// The user has now had the opportunity to see the
// new comment on OSM's website - clear our badge.
appState.markChangesetCommentsRead();
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
),
],
// Messages button - only show when not in simulate mode
const Divider(),
ListTile(
leading: Stack(
children: [
const Icon(Icons.message),
if (appState.hasUnreadMessages)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 12,
minHeight: 12,
),
child: Text(
'${appState.unreadMessageCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onError,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
leading: Badge(
isLabelVisible: appState.hasUnreadMessages,
label: Text('${appState.unreadMessageCount ?? 0}'),
child: const Icon(Icons.message),
),
title: Text(locService.t('auth.viewMessages')),
subtitle: Text(appState.hasUnreadMessages
@@ -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(
@@ -18,7 +18,8 @@ class ProximityAlertsSection extends StatefulWidget {
State<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
}
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
class _ProximityAlertsSectionState extends State<ProximityAlertsSection>
with WidgetsBindingObserver {
late final TextEditingController _distanceController;
bool _notificationsEnabled = false;
bool _checkingPermissions = false;
@@ -26,6 +27,9 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
@override
void initState() {
super.initState();
// Re-check on resume: the user may have just granted notifications in the
// system settings app and come back.
WidgetsBinding.instance.addObserver(this);
final appState = context.read<AppState>();
// Convert meters to display units for the text field
final displayValue = DistanceService.convertFromMeters(
@@ -57,19 +61,59 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().requestNotificationPermissions();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
if (!mounted) return;
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
// Still not granted. On iOS the system prompt only ever appears once, so
// re-requesting silently does nothing — the only remaining path is the
// system settings app.
if (!enabled) {
await _promptOpenSystemSettings();
}
}
Future<void> _promptOpenSystemSettings() async {
final locService = LocalizationService.instance;
final shouldOpen = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(locService.t('proximityAlerts.blockedTitle')),
content: Text(locService.t('proximityAlerts.blockedMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(locService.t('actions.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(locService.t('proximityAlerts.openSettings')),
),
],
),
);
if (shouldOpen == true) {
await ProximityAlertService().openSystemSettings();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkNotificationPermissions();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_distanceController.dispose();
super.dispose();
}
+5 -17
View File
@@ -117,23 +117,11 @@ class SettingsScreen extends StatelessWidget {
final appState = context.watch<AppState>();
return ListTile(
leading: Stack(
children: [
const Icon(Icons.account_circle),
if (appState.hasUnreadMessages)
Positioned(
right: 0,
top: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
],
leading: Badge(
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')),
subtitle: Text(locService.t('auth.osmAccountSubtitle')),
+6 -4
View File
@@ -111,7 +111,7 @@ class UploadQueueScreen extends StatelessWidget {
final appState = context.watch<AppState>();
// 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,
+107 -13
View File
@@ -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<String?> _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;
}
}
@@ -0,0 +1,182 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../state/settings_state.dart';
import 'http_client.dart';
/// Tracks unread comments on the user's own changesets.
///
/// The OSM API has no "read" concept for changeset comments, so we track it
/// ourselves: we remember the `comments_count` we last saw for each of the
/// user's recent changesets, and flag a changeset as having unread comments
/// if it now has a higher count than what we last recorded.
///
/// This only looks at the user's most recent 100 changesets (the OSM API's
/// max page size, and it doesn't support simple offset paging). If a
/// changeset receives a new comment and then ages out of that window
/// entirely before the next check, we will miss it - an accepted limitation
/// of polling a bounded recent-activity window rather than a real
/// notification feed.
class ChangesetCommentsService {
static const _cacheDuration = Duration(minutes: 5);
final _client = UserAgentClient();
DateTime? _lastCheck;
int? _lastResult; // ID of changeset with unread comments, or null if none
bool _lastResultIsSet = false; // whether _lastResult holds a valid cached value
UploadMode? _lastMode;
/// Returns the ID of a changeset with more comments now than the last
/// time [markRead] was called for this upload mode, or null if there are
/// none (or if not logged in, in simulate mode, or on error). When
/// multiple changesets have new comments, returns the most recent one
/// (the API returns changesets newest-first).
Future<int?> getUnreadChangesetId({
required String? username,
required UploadMode uploadMode,
bool forceRefresh = false,
}) async {
if (uploadMode == UploadMode.simulate) return null;
if (username == null || username.isEmpty) return null;
if (!forceRefresh &&
_lastResultIsSet &&
_lastMode == uploadMode &&
_lastCheck != null &&
DateTime.now().difference(_lastCheck!) < _cacheDuration) {
return _lastResult;
}
try {
final current = await _fetchCommentCounts(username, uploadMode);
if (current == null) return null;
final seen = await _loadSeenCounts(uploadMode);
// current is built from the API response, which is ordered
// newest-first, so the first match found is the most recent one.
int? unreadId;
for (final entry in current.entries) {
if (entry.value > (seen[entry.key] ?? 0)) {
unreadId = entry.key;
break;
}
}
_lastCheck = DateTime.now();
_lastResult = unreadId;
_lastResultIsSet = true;
_lastMode = uploadMode;
return unreadId;
} catch (e) {
return null;
}
}
/// Mark all currently-visible changeset comments as read by saving the
/// current comment counts as the new baseline.
Future<void> markRead({
required String? username,
required UploadMode uploadMode,
}) async {
if (uploadMode == UploadMode.simulate) return;
if (username == null || username.isEmpty) return;
try {
final current = await _fetchCommentCounts(username, uploadMode);
if (current == null) return;
await _saveSeenCounts(uploadMode, current);
// Update in-memory cache so getUnreadChangesetId() doesn't need to
// re-fetch immediately after marking read.
_lastCheck = DateTime.now();
_lastResult = null;
_lastResultIsSet = true;
_lastMode = uploadMode;
} catch (e) {
// Best-effort - if this fails the badge will just persist until the
// next successful mark-read attempt.
}
}
/// Clear cached state (useful on logout or upload mode change).
void clearCache() {
_lastCheck = null;
_lastResult = null;
_lastResultIsSet = false;
_lastMode = null;
}
/// Build the URL to the given changeset on the OSM website for the given
/// upload mode.
String getChangesetUrl(int changesetId, UploadMode uploadMode) {
switch (uploadMode) {
case UploadMode.production:
return 'https://www.openstreetmap.org/changeset/$changesetId';
case UploadMode.sandbox:
return 'https://master.apis.dev.openstreetmap.org/changeset/$changesetId';
case UploadMode.simulate:
return 'https://www.openstreetmap.org/changeset/$changesetId';
}
}
/// Fetches the user's most recent changesets and returns a map of
/// changeset ID -> comments_count. Returns null on any error.
Future<Map<int, int>?> _fetchCommentCounts(
String username,
UploadMode uploadMode,
) async {
final apiHost = _getApiHost(uploadMode);
final response = await _client.get(
Uri.parse('$apiHost/api/0.6/changesets.json?display_name=${Uri.encodeQueryComponent(username)}'),
);
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
final changesets = data['changesets'];
if (changesets is! List) return null;
final result = <int, int>{};
for (final cs in changesets) {
final id = cs['id'];
final count = cs['comments_count'];
if (id is int && count is int) {
result[id] = count;
}
}
return result;
}
Future<Map<int, int>> _loadSeenCounts(UploadMode uploadMode) async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(_prefsKey(uploadMode));
if (json == null) return {};
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map((k, v) => MapEntry(int.parse(k), v as int));
} catch (e) {
return {};
}
}
Future<void> _saveSeenCounts(UploadMode uploadMode, Map<int, int> counts) async {
final prefs = await SharedPreferences.getInstance();
final encoded = jsonEncode(counts.map((k, v) => MapEntry(k.toString(), v)));
await prefs.setString(_prefsKey(uploadMode), encoded);
}
String _prefsKey(UploadMode uploadMode) =>
'changeset_comments_seen_${uploadMode.name}';
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';
}
}
}
@@ -0,0 +1,32 @@
import '../models/node_profile.dart';
/// Formats editor direction values exactly as they will be queued for upload.
class DirectionSubmissionFormatter {
const DirectionSubmissionFormatter._();
static Object format(List<double> directions, NodeProfile? profile) {
if (directions.isEmpty) return 0.0;
final fov = profile?.fov;
if (fov != null && fov > 0) {
final ranges = directions
.map((center) => _formatDirectionWithFov(center, fov))
.toList();
return ranges.length == 1 ? ranges.first : ranges.join(';');
}
if (directions.length == 1) return directions.first;
return directions
.map((direction) => direction.round().toString())
.join(';');
}
static String _formatDirectionWithFov(double center, double fov) {
if (fov >= 360) return '0-360';
final halfFov = fov / 2;
final start = (center - halfFov + 360) % 360;
final end = (center + halfFov) % 360;
return '${start.round()}-${end.round()}';
}
}
@@ -0,0 +1,77 @@
import '../models/pending_upload.dart';
import '../state/session_state.dart';
import '../state/settings_state.dart';
import 'direction_submission_formatter.dart';
/// Detects whether an edit would serialize to a different OSM node.
class EditNodeChangeDetector {
const EditNodeChangeDetector._();
static bool hasActualChanges(EditNodeSession session) {
if (session.extractFromWay) return true;
const tolerance = 0.0000001;
if ((session.target.latitude - session.originalNode.coord.latitude).abs() >
tolerance ||
(session.target.longitude - session.originalNode.coord.longitude)
.abs() >
tolerance) {
return true;
}
final originalTags = session.originalNode.tags;
final submittedTags = _getSubmittedTags(session);
final originalDirection = _directionValue(originalTags);
final submittedDirection =
originalDirection == null && session.directions.isEmpty
? null
: _directionValue(submittedTags);
if (originalDirection != submittedDirection) {
return true;
}
return !_tagsEqual(originalTags, submittedTags);
}
static Map<String, String> _getSubmittedTags(EditNodeSession session) {
if (session.profile == null) return <String, String>{};
return PendingUpload(
coord: session.target,
direction: DirectionSubmissionFormatter.format(
session.directions,
session.profile,
),
profile: session.profile,
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags,
changesetComment: session.changesetComment,
uploadMode: UploadMode.production,
operation: UploadOperation.modify,
originalNodeId: session.originalNode.id,
).getCombinedTags();
}
static String? _directionValue(Map<String, String> tags) =>
tags['direction'] ?? tags['camera:direction'];
static bool _tagsEqual(
Map<String, String> originalTags,
Map<String, String> submittedTags,
) {
final original = Map<String, String>.from(originalTags)
..remove('direction')
..remove('camera:direction');
final submitted = Map<String, String>.from(submittedTags)
..remove('direction')
..remove('camera:direction');
if (original.length != submitted.length) return false;
for (final entry in original.entries) {
if (submitted[entry.key] != entry.value) return false;
}
return true;
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:latlong2/latlong.dart';
/// Mean meters per degree of latitude. Constant enough at any latitude for
/// picking a node lookup box.
const double _metersPerDegreeLat = 111320.0;
/// Smallest cos(latitude) we will divide by. Past ~89.4° the true factor tends
/// to zero and the longitude span would blow up to the whole globe.
const double _minCosLatitude = 0.01;
/// A square of roughly [radiusMeters] in every direction around [center].
///
/// Used to pull cached nodes near a GPS position, as opposed to near whatever
/// the map camera is showing — the camera is frozen while the app is
/// backgrounded, so it is not a usable proxy for where the user is.
///
/// A degree of longitude shrinks by cos(latitude), so the box is widened east
/// to west to keep the real-world radius honest as you move away from the
/// equator.
LatLngBounds boundsAround(LatLng center, num radiusMeters) {
final radius = radiusMeters.toDouble().abs();
final latDelta = radius / _metersPerDegreeLat;
final cosLat = math.cos(center.latitude * math.pi / 180).abs();
final lngDelta =
radius / (_metersPerDegreeLat * math.max(cosLat, _minCosLatitude));
return LatLngBounds(
LatLng(
(center.latitude - latDelta).clamp(-90.0, 90.0),
(center.longitude - lngDelta).clamp(-180.0, 180.0),
),
LatLng(
(center.latitude + latDelta).clamp(-90.0, 90.0),
(center.longitude + lngDelta).clamp(-180.0, 180.0),
),
);
}
@@ -0,0 +1,21 @@
import 'package:geolocator/geolocator.dart';
/// Single-flight guard around [Geolocator.requestPermission].
///
/// The Android geolocator plugin can only track one in-flight permission
/// request at a time. If a second `requestPermission()` call is made while
/// the system dialog from a first call is still pending, the plugin tries to
/// reply to the same result callback twice and crashes with
/// `IllegalStateException: Reply already submitted`.
///
/// We have more than one place in the app that may want to (re)check/request
/// location permission around app startup (GPS controller, first-launch
/// prompt). Rather than coordinate all of them, just make sure only one real
/// native call is ever in flight - everyone else awaits that same result.
Future<LocationPermission>? _inFlight;
Future<LocationPermission> requestLocationPermissionOnce() {
return _inFlight ??= Geolocator.requestPermission().whenComplete(() {
_inFlight = null;
});
}
+2 -2
View File
@@ -123,7 +123,7 @@ class MapDataProvider {
_nodeDataManager.addOrUpdateNodes(nodes);
}
/// NodeCache compatibility - alias for addOrUpdateNodes
/// Alias for addOrUpdateNodes (shorter name used by the upload queue)
void addOrUpdate(List<OsmNode> nodes) {
addOrUpdateNodes(nodes);
}
@@ -151,7 +151,7 @@ class MapDataProvider {
);
}
/// NodeCache compatibility methods for upload queue
/// Pending-upload helpers used by the upload queue.
/// These all delegate to the singleton cache to ensure consistency
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
-176
View File
@@ -1,176 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/osm_node.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
const Distance _distance = Distance();
class NodeCache {
// Singleton instance
static final NodeCache instance = NodeCache._internal();
factory NodeCache() => instance;
NodeCache._internal();
final Map<int, OsmNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
/// TODO: Consider moving to compute() if cache operations cause ANR
void addOrUpdate(List<OsmNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
if (existing != null) {
// Preserve any tags starting with underscore when updating existing nodes
final mergedTags = Map<String, String>.from(node.tags);
for (final entry in existing.tags.entries) {
if (entry.key.startsWith('_')) {
mergedTags[entry.key] = entry.value;
}
}
_nodes[node.id] = OsmNode(
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
} else {
_nodes[node.id] = node;
}
}
}
/// Query for all cached nodes currently within the given LatLngBounds.
List<OsmNode> queryByBounds(LatLngBounds bounds) {
return _nodes.values
.where((node) => _inBounds(node.coord, bounds))
.toList();
}
/// Retrieve all cached nodes.
List<OsmNode> getAll() => _nodes.values.toList();
/// Optionally clear the cache (rarely needed)
void clear() => _nodes.clear();
/// Remove the _pending_edit marker from a specific node
void removePendingEditMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_edit')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_edit');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}
/// Remove the _pending_deletion marker from a specific node (when deletion is cancelled)
void removePendingDeletionMarker(int nodeId) {
final node = _nodes[nodeId];
if (node != null && node.tags.containsKey('_pending_deletion')) {
final cleanTags = Map<String, String>.from(node.tags);
cleanTags.remove('_pending_deletion');
_nodes[nodeId] = OsmNode(
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}
/// Remove a node by ID from the cache (used for successful deletions)
void removeNodeById(int nodeId) {
if (_nodes.remove(nodeId) != null) {
debugPrint('[NodeCache] Removed node $nodeId from cache (successful deletion)');
}
}
/// Remove temporary nodes (negative IDs) with _pending_upload marker at the given coordinate
/// This is used when a real node ID is assigned to clean up temp placeholders
void removeTempNodesByCoordinate(LatLng coord, {double tolerance = 0.00001}) {
final nodesToRemove = <int>[];
for (final entry in _nodes.entries) {
final nodeId = entry.key;
final node = entry.value;
// Only consider temp nodes (negative IDs) with pending upload marker
if (nodeId < 0 &&
node.tags.containsKey('_pending_upload') &&
_coordsMatch(node.coord, coord, tolerance)) {
nodesToRemove.add(nodeId);
}
}
for (final nodeId in nodesToRemove) {
_nodes.remove(nodeId);
}
if (nodesToRemove.isNotEmpty) {
debugPrint('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
}
}
/// Remove a specific temporary node by its ID (for queue item-specific cleanup)
void removeTempNodeById(int tempNodeId) {
if (tempNodeId >= 0) {
debugPrint('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
return;
}
if (_nodes.remove(tempNodeId) != null) {
debugPrint('[NodeCache] Removed specific temp node $tempNodeId from cache');
}
}
/// Get a specific node by ID (returns null if not found)
OsmNode? getNodeById(int nodeId) {
return _nodes[nodeId];
}
/// Check if two coordinates match within tolerance
bool _coordsMatch(LatLng coord1, LatLng coord2, double tolerance) {
return (coord1.latitude - coord2.latitude).abs() < tolerance &&
(coord1.longitude - coord2.longitude).abs() < tolerance;
}
/// Find nodes within the specified distance (in meters) of the given coordinate
/// Excludes nodes with the excludeNodeId (useful when checking proximity for edited nodes)
/// Includes pending nodes to warn about potential duplicates
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
final nearbyNodes = <OsmNode>[];
for (final node in _nodes.values) {
// Skip the excluded node (typically the node being edited)
if (excludeNodeId != null && node.id == excludeNodeId) {
continue;
}
// Include all nodes (real and pending) to catch potential duplicates
// Only skip nodes marked for deletion since they won't actually exist after processing
if (node.tags.containsKey('_pending_deletion')) {
continue;
}
final distance = _distance.as(LengthUnit.Meter, coord, node.coord);
if (distance <= distanceMeters) {
nearbyNodes.add(node);
}
}
return nearbyNodes;
}
/// Utility: point-in-bounds for coordinates
bool _inBounds(LatLng coord, LatLngBounds bounds) {
return coord.latitude >= bounds.southWest.latitude &&
coord.latitude <= bounds.northEast.latitude &&
coord.longitude >= bounds.southWest.longitude &&
coord.longitude <= bounds.northEast.longitude;
}
}
+1 -1
View File
@@ -375,7 +375,7 @@ class NodeDataManager extends ChangeNotifier {
);
}
/// NodeCache compatibility methods
/// Pending-upload helpers used by the upload queue.
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
+7 -1
View File
@@ -58,7 +58,13 @@ class NodeSpatialCache {
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained,
// Once a node is known to be constrained (part of a way/relation),
// keep treating it as constrained. Callers that build a fresh
// OsmNode for cache updates (e.g. after an edit upload succeeds)
// don't have way/relation data and default to false, so blindly
// taking the incoming value would "unconstrain" the node until
// the next Overpass fetch.
isConstrained: existing.isConstrained || node.isConstrained,
);
} else {
_nodes[node.id] = node;
+55
View File
@@ -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<bool?> 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';
}
}
}
+162 -76
View File
@@ -12,7 +12,7 @@ import '../dev_config.dart';
class RecentAlert {
final int nodeId;
final DateTime alertTime;
RecentAlert({required this.nodeId, required this.alertTime});
}
@@ -24,125 +24,189 @@ class ProximityAlertService {
ProximityAlertService._internal();
FlutterLocalNotificationsPlugin? _notifications;
bool _isInitialized = false;
/// Whether the plugin itself is usable. Deliberately NOT derived from
/// initialize()'s return value: on iOS that reports the outcome of the
/// permission request, which we intentionally disable below, so it returns
/// false even though the plugin works fine. Gating on it made every
/// notification path dead on iOS.
bool _pluginReady = false;
/// Guards against re-initializing on every settings visit.
Future<void>? _initFuture;
// Simple in-memory tracking of recent alerts to prevent spam
final List<RecentAlert> _recentAlerts = [];
static const Duration _alertCooldown = kProximityAlertCooldown;
// Callback for showing in-app visual alerts
VoidCallback? _onVisualAlert;
/// Initialize the notification plugin and request permissions
Future<void> initialize({VoidCallback? onVisualAlert}) async {
_onVisualAlert = onVisualAlert;
/// Initialize the notification plugin. Permissions are requested separately,
/// on demand, when the user enables proximity alerts.
Future<void> initialize({VoidCallback? onVisualAlert}) {
if (onVisualAlert != null) _onVisualAlert = onVisualAlert;
return _initFuture ??= _doInitialize();
}
Future<void> _doInitialize() async {
_notifications = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
final initialized = await _notifications!.initialize(initSettings);
_isInitialized = initialized ?? false;
// Note: We don't request notification permissions here anymore.
// Permissions are requested on-demand when user enables proximity alerts.
debugPrint('[ProximityAlertService] Initialized: $_isInitialized (permissions deferred)');
await _notifications!.initialize(initSettings);
// Completing without throwing is the real signal that the plugin is up.
_pluginReady = true;
debugPrint('[ProximityAlertService] Plugin ready (permissions deferred)');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_isInitialized = false;
_pluginReady = false;
_initFuture = null; // Allow a later retry
}
}
/// Ensure the plugin is up before touching it, so callers that arrive before
/// MapView's fire-and-forget initialize() completes still work.
Future<bool> _ensureReady() async {
if (_pluginReady) return true;
await initialize();
return _pluginReady;
}
/// Request notification permissions on both platforms
/// Request notification permissions on both platforms.
///
/// Note that on iOS the system prompt appears only ONCE, ever. After the
/// user has answered it, this call returns silently without showing
/// anything — which is why callers must check the result and fall back to
/// sending the user to system settings.
Future<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
// Request permissions - this will show the permission dialog on Android 13+
final result = await _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android notification permission result: $result');
// Also request for iOS (though this was already done in initialization)
await _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
final android = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final result = await android.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android permission result: $result');
return;
}
final ios = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (ios != null) {
final result = await ios.requestPermissions(
alert: true,
badge: true,
sound: true,
);
debugPrint('[ProximityAlertService] iOS permission result: $result');
}
} catch (e) {
debugPrint('[ProximityAlertService] Failed to request permissions: $e');
}
}
/// Open the OS settings page for this app, so the user can grant
/// notifications after the one-shot iOS prompt has already been answered.
/// Uses Geolocator's platform channel purely as a way to open app settings;
/// it is not location-specific.
Future<bool> openSystemSettings() async {
try {
return await Geolocator.openAppSettings();
} catch (e) {
debugPrint('[ProximityAlertService] Failed to open app settings: $e');
return false;
}
}
/// Check proximity to nodes and trigger alerts if needed
/// This should be called on GPS position updates
/// Check proximity to nodes and trigger alerts if needed.
/// This should be called on GPS position updates.
Future<void> checkProximity({
required LatLng userLocation,
required List<OsmNode> nodes,
required List<NodeProfile> enabledProfiles,
required int alertDistance,
}) async {
if (!_isInitialized || nodes.isEmpty) return;
if (!_pluginReady || nodes.isEmpty) return;
// Clean up old alerts (anything older than cooldown period)
final cutoffTime = DateTime.now().subtract(_alertCooldown);
_recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime));
// Check each node for proximity
for (final node in nodes) {
// Skip if we recently alerted for this node
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
continue;
}
// Calculate distance using Geolocator's distanceBetween
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) continue;
final distance = Geolocator.distanceBetween(
userLocation.latitude,
userLocation.longitude,
node.coord.latitude,
node.coord.longitude,
);
// Check if within alert distance
if (distance <= alertDistance) {
// Determine node type for alert message
final nodeType = _getNodeTypeDescription(node, enabledProfiles);
// Trigger both push notification and visual alert
await _showNotification(node, nodeType, distance.round());
_showVisualAlert();
// Track this alert to prevent spam
_recentAlerts.add(RecentAlert(
nodeId: node.id,
alertTime: DateTime.now(),
));
_recentAlerts.add(RecentAlert(nodeId: node.id, alertTime: DateTime.now()));
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
}
}
}
/// Notification IDs must fit in a signed 32-bit int, but OSM node IDs are
/// well past that (currently ~12 billion), so passing one through raises
/// "must fit within the size of a 32-bit integer" and the notification is
/// dropped. Fold to a stable positive 31-bit value instead.
///
/// Deterministic across runs, so re-alerting for the same node replaces the
/// existing notification rather than stacking a duplicate.
static int _notificationId(int nodeId) => nodeId.abs() % 0x7FFFFFFF;
/// Show push notification for proximity alert
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
if (!_isInitialized || _notifications == null) return;
await _show(
id: _notificationId(node.id),
title: 'Surveillance Device Nearby',
body: '$nodeType detected ${distance}m ahead',
);
}
Future<void> _show({
required int id,
required String title,
required String body,
}) async {
if (!_pluginReady || _notifications == null) {
debugPrint('[ProximityAlertService] NOT SHOWN: plugin not ready');
return;
}
// A notification that silently no-ops because permission was never granted
// is indistinguishable from one that was never triggered. Say which.
if (!await areNotificationsEnabled()) {
debugPrint(
'[ProximityAlertService] NOT SHOWN: OS notification permission not '
'granted. Settings → Proximity Alerts → Enable Notifications.',
);
return;
}
const androidDetails = AndroidNotificationDetails(
'proximity_alerts',
'Proximity Alerts',
@@ -154,7 +218,13 @@ class ProximityAlertService {
);
const iosDetails = DarwinNotificationDetails(
// presentAlert is dead on iOS 14+: the plugin's willPresentNotification
// only consults presentBanner/presentList there and ignores presentAlert
// entirely. Setting just presentAlert produced no visible banner at all.
// Keep it for iOS 13 and below, but presentBanner is what actually works.
presentAlert: true,
presentBanner: true,
presentList: true,
presentBadge: false,
presentSound: true,
);
@@ -164,16 +234,16 @@ class ProximityAlertService {
iOS: iosDetails,
);
final title = 'Surveillance Device Nearby';
final body = '$nodeType detected ${distance}m ahead';
try {
await _notifications!.show(
node.id, // Use node ID as notification ID
id, // Use node ID as notification ID
title,
body,
notificationDetails,
);
debugPrint('[ProximityAlertService] SHOWN id=$id "$title" — $body');
} on ArgumentError catch (e) {
debugPrint('[ProximityAlertService] Rejected notification id=$id: $e');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to show notification: $e');
}
@@ -226,30 +296,46 @@ class ProximityAlertService {
_recentAlerts.clear();
}
/// Check if notification permissions are granted
/// Check if notification permissions are granted.
Future<bool> areNotificationsEnabled() async {
if (!_isInitialized || _notifications == null) return false;
if (!await _ensureReady() || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
final result = await androidImpl.areNotificationsEnabled();
return result ?? false;
}
// For iOS, assume enabled if we got this far (permissions were requested during init)
return true;
// iOS: actually ask the system rather than assuming. The old code
// returned a blind `true` here, so the UI could never tell whether
// notifications were really authorized.
final iosImpl = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosImpl != null) {
final options = await iosImpl.checkPermissions();
if (options == null) return false;
return options.isEnabled || options.isProvisionalEnabled;
}
return false;
} catch (e) {
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
return false;
}
}
/// Request permissions again (can be called from settings)
/// Request permissions and report whether they ended up granted.
///
/// A `false` result does NOT mean the prompt was declined just now — on iOS
/// the prompt may not have appeared at all because it was already answered
/// in a previous launch. Callers should offer [openSystemSettings] on false.
Future<bool> requestNotificationPermissions() async {
if (!await _ensureReady()) return false;
await _requestNotificationPermissions();
return await areNotificationsEnabled();
final enabled = await areNotificationsEnabled();
debugPrint('[ProximityAlertService] Permissions after request: $enabled');
return enabled;
}
}
+60
View File
@@ -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<void> 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<bool?> 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}';
}
+12 -2
View File
@@ -28,11 +28,16 @@ class AuthState extends ChangeNotifier {
Future<void> 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<void> logout() async {
@@ -58,11 +63,16 @@ class AuthState extends ChangeNotifier {
Future<void> 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<bool> validateToken() async {
+49 -10
View File
@@ -1,22 +1,28 @@
import 'package:flutter/material.dart';
import '../services/osm_messages_service.dart';
import '../services/changeset_comments_service.dart';
import 'settings_state.dart';
/// State management for OSM message notifications
/// State management for OSM message and changeset comment notifications
class MessagesState extends ChangeNotifier {
final OSMMessagesService _messagesService = OSMMessagesService();
final ChangesetCommentsService _changesetCommentsService = ChangesetCommentsService();
int? _unreadCount;
int? _unreadChangesetId; // ID of a changeset with unread comments, if any
bool _isChecking = false;
// Getters
int? get unreadCount => _unreadCount;
bool get hasUnreadMessages => (_unreadCount ?? 0) > 0;
bool get hasUnreadChangesetComments => _unreadChangesetId != null;
bool get hasUnreadNotifications => hasUnreadMessages || hasUnreadChangesetComments;
bool get isChecking => _isChecking;
/// Check for unread messages
/// Check for unread messages and changeset comments
Future<void> checkMessages({
required String? accessToken,
required String? username,
required UploadMode uploadMode,
bool forceRefresh = false,
}) async {
@@ -26,18 +32,29 @@ class MessagesState extends ChangeNotifier {
notifyListeners();
try {
final count = await _messagesService.getUnreadMessageCount(
accessToken: accessToken,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
);
final results = await Future.wait([
_messagesService.getUnreadMessageCount(
accessToken: accessToken,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
),
_changesetCommentsService.getUnreadChangesetId(
username: username,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
),
]);
if (_unreadCount != count) {
final count = results[0];
final unreadChangesetId = results[1];
if (_unreadCount != count || _unreadChangesetId != unreadChangesetId) {
_unreadCount = count;
_unreadChangesetId = unreadChangesetId;
notifyListeners();
}
} catch (e) {
// Silently handle errors - messages are not critical
// Silently handle errors - notifications are not critical
debugPrint('MessagesState: Error checking messages: $e');
} finally {
_isChecking = false;
@@ -45,6 +62,26 @@ class MessagesState extends ChangeNotifier {
}
}
/// URL of the changeset with unread comments, on OSM's website, or null
/// if there are no unread changeset comments.
String? getUnreadChangesetUrl(UploadMode uploadMode) {
final id = _unreadChangesetId;
if (id == null) return null;
return _changesetCommentsService.getChangesetUrl(id, uploadMode);
}
/// Mark changeset comments as read (called when user views the changeset on OSM)
Future<void> markChangesetCommentsRead({
required String? username,
required UploadMode uploadMode,
}) async {
await _changesetCommentsService.markRead(username: username, uploadMode: uploadMode);
if (_unreadChangesetId != null) {
_unreadChangesetId = null;
notifyListeners();
}
}
/// Get the URL to view messages
String getMessagesUrl(UploadMode uploadMode) {
return _messagesService.getMessagesUrl(uploadMode);
@@ -53,7 +90,9 @@ class MessagesState extends ChangeNotifier {
/// Clear message state (when user logs out or changes mode)
void clearMessages() {
_unreadCount = null;
_unreadChangesetId = null;
_messagesService.clearCache();
_changesetCommentsService.clearCache();
notifyListeners();
}
}
}
+17 -35
View File
@@ -6,8 +6,8 @@ import 'package:latlong2/latlong.dart';
import '../models/pending_upload.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../services/map_data_provider.dart';
import '../services/direction_submission_formatter.dart';
import '../services/uploader.dart';
import '../widgets/node_provider_with_cache.dart';
import '../dev_config.dart';
@@ -20,11 +20,13 @@ class UploadQueueState extends ChangeNotifier {
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
int _activeUploadCount = 0;
bool _isPausedDueToBlock = false;
// Getters
int get pendingCount => _queue.length;
List<PendingUpload> 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<void> init() async {
@@ -124,7 +126,7 @@ class UploadQueueState extends ChangeNotifier {
PendingUpload addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: _formatDirectionsForSubmission(session.directions, session.profile),
direction: DirectionSubmissionFormatter.format(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
@@ -184,7 +186,7 @@ class UploadQueueState extends ChangeNotifier {
final upload = PendingUpload(
coord: coordToUse,
direction: _formatDirectionsForSubmission(session.directions, session.profile),
direction: DirectionSubmissionFormatter.format(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
@@ -326,8 +328,10 @@ class UploadQueueState extends ChangeNotifier {
required bool pauseQueueProcessing,
required UploadMode uploadMode,
required Future<String?> Function() getAccessToken,
required Future<bool?> 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)');
@@ -739,38 +753,6 @@ class UploadQueueState extends ChangeNotifier {
}
}
// Helper method to format multiple directions for submission, supporting profile FOV
dynamic _formatDirectionsForSubmission(List<double> directions, NodeProfile? profile) {
if (directions.isEmpty) return 0.0;
// If profile has FOV, convert center directions to range notation
if (profile?.fov != null && profile!.fov! > 0) {
final ranges = directions.map((center) =>
_formatDirectionWithFov(center, profile.fov!)
).toList();
return ranges.length == 1 ? ranges.first : ranges.join(';');
}
// No profile FOV: use original format (single number or semicolon-separated)
if (directions.length == 1) return directions.first;
return directions.map((d) => d.round().toString()).join(';');
}
// Convert a center direction and FOV to range notation (e.g., 180° center with 90° FOV -> "135-225")
String _formatDirectionWithFov(double center, double fov) {
// Handle 360-degree FOV as special case
if (fov >= 360) {
return '0-360';
}
final halfFov = fov / 2;
final start = (center - halfFov + 360) % 360;
final end = (center + halfFov) % 360;
return '${start.round()}-${end.round()}';
}
// Clean up pending nodes from cache when queue items are deleted/cleared
void _cleanupPendingNodeFromCache(PendingUpload upload) {
if (upload.isDeletion) {
+54
View File
@@ -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')),
),
],
);
}
}
+23 -3
View File
@@ -421,6 +421,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
}
final allowSubmit = appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -493,6 +494,22 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
],
),
)
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),
@@ -594,10 +611,13 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
// Tutorial overlay - always mounted once the initial check
// completes (never conditionally inserted/removed - see
// PositioningTutorialOverlay's class doc for why), fades via
// its own `visible` flag instead.
if (!_isCheckingTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
child: PositioningTutorialOverlay(visible: _showTutorial),
),
],
);
+25 -101
View File
@@ -7,11 +7,11 @@ import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/pending_upload.dart';
import '../services/localization_service.dart';
import '../services/map_data_provider.dart';
import '../services/node_data_manager.dart';
import '../services/changelog_service.dart';
import '../services/edit_node_change_detector.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
import 'proximity_warning_dialog.dart';
@@ -151,102 +151,6 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
);
}
/// Check if the edit session has any actual changes compared to the original node
bool _hasActualChanges(EditNodeSession session) {
// Extract operation is always a change
if (session.extractFromWay) return true;
// Check location change
const double tolerance = 0.0000001; // ~1cm precision
if ((session.target.latitude - session.originalNode.coord.latitude).abs() > tolerance ||
(session.target.longitude - session.originalNode.coord.longitude).abs() > tolerance) {
return true;
}
// Check direction changes
if (!_directionsEqual(session.directions, session.originalNode.directionDeg)) {
return true;
}
// Check tag changes (including operator profile and additional existing tags)
final originalTags = session.originalNode.tags;
final newTags = _getSessionCombinedTags(session);
if (!_tagsEqual(originalTags, newTags)) {
return true;
}
return false;
}
/// Compare two direction lists, handling empty vs [0] cases
bool _directionsEqual(List<double> sessionDirs, List<double> originalDirs) {
// Sort both lists for comparison
final sorted1 = List<double>.from(sessionDirs)..sort();
final sorted2 = List<double>.from(originalDirs)..sort();
// Handle empty list cases
if (sorted1.isEmpty && sorted2.isEmpty) return true;
if (sorted1.isEmpty || sorted2.isEmpty) {
// Special case: if one is empty and the other is [0], consider them different
// because the user either added or removed a direction
return false;
}
if (sorted1.length != sorted2.length) return false;
for (int i = 0; i < sorted1.length; i++) {
if ((sorted1[i] - sorted2[i]).abs() > 0.1) return false; // 0.1° tolerance
}
return true;
}
/// Compare two tag maps, ignoring direction tags (handled separately)
bool _tagsEqual(Map<String, String> tags1, Map<String, String> tags2) {
final filtered1 = Map<String, String>.from(tags1);
final filtered2 = Map<String, String>.from(tags2);
// Remove direction tags - they're handled separately
filtered1.remove('direction');
filtered1.remove('camera:direction');
filtered2.remove('direction');
filtered2.remove('camera:direction');
return _mapEquals(filtered1, filtered2);
}
/// Deep equality check for maps
bool _mapEquals(Map<String, String> map1, Map<String, String> map2) {
if (map1.length != map2.length) return false;
for (final entry in map1.entries) {
if (map2[entry.key] != entry.value) return false;
}
return true;
}
/// Get the combined tags that would be submitted for this session
Map<String, String> _getSessionCombinedTags(EditNodeSession session) {
if (session.profile == null) return <String, String>{};
// Create a temporary PendingUpload to use its getCombinedTags logic
final tempUpload = PendingUpload(
coord: session.target,
direction: session.directions.isNotEmpty ? session.directions.first : 0.0,
profile: session.profile,
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
additionalExistingTags: session.additionalExistingTags, // Include additional existing tags!
changesetComment: session.changesetComment, // Required parameter
uploadMode: UploadMode.production, // Mode doesn't matter for tag combination
operation: UploadOperation.modify,
originalNodeId: session.originalNode.id, // Required for modify operations
);
return tempUpload.getCombinedTags();
}
/// Show dialog explaining why submission is disabled due to no changes
void _showNoChangesDialog(BuildContext context, LocalizationService locService) {
showDialog<void>(
@@ -436,7 +340,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
void commit() {
// Check if there are any actual changes to submit
if (!_hasActualChanges(widget.session)) {
if (!EditNodeChangeDetector.hasActualChanges(widget.session)) {
_showNoChangesDialog(context, locService);
return;
}
@@ -473,6 +377,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -622,6 +527,22 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
],
),
)
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),
@@ -723,10 +644,13 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
],
),
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
// Tutorial overlay - always mounted once the initial check
// completes (never conditionally inserted/removed - see
// PositioningTutorialOverlay's class doc for why), fades via
// its own `visible` flag instead.
if (!_isCheckingTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(),
child: PositioningTutorialOverlay(visible: _showTutorial),
),
],
);
+200 -25
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
@@ -9,9 +10,11 @@ import '../../dev_config.dart';
import '../../app_state.dart' show FollowMeMode;
import '../../services/proximity_alert_service.dart';
import '../../services/coordinate_validation.dart';
import '../../services/localization_service.dart';
import '../../services/location_permission_gate.dart';
import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
import '../../services/geo_bounds.dart';
/// Simple GPS controller that handles precise location permissions only.
/// Key principles:
@@ -21,7 +24,11 @@ import '../../models/node_profile.dart';
class GpsController {
StreamSubscription<Position>? _positionSub;
Timer? _retryTimer;
/// Whether the live stream was started with background delivery armed, so a
/// proximity-alerts toggle knows whether it actually needs to rebuild it.
bool _streamIsBackgroundCapable = false;
// Location state
LatLng? _currentLocation;
bool _hasLocation = false;
@@ -32,11 +39,15 @@ class GpsController {
FollowMeMode Function()? _getCurrentFollowMeMode;
bool Function()? _getProximityAlertsEnabled;
int Function()? _getProximityAlertDistance;
List<OsmNode> Function()? _getNearbyNodes;
List<OsmNode> Function(LatLng userLocation, int radiusMeters)? _getNearbyNodes;
List<NodeProfile> Function()? _getEnabledProfiles;
VoidCallback? _onMapMovedProgrammatically;
bool Function()? _isUserInteracting;
LatLngBounds? _backgroundTrackingBounds;
bool _isFetchingBackgroundNodes = false;
Future<void> Function(LatLngBounds)? _onFetchBackgroundNodes;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLocation;
@@ -50,8 +61,9 @@ class GpsController {
required FollowMeMode Function() getCurrentFollowMeMode,
required bool Function() getProximityAlertsEnabled,
required int Function() getProximityAlertDistance,
required List<OsmNode> Function() getNearbyNodes,
required List<OsmNode> Function(LatLng userLocation, int radiusMeters) getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
Future<void> Function(LatLngBounds)? onFetchBackgroundNodes,
VoidCallback? onMapMovedProgrammatically,
bool Function()? isUserInteracting,
}) async {
@@ -65,6 +77,7 @@ class GpsController {
_getProximityAlertDistance = getProximityAlertDistance;
_getNearbyNodes = getNearbyNodes;
_getEnabledProfiles = getEnabledProfiles;
_onFetchBackgroundNodes = onFetchBackgroundNodes;
_onMapMovedProgrammatically = onMapMovedProgrammatically;
_isUserInteracting = isUserInteracting;
@@ -86,6 +99,38 @@ class GpsController {
_handleInitialFollowMeAnimation(newMode, oldMode);
}
/// Force a fresh fix, e.g. when the app returns from the background.
///
/// The position stream can stay silent after a resume — and a simulated
/// location that "teleported" while the app was suspended produces no
/// movement event at all — leaving _currentLocation stale, so proximity
/// alerts would keep evaluating against the old position.
Future<void> refreshLocation() async {
debugPrint('[GpsController] Refreshing location after resume');
// Restart the stream first: iOS often stops delivering to a subscription
// that spanned suspension.
if (_positionSub != null) {
_stopLocationTracking();
_startPositionStream();
}
try {
// geolocator 10.x: getCurrentPosition takes desiredAccuracy, unlike
// getPositionStream above which takes a LocationSettings object.
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
// Match the stream's forceLocationManager: true, so the one-shot and
// the stream cannot disagree about where we are on Android.
forceAndroidLocationManager: true,
timeLimit: const Duration(seconds: 10),
);
_onPositionReceived(position);
} catch (e) {
debugPrint('[GpsController] Failed to refresh location: $e');
}
}
/// Manual retry (e.g., user pressed follow-me button)
Future<void> retryLocationInit() async {
debugPrint('[GpsController] Manual retry of location initialization');
@@ -108,7 +153,7 @@ class GpsController {
}
// Check permissions
final permission = await Geolocator.requestPermission();
final permission = await requestLocationPermissionOnce();
debugPrint('[GpsController] Location permission result: $permission');
switch (permission) {
@@ -149,24 +194,29 @@ class GpsController {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
// Both platforms stop feeding a backgrounded app by default, which is why
// alerts only ever fired with the app on screen. Keeping the stream alive
// in the background is visible to the user (persistent notification on
// Android, blue status bar on iOS), so only ask for it when proximity
// alerts are actually switched on.
final background = _getProximityAlertsEnabled?.call() ?? false;
debugPrint(
'[GpsController] Starting GPS position stream '
'(${distanceFilter}m filter, background=$background)',
);
try {
_positionSub = Geolocator.getPositionStream(
locationSettings: defaultTargetPlatform == TargetPlatform.android
? AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
forceLocationManager: true,
)
: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
),
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter,
background: background,
),
).listen(
_onPositionReceived,
onError: _onPositionError,
);
_streamIsBackgroundCapable = background;
} catch (e) {
debugPrint('[GpsController] Failed to start position stream: $e');
_hasLocation = false;
@@ -175,6 +225,79 @@ class GpsController {
}
}
/// Platform-specific location settings.
///
/// When [background] is false these are exactly the old foreground-only
/// settings, so nothing changes for users who leave proximity alerts off.
LocationSettings _buildLocationSettings({
required int distanceFilter,
required bool background,
}) {
final locService = LocalizationService.instance;
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
forceLocationManager: true,
// Supplying this config is what makes geolocator run its
// GeolocatorLocationService as a foreground service. Without it
// Android throttles a backgrounded app to a few updates per hour and
// then stops entirely, so alerts never fire.
foregroundNotificationConfig: background
? ForegroundNotificationConfig(
notificationTitle:
locService.t('proximityAlerts.backgroundServiceTitle'),
notificationText:
locService.t('proximityAlerts.backgroundServiceText'),
notificationChannelName:
locService.t('proximityAlerts.backgroundServiceChannel'),
// Without a wake lock the system sleeps and delivers the
// queued positions in one burst on wake — far too late to warn
// anyone about a device they already drove past.
enableWakeLock: true,
setOngoing: true,
)
: null,
);
}
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return AppleSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
// Pairs with UIBackgroundModes=location in Info.plist. Together they
// stop iOS from suspending the app on background, which is what killed
// the position stream (and therefore every alert) before.
allowBackgroundLocationUpdates: background,
// iOS otherwise pauses updates when it decides you have stopped moving
// and never reliably resumes them — a silent death for alerts.
pauseLocationUpdatesAutomatically: false,
// The blue status bar pill. Non-negotiable honesty: the app is reading
// location off screen and the user should be able to see that.
showBackgroundLocationIndicator: background,
activityType: ActivityType.otherNavigation,
);
}
return LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
);
}
/// Rebuild the stream when the proximity-alerts setting is toggled, so
/// background delivery is armed (or dropped) immediately rather than at the
/// next unrelated stream restart.
void updateProximityAlertsEnabled(bool enabled) {
if (_streamIsBackgroundCapable == enabled) return;
debugPrint('[GpsController] Proximity alerts $enabled — rebuilding stream');
_stopLocationTracking();
_startPositionStream();
}
/// Restart position stream with current follow-me settings
void _restartPositionStream() {
if (_positionSub == null) {
@@ -187,12 +310,58 @@ class GpsController {
_startPositionStream();
}
Future<void> _checkBackgroundDataRefresh(LatLng userLocation) async {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
// Debug 1: Check the early return conditions
debugPrint('[GpsController-Background] proximityEnabled: $proximityEnabled, '
'onFetchBackgroundNodes null: ${_onFetchBackgroundNodes == null}, '
'isFetchingBackgroundNodes: $_isFetchingBackgroundNodes');
if (!proximityEnabled || _onFetchBackgroundNodes == null || _isFetchingBackgroundNodes) return;
bool needsRefresh = false;
if (_backgroundTrackingBounds == null) {
debugPrint('[GpsController-Background] Bounds are null. Triggering initial refresh.');
needsRefresh = true;
} else {
final bounds = _backgroundTrackingBounds!;
final northDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, bounds.north, userLocation.longitude);
final southDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, bounds.south, userLocation.longitude);
final eastDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, userLocation.latitude, bounds.east);
final westDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, userLocation.latitude, bounds.west);
final minDist = [northDist, southDist, eastDist, westDist].reduce((a, b) => a < b ? a : b);
// Debug 2: Print the closest distance to the edge
debugPrint('[GpsController-Background] Closest edge distance: ${minDist.toStringAsFixed(1)}m (Threshold: 2500m)');
if (minDist < 2500) {
debugPrint('[GpsController-Background] Threshold crossed. Triggering refresh.');
needsRefresh = true;
}
}
if (needsRefresh) {
_isFetchingBackgroundNodes = true;
try {
final newBounds = boundsAround(userLocation, 5000);
await _onFetchBackgroundNodes?.call(newBounds);
_backgroundTrackingBounds = newBounds;
debugPrint('[GpsController-Background] Successfully fetched and updated tracking bounds.');
} catch (e) {
debugPrint('[GpsController-Background] Background fetch failed: $e');
} finally {
_isFetchingBackgroundNodes = false;
}
}
}
/// Handle incoming GPS position
void _onPositionReceived(Position position) {
// Reject malformed fixes (occasionally NaN/Infinite from the platform
// location stack) before they reach map state.
if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) {
debugPrint(
'[GpsController] Ignoring invalid GPS position: '
'lat=${position.latitude}, lng=${position.longitude}',
@@ -200,7 +369,6 @@ class GpsController {
return;
}
final newLocation = LatLng(position.latitude, position.longitude);
_currentLocation = newLocation;
@@ -217,13 +385,13 @@ class GpsController {
// Handle proximity alerts
_checkProximityAlerts(newLocation);
_checkBackgroundDataRefresh(newLocation);
// Handle follow-me animations
_handleFollowMeUpdate(position, newLocation);
}
/// Handle GPS stream errors
void _onPositionError(dynamic error) {
debugPrint('[GpsController] Position stream error: $error');
if (_hasLocation) {
@@ -239,13 +407,19 @@ class GpsController {
void _checkProximityAlerts(LatLng userLocation) {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
if (!proximityEnabled) return;
final nearbyNodes = _getNearbyNodes?.call() ?? [];
if (nearbyNodes.isEmpty) return;
final alertDistance = _getProximityAlertDistance?.call() ?? 200;
// Look up nodes around where the user actually is, not around whatever the
// map camera happens to show. Backgrounded, the camera never moves, so the
// old viewport-based lookup went stale the moment you drove out of it —
// and even in the foreground it missed alerts whenever the map was panned
// away from your position.
final nearbyNodes = _getNearbyNodes?.call(userLocation, alertDistance) ?? [];
if (nearbyNodes.isEmpty) return;
final enabledProfiles = _getEnabledProfiles?.call() ?? [];
ProximityAlertService().checkProximity(
userLocation: userLocation,
nodes: nearbyNodes,
@@ -377,6 +551,7 @@ class GpsController {
_getProximityAlertDistance = null;
_getNearbyNodes = null;
_getEnabledProfiles = null;
_onFetchBackgroundNodes = null;
_onMapMovedProgrammatically = null;
_isUserInteracting = null;
}
+37 -9
View File
@@ -7,6 +7,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app_state.dart' show AppState, FollowMeMode;
import '../services/offline_area_service.dart';
import '../services/geo_bounds.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
@@ -64,7 +65,7 @@ class MapView extends StatefulWidget {
State<MapView> createState() => MapViewState();
}
class MapViewState extends State<MapView> {
class MapViewState extends State<MapView> with WidgetsBindingObserver {
late final AnimatedMapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
@@ -126,6 +127,8 @@ class MapViewState extends State<MapView> {
_dataManager = MapDataManager();
_interactionManager = MapInteractionManager();
WidgetsBinding.instance.addObserver(this);
// Initialize proximity alert service
ProximityAlertService().initialize(
onVisualAlert: () {
@@ -186,16 +189,16 @@ class MapViewState extends State<MapView> {
}
return 200;
},
getNearbyNodes: () {
getNearbyNodes: (userLocation, radiusMeters) {
if (mounted) {
try {
final LatLngBounds mapBounds;
try {
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return [];
}
return NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds);
// Deliberately not the map camera's visibleBounds: while the app
// is backgrounded the camera is frozen wherever the user left it,
// so a viewport lookup returns nodes from a place they may be
// miles from by now. Box around the live GPS position instead.
return NodeProviderWithCache.instance.getCachedNodesForBounds(
boundsAround(userLocation, radiusMeters),
);
} catch (e) {
debugPrint('[MapView] Could not get nearby nodes: $e');
return [];
@@ -214,6 +217,16 @@ class MapViewState extends State<MapView> {
}
return [];
},
onFetchBackgroundNodes: (bounds) async {
if (!mounted) return;
final appState = context.read<AppState>();
await NodeProviderWithCache.instance.fetchAndUpdate(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
);
},
onMapMovedProgrammatically: () {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
@@ -231,8 +244,18 @@ class MapViewState extends State<MapView> {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The position stream can go quiet across suspension, so pull a fresh fix
// rather than trusting the last one we saw before backgrounding.
if (state == AppLifecycleState.resumed) {
_gpsController.refreshLocation();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_cameraDebounce.dispose();
_tileDebounce.dispose();
_mapPositionDebounce.dispose();
@@ -329,6 +352,11 @@ class MapViewState extends State<MapView> {
// Keep screen awake based on user setting
_updateWakelock(appState.keepScreenAwake);
// Arm or drop background location delivery when the proximity alert
// setting is toggled. No-op when the stream already matches, same as
// _updateWakelock above.
_gpsController.updateProximityAlertsEnabled(appState.proximityAlertsEnabled);
// Check if enabled profiles changed and refresh nodes if needed
_nodeController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles,
+19 -5
View File
@@ -17,6 +17,7 @@ class NodeProviderWithCache extends ChangeNotifier {
final NodeDataManager _nodeDataManager = NodeDataManager();
Timer? _debounceTimer;
Completer<void>? _activeCompleter;
/// Get cached nodes for the given bounds, filtered by enabled profiles
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
@@ -34,14 +35,21 @@ class NodeProviderWithCache extends ChangeNotifier {
}
/// Fetch and update nodes for the given view, with debouncing for rapid map movement
void fetchAndUpdate({
Future<void> fetchAndUpdate({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) {
// Serve cached immediately
notifyListeners();
// Prevent hanging awaits by completing the previous request if it gets cancelled
if (_activeCompleter != null && !_activeCompleter!.isCompleted) {
_activeCompleter!.complete();
}
_activeCompleter = Completer<void>();
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
@@ -52,15 +60,21 @@ class NodeProviderWithCache extends ChangeNotifier {
uploadMode: uploadMode,
isUserInitiated: true,
);
// Notify UI of new data
notifyListeners();
} catch (e) {
debugPrint('[NodeProviderWithCache] Node fetch failed: $e');
// Cache already holds whatever is available for the view
} finally {
// Ensure we only complete if it hasn't been superseded by another call
if (_activeCompleter != null && !_activeCompleter!.isCompleted) {
_activeCompleter!.complete();
}
}
});
return _activeCompleter!.future;
}
/// Clear the cache and repopulate with pending nodes from upload queue
@@ -108,4 +122,4 @@ class NodeProviderWithCache extends ChangeNotifier {
}
return true;
}
}
}
+17
View File
@@ -184,6 +184,23 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
return;
}
if (appState.hasActiveBlock) {
await showDialog<void>(
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(
+144 -69
View File
@@ -4,89 +4,164 @@ import 'package:flutter/material.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
/// Overlay that appears over add/edit node sheets to guide users through
/// Overlay that appears over add/edit node sheets to guide users through
/// the positioning tutorial. Shows a blurred background with tutorial text.
class PositioningTutorialOverlay extends StatelessWidget {
///
/// Stability note: on Android's Impeller (Vulkan) renderer, BackdropFilter
/// snapshots the render pass's "back texture" mid-frame. If that snapshot
/// races against the surface being invalidated - which happens constantly
/// right where this overlay lives, since the enclosing bottom sheet resizes
/// on open/keyboard/rotation - Impeller can hit a null texture and hard-abort
/// the whole process natively (not a catchable Dart error). Two things keep
/// that race from being triggered here:
/// - [RepaintBoundary] isolates the filter onto its own stable composited
/// layer instead of sharing one with the actively-resizing sheet.
/// - The widget is always kept mounted by the parent sheet and toggles
/// visibility via a fade (see [visible]) rather than being abruptly
/// inserted/removed from the tree, which is believed to be the other half
/// of what triggers the crash.
class PositioningTutorialOverlay extends StatefulWidget {
const PositioningTutorialOverlay({
super.key,
required this.visible,
this.onFadeOutComplete,
});
/// Called when the fade-out animation completes (if animated)
/// Whether the tutorial should currently be showing. Toggling this fades
/// the overlay in/out - see class doc for why it's never removed from
/// the tree instead.
final bool visible;
/// Called when the fade-out animation completes (i.e. the overlay has
/// become fully transparent after [visible] turned false).
final VoidCallback? onFadeOutComplete;
@override
State<PositioningTutorialOverlay> createState() => _PositioningTutorialOverlayState();
}
class _PositioningTutorialOverlayState extends State<PositioningTutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
value: widget.visible ? 1.0 : 0.0,
);
_controller.addStatusListener(_onStatusChanged);
}
void _onStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
widget.onFadeOutComplete?.call();
}
}
@override
void didUpdateWidget(covariant PositioningTutorialOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) {
if (widget.visible) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.removeStatusListener(_onStatusChanged);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: kPositioningTutorialBlurSigma,
sigmaY: kPositioningTutorialBlurSigma,
),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3), // Semi-transparent overlay
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tutorial icon
Icon(
Icons.pan_tool_outlined,
size: 48,
color: Colors.white,
// Ignore touches when faded out so the invisible-but-still-mounted
// overlay doesn't block interaction with the sheet underneath.
return IgnorePointer(
ignoring: !widget.visible,
child: FadeTransition(
opacity: _controller,
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return ClipRect(
child: RepaintBoundary(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: kPositioningTutorialBlurSigma,
sigmaY: kPositioningTutorialBlurSigma,
),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3), // Semi-transparent overlay
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tutorial icon
Icon(
Icons.pan_tool_outlined,
size: 48,
color: Colors.white,
),
const SizedBox(height: 16),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 16),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
);
},
);
},
),
),
);
}
}
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
/// Dialog shown once per session when the user has unread OSM messages
/// and/or unread changeset comments, prompting them to view their OSM
/// account details.
class UnreadNotificationsDialog extends StatelessWidget {
final VoidCallback onView;
final VoidCallback onDismiss;
const UnreadNotificationsDialog({
super.key,
required this.onView,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.notifications_active_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(locService.t('auth.unreadNotificationsTitle')),
),
],
),
content: Text(locService.t('auth.unreadNotificationsMessage')),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
onDismiss();
},
child: Text(locService.t('auth.reauthLater')),
),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
onView();
},
icon: const Icon(Icons.arrow_forward, size: 18),
label: Text(locService.t('auth.unreadNotificationsView')),
),
],
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.11.0+61 # The thing after the + is the version code, incremented with each release
version: 2.11.1+62 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
@@ -0,0 +1,73 @@
import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/models/osm_node.dart';
import 'package:deflockapp/services/edit_node_change_detector.dart';
import 'package:deflockapp/state/session_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
void main() {
group('EditNodeChangeDetector direction serialization', () {
test(
'detects changing a range direction to a single direction with the same center',
() {
final session = _sessionFor(
originalDirection: '55-125',
profileFov: null,
);
expect(
EditNodeChangeDetector.hasActualChanges(session),
isTrue,
reason: 'direction=55-125 serializes differently from direction=90',
);
},
);
test('treats an unchanged range direction as unchanged', () {
final session = _sessionFor(originalDirection: '55-125', profileFov: 70);
expect(EditNodeChangeDetector.hasActualChanges(session), isFalse);
});
test('treats an unchanged single direction as unchanged', () {
final session = _sessionFor(originalDirection: '90', profileFov: null);
expect(EditNodeChangeDetector.hasActualChanges(session), isFalse);
});
test('treats an unchanged directionless node as unchanged', () {
final session = _sessionFor(originalDirection: null, profileFov: null)
..directions = <double>[]
..currentDirectionIndex = -1;
expect(EditNodeChangeDetector.hasActualChanges(session), isFalse);
});
});
}
EditNodeSession _sessionFor({
required String? originalDirection,
required double? profileFov,
}) {
const coord = LatLng(37.7749, -122.4194);
final node = OsmNode(
id: 157,
coord: coord,
tags: {'man_made': 'surveillance', 'direction': ?originalDirection},
);
final profile = NodeProfile(
id: 'existing-tags-157',
name: 'Existing tags',
tags: const {},
fov: profileFov,
);
return EditNodeSession(
originalNode: node,
originalHadDirections: originalDirection != null,
profile: profile,
initialDirection: 90,
target: coord,
additionalExistingTags: const {'man_made': 'surveillance'},
);
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/services/geo_bounds.dart';
/// Real-world distance from a bounds edge back to its center, so the box can be
/// checked in meters rather than in degrees.
double _metersBetween(LatLng a, LatLng b) => Geolocator.distanceBetween(
a.latitude,
a.longitude,
b.latitude,
b.longitude,
);
void main() {
group('boundsAround', () {
test('contains the center', () {
const center = LatLng(38.9, -77.0);
final bounds = boundsAround(center, 200);
expect(bounds.contains(center), isTrue);
});
test('reaches at least the requested radius north/south and east/west', () {
const center = LatLng(38.9, -77.0);
const radius = 500;
final bounds = boundsAround(center, radius);
final north = _metersBetween(center, LatLng(bounds.north, center.longitude));
final south = _metersBetween(center, LatLng(bounds.south, center.longitude));
final east = _metersBetween(center, LatLng(center.latitude, bounds.east));
final west = _metersBetween(center, LatLng(center.latitude, bounds.west));
for (final reach in [north, south, east, west]) {
// At least the radius, and not wildly oversized (the box is a square,
// so its corners legitimately overshoot, but its edges should not).
expect(reach, greaterThanOrEqualTo(radius * 0.98));
expect(reach, lessThan(radius * 1.2));
}
});
test('widens longitude at high latitude to keep the real radius', () {
const radius = 1000;
final equator = boundsAround(const LatLng(0, 0), radius);
final far = boundsAround(const LatLng(64.0, 0), radius);
final equatorLngSpan = equator.east - equator.west;
final farLngSpan = far.east - far.west;
// cos(64°) ≈ 0.438, so the degree span must be roughly 2.3x wider.
expect(farLngSpan, greaterThan(equatorLngSpan * 2));
// ...and that wider span should still be ~1000m on the ground.
final reach = _metersBetween(
const LatLng(64.0, 0),
LatLng(64.0, far.east),
);
expect(reach, greaterThanOrEqualTo(radius * 0.98));
expect(reach, lessThan(radius * 1.2));
});
test('does not blow past the poles or the antimeridian', () {
for (final center in [
const LatLng(89.99, 179.99),
const LatLng(-89.99, -179.99),
]) {
final bounds = boundsAround(center, 100000);
expect(bounds.north, lessThanOrEqualTo(90.0));
expect(bounds.south, greaterThanOrEqualTo(-90.0));
expect(bounds.east, lessThanOrEqualTo(180.0));
expect(bounds.west, greaterThanOrEqualTo(-180.0));
}
});
test('a zero radius still yields usable bounds containing the center', () {
const center = LatLng(38.9, -77.0);
final bounds = boundsAround(center, 0);
expect(bounds.contains(center), isTrue);
});
test('grows with the alert distance', () {
const center = LatLng(38.9, -77.0);
final near = boundsAround(center, 100);
final far = boundsAround(center, 1600);
expect(far.north - far.south, greaterThan(near.north - near.south));
});
});
}
@@ -0,0 +1,65 @@
import 'package:deflockapp/models/osm_node.dart';
import 'package:deflockapp/services/node_spatial_cache.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
void main() {
group('NodeSpatialCache constrained flag', () {
setUp(() {
// NodeSpatialCache is a singleton - start each test from a clean slate.
NodeSpatialCache().clear();
});
test('stays constrained after an update that omits the flag', () {
final cache = NodeSpatialCache();
const nodeId = 123;
cache.addOrUpdateNodes([
OsmNode(
id: nodeId,
coord: const LatLng(0, 0),
tags: const {},
isConstrained: true,
),
]);
// Simulate what happens after an edit upload succeeds: a brand-new
// OsmNode is built from upload data, which has no way/relation
// information and so defaults isConstrained to false.
cache.addOrUpdateNodes([
OsmNode(
id: nodeId,
coord: const LatLng(1, 1),
tags: const {'amenity': 'surveillance'},
),
]);
final updated = cache.getNodeById(nodeId);
expect(updated, isNotNull);
expect(updated!.isConstrained, isTrue);
// Non-constraint fields should still be updated as normal.
expect(updated.coord, const LatLng(1, 1));
expect(updated.tags['amenity'], 'surveillance');
});
test('becomes constrained if a later update says it is', () {
final cache = NodeSpatialCache();
const nodeId = 456;
cache.addOrUpdateNodes([
OsmNode(id: nodeId, coord: const LatLng(0, 0), tags: const {}),
]);
cache.addOrUpdateNodes([
OsmNode(
id: nodeId,
coord: const LatLng(0, 0),
tags: const {},
isConstrained: true,
),
]);
expect(cache.getNodeById(nodeId)!.isConstrained, isTrue);
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
/// Mirrors ProximityAlertService._notificationId. Kept in sync deliberately:
/// the real one is private, and this pins the contract the plugin enforces —
/// notification ids must fit in a signed 32-bit int.
int notificationId(int nodeId) => nodeId.abs() % 0x7FFFFFFF;
const int maxInt32 = 2147483647;
void main() {
group('notificationId', () {
test('folds real-world OSM node ids into 32-bit range', () {
// The id from the crash, plus ids around the current OSM high-water mark.
const ids = [
12741890927,
1,
2147483647,
2147483648,
9999999999,
13000000000,
];
for (final id in ids) {
final result = notificationId(id);
expect(result, greaterThanOrEqualTo(0), reason: 'id $id went negative');
expect(result, lessThanOrEqualTo(maxInt32), reason: 'id $id overflowed');
}
});
test('handles negative ids, which OSM uses for not-yet-uploaded nodes', () {
for (final id in [-1, -12741890927, -2147483648]) {
final result = notificationId(id);
expect(result, greaterThanOrEqualTo(0));
expect(result, lessThanOrEqualTo(maxInt32));
}
});
test('is deterministic, so a repeat alert replaces rather than stacks', () {
expect(notificationId(12741890927), notificationId(12741890927));
});
test('separates adjacent node ids', () {
final a = notificationId(12741890927);
final b = notificationId(12741890928);
expect(a, isNot(b));
});
});
}
+164
View File
@@ -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);
});
});
}
+118
View File
@@ -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);
});
});
}