Compare commits

..
Author SHA1 Message Date
Doug BorgandClaude Opus 4.6 a5eca8a6b5 Use explicit double literals for currentZoom in tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:34:00 -06:00
117 changed files with 1861 additions and 7828 deletions

No files matched your search

+4 -5
View File
@@ -27,7 +27,7 @@ jobs:
id: set-info
run: |
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
echo "✅ Pre-release - will build and attach assets, no store uploads"
@@ -187,7 +187,7 @@ jobs:
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Set this keychain as the default
security list-keychain -d user -s $KEYCHAIN_PATH
security default-keychain -s $KEYCHAIN_PATH
@@ -195,7 +195,7 @@ jobs:
# install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
# Also install using the profile's internal UUID for better compatibility
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
@@ -329,8 +329,7 @@ jobs:
--type ios \
--file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID \
--apple-id 6752760780
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
# Clean up sensitive files
rm -rf ~/private_keys
+4 -5
View File
@@ -38,14 +38,13 @@ ios/Flutter/Flutter.framework
ios/Pods/
ios/.generated/
ios/.idea/
ios/Runner.xcworkspace/xcuserdata/
ios/Runner.xcworkspace/project.xcworkspace/xcuserdata/
ios/Runner.xcworkspace/
macos/Pods/
macos/.generated/
macos/Flutter/ephemeral/
Pods/
Podfile
# CocoaPods – commit Podfile.lock if you need reproducible iOS builds
Podfile.lock
Pods/
# Generated icons and splash screens
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
@@ -104,4 +103,4 @@ build_keys.conf
linux/
macos/
web/
windows/
windows/
+11
View File
@@ -0,0 +1,11 @@
---
An alternative approach to addressing this issue could be adjusting the `optionsBuilder` logic to avoid returning any suggestions when the input text field is empty, rather than guarding `onFieldSubmitted`. For instance:
```dart
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) return <String>[];
return suggestions.where((s) => s.contains(textEditingValue.text));
}
```
This ensures that the `RawAutocomplete` widget doesn't offer any options to auto-select on submission when the field is cleared, potentially simplifying the implementation and avoiding the need for additional boolean flags (`guardOnSubmitted`). This pattern can be seen in some implementations "in the wild."
+15 -20
View File
@@ -53,7 +53,7 @@ AppState (Coordinator)
↕️
State Modules (AuthState, ProfileState, etc.)
↕️
Services (MapDataProvider, NodeDataManager, NodeSpatialCache, Uploader)
Services (MapDataProvider, NodeCache, Uploader)
↕️
External APIs (OSM, Overpass, Tile providers)
```
@@ -297,23 +297,26 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
**Smart error handling & splitting:**
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep. This is a deterministic result-set size limit, so shrinking the queried area is the correct fix.
- **Timeout errors**: Treated as a plain retryable network error — no splitting. Timeouts usually indicate server load or an expensive query, not "too much area", so firing off more sub-requests would only add load to an already-struggling server.
- **Rate limiting (HTTP 429)**: Extended backoff (30s), no splitting (would make it worse). Still surfaces the same "slow" status indicator as the node-limit-splitting case, so the user sees consistent feedback either way.
- **Surgical detection**: Only splits on the actual 50k node limit, not timeouts or other network issues.
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep
- **Timeout errors**: Also triggers splitting (dense areas with many profiles)
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
- **Surgical detection**: Only splits on actual limit errors, not network issues
**Query optimization & deduplication:**
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
- **Profile deduplication**: Automatically removes redundant profiles from queries using subsumption analysis
- **User-initiated detection**: Only reports loading status for user-facing operations
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
**Query optimization & deduplication (in `OverpassService._buildQuery`):**
Profiles are deduplicated before query generation via subsumption analysis:
**Profile subsumption optimization (v2.1.1+):**
To reduce Overpass query complexity, profiles are deduplicated before query generation:
- **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching (see `NodeProviderWithCache._matchesAnyProfile`), independent of which clauses were sent to Overpass
- **Safety fallback**: if dedup would remove every profile (e.g. all profiles have only empty tags), the original full list is used instead of sending an empty query
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching
**Why this approach:**
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 45s timeouts. Profile deduplication reduces query complexity by ~80% for default setups. Surgical error detection avoids unnecessary API load from network issues.
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Profile deduplication reduces query complexity by ~80% for default setups, while automatic splitting handles remaining edge cases. Surgical error detection avoids unnecessary API load from network issues.
### 6. Uploader Service Architecture (Refactored v1.5.3)
@@ -851,13 +854,6 @@ To run the app with working OSM authentication, register OAuth2 applications:
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
3. **OAuth2 Application Permissions**
* Uncheck "Confidential application?"
* Read user preferences (read_prefs)
* Modify the map (write_api)
* Read, update status and delete user messages (consume_messages)
4. **Redirect URIs**
* deflockapp://auth
For local builds, create `build_keys.conf` (gitignored):
```bash
@@ -926,8 +922,7 @@ lib/
├── services/ # Business logic
│ ├── map_data_provider.dart
│ ├── uploader.dart
│ ├── node_data_manager.dart
│ └── node_spatial_cache.dart
│ └── node_cache.dart
├── state/ # State management
│ ├── app_state.dart
│ ├── auth_state.dart
+1 -1
View File
@@ -20,7 +20,7 @@ android {
compileSdk = 36
// NDK only needed if you build native plugins; keep your pinned version
ndkVersion = "28.2.13676358"
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
-34
View File
@@ -10,26 +10,6 @@
<!-- 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"
@@ -50,19 +30,6 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<!-- Disable Flutter's implicit deep-link routing so that
app_links/DeepLinkService is the sole handler of incoming
deflockapp:// URIs. Without this, Flutter's engine also
tries to auto-convert the URI into a Navigator route
(e.g. "/add?p=...") which conflicts with the navigation
DeepLinkService performs, causing screens pushed by
DeepLinkService to be immediately buried by a duplicate
HomeScreen route. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<!-- Launcher intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -75,7 +42,6 @@
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="deflockapp" android:host="profiles"/>
<data android:scheme="deflockapp" android:host="node"/>
</intent-filter>
</activity>
+1 -65
View File
@@ -1,71 +1,7 @@
{
"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",
"• Fix node constrainment when part of a way/relation",
"• Show submission status in details sheet",
"• Fix profile import links not opening"
]
},
"2.10.5": {
"content": [
"• Make node tags selectable / copyable",
"• Add status indicator to details sheet for pending nodes",
"• Global toggle for offline features",
"• Auto-open details sheet for new add/edit/deletes"
]
},
"2.10.4": {
"content": [
"• Catch and correct a couple rare map bounds issues",
"• Prevent queueing a deletion without being logged in",
"• Require a reason for deletion, provide options in a dropdown",
"• Add option to hide zoom controls in advanced settings"
]
},
"2.10.3": {
"content": [
"• Data loading performance improvements",
"• Node marker when submitting now green for visibility"
]
},
"2.10.2": {
"content": [
"• Added tap and hold gesture on empty map area to add a node at that location",
"• Added ability to accept direct links to a specific node for callbacks from e.g. FlockHopper",
"• Fixed app hang during startup when suspected locations database needed updating - refresh now happens in background"
]
},
"2.10.1": {
"content": [
"• Operator profiles are now reorderable",
"• Support operator profile import via deflockapp:// links",
"• Makes operator profile UI consistent with device profiles"
]
},
"2.10.0": {
"content": [
"• Simplified profile FOVs; there is now only a checkbox for '360'"
]
},
"2.9.2": {
"content": [
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
"• Added 'Clear Caches' option to tile provider menus - easily free up storage space by clearing cached tiles for specific providers",
"• Enhanced node deletion workflow - users can now provide an informative reason when deleting surveillance devices",
"• Added 'Report issue with OSM base map' link in About screen help section - easily report issues with the underlying OpenStreetMap data"
]
},
"2.9.1": {
"content": [
"• When hitting node render limit, only render nodes closest to center of viewport.",
"• Moved to our own infrastructure for Overpass and routing services, with automatic fallback to public servers."
"• When hitting node render limit, only render nodes closest to center of viewport."
]
},
"2.9.0": {
+2
View File
@@ -20,5 +20,7 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+104 -24
View File
@@ -10,8 +10,9 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5B624583B69E961F78575427 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
80E9445C8798AC7271AAC8BA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 015ABBACED80649D4AFF850D /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -41,15 +42,20 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
015ABBACED80649D4AFF850D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
570CEB9EF97E9EE9B3436119 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7884B7AB945386E2395EFF39 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -57,6 +63,8 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F596A88B00A1B31C43A2B089 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -64,6 +72,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5B624583B69E961F78575427 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -71,13 +80,27 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
80E9445C8798AC7271AAC8BA /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2C653E01607E349213D63C91 /* Pods */ = {
isa = PBXGroup;
children = (
F596A88B00A1B31C43A2B089 /* Pods-Runner.debug.xcconfig */,
7884B7AB945386E2395EFF39 /* Pods-Runner.release.xcconfig */,
570CEB9EF97E9EE9B3436119 /* Pods-Runner.profile.xcconfig */,
8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */,
045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */,
731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -86,10 +109,18 @@
path = RunnerTests;
sourceTree = "<group>";
};
968347D6C1E8FCB1D43AE8AF /* Frameworks */ = {
isa = PBXGroup;
children = (
015ABBACED80649D4AFF850D /* Pods_Runner.framework */,
C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -105,6 +136,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
2C653E01607E349213D63C91 /* Pods */,
968347D6C1E8FCB1D43AE8AF /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -139,6 +172,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
4C9A553A911C8019A2A14C90 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
1789E739C02C24F676C90644 /* Frameworks */,
@@ -157,21 +191,20 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
EF132DD72459C72ECA32FCAD /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
1553428839027C2A5102CCB3 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -205,9 +238,6 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -240,6 +270,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
1553428839027C2A5102CCB3 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -256,6 +303,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
4C9A553A911C8019A2A14C90 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -271,6 +340,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
EF132DD72459C72ECA32FCAD /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -400,6 +491,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -417,6 +509,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -432,6 +525,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -641,20 +735,6 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -5,24 +5,6 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+2 -16
View File
@@ -1,27 +1,13 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ 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
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
+65 -107
View File
@@ -1,114 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Disable Flutter's implicit deep-link routing so that
app_links/DeepLinkService is the sole handler of incoming
deflockapp:// URIs. Without this, Flutter's engine also tries
to auto-convert the URI into a Navigator route (e.g.
"/add?p=...") which conflicts with the navigation
DeepLinkService performs, causing screens pushed by
DeepLinkService to be immediately buried by a duplicate
HomeScreen route. -->
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DeFlock</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>deflockapp</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>deflockapp</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>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>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DeFlock</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>deflockapp</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- OAuth2 redirect handler -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>deflockapp</string>
</array>
</dict>
</array>
<!-- (Optional) allow opening the system browser and returning -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- 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>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+15 -246
View File
@@ -2,7 +2,6 @@ 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';
@@ -21,14 +20,10 @@ 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';
@@ -51,7 +46,6 @@ 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;
@@ -70,15 +64,8 @@ class AppState extends ChangeNotifier {
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
Timer? _messageCheckTimer;
// Node ID to auto-focus (open the details sheet for) after a submit/edit/delete
// completes, when kAutoOpenNodeSheetAfterSubmit is enabled. Consumed (read once
// and cleared) by HomeScreen after picking it up.
int? _pendingFocusNodeId;
AppState() {
instance = this;
_accountBlockState = AccountBlockState();
_authState = AuthState();
_messagesState = MessagesState();
_navigationState = NavigationState();
@@ -91,7 +78,6 @@ class AppState extends ChangeNotifier {
_uploadQueueState = UploadQueueState();
// Set up state change listeners
_accountBlockState.addListener(_onStateChanged);
_authState.addListener(_onStateChanged);
_messagesState.addListener(_onStateChanged);
_navigationState.addListener(_onStateChanged);
@@ -170,11 +156,6 @@ class AppState extends ChangeNotifier {
int get maxNodes => _settingsState.maxNodes;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
bool get keepScreenAwake => _settingsState.keepScreenAwake;
bool get hideZoomControls => _settingsState.hideZoomControls;
bool get offlineFeaturesEnabled => _settingsState.offlineFeaturesEnabled;
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
@@ -184,34 +165,19 @@ 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;
TileType? get selectedTileType => _settingsState.selectedTileType;
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
// Upload queue state
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
/// True if any queue item currently has an open changeset that hasn't
/// been closed yet (creating changeset, uploading node, or closing
/// changeset). Used to gate enabling offline mode / pausing the upload
/// queue so in-flight submissions can finish first.
bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing);
/// 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;
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
@@ -260,17 +226,15 @@ 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 and active blocks on app launch if user is already logged in
// Check for messages 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
@@ -286,9 +250,6 @@ class AppState extends ChangeNotifier {
_isInitialized = true;
// Start background refresh of suspected locations if needed (non-blocking)
_suspectedLocationState.initBackgroundRefresh(offlineMode: _settingsState.offlineMode);
// Check for initial deep link after a small delay to let navigation settle
Future.delayed(const Duration(milliseconds: 500), () {
DeepLinkService().checkInitialLink();
@@ -315,135 +276,34 @@ class AppState extends ChangeNotifier {
}
// ---------- Auth Methods ----------
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
Future<void> login() async {
await _authState.login();
// Check for messages after successful login
if (isLoggedIn) {
checkMessages();
checkActiveBlock();
}
}
Future<void> logout() async {
await _authState.logout();
// Clear message and block state when logging out
// Clear message state when logging out
clearMessages();
_accountBlockState.clear();
}
Future<void> refreshAuthState() async {
await _authState.refreshAuthState();
}
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
Future<void> forceLogin() async {
await _authState.forceLogin();
// Check for messages after successful login
if (isLoggedIn) {
checkMessages();
checkActiveBlock();
}
}
/// 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;
Future<bool> validateToken() async {
return await _authState.validateToken();
}
// ---------- Messages Methods ----------
@@ -451,7 +311,6 @@ class AppState extends ChangeNotifier {
final accessToken = await _authState.getAccessToken();
await _messagesState.checkMessages(
accessToken: accessToken,
username: isLoggedIn ? username : null,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
);
@@ -460,37 +319,11 @@ 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 {
@@ -581,11 +414,6 @@ class AppState extends ChangeNotifier {
void deleteProfile(NodeProfile p) {
_profileState.deleteProfile(p);
}
/// Reload all profiles from storage (useful after migrations modify stored profiles)
Future<void> reloadProfiles() async {
await _profileState.reloadFromStorage();
}
// Callback when a profile is deleted - clear any stale session references
void _onProfileDeleted(NodeProfile deletedProfile) {
@@ -609,10 +437,6 @@ class AppState extends ChangeNotifier {
_operatorProfileState.deleteProfile(p);
}
void reorderOperatorProfiles(int oldIndex, int newIndex) {
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
}
// ---------- Session Methods ----------
void startAddSession() {
_sessionState.startAddSession(enabledProfiles);
@@ -744,44 +568,24 @@ class AppState extends ChangeNotifier {
void commitSession() {
final session = _sessionState.commitSession();
if (session != null) {
final upload = _uploadQueueState.addFromSession(session, uploadMode: uploadMode);
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
_startUploader();
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
_pendingFocusNodeId = upload.tempNodeId;
}
}
}
void commitEditSession() {
final session = _sessionState.commitEditSession();
if (session != null) {
final upload = _uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
_startUploader();
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
_pendingFocusNodeId = upload.tempNodeId;
}
}
}
void deleteNode(OsmNode node, {String? changesetComment}) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
void deleteNode(OsmNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
if (kAutoOpenNodeSheetAfterSubmit) {
_pendingFocusNodeId = node.id;
}
}
/// Consume (read once and clear) the node ID pending auto-focus, if any.
/// Used by HomeScreen to reopen the node details sheet right after a
/// submit/edit/delete completes, when [kAutoOpenNodeSheetAfterSubmit] is
/// enabled. Returns null if no focus is pending.
int? consumePendingFocusNodeId() {
final id = _pendingFocusNodeId;
_pendingFocusNodeId = null;
return id;
}
// ---------- Search Methods ----------
Future<void> search(String query) async {
await _searchState.search(query);
@@ -865,31 +669,6 @@ class AppState extends ChangeNotifier {
}
}
Future<void> setKeepScreenAwake(bool enabled) async {
await _settingsState.setKeepScreenAwake(enabled);
}
Future<void> setHideZoomControls(bool enabled) async {
await _settingsState.setHideZoomControls(enabled);
}
/// Set the master "offline features enabled" toggle. When disabling,
/// also forces offlineMode off (if it was on) and cancels any active
/// area downloads, so we never end up "in offline mode" with the
/// feature disabled. Does NOT delete existing offline area data — that
/// is a separate, explicit user choice handled by the UI.
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
await _settingsState.setOfflineFeaturesEnabled(enabled);
if (!enabled) {
if (offlineMode) {
await setOfflineMode(false);
}
await OfflineAreaService().cancelActiveDownloads();
}
}
Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled);
if (!enabled) {
@@ -913,12 +692,9 @@ 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,11 +717,6 @@ class AppState extends ChangeNotifier {
await _settingsState.deleteTileProvider(providerId);
}
/// Clear all tile caches for a specific provider
Future<void> clearTileProviderCaches(String providerId) async {
await _settingsState.clearTileProviderCaches(providerId);
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode);
@@ -1091,14 +862,12 @@ 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);
+13 -31
View File
@@ -49,16 +49,6 @@ 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
@@ -74,12 +64,8 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0;
// How long to keep the "Submission Complete" confirmation visible in the
// in-flight upload wait dialog (shown when the user enables offline mode or
// pauses the upload queue while a submission is actively in progress) before
// automatically dismissing and applying the requested setting change.
const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2);
// Navigation routing configuration
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
// Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
@@ -99,12 +85,6 @@ const bool kEnableNodeEdits = true; // Set to false to temporarily disable node
// Node extraction features - set to false to hide extract functionality for constrained nodes
const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
// Auto-open tags sheet after add/edit/delete, so the user can watch upload status update live.
const bool kAutoOpenNodeSheetAfterSubmit = false; // Set to true to enable auto-focus behavior
// Profile FOV features - set to false to restrict profiles to 360° FOV only
const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
return kEnableNavigationFeatures && !offlineMode;
@@ -113,16 +93,18 @@ bool enableNavigationFeatures({required bool offlineMode}) {
// Marker/node interaction
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kMapLongPressTimeout = Duration(milliseconds: 600); // Duration to trigger "add node here" on empty map area
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
// Pre-fetch area configuration
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
// Data refresh configuration
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
@@ -149,7 +131,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
// Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
@@ -157,7 +139,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
// Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
// Node display configuration
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
@@ -182,11 +164,11 @@ const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 23;
// Node icon configuration
const double kNodeIconDiameter = 17.0;
const double _kNodeRingThicknessBase = 2.6;
const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior
const double kNodeIconDiameter = 18.0;
const double _kNodeRingThicknessBase = 2.5;
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
const Color kNodeRingColorMock = Color(0xE044BB55); // Add node mock point - white
const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
+7 -70
View File
@@ -12,7 +12,6 @@
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"dismiss": "Verwerfen",
"ok": "OK",
"close": "Schließen",
"submit": "Senden",
@@ -75,20 +74,7 @@
"advancedSettings": "Erweiterte Einstellungen",
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
"proximityAlerts": "Näherungswarnungen",
"keepScreenAwake": "Bildschirm Aktiv Lassen",
"networkStatusIndicator": "Netzwerkstatus-Anzeige",
"hideZoomControls": "Zoom-Steuerung Ausblenden",
"enableOfflineFeatures": "Offline-Funktionen Aktivieren",
"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"
},
"hideZoomControls": {
"title": "Zoom-Steuerung ausblenden",
"subtitle": "Blendet die +/- Zoom-Schaltflächen und die Zoomstufen-Anzeige aus. Sie können weiterhin mit Fingergesten zoomen."
"networkStatusIndicator": "Netzwerkstatus-Anzeige"
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
@@ -100,13 +86,7 @@
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"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."
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
},
"node": {
"title": "Knoten #{}",
@@ -115,25 +95,7 @@
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteReasonLabel": "Grund für Löschung",
"deleteReasonSelectHint": "Auswählen...",
"deleteReasonDeviceRemoved": "Gerät entfernt",
"deleteReasonDuplicateNode": "Doppelter Knoten",
"deleteReasonMisidentifiedInfra": "Falsch identifizierte Infrastruktur",
"deleteReasonSpamVandalism": "Spam/Vandalismus",
"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...",
"uploadClosingChangeset": "Changeset wird geschlossen...",
"uploadError": "Upload fehlgeschlagen",
"uploadComplete": "Upload abgeschlossen",
"viewInQueue": "In Warteschlange anzeigen",
"deletedSuccessfully": "Dieser Knoten wurde erfolgreich von OpenStreetMap gelöscht."
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
},
"addNode": {
"profile": "Profil",
@@ -142,7 +104,6 @@
"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.",
@@ -157,7 +118,6 @@
"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.",
@@ -214,7 +174,6 @@
"tapToLogout": "Zum Abmelden antippen",
"requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen",
"loggedOut": "Abgemeldet",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"testConnection": "Verbindung Testen",
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
@@ -241,15 +200,7 @@
"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",
"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"
"reauthLater": "Später"
},
"queue": {
"title": "Upload-Warteschlange",
@@ -284,12 +235,7 @@
"closingChangeset": " (Changeset schließen...)",
"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."
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
},
"tileProviders": {
"title": "Kachel-Anbieter",
@@ -301,9 +247,6 @@
"addProvider": "Anbieter Hinzufügen",
"deleteProvider": "Anbieter Löschen",
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"clearCaches": "Caches Leeren",
"clearCachesConfirm": "Alle Kachel-Caches für \"{}\" löschen? Dies wird Speicherplatz freigeben, aber die Kacheln müssen beim Betrachten dieser Bereiche erneut heruntergeladen werden.",
"cachesCleared": "Caches für \"{}\" geleert",
"providerName": "Anbieter-Name",
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
"providerNameRequired": "Anbieter-Name ist erforderlich",
@@ -376,8 +319,6 @@
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
"fov360": "360° Sichtfeld",
"fov360Subtitle": "Kamera hat 360-Grad omnidirektionales Sichtfeld",
"submittable": "Übertragbar",
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
"osmTags": "OSM-Tags",
@@ -436,11 +377,7 @@
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
"startRefresh": "Aktualisierung starten",
"refreshStarted": "Aktualisierung gestartet!",
"refreshFailed": "Aktualisierung fehlgeschlagen: {}",
"disableConfirmTitle": "Vorhandene Offline-Bereichsdaten löschen?",
"disableConfirmMessage": "Sie haben bereits Offline-Bereichsdaten. Das Deaktivieren der Offline-Funktionen verhindert, dass die App diese verwendet. Möchten Sie diese Daten für den Fall behalten, dass Sie die Offline-Funktionen später wieder aktivieren, oder sie jetzt löschen, um Speicherplatz freizugeben?",
"keepData": "Daten Behalten",
"deleteData": "Daten Löschen"
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
},
"refineTagsSheet": {
"title": "Tags Verfeinern",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
"minimumDistance": "Mindestabstand zu echten Geräten",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {} vorhandener Überwachungsgeräte ausblenden",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
"updating": "Verdächtige Standorte werden aktualisiert",
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"dismiss": "Dismiss",
"ok": "OK",
"close": "Close",
"submit": "Submit",
@@ -112,20 +111,7 @@
"advancedSettings": "Advanced Settings",
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
"proximityAlerts": "Proximity Alerts",
"networkStatusIndicator": "Network Status Indicator",
"keepScreenAwake": "Keep Screen Awake",
"hideZoomControls": "Hide Zoom Controls",
"enableOfflineFeatures": "Enable Offline Features",
"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"
},
"hideZoomControls": {
"title": "Hide zoom controls",
"subtitle": "Hide the +/- zoom buttons and zoom level indicator. You can still zoom with pinch gestures."
"networkStatusIndicator": "Network Status Indicator"
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
@@ -137,13 +123,7 @@
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"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."
"rangeInfo": "Range: {}-{} {} (default: {})"
},
"node": {
"title": "Node #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Node edit queued for upload",
"deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
"deleteReasonLabel": "Reason for Deletion",
"deleteReasonSelectHint": "Select...",
"deleteReasonDeviceRemoved": "Device removed",
"deleteReasonDuplicateNode": "Duplicate node",
"deleteReasonMisidentifiedInfra": "Misidentified infra",
"deleteReasonSpamVandalism": "Spam/vandalism",
"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...",
"uploadClosingChangeset": "Closing changeset...",
"uploadError": "Upload failed",
"uploadComplete": "Upload complete",
"viewInQueue": "View in Queue",
"deletedSuccessfully": "This node was successfully deleted from OpenStreetMap."
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
},
"addNode": {
"profile": "Profile",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"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",
@@ -274,19 +233,11 @@
"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",
"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"
"reauthLater": "Later"
},
"queue": {
"title": "Upload Queue",
@@ -321,12 +272,7 @@
"closingChangeset": " (Closing changeset...)",
"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."
"pausedByUser": "Upload processing is manually paused."
},
"tileProviders": {
"title": "Tile Providers",
@@ -338,9 +284,6 @@
"addProvider": "Add Provider",
"deleteProvider": "Delete Provider",
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
"clearCaches": "Clear Caches",
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
"cachesCleared": "Caches cleared for \"{}\"",
"providerName": "Provider Name",
"providerNameHint": "e.g., Custom Maps Inc.",
"providerNameRequired": "Provider name is required",
@@ -413,8 +356,6 @@
"fovHint": "FOV in degrees (leave empty for default)",
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
"fovInvalid": "FOV must be between 1 and 360 degrees",
"fov360": "360 FOV",
"fov360Subtitle": "Camera has 360-degree omnidirectional field of view",
"submittable": "Submittable",
"submittableSubtitle": "Whether this profile can be used for camera submissions",
"osmTags": "OSM Tags",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Re-fetch node data for this area",
"startRefresh": "Start Refresh",
"refreshStarted": "Refresh started!",
"refreshFailed": "Refresh failed: {}",
"disableConfirmTitle": "Delete Existing Offline Area Data?",
"disableConfirmMessage": "You have existing offline area data. Disabling offline features will stop the app from using it. Would you like to keep this data on disk in case you re-enable offline features later, or delete it now to free up storage space?",
"keepData": "Keep Data",
"deleteData": "Delete Data"
"refreshFailed": "Refresh failed: {}"
},
"refineTagsSheet": {
"title": "Refine Tags",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
"minimumDistance": "Minimum Distance from Real Nodes",
"minimumDistanceSubtitle": "Hide suspected locations within {} of existing surveillance devices",
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
"updating": "Updating Suspected Locations",
"downloadingAndProcessing": "Downloading and processing data...",
"updateSuccess": "Suspected locations updated successfully",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Editar",
"delete": "Eliminar",
"cancel": "Cancelar",
"dismiss": "Descartar",
"ok": "Aceptar",
"close": "Cerrar",
"submit": "Enviar",
@@ -112,20 +111,7 @@
"advancedSettings": "Configuración Avanzada",
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
"proximityAlerts": "Alertas de Proximidad",
"keepScreenAwake": "Mantener la Pantalla Encendida",
"networkStatusIndicator": "Indicador de Estado de Red",
"hideZoomControls": "Ocultar Controles de Zoom",
"enableOfflineFeatures": "Activar Funciones sin Conexión",
"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"
},
"hideZoomControls": {
"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."
"networkStatusIndicator": "Indicador de Estado de Red"
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
@@ -137,13 +123,7 @@
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"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."
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
},
"node": {
"title": "Nodo #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Edición de nodo en cola para subir",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
"deleteReasonLabel": "Razón para Eliminación",
"deleteReasonSelectHint": "Seleccionar...",
"deleteReasonDeviceRemoved": "Dispositivo removido",
"deleteReasonDuplicateNode": "Nodo duplicado",
"deleteReasonMisidentifiedInfra": "Infraestructura mal identificada",
"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...",
"uploadClosingChangeset": "Cerrando changeset...",
"uploadError": "Error al subir",
"uploadComplete": "Subida completada",
"viewInQueue": "Ver en la Cola",
"deletedSuccessfully": "Este nodo fue eliminado con éxito de OpenStreetMap."
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
},
"addNode": {
"profile": "Perfil",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Toque para cerrar sesión",
"requiredToSubmit": "Requerido para enviar datos de cámaras",
"loggedOut": "Sesión cerrada",
"loginFailed": "Error al iniciar sesión. Inténtelo de nuevo.",
"testConnection": "Probar Conexión",
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Más Tarde"
},
"queue": {
"title": "Cola de Subida",
@@ -321,12 +272,7 @@
"closingChangeset": " (Cerrando changeset...)",
"processingPaused": "Procesamiento de Cola Pausado",
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
"pausedByUser": "El procesamiento de subida está pausado manualmente.",
"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."
"pausedByUser": "El procesamiento de subida está pausado manualmente."
},
"tileProviders": {
"title": "Proveedores de Tiles",
@@ -338,9 +284,6 @@
"addProvider": "Agregar Proveedor",
"deleteProvider": "Eliminar Proveedor",
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"clearCaches": "Limpiar Caché",
"clearCachesConfirm": "¿Limpiar todo el caché de mosaicos para \"{}\"? Esto liberará espacio de almacenamiento pero los mosaicos deberán descargarse nuevamente al ver esas áreas.",
"cachesCleared": "Caché limpiado para \"{}\"",
"providerName": "Nombre del Proveedor",
"providerNameHint": "ej., Mapas Personalizados Inc.",
"providerNameRequired": "El nombre del proveedor es requerido",
@@ -413,8 +356,6 @@
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
"fov360": "Campo de Visión 360°",
"fov360Subtitle": "La cámara tiene un campo de visión omnidireccional de 360 grados",
"submittable": "Envíable",
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
"osmTags": "Etiquetas OSM",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
"startRefresh": "Iniciar Actualización",
"refreshStarted": "¡Actualización iniciada!",
"refreshFailed": "Actualización falló: {}",
"disableConfirmTitle": "¿Eliminar Datos de Áreas sin Conexión Existentes?",
"disableConfirmMessage": "Ya tiene datos de áreas sin conexión existentes. Desactivar las funciones sin conexión evitará que la app los use. ¿Desea conservar estos datos en el dispositivo por si reactiva las funciones sin conexión más tarde, o eliminarlos ahora para liberar espacio de almacenamiento?",
"keepData": "Conservar Datos",
"deleteData": "Eliminar Datos"
"refreshFailed": "Actualización falló: {}"
},
"refineTagsSheet": {
"title": "Refinar Etiquetas",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
"minimumDistance": "Distancia Mínima de Nodos Reales",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {} de dispositivos de vigilancia existentes",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
"updating": "Actualizando Ubicaciones Sospechosas",
"downloadingAndProcessing": "Descargando y procesando datos...",
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Modifier",
"delete": "Supprimer",
"cancel": "Annuler",
"dismiss": "Ignorer",
"ok": "OK",
"close": "Fermer",
"submit": "Soumettre",
@@ -112,20 +111,7 @@
"advancedSettings": "Paramètres Avancés",
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
"proximityAlerts": "Alertes de Proximité",
"keepScreenAwake": "Maintenir l'écran Allumé",
"networkStatusIndicator": "Indicateur de Statut Réseau",
"hideZoomControls": "Masquer les Contrôles de Zoom",
"enableOfflineFeatures": "Activer les Fonctionnalités Hors Ligne",
"enableOfflineFeaturesTitle": "Activer les fonctionnalités hors ligne",
"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"
},
"hideZoomControls": {
"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."
"networkStatusIndicator": "Indicateur de Statut Réseau"
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
@@ -137,13 +123,7 @@
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"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é."
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
},
"node": {
"title": "Nœud #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
"deleteReasonLabel": "Raison de Suppression",
"deleteReasonSelectHint": "Sélectionner...",
"deleteReasonDeviceRemoved": "Appareil retiré",
"deleteReasonDuplicateNode": "Nœud dupliqué",
"deleteReasonMisidentifiedInfra": "Infrastructure mal identifiée",
"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...",
"uploadClosingChangeset": "Fermeture du changeset...",
"uploadError": "Échec du téléchargement",
"uploadComplete": "Téléchargement terminé",
"viewInQueue": "Voir dans la File d'Attente",
"deletedSuccessfully": "Ce nœud a été supprimé avec succès d'OpenStreetMap."
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
},
"addNode": {
"profile": "Profil",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Appuyer pour se déconnecter",
"requiredToSubmit": "Requis pour soumettre des données de caméras",
"loggedOut": "Déconnecté",
"loginFailed": "Échec de la connexion. Veuillez réessayer.",
"testConnection": "Tester Connexion",
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Plus Tard"
},
"queue": {
"title": "File de Téléchargement",
@@ -321,12 +272,7 @@
"closingChangeset": " (Fermeture du changeset...)",
"processingPaused": "Traitement de la File d'Attente Interrompu",
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
"pausedByUser": "Le traitement des téléversements est interrompu manuellement.",
"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."
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
},
"tileProviders": {
"title": "Fournisseurs de Tuiles",
@@ -338,9 +284,6 @@
"addProvider": "Ajouter Fournisseur",
"deleteProvider": "Supprimer Fournisseur",
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"clearCaches": "Vider les Caches",
"clearCachesConfirm": "Vider tous les caches de tuiles pour \"{}\"? Cela libérera de l'espace de stockage mais les tuiles devront être téléchargées à nouveau lors de la consultation de ces zones.",
"cachesCleared": "Caches vidés pour \"{}\"",
"providerName": "Nom du Fournisseur",
"providerNameHint": "ex., Cartes Personnalisées Inc.",
"providerNameRequired": "Le nom du fournisseur est requis",
@@ -413,8 +356,6 @@
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
"fov360": "Champ de Vision 360°",
"fov360Subtitle": "La caméra a un champ de vision omnidirectionnel de 360 degrés",
"submittable": "Soumissible",
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
"osmTags": "Balises OSM",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
"startRefresh": "Démarrer l'Actualisation",
"refreshStarted": "Actualisation démarrée !",
"refreshFailed": "Actualisation échouée : {}",
"disableConfirmTitle": "Supprimer les Données de Zones Hors Ligne Existantes ?",
"disableConfirmMessage": "Vous avez déjà des données de zones hors ligne. Désactiver les fonctionnalités hors ligne empêchera l'application de les utiliser. Souhaitez-vous conserver ces données sur l'appareil au cas où vous réactiveriez les fonctionnalités hors ligne plus tard, ou les supprimer maintenant pour libérer de l'espace de stockage ?",
"keepData": "Conserver les Données",
"deleteData": "Supprimer les Données"
"refreshFailed": "Actualisation échouée : {}"
},
"refineTagsSheet": {
"title": "Affiner les Étiquettes",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
"minimumDistance": "Distance Minimale des Nœuds Réels",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {} des dispositifs de surveillance existants",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants",
"updating": "Mise à Jour des Emplacements Suspects",
"downloadingAndProcessing": "Téléchargement et traitement des données...",
"updateSuccess": "Emplacements suspects mis à jour avec succès",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Modifica",
"delete": "Elimina",
"cancel": "Annulla",
"dismiss": "Ignora",
"ok": "OK",
"close": "Chiudi",
"submit": "Invia",
@@ -112,20 +111,7 @@
"advancedSettings": "Impostazioni Avanzate",
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
"proximityAlerts": "Avvisi di Prossimità",
"keepScreenAwake": "Mantieni lo Schermo Attivo",
"networkStatusIndicator": "Indicatore di Stato di Rete",
"hideZoomControls": "Nascondi Controlli Zoom",
"enableOfflineFeatures": "Abilita Funzionalità Offline",
"enableOfflineFeaturesTitle": "Abilita funzionalità offline",
"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"
},
"hideZoomControls": {
"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."
"networkStatusIndicator": "Indicatore di Stato di Rete"
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
@@ -137,13 +123,7 @@
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"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à."
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
},
"node": {
"title": "Nodo #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
"confirmDeleteTitle": "Elimina Nodo",
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
"deleteReasonLabel": "Motivo per Eliminazione",
"deleteReasonSelectHint": "Seleziona...",
"deleteReasonDeviceRemoved": "Dispositivo rimosso",
"deleteReasonDuplicateNode": "Nodo duplicato",
"deleteReasonMisidentifiedInfra": "Infrastruttura identificata erroneamente",
"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...",
"uploadClosingChangeset": "Chiusura changeset...",
"uploadError": "Caricamento fallito",
"uploadComplete": "Caricamento completato",
"viewInQueue": "Visualizza in Coda",
"deletedSuccessfully": "Questo nodo è stato eliminato con successo da OpenStreetMap."
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
},
"addNode": {
"profile": "Profilo",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Tocca per disconnetterti",
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
"loggedOut": "Disconnesso",
"loginFailed": "Accesso non riuscito. Riprova.",
"testConnection": "Testa Connessione",
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Più Tardi"
},
"queue": {
"title": "Coda di Upload",
@@ -321,12 +272,7 @@
"closingChangeset": " (Chiusura changeset...)",
"processingPaused": "Elaborazione Coda Sospesa",
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.",
"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."
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
},
"tileProviders": {
"title": "Fornitori di Tile",
@@ -338,9 +284,6 @@
"addProvider": "Aggiungi Fornitore",
"deleteProvider": "Elimina Fornitore",
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"clearCaches": "Svuota Cache",
"clearCachesConfirm": "Svuotare tutte le cache delle tessere per \"{}\"? Questo libererà spazio di archiviazione ma le tessere dovranno essere scaricate nuovamente quando si visualizzano quelle aree.",
"cachesCleared": "Cache svuotate per \"{}\"",
"providerName": "Nome Fornitore",
"providerNameHint": "es., Mappe Personalizzate Inc.",
"providerNameRequired": "Il nome del fornitore è obbligatorio",
@@ -413,8 +356,6 @@
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
"fov360": "Campo Visivo 360°",
"fov360Subtitle": "La telecamera ha un campo visivo omnidirezionale di 360 gradi",
"submittable": "Inviabile",
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
"osmTags": "Tag OSM",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
"startRefresh": "Avvia Aggiornamento",
"refreshStarted": "Aggiornamento avviato!",
"refreshFailed": "Aggiornamento fallito: {}",
"disableConfirmTitle": "Eliminare i Dati delle Aree Offline Esistenti?",
"disableConfirmMessage": "Hai già dati di aree offline esistenti. Disabilitare le funzionalità offline impedirà all'app di usarli. Vuoi conservare questi dati sul dispositivo nel caso in cui riattivi le funzionalità offline in seguito, oppure eliminarli ora per liberare spazio di archiviazione?",
"keepData": "Conserva Dati",
"deleteData": "Elimina Dati"
"refreshFailed": "Aggiornamento fallito: {}"
},
"refineTagsSheet": {
"title": "Affina Tag",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
"minimumDistance": "Distanza Minima dai Nodi Reali",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {} dai dispositivi di sorveglianza esistenti",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
"updating": "Aggiornamento Posizioni Sospette",
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
"updateSuccess": "Posizioni sospette aggiornate con successo",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Bewerken",
"delete": "Verwijderen",
"cancel": "Annuleren",
"dismiss": "Negeren",
"ok": "OK",
"close": "Sluiten",
"submit": "Indienen",
@@ -112,20 +111,7 @@
"advancedSettings": "Geavanceerde Instellingen",
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
"proximityAlerts": "Nabijheids Waarschuwingen",
"keepScreenAwake": "Scherm Actief Houden",
"networkStatusIndicator": "Netwerk Status Indicator",
"hideZoomControls": "Zoomknoppen Verbergen",
"enableOfflineFeatures": "Offline Functies Inschakelen",
"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"
},
"hideZoomControls": {
"title": "Zoomknoppen verbergen",
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
"networkStatusIndicator": "Netwerk Status Indicator"
},
"proximityAlerts": {
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
@@ -137,13 +123,7 @@
"enableNotifications": "Schakel Meldingen In",
"checkingPermissions": "Toestemmingen controleren...",
"alertDistance": "Waarschuwingsafstand: ",
"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."
"rangeInfo": "Bereik: {}-{} {} (standaard: {})"
},
"node": {
"title": "Node #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteReasonLabel": "Reden voor Verwijdering",
"deleteReasonSelectHint": "Selecteer...",
"deleteReasonDeviceRemoved": "Apparaat verwijderd",
"deleteReasonDuplicateNode": "Dubbele node",
"deleteReasonMisidentifiedInfra": "Verkeerd geïdentificeerde infrastructuur",
"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...",
"uploadClosingChangeset": "Changeset sluiten...",
"uploadError": "Upload mislukt",
"uploadComplete": "Upload voltooid",
"viewInQueue": "Bekijk in Wachtrij",
"deletedSuccessfully": "Deze node is succesvol verwijderd van OpenStreetMap."
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."
},
"addNode": {
"profile": "Profiel",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Tik om uit te loggen",
"requiredToSubmit": "Vereist om camera gegevens in te dienen",
"loggedOut": "Uitgelogd",
"loginFailed": "Inloggen mislukt. Probeer het opnieuw.",
"testConnection": "Test Verbinding",
"testConnectionSubtitle": "Verifieer dat OSM credentials werken",
"connectionOK": "Verbinding OK - credentials zijn geldig",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Later"
},
"queue": {
"title": "Upload Wachtrij",
@@ -321,12 +272,7 @@
"closingChangeset": " (Changeset sluiten...)",
"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."
"pausedByUser": "Upload verwerking is handmatig gepauzeerd."
},
"tileProviders": {
"title": "Tile Providers",
@@ -338,9 +284,6 @@
"addProvider": "Voeg Provider Toe",
"deleteProvider": "Verwijder Provider",
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
"clearCaches": "Cache Wissen",
"clearCachesConfirm": "Alle tegelcaches wissen voor \"{}\"? Dit zal opslagruimte vrijmaken maar tegels moeten opnieuw worden gedownload bij het bekijken van die gebieden.",
"cachesCleared": "Caches gewist voor \"{}\"",
"providerName": "Provider Naam",
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
"providerNameRequired": "Provider naam is vereist",
@@ -413,8 +356,6 @@
"fovHint": "FOV in graden (laat leeg voor standaard)",
"fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat",
"fovInvalid": "FOV moet tussen 1 en 360 graden zijn",
"fov360": "360° Gezichtsveld",
"fov360Subtitle": "Camera heeft een 360-graden omnidirectioneel gezichtsveld",
"submittable": "Indienbaar",
"submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen",
"osmTags": "OSM Tags",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
"startRefresh": "Start Verversen",
"refreshStarted": "Verversen gestart!",
"refreshFailed": "Verversen mislukt: {}",
"disableConfirmTitle": "Bestaande Offline Gebiedsgegevens Verwijderen?",
"disableConfirmMessage": "U heeft al bestaande offline gebiedsgegevens. Het uitschakelen van offline functies zal voorkomen dat de app deze gebruikt. Wilt u deze gegevens op het apparaat bewaren voor het geval u offline functies later weer inschakelt, of ze nu verwijderen om opslagruimte vrij te maken?",
"keepData": "Gegevens Behouden",
"deleteData": "Gegevens Verwijderen"
"refreshFailed": "Verversen mislukt: {}"
},
"refineTagsSheet": {
"title": "Verfijn Tags",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
"minimumDistance": "Minimum Afstand van Echte Nodes",
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten",
"updating": "Verdachte Locaties Bijwerken",
"downloadingAndProcessing": "Data downloaden en verwerken...",
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Edytuj",
"delete": "Usuń",
"cancel": "Anuluj",
"dismiss": "Odrzuć",
"ok": "OK",
"close": "Zamknij",
"submit": "Zgłoś",
@@ -112,20 +111,7 @@
"advancedSettings": "Ustawienia Zaawansowane",
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
"proximityAlerts": "Alerty Bliskości",
"keepScreenAwake": "Nie Wygaszaj Ekranu",
"networkStatusIndicator": "Wskaźnik Stanu Sieci",
"hideZoomControls": "Ukryj Kontrolki Zoomu",
"enableOfflineFeatures": "Włącz Funkcje Offline",
"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"
},
"hideZoomControls": {
"title": "Ukryj kontrolki zoomu",
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
"networkStatusIndicator": "Wskaźnik Stanu Sieci"
},
"proximityAlerts": {
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
@@ -137,13 +123,7 @@
"enableNotifications": "Włącz Powiadomienia",
"checkingPermissions": "Sprawdzanie uprawnień...",
"alertDistance": "Odległość alertu: ",
"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."
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})"
},
"node": {
"title": "Węzeł #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
"confirmDeleteTitle": "Usuń Węzeł",
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
"deleteReasonLabel": "Powód Usunięcia",
"deleteReasonSelectHint": "Wybierz...",
"deleteReasonDeviceRemoved": "Urządzenie usunięte",
"deleteReasonDuplicateNode": "Zduplikowany węzeł",
"deleteReasonMisidentifiedInfra": "Błędnie zidentyfikowana infrastruktura",
"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...",
"uploadClosingChangeset": "Zamykanie zestawu zmian...",
"uploadError": "Przesyłanie nie powiodło się",
"uploadComplete": "Przesyłanie zakończone",
"viewInQueue": "Zobacz w Kolejce",
"deletedSuccessfully": "Ten węzeł został pomyślnie usunięty z OpenStreetMap."
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć."
},
"addNode": {
"profile": "Profil",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Stuknij aby się wylogować",
"requiredToSubmit": "Wymagane do zgłaszania danych kamer",
"loggedOut": "Wylogowany",
"loginFailed": "Logowanie nie powiodło się. Spróbuj ponownie.",
"testConnection": "Testuj Połączenie",
"testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają",
"connectionOK": "Połączenie OK - dane logowania są ważne",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Później"
},
"queue": {
"title": "Kolejka Przesyłania",
@@ -321,12 +272,7 @@
"closingChangeset": " (Zamykanie zestawu zmian...)",
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.",
"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."
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane."
},
"tileProviders": {
"title": "Dostawcy Kafelków",
@@ -338,9 +284,6 @@
"addProvider": "Dodaj Dostawcę",
"deleteProvider": "Usuń Dostawcę",
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
"clearCaches": "Wyczyść Cache",
"clearCachesConfirm": "Wyczyścić wszystkie cache kafelków dla \"{}\"? To zwolni miejsce na dysku, ale kafelki będą musiały zostać ponownie pobrane podczas przeglądania tych obszarów.",
"cachesCleared": "Cache wyczyszczone dla \"{}\"",
"providerName": "Nazwa Dostawcy",
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
"providerNameRequired": "Nazwa dostawcy jest wymagana",
@@ -413,8 +356,6 @@
"fovHint": "FOV w stopniach (zostaw puste dla domyślnego)",
"fovSubtitle": "Pole widzenia kamery - używane dla szerokości stożka i formatu zgłaszania zasięgu",
"fovInvalid": "FOV musi być między 1 a 360 stopniami",
"fov360": "Pole Widzenia 360°",
"fov360Subtitle": "Kamera ma wielokierunkowe pole widzenia o zasięgu 360 stopni",
"submittable": "Możliwy do Zgłoszenia",
"submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer",
"osmTags": "Tagi OSM",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
"startRefresh": "Rozpocznij Odświeżanie",
"refreshStarted": "Odświeżanie rozpoczęte!",
"refreshFailed": "Odświeżanie nie powiodło się: {}",
"disableConfirmTitle": "Usunąć Istniejące Dane Obszarów Offline?",
"disableConfirmMessage": "Masz już istniejące dane obszarów offline. Wyłączenie funkcji offline uniemożliwi aplikacji ich używanie. Czy chcesz zachować te dane na urządzeniu na wypadek ponownego włączenia funkcji offline później, czy usunąć je teraz, aby zwolnić miejsce na dysku?",
"keepData": "Zachowaj Dane",
"deleteData": "Usuń Dane"
"refreshFailed": "Odświeżanie nie powiodło się: {}"
},
"refineTagsSheet": {
"title": "Doprecyzuj Tagi",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru",
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Editar",
"delete": "Excluir",
"cancel": "Cancelar",
"dismiss": "Dispensar",
"ok": "OK",
"close": "Fechar",
"submit": "Enviar",
@@ -112,20 +111,7 @@
"advancedSettings": "Configurações Avançadas",
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
"proximityAlerts": "Alertas de Proximidade",
"keepScreenAwake": "Manter Ecrã Ativa",
"networkStatusIndicator": "Indicador de Status de Rede",
"hideZoomControls": "Ocultar Controlos de Zoom",
"enableOfflineFeatures": "Ativar Funcionalidades Offline",
"enableOfflineFeaturesTitle": "Ativar funcionalidades offline",
"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"
},
"hideZoomControls": {
"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."
"networkStatusIndicator": "Indicador de Status de Rede"
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
@@ -137,13 +123,7 @@
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"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."
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
},
"node": {
"title": "Nó #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Edição de nó na fila para envio",
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
"confirmDeleteTitle": "Excluir Nó",
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
"deleteReasonLabel": "Motivo para Exclusão",
"deleteReasonSelectHint": "Selecionar...",
"deleteReasonDeviceRemoved": "Dispositivo removido",
"deleteReasonDuplicateNode": "Nó duplicado",
"deleteReasonMisidentifiedInfra": "Infraestrutura mal identificada",
"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...",
"uploadClosingChangeset": "Fechando changeset...",
"uploadError": "Falha no envio",
"uploadComplete": "Envio concluído",
"viewInQueue": "Ver na Fila",
"deletedSuccessfully": "Este nó foi excluído com sucesso do OpenStreetMap."
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
},
"addNode": {
"profile": "Perfil",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Toque para sair",
"requiredToSubmit": "Necessário para enviar dados de câmeras",
"loggedOut": "Deslogado",
"loginFailed": "Falha no login. Tente novamente.",
"testConnection": "Testar Conexão",
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Mais Tarde"
},
"queue": {
"title": "Fila de Upload",
@@ -321,12 +272,7 @@
"closingChangeset": " (Fechando changeset...)",
"processingPaused": "Processamento da Fila Pausado",
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
"pausedByUser": "O processamento de upload está pausado manualmente.",
"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."
"pausedByUser": "O processamento de upload está pausado manualmente."
},
"tileProviders": {
"title": "Provedores de Tiles",
@@ -338,9 +284,6 @@
"addProvider": "Adicionar Provedor",
"deleteProvider": "Excluir Provedor",
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"clearCaches": "Limpar Caches",
"clearCachesConfirm": "Limpar todos os caches de mapas para \"{}\"? Isso liberará espaço de armazenamento, mas os mapas precisarão ser baixados novamente ao visualizar essas áreas.",
"cachesCleared": "Caches limpos para \"{}\"",
"providerName": "Nome do Provedor",
"providerNameHint": "ex., Mapas Personalizados Inc.",
"providerNameRequired": "Nome do provedor é obrigatório",
@@ -413,8 +356,6 @@
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
"fov360": "Campo de Visão 360°",
"fov360Subtitle": "A câmera tem um campo de visão omnidirecional de 360 graus",
"submittable": "Enviável",
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
"osmTags": "Tags OSM",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
"startRefresh": "Iniciar Atualização",
"refreshStarted": "Atualização iniciada!",
"refreshFailed": "Atualização falhou: {}",
"disableConfirmTitle": "Eliminar Dados de Áreas Offline Existentes?",
"disableConfirmMessage": "Já tem dados de áreas offline existentes. Desativar as funcionalidades offline impedirá que a app os utilize. Deseja manter estes dados no dispositivo caso reative as funcionalidades offline mais tarde, ou eliminá-los agora para libertar espaço de armazenamento?",
"keepData": "Manter Dados",
"deleteData": "Eliminar Dados"
"refreshFailed": "Atualização falhou: {}"
},
"refineTagsSheet": {
"title": "Refinar Tags",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
"minimumDistance": "Distância Mínima de Nós Reais",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {} de dispositivos de vigilância existentes",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes",
"updating": "Atualizando Localizações Suspeitas",
"downloadingAndProcessing": "Baixando e processando dados...",
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Düzenle",
"delete": "Sil",
"cancel": "İptal",
"dismiss": "Kapat",
"ok": "Tamam",
"close": "Kapat",
"submit": "Gönder",
@@ -112,20 +111,7 @@
"advancedSettings": "Gelişmiş Ayarlar",
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
"proximityAlerts": "Yakınlık Uyarıları",
"keepScreenAwake": "Ekranı Açık Tut",
"networkStatusIndicator": "Ağ Durumu Göstergesi",
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle",
"enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir",
"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"
},
"hideZoomControls": {
"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."
"networkStatusIndicator": "Ağ Durumu Göstergesi"
},
"proximityAlerts": {
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
@@ -137,13 +123,7 @@
"enableNotifications": "Bildirimleri Etkinleştir",
"checkingPermissions": "İzinler kontrol ediliyor...",
"alertDistance": "Uyarı mesafesi: ",
"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."
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})"
},
"node": {
"title": "Düğüm #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
"confirmDeleteTitle": "Düğümü Sil",
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteReasonLabel": "Silme Nedeni",
"deleteReasonSelectHint": "Seçin...",
"deleteReasonDeviceRemoved": "Cihaz kaldırıldı",
"deleteReasonDuplicateNode": "Yinelenen düğüm",
"deleteReasonMisidentifiedInfra": "Yanlış tanımlanmış altyapı",
"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...",
"uploadClosingChangeset": "Değişiklik seti kapatılıyor...",
"uploadError": "Yükleme başarısız oldu",
"uploadComplete": "Yükleme tamamlandı",
"viewInQueue": "Sırada Görüntüle",
"deletedSuccessfully": "Bu düğüm OpenStreetMap'ten başarıyla silindi."
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"addNode": {
"profile": "Profil",
@@ -179,7 +141,6 @@
"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.",
@@ -194,7 +155,6 @@
"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.",
@@ -251,7 +211,6 @@
"tapToLogout": "Çıkış yapmak için dokun",
"requiredToSubmit": "Kamera verisi göndermek için gerekli",
"loggedOut": "Çıkış yapıldı",
"loginFailed": "Giriş başarısız oldu. Lütfen tekrar deneyin.",
"testConnection": "Bağlantıyı Test Et",
"testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın",
"connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli",
@@ -278,15 +237,7 @@
"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",
"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"
"reauthLater": "Sonra"
},
"queue": {
"title": "Yükleme Kuyruğu",
@@ -321,12 +272,7 @@
"closingChangeset": " (Değişiklik seti kapatılıyor...)",
"processingPaused": "Kuyruk İşleme Duraklatıldı",
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.",
"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."
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı."
},
"tileProviders": {
"title": "Döşeme Sağlayıcıları",
@@ -338,9 +284,6 @@
"addProvider": "Sağlayıcı Ekle",
"deleteProvider": "Sağlayıcıyı Sil",
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
"clearCaches": "Önbellekleri Temizle",
"clearCachesConfirm": "\"{}\" için tüm döşeme önbelleklerini temizle? Bu depolama alanını boşaltacak ancak bu alanları görüntülerken döşemelerin tekrar indirilmesi gerekecek.",
"cachesCleared": "\"{}\" için önbellekler temizlendi",
"providerName": "Sağlayıcı Adı",
"providerNameHint": "örn., Özel Haritalar A.Ş.",
"providerNameRequired": "Sağlayıcı adı gerekli",
@@ -413,8 +356,6 @@
"fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)",
"fovSubtitle": "Kamera görüş alanı - koni genişliği ve aralık gönderim formatı için kullanılır",
"fovInvalid": "FOV 1 ile 360 derece arasında olmalıdır",
"fov360": "360° Görüş Alanı",
"fov360Subtitle": "Kamera 360 derece çok yönlü görüş alanına sahiptir",
"submittable": "Gönderilebilir",
"submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı",
"osmTags": "OSM Etiketleri",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
"startRefresh": "Yenilemeyi Başlat",
"refreshStarted": "Yenileme başladı!",
"refreshFailed": "Yenileme başarısız: {}",
"disableConfirmTitle": "Mevcut Çevrimdışı Alan Verileri Silinsin mi?",
"disableConfirmMessage": "Zaten mevcut çevrimdışı alan verileriniz var. Çevrimdışı özellikleri devre dışı bırakmak, uygulamanın bunları kullanmasını engelleyecektir. Daha sonra çevrimdışı özellikleri tekrar etkinleştirme ihtimaline karşı bu verileri cihazda saklamak mı istersiniz, yoksa depolama alanını boşaltmak için şimdi silmek mi istersiniz?",
"keepData": "Verileri Sakla",
"deleteData": "Verileri Sil"
"refreshFailed": "Yenileme başarısız: {}"
},
"refineTagsSheet": {
"title": "Etiketleri Düzenle",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {} yakınındaki şüpheli konumları gizle",
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}m yakınındaki şüpheli konumları gizle",
"updating": "Şüpheli Konumlar Güncelleniyor",
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "Редагувати",
"delete": "Видалити",
"cancel": "Скасувати",
"dismiss": "Відхилити",
"ok": "ОК",
"close": "Закрити",
"submit": "Подати",
@@ -112,20 +111,7 @@
"advancedSettings": "Розширені Налаштування",
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
"proximityAlerts": "Сповіщення Про Близькість",
"keepScreenAwake": "Тримати екран активним",
"networkStatusIndicator": "Індикатор Стану Мережі",
"hideZoomControls": "Приховати Елементи Керування Масштабом",
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
},
"keepScreenAwake": {
"title": "Тримати екран активним під час використання програми",
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
},
"hideZoomControls": {
"title": "Приховати елементи керування масштабом",
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
"networkStatusIndicator": "Індикатор Стану Мережі"
},
"proximityAlerts": {
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
@@ -137,13 +123,7 @@
"enableNotifications": "Увімкнути Сповіщення",
"checkingPermissions": "Перевірка дозволів...",
"alertDistance": "Відстань сповіщення: ",
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})",
"backgroundServiceTitle": "Сповіщення про наближення активні",
"backgroundServiceText": "Пошук пристроїв стеження поблизу",
"backgroundServiceChannel": "Моніторинг сповіщень про наближення",
"openSettings": "Відкрити налаштування",
"blockedTitle": "Сповіщення заблоковано",
"blockedMessage": "Сповіщення для DeFlock вимкнено. Увімкніть їх у налаштуваннях системи, щоб отримувати сповіщення про наближення."
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})"
},
"node": {
"title": "Вузол #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
"confirmDeleteTitle": "Видалити Вузол",
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
"deleteReasonLabel": "Причина Видалення",
"deleteReasonSelectHint": "Вибрати...",
"deleteReasonDeviceRemoved": "Пристрій видалено",
"deleteReasonDuplicateNode": "Дубльований вузол",
"deleteReasonMisidentifiedInfra": "Неправильно ідентифікована інфраструктура",
"deleteReasonSpamVandalism": "Спам/вандалізм",
"deleteReasonCustom": "Власний..",
"deleteReasonHint": "Опишіть причину...",
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
"accountBlockedCannotDelete": "Ваш обліковий запис OpenStreetMap має активне блокування і не може видаляти вузли.",
"uploadPending": "Завантаження очікує",
"uploadCreatingChangeset": "Створення набору змін...",
"uploadUploading": "Завантаження...",
"uploadClosingChangeset": "Закриття набору змін...",
"uploadError": "Помилка завантаження",
"uploadComplete": "Завантаження завершено",
"viewInQueue": "Переглянути в Черзі",
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
},
"addNode": {
"profile": "Профіль",
@@ -179,7 +141,6 @@
"direction": "Напрямок {}°",
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може подавати нові вузли.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.",
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
@@ -194,7 +155,6 @@
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може редагувати вузли.",
"sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.",
@@ -251,7 +211,6 @@
"tapToLogout": "Натисніть для виходу",
"requiredToSubmit": "Потрібно для подання даних камер",
"loggedOut": "Вихід здійснено",
"loginFailed": "Не вдалося увійти. Спробуйте ще раз.",
"testConnection": "Тестувати З'єднання",
"testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють",
"connectionOK": "З'єднання в порядку - облікові дані дійсні",
@@ -278,15 +237,7 @@
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
"reauthNow": "Зробити Зараз",
"newChangesetComments": "Новий Коментар до Набору Змін",
"newChangesetCommentsSubtitle": "Хтось прокоментував один або декілька ваших наборів змін",
"reauthLater": "Пізніше",
"unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення",
"unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.",
"unreadNotificationsView": "Переглянути Зараз",
"activeBlockTitle": "Ваш обліковий запис OSM має активне блокування",
"activeBlockMessage": "Ваш обліковий запис OpenStreetMap наразі має активне блокування і не може надсилати дані. Ви мали отримати повідомлення з додатковими деталями; перевірте свою поштову скриньку OSM, щоб дізнатися більше.",
"viewBlockDetails": "Переглянути Деталі Блокування"
"reauthLater": "Пізніше"
},
"queue": {
"title": "Черга Завантаження",
@@ -321,12 +272,7 @@
"closingChangeset": " (Закриття набору змін...)",
"processingPaused": "Обробка Черги Призупинена",
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
"pausedByUser": "Обробка завантаження призупинена вручну.",
"pausedDueToBlock": "Обробку черги завантаження призупинено, оскільки ваш обліковий запис OSM має активне блокування.",
"inFlightWaitTitle": "Завершення Подання",
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
"inFlightCompleteTitle": "Подання Завершено",
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
"pausedByUser": "Обробка завантаження призупинена вручну."
},
"tileProviders": {
"title": "Постачальники Плиток",
@@ -338,9 +284,6 @@
"addProvider": "Додати Постачальника",
"deleteProvider": "Видалити Постачальника",
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
"clearCaches": "Очистити Кеш",
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
"cachesCleared": "Кеш очищено для \"{}\"",
"providerName": "Назва Постачальника",
"providerNameHint": "напр., Кастомні Карти ТОВ",
"providerNameRequired": "Назва постачальника обов'язкова",
@@ -413,8 +356,6 @@
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
"fov360": "Поле Зору 360°",
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
"submittable": "Можна Подавати",
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
"osmTags": "OSM Теги",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
"startRefresh": "Почати Оновлення",
"refreshStarted": "Оновлення розпочато!",
"refreshFailed": "Оновлення не вдалося: {}",
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
"keepData": "Зберегти Дані",
"deleteData": "Видалити Дані"
"refreshFailed": "Оновлення не вдалося: {}"
},
"refineTagsSheet": {
"title": "Уточнити Теги",
@@ -582,7 +519,7 @@
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
"updating": "Оновлення Підозрілих Локацій",
"downloadingAndProcessing": "Завантаження та обробка даних...",
"updateSuccess": "Підозрілі локації успішно оновлено",
+7 -70
View File
@@ -49,7 +49,6 @@
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"dismiss": "关闭",
"ok": "确定",
"close": "关闭",
"submit": "提交",
@@ -112,20 +111,7 @@
"advancedSettings": "高级设置",
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
"proximityAlerts": "邻近警报",
"keepScreenAwake": "保持屏幕常亮",
"networkStatusIndicator": "网络状态指示器",
"hideZoomControls": "隐藏缩放控件",
"enableOfflineFeatures": "启用离线功能",
"enableOfflineFeaturesTitle": "启用离线功能",
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
},
"keepScreenAwake": {
"title": "使用应用时保持屏幕常亮",
"subtitle": "防止在使用应用期间屏幕自动熄灭"
},
"hideZoomControls": {
"title": "隐藏缩放控件",
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
"networkStatusIndicator": "网络状态指示器"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
@@ -137,13 +123,7 @@
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"rangeInfo": "范围:{}-{} {}(默认:{})",
"backgroundServiceTitle": "邻近提醒已启用",
"backgroundServiceText": "正在搜索附近的监控设备",
"backgroundServiceChannel": "邻近提醒监控",
"openSettings": "打开设置",
"blockedTitle": "通知已被阻止",
"blockedMessage": "DeFlock 的通知已关闭。请在系统设置中启用,以接收邻近提醒。"
"rangeInfo": "范围:{}-{} {}(默认:{})"
},
"node": {
"title": "节点 #{}",
@@ -152,25 +132,7 @@
"editQueuedForUpload": "节点编辑已排队上传",
"deleteQueuedForUpload": "节点删除已排队上传",
"confirmDeleteTitle": "删除节点",
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
"deleteReasonLabel": "删除原因",
"deleteReasonSelectHint": "请选择...",
"deleteReasonDeviceRemoved": "设备已移除",
"deleteReasonDuplicateNode": "重复节点",
"deleteReasonMisidentifiedInfra": "识别错误的基础设施",
"deleteReasonSpamVandalism": "垃圾信息/破坏",
"deleteReasonCustom": "自定义..",
"deleteReasonHint": "请描述原因...",
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
"accountBlockedCannotDelete": "您的 OpenStreetMap 账户有一个有效封禁,无法删除节点。",
"uploadPending": "上传待处理",
"uploadCreatingChangeset": "正在创建变更集...",
"uploadUploading": "正在上传...",
"uploadClosingChangeset": "正在关闭变更集...",
"uploadError": "上传失败",
"uploadComplete": "上传完成",
"viewInQueue": "在队列中查看",
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
},
"addNode": {
"profile": "配置文件",
@@ -179,7 +141,6 @@
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法提交新节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
@@ -194,7 +155,6 @@
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法编辑节点。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
@@ -251,7 +211,6 @@
"tapToLogout": "点击登出",
"requiredToSubmit": "提交摄像头数据所需",
"loggedOut": "已登出",
"loginFailed": "登录失败,请重试。",
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
@@ -278,15 +237,7 @@
"reauthExplanation": "您必须刷新身份验证才能通过应用接收 OSM 消息通知。",
"reauthBenefit": "这将在您在 OpenStreetMap 上有未读消息时启用通知点。",
"reauthNow": "现在执行",
"newChangesetComments": "新的变更集评论",
"newChangesetCommentsSubtitle": "有人评论了您的一个或多个变更集",
"reauthLater": "稍后",
"unreadNotificationsTitle": "您有未读通知",
"unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。",
"unreadNotificationsView": "立即查看",
"activeBlockTitle": "您的 OSM 账户有一个有效封禁",
"activeBlockMessage": "您的 OpenStreetMap 账户目前有有效封禁,无法提交内容。您应该已收到一条包含更多详情的消息;请查看您的 OSM 收件箱以了解更多信息。",
"viewBlockDetails": "查看封禁详情"
"reauthLater": "稍后"
},
"queue": {
"title": "上传队列",
@@ -321,12 +272,7 @@
"closingChangeset": " (关闭变更集...)",
"processingPaused": "队列处理已暂停",
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
"pausedByUser": "上传处理已手动暂停。",
"pausedDueToBlock": "由于您的 OSM 账户有一个有效封禁,上传处理已暂停。",
"inFlightWaitTitle": "正在完成提交",
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
"inFlightCompleteTitle": "提交完成",
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
"pausedByUser": "上传处理已手动暂停。"
},
"tileProviders": {
"title": "瓦片提供商",
@@ -338,9 +284,6 @@
"addProvider": "添加提供商",
"deleteProvider": "删除提供商",
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
"clearCaches": "清除缓存",
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
"cachesCleared": "已清除 \"{}\" 的缓存",
"providerName": "提供商名称",
"providerNameHint": "例如,自定义地图公司",
"providerNameRequired": "提供商名称为必填项",
@@ -413,8 +356,6 @@
"fovHint": "视场角度数(留空使用默认值)",
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
"fovInvalid": "视场角必须在1到360度之间",
"fov360": "360°视场角",
"fov360Subtitle": "摄像头具有360度全方向视场角",
"submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签",
@@ -473,11 +414,7 @@
"refreshNodesSubtitle": "重新获取此区域的节点数据",
"startRefresh": "开始刷新",
"refreshStarted": "刷新已开始!",
"refreshFailed": "刷新失败:{}",
"disableConfirmTitle": "删除现有的离线区域数据?",
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
"keepData": "保留数据",
"deleteData": "删除数据"
"refreshFailed": "刷新失败:{}"
},
"refineTagsSheet": {
"title": "细化标签",
@@ -582,7 +519,7 @@
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
"minimumDistance": "与真实节点的最小距离",
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
"updating": "正在更新疑似位置",
"downloadingAndProcessing": "正在下载和处理数据...",
"updateSuccess": "疑似位置更新成功",
+2 -48
View File
@@ -17,44 +17,12 @@ import 'services/localization_service.dart';
import 'services/provider_tile_cache_manager.dart';
import 'services/version_service.dart';
import 'services/deep_link_service.dart';
import 'services/deflock_tile_provider.dart' show TileLoadCancelledException, TileNotAvailableOfflineException;
/// Suppress console noise for expected, already-handled tile load errors.
///
/// [TileLoadCancelledException] (tile scrolled off screen) and
/// [TileNotAvailableOfflineException] (no cached data available) are thrown
/// deliberately by DeflockOfflineTileImageProvider and are already handled —
/// TileLayerManager.onTileLoadError() specifically skips retry logic for
/// both. Flutter's image pipeline reports them via
/// MultiFrameImageStreamCompleter with `silent: true`, but
/// FlutterError.dumpErrorToConsole has a debug-mode assert() that ignores
/// the silent flag and always prints to console anyway. This filters those
/// two specific, expected exception types back out while leaving all other
/// errors (including genuinely silent-but-unexpected ones) reported as
/// normal.
void _installTileErrorFilter() {
final defaultOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
final exception = details.exception;
if (details.silent &&
(exception is TileLoadCancelledException ||
exception is TileNotAvailableOfflineException)) {
return; // Already handled — don't spam the console.
}
if (defaultOnError != null) {
defaultOnError(details);
} else {
FlutterError.presentError(details);
}
};
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_installTileErrorFilter();
// Initialize version service
await VersionService().init();
@@ -124,22 +92,8 @@ class DeFlockApp extends StatelessWidget {
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
},
initialRoute: '/',
onUnknownRoute: (settings) {
// Fallback for any genuinely unknown named route (e.g. a stale/bad
// link). Deep links (deflockapp://...) are handled exclusively by
// DeepLinkService via the app_links package - Flutter's own
// implicit deep-link routing is disabled at the platform level
// (see AndroidManifest.xml's flutter_deeplinking_enabled and
// Info.plist's FlutterDeepLinkingEnabled) to avoid this handler
// double-navigating and burying screens pushed by DeepLinkService.
debugPrint('[Navigation] Unknown route intercepted: ${settings.name}');
return MaterialPageRoute(
settings: settings,
builder: (_) => const HomeScreen(),
);
},
);
);
}
}
-78
View File
@@ -3,12 +3,9 @@ import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_state.dart';
import 'services/offline_area_service.dart';
import 'services/profile_service.dart';
import 'services/suspected_location_cache.dart';
import 'widgets/nuclear_reset_dialog.dart';
import 'dev_config.dart';
/// One-time migrations that run when users upgrade to specific versions.
/// Each migration function is named after the version where it should run.
@@ -145,76 +142,6 @@ class OneTimeMigrations {
}
}
/// Clear non-360 FOV values from all profiles (v2.10.0)
static Future<void> migrate_2_10_0(AppState appState) async {
try {
// Only perform this migration if non-360 FOVs are disabled
if (kEnableNon360FOVs) {
debugPrint('[Migration] 2.10.0: Non-360 FOVs enabled, skipping FOV cleanup');
return;
}
// Load all profiles from storage
final profiles = await ProfileService().load();
bool anyProfileChanged = false;
int profilesCleared = 0;
// Clear non-360 FOV values from all profiles
final updatedProfiles = profiles.map((profile) {
if (profile.fov != null) {
// Use approximation to handle floating point precision issues
final fovValue = profile.fov!;
final is360 = (fovValue - 360.0).abs() < 0.01; // Within 0.01 degrees of 360
if (!is360) {
debugPrint('[Migration] 2.10.0: Clearing FOV $fovValue from profile: ${profile.name}');
anyProfileChanged = true;
profilesCleared++;
return profile.copyWith(fov: null);
}
}
return profile;
}).toList();
// Save updated profiles back to storage if any changes were made
if (anyProfileChanged) {
await ProfileService().save(updatedProfiles);
await appState.reloadProfiles();
debugPrint('[Migration] 2.10.0: Cleared FOV from $profilesCleared profiles');
}
debugPrint('[Migration] 2.10.0 completed: cleared non-360 FOV values from profiles');
} catch (e, stackTrace) {
debugPrint('[Migration] 2.10.0 ERROR: Failed to clear non-360 FOV values: $e');
debugPrint('[Migration] 2.10.0 ERROR: Stack trace: $stackTrace');
// Don't rethrow - this is non-critical, FOV restrictions will still apply going forward
}
}
/// Enable offline features for existing users who already have offline
/// area data (v2.10.5). New installs and existing users who never used
/// offline areas will keep the new "offline features enabled" setting at
/// its default of false.
static Future<void> migrate_2_10_5(AppState appState) async {
try {
final offlineAreaService = OfflineAreaService();
await offlineAreaService.ensureInitialized();
if (offlineAreaService.offlineAreas.isNotEmpty) {
await appState.setOfflineFeaturesEnabled(true);
debugPrint('[Migration] 2.10.5: Existing offline area data found - enabled offline features');
} else {
debugPrint('[Migration] 2.10.5: No existing offline area data - leaving offline features disabled');
}
debugPrint('[Migration] 2.10.5 completed: offline features migration done');
} catch (e) {
debugPrint('[Migration] 2.10.5 ERROR: Failed to migrate offline features setting: $e');
// Don't rethrow - this is non-critical, worst case is the user needs
// to manually re-enable the toggle to see their existing offline areas.
}
}
/// Get the migration function for a specific version
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
switch (version) {
@@ -230,16 +157,11 @@ class OneTimeMigrations {
return migrate_2_1_0;
case '2.7.3':
return migrate_2_7_3;
case '2.10.0':
return migrate_2_10_0;
case '2.10.5':
return migrate_2_10_5;
default:
return null;
}
}
/// Run migration for a specific version with nuclear reset on failure
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
try {
-274
View File
@@ -1,9 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
import '../services/nuclear_reset_service.dart';
import '../widgets/welcome_dialog.dart';
import '../widgets/submission_guide_dialog.dart';
@@ -79,20 +76,10 @@ class AboutScreen extends StatelessWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// About OpenStreetMap section
_buildAboutOSMSection(context),
const SizedBox(height: 24),
// Information dialogs section
_buildDialogButtons(context),
const SizedBox(height: 24),
_buildHelpLinks(context),
// Dev-only nuclear reset button at very bottom
if (kDebugMode || kEnableDevelopmentModes) ...[
const SizedBox(height: 32),
_buildDevNuclearResetButton(context),
const SizedBox(height: 16),
],
],
),
),
@@ -109,8 +96,6 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 8),
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
const SizedBox(height: 8),
_buildLinkText(context, 'Report issue with OSM base map', 'https://www.openstreetmap.org/fixthemap'),
const SizedBox(height: 8),
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
const SizedBox(height: 8),
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
@@ -136,39 +121,6 @@ class AboutScreen extends StatelessWidget {
);
}
Widget _buildAboutOSMSection(BuildContext context) {
final locService = LocalizationService.instance;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _launchUrl('https://openstreetmap.org', context),
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
);
}
Widget _buildDialogButtons(BuildContext context) {
final locService = LocalizationService.instance;
@@ -213,231 +165,5 @@ class AboutScreen extends StatelessWidget {
);
}
/// Dev-only nuclear reset button (only visible in debug mode)
Widget _buildDevNuclearResetButton(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.developer_mode,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
'These tools are only available in debug mode for development and troubleshooting.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _showNuclearResetConfirmation(context),
icon: const Icon(Icons.delete_forever, color: Colors.red),
label: const Text(
'Nuclear Reset (Clear All Data)',
style: TextStyle(color: Colors.red),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
),
),
),
],
),
),
);
}
/// Show confirmation dialog for nuclear reset
Future<void> _showNuclearResetConfirmation(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red),
SizedBox(width: 8),
Text('Nuclear Reset'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This will completely clear ALL app data:',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text('• All settings and preferences'),
Text('• OAuth login credentials'),
Text('• Custom profiles and operators'),
Text('• Upload queue and cached data'),
Text('• Downloaded offline areas'),
Text('• Everything else'),
SizedBox(height: 16),
Text(
'The app will behave exactly like a fresh install after this operation.',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
SizedBox(height: 12),
Text(
'This action cannot be undone.',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Nuclear Reset'),
),
],
),
);
if (confirmed == true && context.mounted) {
await _performNuclearReset(context);
}
}
/// Perform the nuclear reset operation
Future<void> _performNuclearReset(BuildContext context) async {
// Show progress dialog
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Clearing all app data...'),
],
),
),
);
try {
// Perform the nuclear reset
await NuclearResetService.clearEverything();
if (!context.mounted) return;
// Close progress dialog
Navigator.of(context).pop();
// Show completion dialog
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: Colors.green),
SizedBox(width: 8),
Text('Reset Complete'),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'All app data has been cleared successfully.',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text(
'Please close and restart the app to continue with a fresh state.',
style: TextStyle(fontWeight: FontWeight.w500),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
} catch (e) {
if (!context.mounted) return;
// Close progress dialog if it's still open
Navigator.of(context).pop();
// Show error dialog
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error, color: Colors.red),
SizedBox(width: 8),
Text('Reset Failed'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'An error occurred during the nuclear reset:',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
Text(
e.toString(),
style: const TextStyle(fontFamily: 'monospace'),
),
const SizedBox(height: 12),
const Text(
'Some data may have been partially cleared. You may want to manually clear app data through device settings.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
}
}
@@ -3,9 +3,6 @@ import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/suspected_locations_section.dart';
import 'settings/sections/tile_provider_section.dart';
import 'settings/sections/keep_screen_awake_section.dart';
import 'settings/sections/hide_zoom_controls_section.dart';
import '../services/localization_service.dart';
class AdvancedSettingsScreen extends StatelessWidget {
@@ -35,11 +32,6 @@ class AdvancedSettingsScreen extends StatelessWidget {
Divider(),
SuspectedLocationsSection(),
Divider(),
KeepScreenAwakeSection(),
Divider(),
HideZoomControlsSection(),
Divider(),
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
// Divider(),
TileProviderSection(),
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
@@ -126,50 +125,4 @@ class MapInteractionHandler {
// Clear suspected location selection
appState.clearSuspectedLocationSelection();
}
/// Handle long press on empty map area (add node here)
void handleMapLongPress({
required BuildContext context,
required LatLng tapLocation,
required AnimatedMapController mapController,
required VoidCallback onAddNode,
}) {
final appState = context.read<AppState>();
debugPrint('[MapInteractionHandler] Long press at: ${tapLocation.latitude}, ${tapLocation.longitude}');
// Check if we should handle this long press
// Don't handle if any sheet is open or if in search mode
if (appState.isInSearchMode || appState.showingOverview) {
debugPrint('[MapInteractionHandler] Long press ignored - in search/navigation mode');
return;
}
// Don't handle if any session is active (add/edit sheets are open)
if (appState.session != null || appState.editSession != null) {
debugPrint('[MapInteractionHandler] Long press ignored - node sheet already open');
return;
}
// Disable follow-me when user long presses
appState.setFollowMeMode(FollowMeMode.off);
// First, center the map on the tap location
try {
mapController.animateTo(
dest: tapLocation,
zoom: mapController.mapController.camera.zoom,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} catch (e) {
debugPrint('[MapInteractionHandler] Could not center map on long press location: $e');
}
// After a short delay to let the map center, open the add node sheet
// This matches the existing pattern used for node taps
Future.delayed(const Duration(milliseconds: 350), () {
onAddNode();
});
}
}
+73 -172
View File
@@ -1,15 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../widgets/map_view.dart';
import '../services/localization_service.dart';
import '../services/map_data_provider.dart';
import '../widgets/node_tag_sheet.dart';
import '../widgets/download_area_dialog.dart';
@@ -18,17 +15,14 @@ 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';
import '../services/changelog_service.dart';
import '../services/deep_link_service.dart';
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});
@@ -37,7 +31,7 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin, WidgetsBindingObserver {
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
@@ -55,41 +49,22 @@ 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();
_mapInteractionHandler = MapInteractionHandler();
DeepLinkService().onNodeDeepLink = _handleNodeDeepLink;
}
@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) {
@@ -192,7 +167,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin,
}
// Request location permission (this will show system dialog if needed)
final permission = await requestLocationPermissionOnce();
final permission = await Geolocator.requestPermission();
debugPrint('[HomeScreen] First launch location permission result: $permission');
}
} catch (e) {
@@ -266,21 +241,6 @@ 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,
@@ -326,40 +286,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin,
);
}
void _onMapLongPress(LatLng location) {
// Don't handle long press if tag sheet is open
if (_sheetCoordinator.tagSheetHeight > 0) {
debugPrint('[HomeScreen] Long press ignored - tag sheet is open');
return;
}
_mapInteractionHandler.handleMapLongPress(
context: context,
tapLocation: location,
mapController: _mapController,
onAddNode: _openAddNodeSheet,
);
}
void _handleNodeDeepLink(OsmNode node) {
try {
_mapController.animateTo(
dest: node.coord,
zoom: 16.0,
duration: const Duration(milliseconds: 600),
curve: Curves.easeOut,
);
} catch (e) {
debugPrint('[HomeScreen] Could not animate to deep link node: $e');
}
Future.delayed(const Duration(milliseconds: 700), () {
if (mounted) {
openNodeTagSheet(node);
}
});
}
void openNodeTagSheet(OsmNode node) {
// Handle the map interaction (centering and follow-me disable)
_mapInteractionHandler.handleNodeTap(
@@ -488,42 +414,9 @@ 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();
});
}
// Auto-focus a node's details sheet right after it was submitted/edited/deleted,
// behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback).
if (kAutoOpenNodeSheetAfterSubmit) {
final focusNodeId = appState.consumePendingFocusNodeId();
if (focusNodeId != null && !_sheetCoordinator.hasActiveNodeSheet) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final node = MapDataProvider().getNodeById(focusNodeId);
if (node != null) {
openNodeTagSheet(node);
}
});
}
}
// Pass the active sheet height directly to the map
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
@@ -561,11 +454,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin,
final appState = context.watch<AppState>();
return IconButton(
tooltip: LocalizationService.instance.settings,
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),
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,
),
),
),
],
),
onPressed: () => Navigator.pushNamed(context, '/settings'),
);
@@ -593,7 +498,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin,
// Re-render when location status changes (for follow-me button state)
setState(() {});
},
onMapLongPress: _onMapLongPress,
onUserGesture: () {
// Only clear selected node if tag sheet is not open
// This prevents nodes from losing their grey-out when map is moved while viewing tags
@@ -652,70 +556,67 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin,
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final showDownloadButton = appState.offlineFeaturesEnabled;
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
return Row(
children: [
Expanded(
// Take full width when the download button is hidden
flex: showDownloadButton ? 7 : 10,
child: ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
if (showDownloadButton) ...[
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: canDownload ? () {
// Check minimum zoom level before opening download dialog
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForOfflineDownload) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('download.areaTooBigMessage',
params: [kMinZoomForOfflineDownload.toString()])
),
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
return FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: canDownload ? () {
// Check minimum zoom level before opening download dialog
final currentZoom = _mapController.mapController.camera.zoom;
if (currentZoom < kMinZoomForOfflineDownload) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('download.areaTooBigMessage',
params: [kMinZoomForOfflineDownload.toString()])
),
);
return;
}
showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
);
} : null,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
return;
}
showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
);
} : null,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
],
],
);
},
);
},
),
),
],
),
),
),
);
+13 -38
View File
@@ -1,7 +1,4 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import 'settings/sections/enable_offline_features_section.dart';
import 'settings/sections/offline_mode_section.dart';
import 'settings/sections/offline_areas_section.dart';
import '../services/localization_service.dart';
@@ -19,42 +16,20 @@ class OfflineSettingsScreen extends StatelessWidget {
appBar: AppBar(
title: Text(locService.t('settings.offlineSettings')),
),
body: Consumer<AppState>(
builder: (context, appState, child) {
final offlineFeaturesEnabled = appState.offlineFeaturesEnabled;
return ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
const EnableOfflineFeaturesSection(),
const Divider(),
// Grey out and disable interaction with the rest of the
// offline settings when the master toggle is off, while
// keeping them visible/discoverable.
Opacity(
opacity: offlineFeaturesEnabled ? 1.0 : 0.4,
child: IgnorePointer(
ignoring: !offlineFeaturesEnabled,
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
),
),
),
],
);
},
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
OfflineModeSection(),
Divider(),
OfflineAreasSection(),
],
),
),
);
}
}
}
+2 -7
View File
@@ -155,13 +155,8 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
final tagMap = <String, String>{};
for (final e in _tags) {
final key = e.key.trim();
final value = e.value.trim();
// Skip if key is empty OR value is empty (no empty values for operator profiles)
if (key.isEmpty || value.isEmpty) continue;
tagMap[key] = value;
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
}
final newProfile = widget.profile.copyWith(
+76 -73
View File
@@ -17,12 +17,11 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
@override
void initState() {
super.initState();
// Check for messages and active blocks when screen loads
// Check for messages when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = context.read<AppState>();
if (appState.isLoggedIn) {
appState.checkMessages();
appState.checkAndPromptForActiveBlock(context);
}
});
}
@@ -75,51 +74,22 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin(context: context);
await appState.forceLogin();
// 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.
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
}
},
),
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.testConnection(context);
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -158,46 +128,38 @@ 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: Badge(
isLabelVisible: appState.hasUnreadMessages,
label: Text('${appState.unreadMessageCount ?? 0}'),
child: const Icon(Icons.message),
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,
),
),
),
],
),
title: Text(locService.t('auth.viewMessages')),
subtitle: Text(appState.hasUnreadMessages
@@ -233,9 +195,50 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
child: UploadModeSection(),
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
],
// Information Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final url = Uri.parse('https://openstreetmap.org');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
),
// Account deletion section - only show when logged in and not in simulate mode
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
const SizedBox(height: 16),
+25 -78
View File
@@ -6,7 +6,6 @@ import '../models/node_profile.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../widgets/nsi_tag_value_field.dart';
import '../dev_config.dart';
class ProfileEditor extends StatefulWidget {
const ProfileEditor({super.key, required this.profile});
@@ -23,7 +22,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
late bool _requiresDirection;
late bool _submittable;
late TextEditingController _fovCtrl;
late bool _is360Fov;
static const _defaultTags = [
MapEntry('man_made', 'surveillance'),
@@ -43,7 +41,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
_requiresDirection = widget.profile.requiresDirection;
_submittable = widget.profile.submittable;
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
_is360Fov = widget.profile.fov == 360.0;
if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults
@@ -97,68 +94,28 @@ class _ProfileEditorState extends State<ProfileEditor> {
),
const SizedBox(height: 16),
if (widget.profile.editable) ...[
// Direction and FOV configuration - show different UI based on dev config
if (kEnableNon360FOVs) ...[
// Old UI: direction required + optional custom FOV text field (development mode)
CheckboxListTile(
title: Text(locService.t('profileEditor.requiresDirection')),
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
value: _requiresDirection,
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
if (_requiresDirection) Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: TextField(
controller: _fovCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: locService.t('profileEditor.fov'),
hintText: locService.t('profileEditor.fovHint'),
helperText: locService.t('profileEditor.fovSubtitle'),
errorText: _validateFov(),
suffixText: '°',
),
onChanged: (value) => setState(() {}), // Trigger validation
CheckboxListTile(
title: Text(locService.t('profileEditor.requiresDirection')),
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
value: _requiresDirection,
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
controlAffinity: ListTileControlAffinity.leading,
),
if (_requiresDirection) Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: TextField(
controller: _fovCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: locService.t('profileEditor.fov'),
hintText: locService.t('profileEditor.fovHint'),
helperText: locService.t('profileEditor.fovSubtitle'),
errorText: _validateFov(),
suffixText: '°',
),
onChanged: (value) => setState(() {}), // Trigger validation
),
] else ...[
// New UI: mutually exclusive direction vs 360° FOV checkboxes (production mode)
CheckboxListTile(
title: Text(locService.t('profileEditor.requiresDirection')),
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
value: _requiresDirection,
onChanged: widget.profile.editable
? (value) {
setState(() {
_requiresDirection = value ?? false;
// Make mutually exclusive with 360° FOV
if (_requiresDirection) {
_is360Fov = false;
}
});
}
: null,
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: Text(locService.t('profileEditor.fov360')),
subtitle: Text(locService.t('profileEditor.fov360Subtitle')),
value: _is360Fov,
onChanged: widget.profile.editable
? (value) {
setState(() {
_is360Fov = value ?? false;
// Make mutually exclusive with direction requirement
if (_is360Fov) {
_requiresDirection = false;
}
});
}
: null,
controlAffinity: ListTileControlAffinity.leading,
),
],
),
CheckboxListTile(
title: Text(locService.t('profileEditor.submittable')),
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
@@ -242,9 +199,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
}
String? _validateFov() {
// Only validate when using text field mode
if (!kEnableNon360FOVs) return null;
final text = _fovCtrl.text.trim();
if (text.isEmpty) return null; // Optional field
@@ -265,21 +219,14 @@ class _ProfileEditorState extends State<ProfileEditor> {
return;
}
// Validate FOV if using text field mode
if (kEnableNon360FOVs && _validateFov() != null) {
// Validate FOV if provided
if (_validateFov() != null) {
return; // Don't save if FOV validation fails
}
// Parse FOV based on dev config mode
double? fov;
if (kEnableNon360FOVs) {
// Old mode: parse from text field
final fovText = _fovCtrl.text.trim();
fov = fovText.isEmpty ? null : double.tryParse(fovText);
} else {
// New mode: use checkbox state
fov = _is360Fov ? 360.0 : null;
}
// Parse FOV
final fovText = _fovCtrl.text.trim();
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
final tagMap = <String, String>{};
for (final e in _tags) {
@@ -40,12 +40,10 @@ class AuthSection extends StatelessWidget {
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin(context: context);
await appState.forceLogin();
// 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.
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
}
},
),
@@ -55,7 +53,7 @@ class AuthSection extends StatelessWidget {
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.testConnection(context);
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -1,101 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
/// Master toggle for all offline-area functionality (downloading, browsing,
/// and using cached tiles/nodes offline). When disabled, the rest of the
/// Offline Settings screen (offline mode + offline areas list) is greyed out
/// and the download button on the map screen is hidden. Functionally, the
/// app will behave as if no offline area data exists at all while this is
/// off, regardless of whether area files still exist on disk.
class EnableOfflineFeaturesSection extends StatelessWidget {
const EnableOfflineFeaturesSection({super.key});
Future<void> _handleToggle(BuildContext context, AppState appState, bool value) async {
if (value) {
// Enabling requires no confirmation.
await appState.setOfflineFeaturesEnabled(true);
return;
}
// Disabling: if offline area data already exists, ask whether to keep
// or delete it. Either choice proceeds to disable the toggle - there is
// no "cancel" path once this dialog is shown.
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
if (!context.mounted) return;
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
if (!hasExistingAreas) {
await appState.setOfflineFeaturesEnabled(false);
return;
}
final locService = LocalizationService.instance;
final shouldDelete = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(locService.t('offlineAreas.disableConfirmTitle')),
content: Text(locService.t('offlineAreas.disableConfirmMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locService.t('offlineAreas.keepData')),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text(locService.t('offlineAreas.deleteData')),
),
],
),
);
if (shouldDelete == true) {
for (final area in offlineService.offlineAreas.toList()) {
offlineService.deleteArea(area.id);
}
}
// Disable regardless of the user's data choice.
await appState.setOfflineFeaturesEnabled(false);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.enableOfflineFeatures'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(locService.t('settings.enableOfflineFeaturesTitle')),
subtitle: Text(
locService.t('settings.enableOfflineFeaturesSubtitle'),
style: const TextStyle(fontSize: 12),
),
value: appState.offlineFeaturesEnabled,
onChanged: (value) => _handleToggle(context, appState, value),
),
],
);
},
);
}
}
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class HideZoomControlsSection extends StatelessWidget {
const HideZoomControlsSection({super.key});
@override
Widget build(BuildContext context) {
// Standard Pattern: Use AnimatedBuilder to respond to language/localization changes
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Bold Section Header (matches Keep Screen Awake, Proximity, etc.)
Text(
locService.t('settings.hideZoomControls'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// The Functional Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero, // Aligns switch text with the bold header
title: Text(locService.t('hideZoomControls.title')),
subtitle: Text(
locService.t('hideZoomControls.subtitle'),
style: const TextStyle(fontSize: 12),
),
value: appState.hideZoomControls,
onChanged: (bool value) {
appState.setHideZoomControls(value);
},
),
],
);
},
);
}
}
@@ -1,46 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class KeepScreenAwakeSection extends StatelessWidget {
const KeepScreenAwakeSection({super.key});
@override
Widget build(BuildContext context) {
// Standard Pattern: Use AnimatedBuilder to respond to language/localization changes
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Bold Section Header (matches Max Nodes, Proximity, etc.)
Text(
locService.t('settings.keepScreenAwake'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// The Functional Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero, // Aligns switch text with the bold header
title: Text(locService.t('keepScreenAwake.title')),
subtitle: Text(
locService.t('keepScreenAwake.subtitle'),
style: const TextStyle(fontSize: 12),
),
value: appState.keepScreenAwake,
onChanged: (bool value) {
appState.setKeepScreenAwake(value);
},
),
],
);
},
);
}
}
@@ -38,7 +38,7 @@ class NodeProfilesSection extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: appState.profiles.length,
onReorderItem: (oldIndex, newIndex) {
onReorder: (oldIndex, newIndex) {
appState.reorderProfiles(oldIndex, newIndex);
},
itemBuilder: (context, index) {
@@ -3,8 +3,6 @@ import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/offline_area_service.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/in_flight_upload_wait_dialog.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
@@ -51,17 +49,10 @@ class OfflineModeSection extends StatelessWidget {
}
}
// Proceed with the change, respecting any in-flight submission that
// needs to finish before offline mode actually takes effect.
if (!context.mounted) return;
await applyQueueSettingChangeRespectingInFlightUploads(
context: context,
appState: appState,
applyChange: () => appState.setOfflineMode(value),
);
// Proceed with the change
await appState.setOfflineMode(value);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -54,64 +54,47 @@ class OperatorProfilesSection extends StatelessWidget {
),
)
else
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: appState.operatorProfiles.length,
onReorderItem: (oldIndex, newIndex) {
appState.reorderOperatorProfiles(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final p = appState.operatorProfiles[index];
return ListTile(
key: ValueKey(p.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(
Icons.drag_handle,
color: Colors.grey,
...appState.operatorProfiles.map(
(p) => ListTile(
title: Text(p.name),
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
),
title: Text(p.name),
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
],
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
],
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
);
},
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
),
],
);
@@ -18,8 +18,7 @@ class ProximityAlertsSection extends StatefulWidget {
State<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
}
class _ProximityAlertsSectionState extends State<ProximityAlertsSection>
with WidgetsBindingObserver {
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
late final TextEditingController _distanceController;
bool _notificationsEnabled = false;
bool _checkingPermissions = false;
@@ -27,9 +26,6 @@ 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(
@@ -61,59 +57,19 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection>
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().requestNotificationPermissions();
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();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_distanceController.dispose();
super.dispose();
}
@@ -104,9 +104,6 @@ class TileProviderSection extends StatelessWidget {
case 'edit':
_editProvider(context, provider);
break;
case 'clear_cache':
_clearProviderCaches(context, provider);
break;
case 'delete':
_deleteProvider(context, provider);
break;
@@ -123,16 +120,6 @@ class TileProviderSection extends StatelessWidget {
],
),
),
PopupMenuItem(
value: 'clear_cache',
child: Row(
children: [
const Icon(Icons.clear_all),
const SizedBox(width: 8),
Text(locService.t('tileProviders.clearCaches')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
@@ -167,37 +154,6 @@ class TileProviderSection extends StatelessWidget {
);
}
void _clearProviderCaches(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.clearCaches')),
content: Text(locService.t('tileProviders.clearCachesConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await context.read<AppState>().clearTileProviderCaches(provider.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('tileProviders.cachesCleared', params: [provider.name])),
),
);
}
},
child: Text(locService.t('tileProviders.clearCaches')),
),
],
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
+17 -5
View File
@@ -117,11 +117,23 @@ class SettingsScreen extends StatelessWidget {
final appState = context.watch<AppState>();
return ListTile(
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),
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,
),
),
),
],
),
title: Text(locService.t('auth.osmAccountTitle')),
subtitle: Text(locService.t('auth.osmAccountSubtitle')),
+5 -26
View File
@@ -3,8 +3,6 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/pending_upload.dart';
import '../services/localization_service.dart';
import '../widgets/in_flight_upload_wait_dialog.dart';
class UploadQueueScreen extends StatelessWidget {
const UploadQueueScreen({super.key});
@@ -86,22 +84,6 @@ class UploadQueueScreen extends StatelessWidget {
}
}
Future<void> _handlePauseQueueChange(BuildContext context, AppState appState, bool value) async {
// Only need to wait for in-flight uploads when turning pause ON.
// Resuming (turning it off) can happen immediately.
if (!value) {
await appState.setPauseQueueProcessing(value);
return;
}
await applyQueueSettingChangeRespectingInFlightUploads(
context: context,
appState: appState,
applyChange: () => appState.setPauseQueueProcessing(value),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -111,7 +93,7 @@ class UploadQueueScreen extends StatelessWidget {
final appState = context.watch<AppState>();
// Check if queue processing is paused
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing || appState.isQueuePausedDueToBlock;
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing;
return Scaffold(
appBar: AppBar(
@@ -151,11 +133,9 @@ class UploadQueueScreen extends StatelessWidget {
),
),
Text(
appState.isQueuePausedDueToBlock
? locService.t('queue.pausedDueToBlock')
: appState.offlineMode
? locService.t('queue.pausedDueToOffline')
: locService.t('queue.pausedByUser'),
appState.offlineMode
? locService.t('queue.pausedDueToOffline')
: locService.t('queue.pausedByUser'),
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
@@ -186,10 +166,9 @@ class UploadQueueScreen extends StatelessWidget {
value: appState.pauseQueueProcessing,
onChanged: appState.offlineMode
? null // Disable when offline mode is on
: (value) => _handlePauseQueueChange(context, appState, value),
: (value) => appState.setPauseQueueProcessing(value),
),
),
const SizedBox(height: 16),
+15 -109
View File
@@ -2,42 +2,14 @@ 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
@@ -46,10 +18,8 @@ class AuthService {
late OAuth2Helper _helper;
String? _displayName;
UploadMode _mode = UploadMode.production;
final OSMBlockService _blockService;
AuthService({UploadMode mode = UploadMode.production, OSMBlockService? blockService})
: _blockService = blockService ?? OSMBlockService() {
AuthService({UploadMode mode = UploadMode.production}) {
setUploadMode(mode);
}
@@ -116,70 +86,19 @@ 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})');
if (token?.accessToken == null) {
log('OAuth error: token null or missing accessToken');
throw AuthApiException(
'OAuth token exchange did not return an access token',
statusCode: token.httpStatusCode,
);
return null;
}
final tokenMap = {
'accessToken': token.accessToken,
'accessToken': token!.accessToken,
'refreshToken': token.refreshToken,
};
final tokenJson = jsonEncode(tokenMap);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
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;
}
_displayName = await _fetchUsername(token.accessToken!);
return _displayName;
} on AccountBlockedException {
rethrow;
} on AuthApiException {
rethrow;
} catch (e) {
debugPrint('AuthService: OAuth login failed: $e');
log('OAuth login failed: $e');
@@ -265,37 +184,24 @@ class AuthService {
final _client = UserAgentClient();
Future<String?> _fetchUsername(String accessToken) async {
final http.Response resp;
try {
resp = await _client.get(
final 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');
rethrow;
return null;
}
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;
}
}
-17
View File
@@ -225,28 +225,11 @@ class ChangelogService {
versionsNeedingMigration.add('1.6.3');
}
if (needsMigration(lastSeenVersion, currentVersion, '1.8.0')) {
versionsNeedingMigration.add('1.8.0');
}
if (needsMigration(lastSeenVersion, currentVersion, '2.1.0')) {
versionsNeedingMigration.add('2.1.0');
}
if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
versionsNeedingMigration.add('2.7.3');
}
if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) {
versionsNeedingMigration.add('2.10.0');
}
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
versionsNeedingMigration.add('2.10.5');
}
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0');
// }
@@ -1,182 +0,0 @@
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';
}
}
}
-27
View File
@@ -1,27 +0,0 @@
/// Shared validation helpers for geographic coordinates and zoom levels.
///
/// Used wherever a coordinate or zoom level comes from an external source
/// (GPS fixes, persisted preferences, live map camera state) that isn't
/// guaranteed to be finite/in-range.
library;
import '../dev_config.dart' show kAbsoluteMaxZoom;
/// Validate that a latitude value is finite and within -90 to 90.
bool isValidLatitude(double value) {
return !value.isNaN && !value.isInfinite && value >= -90.0 && value <= 90.0;
}
/// Validate that a longitude value is finite and within -180 to 180.
bool isValidLongitude(double value) {
return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0;
}
/// Validate that a zoom level is finite and within a sane range.
///
/// Defaults to 1.0 to [kAbsoluteMaxZoom]. `num` is used (rather than
/// `double`) so the `int` constant [kAbsoluteMaxZoom] can be used directly
/// as a default parameter value.
bool isValidZoom(double zoom, {num min = 1.0, num max = kAbsoluteMaxZoom}) {
return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max;
}
+1 -106
View File
@@ -1,17 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../models/osm_node.dart';
import 'http_client.dart';
import 'profile_import_service.dart';
import 'operator_profile_import_service.dart';
import '../screens/profile_editor.dart';
import '../screens/operator_profile_editor.dart';
class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal();
@@ -20,9 +13,6 @@ class DeepLinkService {
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
/// Callback for HomeScreen to receive node deep links
void Function(OsmNode node)? onNodeDeepLink;
/// Initialize deep link handling (sets up stream listener only)
Future<void> init() async {
@@ -52,9 +42,6 @@ class DeepLinkService {
case 'profiles':
_handleProfilesLink(uri);
break;
case 'node':
_handleNodeLink(uri);
break;
case 'auth':
// OAuth links are handled by flutter_web_auth_2
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
@@ -81,59 +68,6 @@ class DeepLinkService {
}
}
/// Handle node deep link: `deflockapp://node?id=<nodeId>`
Future<void> _handleNodeLink(Uri uri) async {
final idStr = uri.queryParameters['id'];
final nodeId = int.tryParse(idStr ?? '');
if (nodeId == null) {
_showError('Invalid node link: missing or invalid ID');
return;
}
final node = await _fetchNodeById(nodeId);
if (node == null) {
_showError('Node $nodeId not found');
return;
}
if (onNodeDeepLink != null) {
onNodeDeepLink!(node);
} else {
debugPrint('[DeepLinkService] No node deep link handler registered');
}
}
/// Fetch an OSM node by ID from the OpenStreetMap API
Future<OsmNode?> _fetchNodeById(int nodeId) async {
try {
final url = Uri.parse('https://api.openstreetmap.org/api/0.6/node/$nodeId.json');
final client = UserAgentClient();
final response = await client.get(url);
if (response.statusCode != 200) return null;
final json = jsonDecode(response.body);
final elements = json['elements'] as List?;
if (elements == null || elements.isEmpty) return null;
final e = elements[0];
final tags = <String, String>{};
if (e['tags'] != null) {
(e['tags'] as Map<String, dynamic>).forEach((k, v) {
tags[k] = v.toString();
});
}
return OsmNode(
id: e['id'] as int,
coord: LatLng((e['lat'] as num).toDouble(), (e['lon'] as num).toDouble()),
tags: tags,
);
} catch (e) {
debugPrint('[DeepLinkService] Failed to fetch node $nodeId: $e');
return null;
}
}
/// Handle profile-related deep links
void _handleProfilesLink(Uri uri) {
final segments = uri.pathSegments;
@@ -152,16 +86,8 @@ class DeepLinkService {
}
}
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
void _handleAddProfileLink(Uri uri) {
// Check for operator profile parameter first
final operatorBase64Data = uri.queryParameters['op'];
if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) {
_handleOperatorProfileImport(operatorBase64Data);
return;
}
// Otherwise check for device profile parameter
final base64Data = uri.queryParameters['p'];
if (base64Data == null || base64Data.isEmpty) {
@@ -181,20 +107,6 @@ class DeepLinkService {
_navigateToProfileEditor(profile);
}
/// Handle operator profile import from deep link
void _handleOperatorProfileImport(String base64Data) {
// Parse operator profile from base64
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
if (operatorProfile == null) {
_showError('Invalid operator profile data');
return;
}
// Navigate to operator profile editor with the imported profile
_navigateToOperatorProfileEditor(operatorProfile);
}
/// Navigate to profile editor with pre-filled profile data
void _navigateToProfileEditor(NodeProfile profile) {
final context = _navigatorKey?.currentContext;
@@ -212,23 +124,6 @@ class DeepLinkService {
);
}
/// Navigate to operator profile editor with pre-filled operator profile data
void _navigateToOperatorProfileEditor(OperatorProfile operatorProfile) {
final context = _navigatorKey?.currentContext;
if (context == null) {
debugPrint('[DeepLinkService] No navigator context available');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: operatorProfile),
),
);
}
/// Show error message to user
void _showError(String message) {
final context = _navigatorKey?.currentContext;
@@ -1,32 +0,0 @@
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()}';
}
}
@@ -1,77 +0,0 @@
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
@@ -1,40 +0,0 @@
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),
),
);
}
@@ -1,21 +0,0 @@
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;
});
}
+3 -3
View File
@@ -54,8 +54,8 @@ class MapDataProvider {
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
int maxResults = 0, // 0 = no limit for offline downloads
int maxTries = 3,
}) async {
if (AppState.instance.offlineMode) {
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
}
@@ -123,7 +123,7 @@ class MapDataProvider {
_nodeDataManager.addOrUpdateNodes(nodes);
}
/// Alias for addOrUpdateNodes (shorter name used by the upload queue)
/// NodeCache compatibility - alias for addOrUpdateNodes
void addOrUpdate(List<OsmNode> nodes) {
addOrUpdateNodes(nodes);
}
@@ -151,7 +151,7 @@ class MapDataProvider {
);
}
/// Pending-upload helpers used by the upload queue.
/// NodeCache compatibility methods for 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
@@ -0,0 +1,176 @@
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;
}
}
+5 -28
View File
@@ -215,18 +215,7 @@ class NodeDataManager extends ChangeNotifier {
}
}
/// Fetch nodes, splitting the query area into quadrants only when Overpass
/// reports the query would exceed its hard 50k node limit ([NodeLimitError]).
///
/// Splitting is deliberately NOT used for other failure modes:
/// - [RateLimitError] (HTTP 429): the server explicitly asked us to slow
/// down, so firing off up to 4x more requests would be counterproductive.
/// We back off instead, while still surfacing the "slow" status
/// indicator to the user (same UI feedback as the splitting case).
/// - Timeouts / other transient failures now surface as [NetworkError]
/// (see [OverpassService]) and are not caught here at all — they're
/// handled by the normal retry/backoff/fallback logic and reported as a
/// plain timeout/error status by the caller.
/// Fetch nodes with automatic area splitting if needed
Future<List<OsmNode>> fetchWithSplitting(
LatLngBounds bounds,
List<NodeProfile> profiles, {
@@ -249,7 +238,7 @@ class NodeDataManager extends ChangeNotifier {
return nodes;
} on NodeLimitError {
// Hit the deterministic 50k node limit - split area if not too deep
// Hit node limit or timeout - split area if not too deep
if (splitDepth >= maxSplitDepth) {
debugPrint('[NodeDataManager] Max split depth reached, giving up');
return [];
@@ -265,20 +254,13 @@ class NodeDataManager extends ChangeNotifier {
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
} on RateLimitError {
// Rate limited - back off without splitting the area. Splitting here
// would multiply request volume against a server that just told us to
// slow down. We still show the same "slow" status indicator so the
// user gets feedback, but there is no recursive quadrant fetching.
debugPrint('[NodeDataManager] Rate limited, backing off (no area splitting)');
if (isUserInitiated) {
NetworkStatus.instance.setSplitting();
}
// Rate limited - wait and return empty
debugPrint('[NodeDataManager] Rate limited, backing off');
await Future.delayed(const Duration(seconds: 30));
return [];
}
}
/// Fetch data by splitting area into quadrants
Future<List<OsmNode>> _fetchSplitAreas(
LatLngBounds bounds,
@@ -375,7 +357,7 @@ class NodeDataManager extends ChangeNotifier {
);
}
/// Pending-upload helpers used by the upload queue.
/// NodeCache compatibility methods
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
@@ -390,13 +372,8 @@ class NodeDataManager extends ChangeNotifier {
/// Load all offline nodes into cache (call at app startup)
Future<void> preloadOfflineNodes() async {
// Skip entirely when the user has disabled offline features — this call
// isn't gated by OfflineAreaService.hasOfflineAreasForProvider*() since
// it fetches directly via fetchLocalNodes, so we guard it explicitly.
if (!AppState.instance.offlineFeaturesEnabled) return;
try {
final offlineAreaService = OfflineAreaService();
for (final area in offlineAreaService.offlineAreas) {
if (area.status != OfflineAreaStatus.complete) continue;
+1 -7
View File
@@ -58,13 +58,7 @@ class NodeSpatialCache {
id: node.id,
coord: node.coord,
tags: mergedTags,
// 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,
isConstrained: node.isConstrained,
);
} else {
_nodes[node.id] = node;
-11
View File
@@ -29,13 +29,7 @@ class OfflineAreaService {
/// Fast check: do we have any completed offline areas for a specific provider/type?
/// This allows smart cache routing without expensive filesystem searches.
/// Safe to call before initialization - returns false if not yet initialized.
///
/// Returns false immediately if the user has disabled offline features via
/// the master "Enable Offline Features" toggle, regardless of whether area
/// data still exists on disk — this fully gates the offline-first tile
/// pipeline without needing to touch fetchLocalTile/fetchLocalNodes.
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
if (!AppState.instance.offlineFeaturesEnabled) return false;
if (!_initialized) {
return false; // No offline areas loaded yet
}
@@ -50,11 +44,7 @@ class OfflineAreaService {
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
/// offline-first path for tiles that will never be found locally.
///
/// Also returns false immediately when offline features are disabled (see
/// [hasOfflineAreasForProvider] for rationale).
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
if (!AppState.instance.offlineFeaturesEnabled) return false;
if (!_initialized) return false;
return _areas.any((area) =>
area.status == OfflineAreaStatus.complete &&
@@ -64,7 +54,6 @@ class OfflineAreaService {
zoom <= area.maxZoom
);
}
/// Reset service state and inject areas for unit tests.
@visibleForTesting
@@ -1,100 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/operator_profile.dart';
class OperatorProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded)
static const int maxBase64Length = 70000;
/// Parse and validate an operator profile from a base64-encoded JSON string
/// Returns null if parsing/validation fails
static OperatorProfile? parseProfileFromBase64(String base64Data) {
try {
// Basic size validation before expensive decode
if (base64Data.length > maxBase64Length) {
debugPrint('[OperatorProfileImportService] Base64 data too large: ${base64Data.length} characters');
return null;
}
// Decode base64
final jsonBytes = base64Decode(base64Data);
final jsonString = utf8.decode(jsonBytes);
// Parse JSON
final jsonData = jsonDecode(jsonString) as Map<String, dynamic>;
// Validate and sanitize the profile data
final sanitizedProfile = _validateAndSanitizeProfile(jsonData);
return sanitizedProfile;
} catch (e) {
debugPrint('[OperatorProfileImportService] Failed to parse profile from base64: $e');
return null;
}
}
/// Validate operator profile structure and sanitize all string values
static OperatorProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
try {
// Extract and sanitize required fields
final name = _sanitizeString(data['name']);
if (name == null || name.isEmpty) {
debugPrint('[OperatorProfileImportService] Operator profile name is required');
return null;
}
// Extract and sanitize tags
final tagsData = data['tags'];
if (tagsData is! Map<String, dynamic>) {
debugPrint('[OperatorProfileImportService] Operator profile tags must be a map');
return null;
}
final sanitizedTags = <String, String>{};
for (final entry in tagsData.entries) {
final key = _sanitizeString(entry.key);
final value = _sanitizeString(entry.value);
if (key != null && key.isNotEmpty) {
// Allow empty values for refinement purposes
sanitizedTags[key] = value ?? '';
}
}
if (sanitizedTags.isEmpty) {
debugPrint('[OperatorProfileImportService] Operator profile must have at least one valid tag');
return null;
}
return OperatorProfile(
id: const Uuid().v4(), // Always generate new ID for imported profiles
name: name,
tags: sanitizedTags,
);
} catch (e) {
debugPrint('[OperatorProfileImportService] Failed to validate operator profile: $e');
return null;
}
}
/// Sanitize a string value by trimming and removing potentially harmful characters
static String? _sanitizeString(dynamic value) {
if (value == null) return null;
final str = value.toString().trim();
// Remove control characters and limit length
final sanitized = str.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
// Limit length to prevent abuse
const maxLength = 500;
if (sanitized.length > maxLength) {
return sanitized.substring(0, maxLength);
}
return sanitized;
}
}
-55
View File
@@ -1,55 +0,0 @@
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';
}
}
}
@@ -0,0 +1,23 @@
/// Exception thrown when Overpass API returns an error indicating too many nodes were requested.
/// This typically happens when querying large areas that would return more than 50k nodes.
class OverpassNodeLimitException implements Exception {
final String message;
final String? serverResponse;
OverpassNodeLimitException(this.message, {this.serverResponse});
@override
String toString() => 'OverpassNodeLimitException: $message';
}
/// Exception thrown when Overpass API rate limits the request.
/// Should trigger longer backoff delays, not area splitting.
class OverpassRateLimitException implements Exception {
final String message;
final String? serverResponse;
OverpassRateLimitException(this.message, {this.serverResponse});
@override
String toString() => 'OverpassRateLimitException: $message';
}
+109 -251
View File
@@ -8,288 +8,149 @@ import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../dev_config.dart';
import 'http_client.dart';
import 'service_policy.dart';
/// Simple Overpass API client with retry and fallback logic.
/// Simple Overpass API client with proper HTTP retry logic.
/// Single responsibility: Make requests, handle network errors, return data.
class OverpassService {
static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter';
static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter';
static const _policy = ResiliencePolicy(
maxRetries: 3,
httpTimeout: Duration(seconds: 45),
);
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
final http.Client _client;
/// Optional override endpoint. When null, uses [defaultEndpoint].
final String? _endpointOverride;
OverpassService({http.Client? client, String? endpoint})
: _client = client ?? UserAgentClient(),
_endpointOverride = endpoint;
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient();
/// Resolve the primary endpoint: constructor override or default.
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
/// Fetch surveillance nodes from Overpass API with retry and fallback.
///
/// Throws:
/// - [NodeLimitError] when the query would exceed Overpass's hard 50k node
/// limit. This is the *only* case where the caller should split the
/// query area into smaller regions, since it's a deterministic function
/// of how much data lives in the requested bounds.
/// - [RateLimitError] when rate limited (HTTP 429 or equivalent). Callers
/// should back off, not split/retry — splitting would only amplify load
/// on a server that just told us to slow down.
/// - [NetworkError] for timeouts and other retryable HTTP/network failures.
/// Timeouts are usually a sign of server load or an expensive query, not
/// "too much area" — retrying with a hail of smaller sub-requests makes
/// the problem worse, so timeouts are treated like any other transient
/// network failure (handled by the existing retry/backoff/fallback logic).
/// Fetch surveillance nodes from Overpass API with proper retry logic.
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
Future<List<OsmNode>> fetchNodes({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
ResiliencePolicy? policy,
int maxRetries = 3,
}) async {
if (profiles.isEmpty) return [];
final query = _buildQuery(bounds, profiles);
final endpoint = _primaryEndpoint;
final canFallback = _endpointOverride == null;
final effectivePolicy = policy ?? _policy;
return executeWithFallback<List<OsmNode>>(
primaryUrl: endpoint,
fallbackUrl: canFallback ? fallbackEndpoint : null,
execute: (url) => _attemptFetch(url, query, effectivePolicy),
classifyError: _classifyError,
policy: effectivePolicy,
);
}
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
debugPrint('[OverpassService] POST $endpoint');
try {
final response = await _client.post(
Uri.parse(endpoint),
body: {'data': query},
).timeout(policy.httpTimeout);
if (response.statusCode == 200) {
return _parseResponse(response.body);
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
final response = await _client.post(
Uri.parse(_endpoint),
body: {'data': query},
).timeout(kOverpassQueryTimeout);
if (response.statusCode == 200) {
return _parseResponse(response.body);
}
// Check for specific error types
final errorBody = response.body;
// Node limit error - caller should split area
if (response.statusCode == 400 &&
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
debugPrint('[OverpassService] Node limit exceeded, area should be split');
throw NodeLimitError('Query exceeded 50k node limit');
}
// Timeout error - also try splitting (complex query)
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[OverpassService] Query timeout, area should be split');
throw NodeLimitError('Query timed out - area too complex');
}
// Rate limit - throw immediately, don't retry
if (response.statusCode == 429 ||
errorBody.contains('rate limited') ||
errorBody.contains('too many requests')) {
debugPrint('[OverpassService] Rate limited by Overpass');
throw RateLimitError('Rate limited by Overpass API');
}
// Other HTTP errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) {
// Handle specific error types without retry
if (e is NodeLimitError || e is RateLimitError) {
rethrow;
}
// Network/timeout errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('Network error after $maxRetries retries: $e');
}
final errorBody = response.body;
// Node limit error - a deterministic result-set size limit. This is
// the one case where splitting the query area into smaller regions is
// the correct fix, since it directly reduces nodes-per-request.
if (response.statusCode == 400 &&
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
debugPrint('[OverpassService] Node limit exceeded (50k), area should be split');
throw NodeLimitError('Query exceeded 50k node limit');
}
// Rate limit - back off, don't split or hammer the server with more requests.
if (response.statusCode == 429 ||
errorBody.contains('rate limited') ||
errorBody.contains('too many requests')) {
debugPrint('[OverpassService] Rate limited by Overpass');
throw RateLimitError('Rate limited by Overpass API');
}
// Timeout / runtime limit exceeded - treat as a plain retryable network
// error. This is usually server load or query cost, not "area too
// big" — splitting into up to 64 sub-requests would only make load
// on the server worse.
if (errorBody.contains('timeout') ||
errorBody.contains('runtime limit exceeded') ||
errorBody.contains('Query timed out')) {
debugPrint('[OverpassService] Query timed out');
throw NetworkError('Query timed out');
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) {
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
rethrow;
}
throw NetworkError('Network error: $e');
}
throw NetworkError('Max retries exceeded');
}
static ErrorDisposition _classifyError(Object error) {
if (error is NodeLimitError) return ErrorDisposition.abort;
if (error is RateLimitError) return ErrorDisposition.fallback;
return ErrorDisposition.retry;
}
/// Build Overpass QL query for given bounds and profiles.
///
/// Profiles are deduplicated first: if one profile's non-empty tags are a
/// subset of another's (with identical values), the broader profile's query
/// clause already returns every node the narrower one would, so the
/// narrower clause is redundant and can be dropped. This directly reduces
/// query size/complexity without changing the result set — client-side
/// filtering (matching returned nodes against the full profile list) still
/// happens independently, so nothing is lost.
/// Build Overpass QL query for given bounds and profiles
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
final profilesToQuery = _deduplicateProfilesForQuery(profiles);
if (profilesToQuery.length < profiles.length) {
debugPrint(
'[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency');
}
// Per-profile tag filter strings (e.g. ["man_made"="surveillance"]["surveillance:type"="ALPR"]),
// excluding empty tag values. Reused twice below: once to select our nodes
// in the bbox, and again to re-select just our nodes out of the pool of
// way/relation-connected nodes (which may include unrelated node types).
final tagFilterStrings = profilesToQuery.map((profile) {
return profile.tags.entries
final nodeClauses = profiles.map((profile) {
// Convert profile tags to Overpass filter format, excluding empty values
final tagFilters = profile.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.map((entry) => '["${entry.key}"="${entry.value}"]')
.join();
}).toList();
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n ');
final bboxStr = '${bounds.southWest.latitude},${bounds.southWest.longitude},'
'${bounds.northEast.latitude},${bounds.northEast.longitude}';
final allNodesClauses =
tagFilterStrings.map((filters) => 'node$filters($bboxStr);').join('\n ');
final resNodesClauses =
tagFilterStrings.map((filters) => 'node.resNodes$filters;').join('\n ');
// Query strategy:
// 1. Find all matching nodes in bbox -> .allNodes, output them with tags ("out body").
// 2. Find any way/relation that references one of .allNodes ("bn." = "by node"),
// then pull the node IDs those ways/relations reference -> .wayNodes/.relNodes.
// (`node(w)`/`node(r)` on a way/relation set returns only node IDs, not
// full tags/coords, keeping the query/response cheap.)
// 3. Re-filter that combined node pool down to just our own matching
// surveillance nodes (.resNodes may contain unrelated node types too).
// 4. `convert restriction ::id = id();` emits each of those node IDs as a
// lightweight synthetic `{"type":"restriction","id":<nodeId>}` element —
// a flat, unambiguous list of which node IDs are "constrained" (part of
// some way/relation), without needing the full way/relation node lists.
return '''
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
(
$allNodesClauses
)->.allNodes;
.allNodes out body;
way(bn.allNodes); node(w)->.wayNodes;
relation(bn.allNodes); node(r)->.relNodes;
(.wayNodes;.relNodes;)->.resNodes;
(
$resNodesClauses
$nodeClauses
);
convert restriction ::id = id();
out;
out body;
(
way(bn);
rel(bn);
);
out skel;
''';
}
/// Deduplicate profiles for Overpass queries by removing profiles that are
/// subsumed by others. A profile A subsumes profile B if all of A's
/// non-empty tags exist in B with identical values — meaning every node
/// matching B's filter also matches A's filter, so B's clause is redundant.
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
if (profiles.length <= 1) return profiles;
final result = <NodeProfile>[];
for (final candidate in profiles) {
// Skip profiles that only have empty tags - they would match
// everything and shouldn't be treated as subsuming or subsumable.
final candidateNonEmptyTags = candidate.tags.entries
.where((entry) => entry.value.trim().isNotEmpty)
.toList();
if (candidateNonEmptyTags.isEmpty) continue;
// Check if any existing profile in our result subsumes this candidate
bool isSubsumed = false;
for (final existing in result) {
if (_profileSubsumes(existing, candidate)) {
isSubsumed = true;
break;
}
}
if (!isSubsumed) {
// This candidate is not subsumed, so add it. But first, remove any
// existing profiles that this candidate subsumes (candidate is
// broader, so those clauses are now redundant).
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
result.add(candidate);
}
}
// Safety check: if dedup removed everything (e.g. all-empty-tag
// profiles), fall back to the original list rather than sending an
// empty query.
return result.isNotEmpty ? result : profiles;
}
/// Check if [broaderProfile] subsumes [specificProfile]: true if all
/// non-empty tags in [broaderProfile] exist in [specificProfile] with
/// identical values.
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
final broaderTags = Map.fromEntries(
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
);
final specificTags = Map.fromEntries(
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
);
// If broader has no non-empty tags, it doesn't subsume anything (it
// would match everything, which isn't a meaningful "broader filter").
if (broaderTags.isEmpty) return false;
// If broader has more non-empty tags than specific, it can't subsume it.
if (broaderTags.length > specificTags.length) return false;
for (final entry in broaderTags.entries) {
if (specificTags[entry.key] != entry.value) return false;
}
return true;
}
/// Parse Overpass JSON response into OsmNode objects.
///
/// The query (see [_buildQuery]) produces two kinds of elements:
/// - `type: "node"` — our matched surveillance nodes, with full tags/coords
/// (from `.allNodes out body;`).
/// - `type: "restriction"` — synthetic elements (from
/// `convert restriction ::id = id();`) whose `id` is the OSM node ID of
/// one of our nodes that Overpass determined is referenced by some way
/// or relation. These carry no lat/lon/tags — just the id — and exist
/// purely to tell us which node IDs are "constrained".
/// Parse Overpass JSON response into OsmNode objects
List<OsmNode> _parseResponse(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>;
final nodeElements = <Map<String, dynamic>>[];
final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes and constrained-node markers.
// First pass: collect surveillance nodes and identify constrained nodes
for (final element in elements.whereType<Map<String, dynamic>>()) {
final type = element['type'] as String?;
if (type == 'node') {
nodeElements.add(element);
} else if (type == 'restriction') {
final rawId = element['id'];
final nodeId = rawId is int ? rawId : int.tryParse(rawId.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
} else if (type == 'way' || type == 'relation') {
// Mark referenced nodes as constrained
final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
for (final ref in refs) {
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId);
}
}
}
// Second pass: create OsmNode objects
final nodes = nodeElements.map((element) {
final nodeId = element['id'] as int;
@@ -300,16 +161,13 @@ out;
isConstrained: constrainedNodeIds.contains(nodeId),
);
}).toList();
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
return nodes;
}
}
/// Error thrown when a query would exceed Overpass's 50k node limit.
/// The caller should split the query area into smaller regions to resolve this
/// — it's a deterministic function of how much data lives within the bounds.
/// Error thrown when query exceeds node limits or is too complex - area should be split
class NodeLimitError extends Error {
final String message;
NodeLimitError(this.message);
@@ -325,10 +183,10 @@ class RateLimitError extends Error {
String toString() => 'RateLimitError: $message';
}
/// Error thrown for network/HTTP issues (including timeouts) - retryable
/// Error thrown for network/HTTP issues - retryable
class NetworkError extends Error {
final String message;
NetworkError(this.message);
@override
String toString() => 'NetworkError: $message';
}
}
+2 -7
View File
@@ -3,7 +3,6 @@ import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/node_profile.dart';
import '../dev_config.dart';
class ProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded)
@@ -73,17 +72,13 @@ class ProfileImportService {
final requiresDirection = data['requiresDirection'] ?? true;
final submittable = data['submittable'] ?? true;
// Parse FOV if provided, applying restrictions based on dev config
// Parse FOV if provided
double? fov;
if (data['fov'] != null) {
if (data['fov'] is num) {
final fovValue = (data['fov'] as num).toDouble();
if (fovValue > 0 && fovValue <= 360) {
// If non-360 FOVs are disabled, only allow 360° FOV
if (kEnableNon360FOVs || fovValue == 360.0) {
fov = fovValue;
}
// Otherwise, clear non-360 FOV values (set fov to null)
fov = fovValue;
}
}
}
@@ -61,13 +61,10 @@ class ProviderTileCacheManager {
/// Delete a specific provider's cache directory and remove the store.
static Future<void> deleteCache(String providerId, String tileTypeId) async {
final key = '$providerId/$tileTypeId';
final store = _stores[key];
final store = _stores.remove(key);
if (store != null) {
// Use the store's clear method to properly reset its internal state
await store.clear();
// Don't remove from registry - let it be reused with clean state
} else if (_baseCacheDir != null) {
// Fallback for stores not in registry
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
if (await cacheDir.exists()) {
await cacheDir.delete(recursive: true);
+14 -75
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
@@ -28,10 +27,6 @@ class ProviderTileCacheStore implements MapCachingProvider {
/// [putTile] call to avoid blocking construction.
int? _estimatedSize;
/// Semaphore to limit concurrent file I/O operations and prevent
/// "too many open files" errors during heavy tile loading.
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
/// Throttle: don't re-scan more than once per minute.
DateTime? _lastPruneCheck;
@@ -57,16 +52,9 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try {
// Use semaphore to limit concurrent file I/O operations
final result = await _ioSemaphore.execute(() async {
final bytes = await tileFile.readAsBytes();
final metaJson = json.decode(await metaFile.readAsString())
as Map<String, dynamic>;
return (bytes: bytes, metaJson: metaJson);
});
final bytes = result.bytes;
final metaJson = result.metaJson;
final bytes = await tileFile.readAsBytes();
final metaJson = json.decode(await metaFile.readAsString())
as Map<String, dynamic>;
final metadata = CachedMapTileMetadata(
staleAt: DateTime.fromMillisecondsSinceEpoch(
@@ -132,13 +120,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
// Write .tile before .meta: if we crash between the two writes, the
// read path's both-must-exist check sees a miss rather than an orphan .meta.
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
await _ioSemaphore.execute(() async {
if (bytes != null) {
await tileFile.writeAsBytes(bytes);
}
await metaFile.writeAsString(metaJson);
});
if (bytes != null) {
await tileFile.writeAsBytes(bytes);
}
await metaFile.writeAsString(metaJson);
// Reset size estimate so it resyncs from disk on next check.
// This avoids drift from overwrites where the old size isn't subtracted.
@@ -265,19 +250,14 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try {
final deletedBytes = await _ioSemaphore.execute(() async {
await entry.file.delete();
var bytes = entry.stat.size;
if (await metaFile.exists()) {
final metaStat = await metaFile.stat();
await metaFile.delete();
bytes += metaStat.size;
}
return bytes;
});
freedBytes += deletedBytes;
await entry.file.delete();
freedBytes += entry.stat.size;
evictedKeys.add(key);
if (await metaFile.exists()) {
final metaStat = await metaFile.stat();
await metaFile.delete();
freedBytes += metaStat.size;
}
} catch (e) {
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
}
@@ -333,44 +313,3 @@ class ProviderTileCacheStore implements MapCachingProvider {
@visibleForTesting
Future<void> forceEviction() => _evictIfNeeded();
}
/// Simple semaphore to limit concurrent operations and prevent resource exhaustion.
class _Semaphore {
final int maxCount;
int _currentCount;
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
_Semaphore(this.maxCount) : _currentCount = maxCount;
/// Acquire a permit. Returns a Future that completes when a permit is available.
Future<void> acquire() {
if (_currentCount > 0) {
_currentCount--;
return Future.value();
} else {
final completer = Completer<void>();
_waitQueue.add(completer);
return completer.future;
}
}
/// Release a permit, potentially unblocking a waiting operation.
void release() {
if (_waitQueue.isNotEmpty) {
final completer = _waitQueue.removeFirst();
completer.complete();
} else {
_currentCount++;
}
}
/// Execute a function while holding a permit.
Future<T> execute<T>(Future<T> Function() operation) async {
await acquire();
try {
return await operation();
} finally {
release();
}
}
}
+76 -162
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,189 +24,125 @@ class ProximityAlertService {
ProximityAlertService._internal();
FlutterLocalNotificationsPlugin? _notifications;
/// 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;
bool _isInitialized = false;
// 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. 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 {
/// Initialize the notification plugin and request permissions
Future<void> initialize({VoidCallback? onVisualAlert}) async {
_onVisualAlert = onVisualAlert;
_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 {
await _notifications!.initialize(initSettings);
// Completing without throwing is the real signal that the plugin is up.
_pluginReady = true;
debugPrint('[ProximityAlertService] Plugin ready (permissions deferred)');
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)');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_pluginReady = false;
_initFuture = null; // Allow a later retry
_isInitialized = false;
}
}
/// 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.
///
/// 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.
/// Request notification permissions on both platforms
Future<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
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');
}
// 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,
);
} 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 (!_pluginReady || nodes.isEmpty) return;
if (!_isInitialized || 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;
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
continue;
}
// Calculate distance using Geolocator's distanceBetween
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();
_recentAlerts.add(RecentAlert(nodeId: node.id, alertTime: DateTime.now()));
// Track this alert to prevent spam
_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 {
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;
}
if (!_isInitialized || _notifications == null) return;
const androidDetails = AndroidNotificationDetails(
'proximity_alerts',
'Proximity Alerts',
@@ -218,13 +154,7 @@ 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,
);
@@ -234,16 +164,16 @@ class ProximityAlertService {
iOS: iosDetails,
);
final title = 'Surveillance Device Nearby';
final body = '$nodeType detected ${distance}m ahead';
try {
await _notifications!.show(
id, // Use node ID as notification ID
node.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');
}
@@ -296,46 +226,30 @@ class ProximityAlertService {
_recentAlerts.clear();
}
/// Check if notification permissions are granted.
/// Check if notification permissions are granted
Future<bool> areNotificationsEnabled() async {
if (!await _ensureReady() || _notifications == null) return false;
if (!_isInitialized || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
final result = await androidImpl.areNotificationsEnabled();
return result ?? false;
}
// 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;
// For iOS, assume enabled if we got this far (permissions were requested during init)
return true;
} catch (e) {
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
return false;
}
}
/// 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.
/// Request permissions again (can be called from settings)
Future<bool> requestNotificationPermissions() async {
if (!await _ensureReady()) return false;
await _requestNotificationPermissions();
final enabled = await areNotificationsEnabled();
debugPrint('[ProximityAlertService] Permissions after request: $enabled');
return enabled;
return await areNotificationsEnabled();
}
}
+29 -69
View File
@@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart';
import '../dev_config.dart';
import 'http_client.dart';
import 'service_policy.dart';
class RouteResult {
final List<LatLng> waypoints;
final double distanceMeters;
final double durationSeconds;
const RouteResult({
required this.waypoints,
required this.distanceMeters,
required this.durationSeconds,
});
@override
String toString() {
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
@@ -26,27 +26,14 @@ class RouteResult {
}
class RoutingService {
static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions';
static const _policy = ResiliencePolicy(
maxRetries: 1,
httpTimeout: Duration(seconds: 30),
);
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
final http.Client _client;
/// Optional override URL. When null, uses [defaultUrl].
final String? _baseUrlOverride;
RoutingService({http.Client? client, String? baseUrl})
: _client = client ?? UserAgentClient(),
_baseUrlOverride = baseUrl;
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient();
void close() => _client.close();
/// Resolve the primary URL to use: constructor override or default.
String get _primaryUrl => _baseUrlOverride ?? defaultUrl;
// Calculate route between two points
// Calculate route between two points using alprwatch
Future<RouteResult> calculateRoute({
required LatLng start,
required LatLng end,
@@ -66,7 +53,8 @@ class RoutingService {
'tags': tags,
};
}).toList();
final uri = Uri.parse(_baseUrl);
final params = {
'start': {
'longitude': start.longitude,
@@ -78,25 +66,11 @@ class RoutingService {
},
'avoidance_distance': avoidanceDistance,
'enabled_profiles': enabledProfiles,
'show_exclusion_zone': false,
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
};
final primaryUrl = _primaryUrl;
final canFallback = _baseUrlOverride == null;
return executeWithFallback<RouteResult>(
primaryUrl: primaryUrl,
fallbackUrl: canFallback ? fallbackUrl : null,
execute: (url) => _postRoute(url, params),
classifyError: _classifyError,
policy: _policy,
);
}
Future<RouteResult> _postRoute(String url, Map<String, dynamic> params) async {
final uri = Uri.parse(url);
debugPrint('[RoutingService] POST $uri');
debugPrint('[RoutingService] alprwatch request: $uri $params');
try {
final response = await _client.post(
uri,
@@ -104,7 +78,7 @@ class RoutingService {
'Content-Type': 'application/json'
},
body: json.encode(params)
).timeout(_policy.httpTimeout);
).timeout(kNavigationRoutingTimeout);
if (response.statusCode != 200) {
if (kDebugMode) {
@@ -117,25 +91,24 @@ class RoutingService {
: body;
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
}
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}',
statusCode: response.statusCode);
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}
final data = json.decode(response.body) as Map<String, dynamic>;
debugPrint('[RoutingService] response data: $data');
// Check response status
debugPrint('[RoutingService] alprwatch response data: $data');
// Check alprwatch response status
final ok = data['ok'] as bool? ?? false;
if ( ! ok ) {
final message = data['error'] as String? ?? 'Unknown routing error';
throw RoutingException('API error: $message', isApiError: true);
throw RoutingException('alprwatch error: $message');
}
final route = data['result']['route'] as Map<String, dynamic>?;
if (route == null) {
throw RoutingException('No route found between these points', isApiError: true);
throw RoutingException('No route found between these points');
}
final waypoints = (route['coordinates'] as List<dynamic>?)
?.map((inner) {
final pair = inner as List<dynamic>;
@@ -143,19 +116,19 @@ class RoutingService {
final lng = (pair[0] as num).toDouble();
final lat = (pair[1] as num).toDouble();
return LatLng(lat, lng);
}).whereType<LatLng>().toList() ?? [];
}).whereType<LatLng>().toList() ?? [];
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
final result = RouteResult(
waypoints: waypoints,
distanceMeters: distance,
durationSeconds: duration,
);
debugPrint('[RoutingService] Route calculated: $result');
return result;
} catch (e) {
debugPrint('[RoutingService] Route calculation failed: $e');
if (e is RoutingException) {
@@ -165,26 +138,13 @@ class RoutingService {
}
}
}
static ErrorDisposition _classifyError(Object error) {
if (error is! RoutingException) return ErrorDisposition.retry;
if (error.isApiError) return ErrorDisposition.abort;
final status = error.statusCode;
if (status != null && status >= 400 && status < 500) {
if (status == 429) return ErrorDisposition.fallback;
return ErrorDisposition.abort;
}
return ErrorDisposition.retry;
}
}
class RoutingException implements Exception {
final String message;
final int? statusCode;
final bool isApiError;
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
const RoutingException(this.message);
@override
String toString() => 'RoutingException: $message';
}
+47 -94
View File
@@ -152,10 +152,11 @@ class ServicePolicy {
'attributionUrl: $attributionUrl)';
}
/// Resolves service URLs to their applicable [ServicePolicy].
/// Resolves URLs and tile providers to their applicable [ServicePolicy].
///
/// Built-in patterns cover all OSMF official services and common third-party
/// tile providers. Falls back to permissive defaults for unrecognized hosts.
/// tile providers. Custom overrides can be registered for self-hosted endpoints
/// via [registerCustomPolicy].
class ServicePolicyResolver {
/// Host → ServiceType mapping for known services.
static final Map<String, ServiceType> _hostPatterns = {
@@ -165,7 +166,6 @@ class ServicePolicyResolver {
'tile.openstreetmap.org': ServiceType.osmTileServer,
'nominatim.openstreetmap.org': ServiceType.nominatim,
'overpass-api.de': ServiceType.overpass,
'overpass.deflock.org': ServiceType.overpass,
'taginfo.openstreetmap.org': ServiceType.tagInfo,
'tiles.virtualearth.net': ServiceType.bingTiles,
'api.mapbox.com': ServiceType.mapboxTiles,
@@ -183,14 +183,25 @@ class ServicePolicyResolver {
ServiceType.custom: const ServicePolicy(),
};
/// Custom host overrides registered at runtime (for self-hosted services).
static final Map<String, ServicePolicy> _customOverrides = {};
/// Resolve a URL to its applicable [ServicePolicy].
///
/// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
/// for unrecognized hosts.
/// Checks custom overrides first, then built-in host patterns. Falls back
/// to [ServicePolicy.custom] for unrecognized hosts.
static ServicePolicy resolve(String url) {
final host = _extractHost(url);
if (host == null) return const ServicePolicy();
// Check custom overrides first (exact or subdomain matching)
for (final entry in _customOverrides.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) {
return entry.value;
}
}
// Check built-in patterns (support subdomain matching)
for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) {
return _policies[entry.value] ?? const ServicePolicy();
@@ -207,6 +218,14 @@ class ServicePolicyResolver {
final host = _extractHost(url);
if (host == null) return ServiceType.custom;
// Check custom overrides first — a registered custom policy means
// the host is treated as ServiceType.custom with custom rules.
for (final entry in _customOverrides.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) {
return ServiceType.custom;
}
}
for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) {
return entry.value;
@@ -220,6 +239,29 @@ class ServicePolicyResolver {
static ServicePolicy resolveByType(ServiceType type) =>
_policies[type] ?? const ServicePolicy();
/// Register a custom policy override for a host pattern.
///
/// Use this to configure self-hosted services:
/// ```dart
/// ServicePolicyResolver.registerCustomPolicy(
/// 'tiles.myserver.com',
/// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20),
/// );
/// ```
static void registerCustomPolicy(String hostPattern, ServicePolicy policy) {
_customOverrides[hostPattern] = policy;
}
/// Remove a custom policy override.
static void removeCustomPolicy(String hostPattern) {
_customOverrides.remove(hostPattern);
}
/// Clear all custom policy overrides (useful for testing).
static void clearCustomPolicies() {
_customOverrides.clear();
}
/// Extract the host from a URL or URL template.
static String? _extractHost(String url) {
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
@@ -241,95 +283,6 @@ class ServicePolicyResolver {
}
}
/// How the retry/fallback engine should handle an error.
enum ErrorDisposition {
/// Stop immediately. Don't retry, don't try fallback. (400, business logic)
abort,
/// Don't retry same server, but DO try fallback endpoint. (429 rate limit)
fallback,
/// Retry with backoff against same server, then fallback if exhausted. (5xx, network)
retry,
}
/// Retry and fallback configuration for resilient HTTP services.
class ResiliencePolicy {
final int maxRetries;
final Duration httpTimeout;
final Duration _retryBackoffBase;
final int _retryBackoffMaxMs;
const ResiliencePolicy({
this.maxRetries = 1,
this.httpTimeout = const Duration(seconds: 30),
Duration retryBackoffBase = const Duration(milliseconds: 200),
int retryBackoffMaxMs = 5000,
}) : _retryBackoffBase = retryBackoffBase,
_retryBackoffMaxMs = retryBackoffMaxMs;
Duration retryDelay(int attempt) {
final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt))
.clamp(0, _retryBackoffMaxMs);
return Duration(milliseconds: ms);
}
}
/// Execute a request with retry and fallback logic.
///
/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times.
/// 2. On each failure, calls [classifyError] to determine disposition:
/// - [ErrorDisposition.abort]: rethrows immediately
/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available)
/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted
/// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error,
/// repeats the retry loop against the fallback.
Future<T> executeWithFallback<T>({
required String primaryUrl,
required String? fallbackUrl,
required Future<T> Function(String url) execute,
required ErrorDisposition Function(Object error) classifyError,
ResiliencePolicy policy = const ResiliencePolicy(),
}) async {
try {
return await _executeWithRetries(primaryUrl, execute, classifyError, policy);
} catch (e) {
// _executeWithRetries rethrows abort/fallback/exhausted-retry errors.
// Re-classify only to distinguish abort (which must not fall back) from
// fallback/retry-exhausted (which should). This is the one intentional
// re-classification — _executeWithRetries cannot short-circuit past the
// outer try/catch.
if (classifyError(e) == ErrorDisposition.abort) rethrow;
if (fallbackUrl == null) rethrow;
debugPrint('[Resilience] Primary failed ($e), trying fallback');
return _executeWithRetries(fallbackUrl, execute, classifyError, policy);
}
}
Future<T> _executeWithRetries<T>(
String url,
Future<T> Function(String url) execute,
ErrorDisposition Function(Object error) classifyError,
ResiliencePolicy policy,
) async {
for (int attempt = 0; attempt <= policy.maxRetries; attempt++) {
try {
return await execute(url);
} catch (e) {
final disposition = classifyError(e);
if (disposition == ErrorDisposition.abort) rethrow;
if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback
// disposition == retry
if (attempt < policy.maxRetries) {
final delay = policy.retryDelay(attempt);
debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
rethrow; // retries exhausted, let caller try fallback
}
}
throw StateError('Unreachable'); // loop always returns or throws
}
/// Reusable per-service rate limiter and concurrency controller.
///
/// Enforces the rate limits and concurrency constraints defined in each
@@ -58,18 +58,13 @@ class SuspectedLocationDatabase {
}
/// Create database tables
///
/// Takes a [DatabaseExecutor] (rather than [Database]) so this can be
/// called either directly on the database (e.g. from [onCreate]) or inside
/// a transaction (see [insertBatch]).
Future<void> _createTables(DatabaseExecutor db, int version) async {
Future<void> _createTables(Database db, int version) async {
debugPrint('[SuspectedLocationDatabase] Creating tables...');
// Main suspected locations table
await db.execute('''
CREATE TABLE $_tableName (
$_columnTicketNo TEXT PRIMARY KEY,
$_columnCentroidLat REAL NOT NULL,
$_columnCentroidLng REAL NOT NULL,
$_columnBounds TEXT,
+5 -20
View File
@@ -35,28 +35,13 @@ class SuspectedLocationService {
// Load cache data
await _cache.loadFromStorage();
// Note: Auto-refresh logic moved to initBackgroundRefresh() to avoid blocking app startup
if (_isEnabled && (await _shouldRefresh()) && offlineMode) {
final lastFetch = await _cache.lastFetchTime;
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}');
}
}
/// Check if background refresh should happen and start it (non-blocking)
Future<void> initBackgroundRefresh({bool offlineMode = false}) async {
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
if (_isEnabled && (await _shouldRefresh()) && !offlineMode) {
debugPrint('[SuspectedLocationService] Starting background auto-refresh of CSV data (older than $_maxAge or missing)');
// Don't await - let it run in background
_fetchData().then((success) {
if (success) {
debugPrint('[SuspectedLocationService] Background auto-refresh completed successfully');
} else {
debugPrint('[SuspectedLocationService] Background auto-refresh failed');
}
}).catchError((error) {
debugPrint('[SuspectedLocationService] Background auto-refresh error: $error');
});
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
await _fetchData();
} else if (_isEnabled && (await _shouldRefresh()) && offlineMode) {
final lastFetch = await _cache.lastFetchTime;
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}');
}
}
+3 -3
View File
@@ -104,7 +104,7 @@ class Uploader {
// Create new node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
@@ -141,7 +141,7 @@ class Uploader {
// Update existing node with version
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
@@ -190,7 +190,7 @@ class Uploader {
// Extract creates a new node with tags from the original node
final mergedTags = p.getCombinedTags();
final tagsXml = mergedTags.entries.map((e) =>
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
final nodeXml = '''
<osm>
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
-60
View File
@@ -1,60 +0,0 @@
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}';
}
+2 -12
View File
@@ -28,16 +28,11 @@ 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 {
@@ -63,16 +58,11 @@ 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 {
+10 -49
View File
@@ -1,28 +1,22 @@
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 and changeset comment notifications
/// State management for OSM message 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 and changeset comments
/// Check for unread messages
Future<void> checkMessages({
required String? accessToken,
required String? username,
required UploadMode uploadMode,
bool forceRefresh = false,
}) async {
@@ -32,29 +26,18 @@ class MessagesState extends ChangeNotifier {
notifyListeners();
try {
final results = await Future.wait([
_messagesService.getUnreadMessageCount(
accessToken: accessToken,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
),
_changesetCommentsService.getUnreadChangesetId(
username: username,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
),
]);
final count = await _messagesService.getUnreadMessageCount(
accessToken: accessToken,
uploadMode: uploadMode,
forceRefresh: forceRefresh,
);
final count = results[0];
final unreadChangesetId = results[1];
if (_unreadCount != count || _unreadChangesetId != unreadChangesetId) {
if (_unreadCount != count) {
_unreadCount = count;
_unreadChangesetId = unreadChangesetId;
notifyListeners();
}
} catch (e) {
// Silently handle errors - notifications are not critical
// Silently handle errors - messages are not critical
debugPrint('MessagesState: Error checking messages: $e');
} finally {
_isChecking = false;
@@ -62,26 +45,6 @@ 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);
@@ -90,9 +53,7 @@ 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();
}
}
}
+3 -3
View File
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
_calculateRoute();
}
/// Calculate route via RoutingService (primary + fallback endpoints).
/// Calculate route using alprwatch
void _calculateRoute() {
if (_routeStart == null || _routeEnd == null) return;
debugPrint('[NavigationState] Calculating route...');
debugPrint('[NavigationState] Calculating route with alprwatch...');
_isCalculating = true;
_routingError = null;
notifyListeners();
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
_showingOverview = true;
_provisionalPinLocation = null; // Hide provisional pin
debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
notifyListeners();
}).catchError((error) {
+1 -58
View File
@@ -1,16 +1,12 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/operator_profile.dart';
import '../services/operator_profile_service.dart';
class OperatorProfileState extends ChangeNotifier {
static const String _profileOrderPrefsKey = 'operator_profile_order';
final List<OperatorProfile> _profiles = [];
List<String> _customOrder = []; // List of profile IDs in user's preferred order
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
Future<void> init({bool addDefaults = false}) async {
_profiles.addAll(await OperatorProfileService().load());
@@ -20,10 +16,6 @@ class OperatorProfileState extends ChangeNotifier {
_profiles.addAll(OperatorProfile.getDefaults());
await OperatorProfileService().save(_profiles);
}
// Load custom order from prefs
final prefs = await SharedPreferences.getInstance();
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
}
void addOrUpdateProfile(OperatorProfile p) {
@@ -42,53 +34,4 @@ class OperatorProfileState extends ChangeNotifier {
OperatorProfileService().save(_profiles);
notifyListeners();
}
// Reorder profiles (for drag-and-drop in settings)
void reorderProfiles(int oldIndex, int newIndex) {
final orderedProfiles = _getOrderedProfiles();
// With onReorderItem callback, newIndex is already adjusted
final item = orderedProfiles.removeAt(oldIndex);
orderedProfiles.insert(newIndex, item);
// Update custom order with new sequence
_customOrder = orderedProfiles.map((p) => p.id).toList();
_saveCustomOrder();
notifyListeners();
}
// Get profiles in custom order, with unordered profiles at the end
List<OperatorProfile> _getOrderedProfiles() {
if (_customOrder.isEmpty) {
return List.from(_profiles);
}
final ordered = <OperatorProfile>[];
final profilesById = {for (final p in _profiles) p.id: p};
// Add profiles in custom order
for (final id in _customOrder) {
final profile = profilesById[id];
if (profile != null) {
ordered.add(profile);
profilesById.remove(id);
}
}
// Add any remaining profiles that weren't in the custom order
ordered.addAll(profilesById.values);
return ordered;
}
// Save custom order to disk
Future<void> _saveCustomOrder() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
} catch (e) {
// Fail gracefully in tests or if SharedPreferences isn't available
debugPrint('[OperatorProfileState] Failed to save custom order: $e');
}
}
}
+4 -23
View File
@@ -57,28 +57,6 @@ class ProfileState extends ChangeNotifier {
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
}
/// Reload all profiles from storage (useful after migrations modify stored profiles)
Future<void> reloadFromStorage() async {
// Preserve enabled state by ID
final enabledIds = _enabled.map((p) => p.id).toSet();
// Clear and reload profiles
_profiles.clear();
_profiles.addAll(await ProfileService().load());
// Restore enabled state for profiles that still exist
_enabled.clear();
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
// Safety: Always have at least one enabled profile
if (_enabled.isEmpty && _profiles.isNotEmpty) {
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
_enabled.add(builtIn);
}
notifyListeners();
}
void toggleProfile(NodeProfile p, bool e) {
if (e) {
_enabled.add(p);
@@ -129,7 +107,10 @@ class ProfileState extends ChangeNotifier {
void reorderProfiles(int oldIndex, int newIndex) {
final orderedProfiles = _getOrderedProfiles();
// With onReorderItem callback, newIndex is already adjusted
// Standard Flutter reordering logic
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = orderedProfiles.removeAt(oldIndex);
orderedProfiles.insert(newIndex, item);
+1 -75
View File
@@ -4,7 +4,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart';
import '../models/tile_provider.dart';
import '../services/provider_tile_cache_manager.dart';
import '../dev_config.dart';
import '../keys.dart';
@@ -39,16 +38,9 @@ class SettingsState extends ChangeNotifier {
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
static const String _distanceUnitPrefsKey = 'distance_unit';
static const String _keepScreenAwakePrefsKey = 'keep_screen_awake';
static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls';
static const String _offlineFeaturesEnabledPrefsKey = 'offline_features_enabled';
bool _offlineMode = false;
bool _pauseQueueProcessing = false;
bool _keepScreenAwake = false;
bool _hideZoomControls = false;
bool _offlineFeaturesEnabled = false;
int _maxNodes = kDefaultMaxNodes;
// Default must account for missing secrets (preview builds) even before init() runs
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
@@ -72,12 +64,7 @@ class SettingsState extends ChangeNotifier {
int get proximityAlertDistance => _proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
bool get keepScreenAwake => _keepScreenAwake;
bool get hideZoomControls => _hideZoomControls;
bool get offlineFeaturesEnabled => _offlineFeaturesEnabled;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
DistanceUnit get distanceUnit => _distanceUnit;
@@ -150,20 +137,7 @@ class SettingsState extends ChangeNotifier {
// Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
// Load keep screen awake setting
_keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false;
// Load hide zoom controls setting
_hideZoomControls = prefs.getBool(_hideZoomControlsPrefsKey) ?? false;
// Load offline features enabled setting (defaults to false; migration
// for existing users with pre-existing offline area data lives in
// migrations.dart / migrate_2_10_5)
_offlineFeaturesEnabled = prefs.getBool(_offlineFeaturesEnabledPrefsKey) ?? false;
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
@@ -358,17 +332,6 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
/// Clear all tile caches for a specific provider
Future<void> clearTileProviderCaches(String providerId) async {
final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
if (provider == null) return;
// Clear cache for each tile type in this provider
for (final tileType in provider.tileTypes) {
await ProviderTileCacheManager.deleteCache(providerId, tileType.id);
}
}
/// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) {
@@ -422,44 +385,7 @@ class SettingsState extends ChangeNotifier {
}
}
/// Set keep screen awake enabled/disabled
Future<void> setKeepScreenAwake(bool enabled) async {
if (_keepScreenAwake != enabled) {
_keepScreenAwake = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keepScreenAwakePrefsKey, enabled);
notifyListeners();
}
}
/// Set hide zoom controls enabled/disabled
Future<void> setHideZoomControls(bool enabled) async {
if (_hideZoomControls != enabled) {
_hideZoomControls = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hideZoomControlsPrefsKey, enabled);
notifyListeners();
}
}
/// Set the master "offline features enabled" toggle. When this is off,
/// offline areas (downloading, browsing, and using cached tiles/nodes) are
/// disabled entirely. Note: any cascading side effects (forcing offline
/// mode off, cancelling active downloads, prompting to delete existing
/// area data) are handled by the caller (see AppState.setOfflineFeaturesEnabled)
/// since those require access to services not owned by SettingsState.
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
if (_offlineFeaturesEnabled != enabled) {
_offlineFeaturesEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_offlineFeaturesEnabledPrefsKey, enabled);
notifyListeners();
}
}
/// Set distance for avoidance of nodes during navigation
// Set distance for avoidance of nodes during navigation
Future<void> setNavigationAvoidanceDistance(int distance) async {
if (_navigationAvoidanceDistance != distance) {
_navigationAvoidanceDistance = distance;
-5
View File
@@ -61,11 +61,6 @@ class SuspectedLocationState extends ChangeNotifier {
notifyListeners();
}
/// Start background refresh if needed (non-blocking)
Future<void> initBackgroundRefresh({bool offlineMode = false}) async {
await _service.initBackgroundRefresh(offlineMode: offlineMode);
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled);
+39 -24
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,13 +20,11 @@ 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 {
@@ -123,10 +121,10 @@ class UploadQueueState extends ChangeNotifier {
}
// Add a completed session to the upload queue
PendingUpload addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: DirectionSubmissionFormatter.format(session.directions, session.profile),
direction: _formatDirectionsForSubmission(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
@@ -161,11 +159,10 @@ class UploadQueueState extends ChangeNotifier {
NodeProviderWithCache.instance.notifyListeners();
notifyListeners();
return upload;
}
// Add a completed edit session to the upload queue
PendingUpload addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
// Determine operation type and coordinates
final UploadOperation operation;
final LatLng coordToUse;
@@ -186,7 +183,7 @@ class UploadQueueState extends ChangeNotifier {
final upload = PendingUpload(
coord: coordToUse,
direction: DirectionSubmissionFormatter.format(session.directions, session.profile),
direction: _formatDirectionsForSubmission(session.directions, session.profile),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
refinedTags: session.refinedTags,
@@ -253,16 +250,15 @@ class UploadQueueState extends ChangeNotifier {
NodeProviderWithCache.instance.notifyListeners();
notifyListeners();
return upload;
}
// Add a node deletion to the upload queue
PendingUpload addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: node.coord,
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID
changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
changesetComment: 'Delete a surveillance node', // Default comment for deletions
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,
@@ -286,7 +282,6 @@ class UploadQueueState extends ChangeNotifier {
NodeProviderWithCache.instance.notifyListeners();
notifyListeners();
return upload;
}
void clearQueue() {
@@ -328,10 +323,8 @@ 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;
@@ -390,16 +383,6 @@ 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)');
@@ -753,6 +736,38 @@ 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
@@ -1,54 +0,0 @@
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')),
),
],
);
}
}
+3 -23
View File
@@ -421,7 +421,6 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
}
final allowSubmit = appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -494,22 +493,6 @@ 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),
@@ -611,13 +594,10 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
],
),
// 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)
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(visible: _showTutorial),
child: PositioningTutorialOverlay(),
),
],
);
+101 -25
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,6 +151,102 @@ 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>(
@@ -340,7 +436,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
void commit() {
// Check if there are any actual changes to submit
if (!EditNodeChangeDetector.hasActualChanges(widget.session)) {
if (!_hasActualChanges(widget.session)) {
_showNoChangesDialog(context, locService);
return;
}
@@ -377,7 +473,6 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -527,22 +622,6 @@ 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),
@@ -644,13 +723,10 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
],
),
// 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)
// Tutorial overlay - show only if tutorial should be shown and we're done checking
if (!_isCheckingTutorial && _showTutorial)
Positioned.fill(
child: PositioningTutorialOverlay(visible: _showTutorial),
child: PositioningTutorialOverlay(),
),
],
);
@@ -1,157 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../services/localization_service.dart';
/// Applies a queue-pausing settings change (enabling offline mode or pausing
/// the upload queue), but if there's an in-flight submission (a queue item
/// that has already opened a changeset and hasn't closed it yet), first
/// shows a non-dismissible-by-tap-outside dialog explaining that the
/// submission needs to finish. Once it completes, a brief confirmation is
/// shown before the dialog auto-closes and the setting takes effect.
///
/// If nothing is in-flight, [applyChange] is invoked immediately with no
/// dialog shown at all.
///
/// The dialog can also be dismissed early by the user tapping "View in
/// Queue", in case something is stuck (e.g. a failed/erroring item) — this
/// lets the user navigate to the queue screen to investigate/clear it out,
/// rather than getting stuck waiting indefinitely.
Future<void> applyQueueSettingChangeRespectingInFlightUploads({
required BuildContext context,
required AppState appState,
required Future<void> Function() applyChange,
}) async {
if (!appState.hasInFlightUploads) {
await applyChange();
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => InFlightUploadWaitDialog(
onAllComplete: () {
// Fire and forget - the dialog doesn't need to await this.
applyChange();
},
),
);
}
class InFlightUploadWaitDialog extends StatefulWidget {
/// Called exactly once, as soon as no queue items are actively processing.
final VoidCallback onAllComplete;
const InFlightUploadWaitDialog({super.key, required this.onAllComplete});
@override
State<InFlightUploadWaitDialog> createState() => _InFlightUploadWaitDialogState();
}
class _InFlightUploadWaitDialogState extends State<InFlightUploadWaitDialog> {
bool _completed = false;
bool _appliedChange = false;
Timer? _autoCloseTimer;
late final AppState _appState;
@override
void initState() {
super.initState();
_appState = AppState.instance;
_appState.addListener(_onAppStateChanged);
// Handle the (unlikely) race where the in-flight upload finishes between
// the initial check and this dialog actually mounting.
WidgetsBinding.instance.addPostFrameCallback((_) => _onAppStateChanged());
}
void _onAppStateChanged() {
if (!mounted || _completed) return;
if (_appState.hasInFlightUploads) return;
setState(() {
_completed = true;
});
if (!_appliedChange) {
_appliedChange = true;
widget.onAllComplete();
}
_autoCloseTimer = Timer(kInFlightUploadCompleteDisplayDuration, () {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
});
}
@override
void dispose() {
_appState.removeListener(_onAppStateChanged);
_autoCloseTimer?.cancel();
super.dispose();
}
void _viewInQueue() {
Navigator.of(context, rootNavigator: true).pop();
Navigator.of(context).pushNamed('/settings/queue');
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return PopScope(
canPop: false,
child: AlertDialog(
title: Row(
children: [
Icon(
_completed ? Icons.check_circle : Icons.cloud_upload,
color: _completed ? Colors.green : Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_completed
? locService.t('queue.inFlightCompleteTitle')
: locService.t('queue.inFlightWaitTitle'),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_completed
? locService.t('queue.inFlightCompleteMessage')
: locService.t('queue.inFlightWaitMessage'),
),
const SizedBox(height: 20),
Center(
child: _completed
? const Icon(Icons.check_circle, size: 40, color: Colors.green)
: const CircularProgressIndicator(),
),
],
),
actions: [
if (!_completed)
TextButton(
onPressed: _viewInQueue,
child: Text(locService.t('node.viewInQueue')),
),
],
),
);
},
);
}
}
+16 -213
View File
@@ -1,20 +1,14 @@
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';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
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:
@@ -24,11 +18,7 @@ import '../../services/geo_bounds.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;
@@ -39,15 +29,11 @@ class GpsController {
FollowMeMode Function()? _getCurrentFollowMeMode;
bool Function()? _getProximityAlertsEnabled;
int Function()? _getProximityAlertDistance;
List<OsmNode> Function(LatLng userLocation, int radiusMeters)? _getNearbyNodes;
List<OsmNode> Function()? _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;
@@ -61,9 +47,8 @@ class GpsController {
required FollowMeMode Function() getCurrentFollowMeMode,
required bool Function() getProximityAlertsEnabled,
required int Function() getProximityAlertDistance,
required List<OsmNode> Function(LatLng userLocation, int radiusMeters) getNearbyNodes,
required List<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
Future<void> Function(LatLngBounds)? onFetchBackgroundNodes,
VoidCallback? onMapMovedProgrammatically,
bool Function()? isUserInteracting,
}) async {
@@ -77,7 +62,6 @@ class GpsController {
_getProximityAlertDistance = getProximityAlertDistance;
_getNearbyNodes = getNearbyNodes;
_getEnabledProfiles = getEnabledProfiles;
_onFetchBackgroundNodes = onFetchBackgroundNodes;
_onMapMovedProgrammatically = onMapMovedProgrammatically;
_isUserInteracting = isUserInteracting;
@@ -99,38 +83,6 @@ 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');
@@ -153,7 +105,7 @@ class GpsController {
}
// Check permissions
final permission = await requestLocationPermissionOnce();
final permission = await Geolocator.requestPermission();
debugPrint('[GpsController] Location permission result: $permission');
switch (permission) {
@@ -193,30 +145,19 @@ class GpsController {
void _startPositionStream() {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
// 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)',
);
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
try {
_positionSub = Geolocator.getPositionStream(
locationSettings: _buildLocationSettings(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high, // Request best, accept what we get
distanceFilter: distanceFilter,
background: background,
),
).listen(
_onPositionReceived,
onError: _onPositionError,
);
_streamIsBackgroundCapable = background;
} catch (e) {
debugPrint('[GpsController] Failed to start position stream: $e');
_hasLocation = false;
@@ -225,79 +166,6 @@ 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) {
@@ -310,65 +178,8 @@ 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}',
);
return;
}
final newLocation = LatLng(position.latitude, position.longitude);
_currentLocation = newLocation;
@@ -385,8 +196,7 @@ class GpsController {
// Handle proximity alerts
_checkProximityAlerts(newLocation);
_checkBackgroundDataRefresh(newLocation);
// Handle follow-me animations
_handleFollowMeUpdate(position, newLocation);
}
@@ -407,19 +217,13 @@ class GpsController {
void _checkProximityAlerts(LatLng userLocation) {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
if (!proximityEnabled) 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) ?? [];
final nearbyNodes = _getNearbyNodes?.call() ?? [];
if (nearbyNodes.isEmpty) return;
final alertDistance = _getProximityAlertDistance?.call() ?? 200;
final enabledProfiles = _getEnabledProfiles?.call() ?? [];
ProximityAlertService().checkProximity(
userLocation: userLocation,
nodes: nearbyNodes,
@@ -551,8 +355,7 @@ class GpsController {
_getProximityAlertDistance = null;
_getNearbyNodes = null;
_getEnabledProfiles = null;
_onFetchBackgroundNodes = null;
_onMapMovedProgrammatically = null;
_isUserInteracting = null;
}
}
}
+57 -65
View File
@@ -17,7 +17,6 @@ class MapOverlays extends StatelessWidget {
final EditNodeSession? editSession;
final String? attribution; // Attribution for current tile provider
final VoidCallback? onSearchPressed; // Callback for search button
final bool hideZoomControls; // Hide zoom indicator + zoom in/out buttons
const MapOverlays({
super.key,
required this.mapController,
@@ -26,10 +25,8 @@ class MapOverlays extends StatelessWidget {
this.editSession,
this.attribution,
this.onSearchPressed,
this.hideZoomControls = false,
});
/// Show full attribution text in a dialog with license link.
void _showAttributionDialog(BuildContext context, String attribution) {
final locService = LocalizationService.instance;
@@ -141,37 +138,35 @@ class MapOverlays extends StatelessWidget {
),
// Zoom indicator, positioned relative to button bar with left safe area
if (!hideZoomControls)
Positioned(
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
builder: (context) {
double zoom = 15.0; // fallback
try {
zoom = mapController.mapController.camera.zoom;
} catch (_) {
// Map controller not ready yet
}
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
},
),
Positioned(
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.52),
borderRadius: BorderRadius.circular(7),
),
child: Builder(
builder: (context) {
double zoom = 15.0; // fallback
try {
zoom = mapController.mapController.camera.zoom;
} catch (_) {
// Map controller not ready yet
}
return Text(
'Zoom: ${zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
},
),
),
),
// Attribution overlay, positioned relative to button bar with left safe area
if (attribution != null)
@@ -233,40 +228,37 @@ class MapOverlays extends StatelessWidget {
// Layer selector button
const LayerSelectorButton(),
if (!hideZoomControls) ...[
const SizedBox(height: 8),
// Zoom in button
FloatingActionButton(
mini: true,
heroTag: "zoom_in",
onPressed: () {
try {
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
// Zoom out button
FloatingActionButton(
mini: true,
heroTag: "zoom_out",
onPressed: () {
try {
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.remove),
),
],
const SizedBox(height: 8),
// Zoom in button
FloatingActionButton(
mini: true,
heroTag: "zoom_in",
onPressed: () {
try {
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
// Zoom out button
FloatingActionButton(
mini: true,
heroTag: "zoom_out",
onPressed: () {
try {
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
} catch (_) {
// Map controller not ready yet
}
},
child: const Icon(Icons.remove),
),
],
);
},
),
+28 -11
View File
@@ -3,7 +3,6 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../services/coordinate_validation.dart';
/// Manages map position persistence and initial positioning.
/// Handles saving/loading last map position and moving to initial locations.
@@ -30,9 +29,9 @@ class MapPositionManager {
final lng = prefs.getDouble('last_map_longitude');
final zoom = prefs.getDouble('last_map_zoom');
if (lat != null && lng != null &&
isValidLatitude(lat) && isValidLongitude(lng)) {
final validZoom = zoom != null && isValidZoom(zoom) ? zoom : 15.0;
if (lat != null && lng != null &&
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
_initialLocation = LatLng(lat, lng);
_initialZoom = validZoom;
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
@@ -51,9 +50,9 @@ class MapPositionManager {
try {
final zoom = _initialZoom ?? 15.0;
// Double-check coordinates are valid before moving
if (isValidLatitude(_initialLocation!.latitude) &&
isValidLongitude(_initialLocation!.longitude) &&
isValidZoom(zoom)) {
if (_isValidCoordinate(_initialLocation!.latitude) &&
_isValidCoordinate(_initialLocation!.longitude) &&
_isValidZoom(zoom)) {
controller.mapController.move(_initialLocation!, zoom);
_hasMovedToInitialLocation = true;
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
@@ -71,9 +70,9 @@ class MapPositionManager {
Future<void> saveMapPosition(LatLng location, double zoom) async {
try {
// Validate coordinates and zoom before saving
if (!isValidLatitude(location.latitude) ||
!isValidLongitude(location.longitude) ||
!isValidZoom(zoom)) {
if (!_isValidCoordinate(location.latitude) ||
!_isValidCoordinate(location.longitude) ||
!_isValidZoom(zoom)) {
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
return;
}
@@ -88,6 +87,8 @@ class MapPositionManager {
}
}
/// Clear any stored map position (useful for recovery from invalid data)
static Future<void> clearStoredMapPosition() async {
try {
@@ -100,4 +101,20 @@ class MapPositionManager {
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
}
}
}
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
bool _isValidCoordinate(double value) {
return !value.isNaN &&
!value.isInfinite &&
value >= -180.0 &&
value <= 180.0;
}
/// Validate that a zoom level is valid
bool _isValidZoom(double zoom) {
return !zoom.isNaN &&
!zoom.isInfinite &&
zoom >= 1.0 &&
zoom <= 25.0;
}
}
+11 -137
View File
@@ -3,11 +3,9 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
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';
@@ -27,12 +25,9 @@ import 'node_limit_indicator.dart';
import 'proximity_alert_banner.dart';
import '../dev_config.dart';
import '../services/proximity_alert_service.dart';
import '../services/coordinate_validation.dart';
import 'sheet_aware_map.dart';
import 'custom_scale_bar.dart';
class MapView extends StatefulWidget {
final AnimatedMapController controller;
const MapView({
@@ -47,7 +42,6 @@ class MapView extends StatefulWidget {
this.onSearchPressed,
this.onNodeLimitChanged,
this.onLocationStatusChanged,
this.onMapLongPress,
});
final FollowMeMode followMeMode;
@@ -59,13 +53,12 @@ class MapView extends StatefulWidget {
final VoidCallback? onSearchPressed;
final void Function(bool isLimited)? onNodeLimitChanged;
final VoidCallback? onLocationStatusChanged;
final void Function(LatLng)? onMapLongPress;
@override
State<MapView> createState() => MapViewState();
}
class MapViewState extends State<MapView> with WidgetsBindingObserver {
class MapViewState extends State<MapView> {
late final AnimatedMapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
@@ -84,13 +77,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
// Track map center to clear queue on significant panning
LatLng? _lastCenter;
// Last known-good camera center/zoom, used to self-heal if the camera
// ever gets pushed into an invalid state.
LatLng? _lastValidCenter;
double? _lastValidZoom;
// State for proximity alert banner
bool _showProximityBanner = false;
@@ -98,20 +84,7 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
// Track active pointers to suppress follow-me animations during touch
int _activePointers = 0;
bool _isWakelockEnabled = false;
void _updateWakelock(bool shouldBeAwake) {
if (_isWakelockEnabled != shouldBeAwake) {
_isWakelockEnabled = shouldBeAwake;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (shouldBeAwake) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
});
}
}
@override
void initState() {
@@ -127,8 +100,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
_dataManager = MapDataManager();
_interactionManager = MapInteractionManager();
WidgetsBinding.instance.addObserver(this);
// Initialize proximity alert service
ProximityAlertService().initialize(
onVisualAlert: () {
@@ -189,16 +160,16 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
}
return 200;
},
getNearbyNodes: (userLocation, radiusMeters) {
getNearbyNodes: () {
if (mounted) {
try {
// 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),
);
final LatLngBounds mapBounds;
try {
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return [];
}
return NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds);
} catch (e) {
debugPrint('[MapView] Could not get nearby nodes: $e');
return [];
@@ -217,16 +188,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
}
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();
@@ -244,25 +205,14 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
@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();
_nodeController.dispose();
_tileManager.dispose();
_gpsController.dispose();
WakelockPlus.disable();
// PrefetchAreaService no longer used - replaced with NodeDataManager
super.dispose();
}
@@ -308,27 +258,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
}
/// Known-good fallback location for [initialCenter]: validated GPS
/// location, then validated persisted position, then a hardcoded default.
LatLng get _safeInitialCenter {
final gpsLocation = _gpsController.currentLocation;
if (gpsLocation != null &&
isValidLatitude(gpsLocation.latitude) &&
isValidLongitude(gpsLocation.longitude)) {
return gpsLocation;
}
final persisted = _positionManager.initialLocation;
if (persisted != null &&
isValidLatitude(persisted.latitude) &&
isValidLongitude(persisted.longitude)) {
return persisted;
}
return LatLng(37.7749, -122.4194);
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
@@ -349,14 +278,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
final session = appState.session;
final editSession = appState.editSession;
// 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,
@@ -473,47 +394,18 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
mapController: _controller.mapController,
options: MapOptions(
initialCenter: _safeInitialCenter,
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
minZoom: 1.0,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
interactionOptions: _interactionManager.getInteractionOptions(editSession),
onPositionChanged: (pos, gesture) {
// Self-heal if the camera center/zoom ever becomes invalid
// (observed to happen silently during ordinary panning,
// leaving a blank grey map with no tiles/markers). Snap back
// to the last known-good position immediately.
if (!isValidLatitude(pos.center.latitude) ||
!isValidLongitude(pos.center.longitude) ||
!isValidZoom(pos.zoom)) {
debugPrint(
'[MapView] Detected invalid camera position '
'(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) '
'- snapping back to last known-good position',
);
final recoveryCenter = _lastValidCenter ?? _safeInitialCenter;
final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0);
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
_controller.mapController.move(recoveryCenter, recoveryZoom);
} catch (e) {
debugPrint('[MapView] Failed to recover camera position: $e');
}
});
return; // Don't process this invalid position further
}
_lastValidCenter = pos.center;
_lastValidZoom = pos.zoom;
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) {
widget.onUserGesture();
}
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
// User tried to zoom out below minimum - snap back to minimum zoom
_controller.animateTo(
@@ -604,15 +496,6 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
}
},
onTap: (tapPosition, point) {
// Handle tap on empty map area - currently no action needed
debugPrint('[MapView] Tap at: $point');
},
onLongPress: (tapPosition, point) {
// Handle long press on empty map area - add node here
debugPrint('[MapView] Long press at: $point');
widget.onMapLongPress?.call(point);
},
),
children: [
_tileManager.buildTileLayer(
@@ -624,24 +507,17 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
// When zoom controls (and the zoom level indicator) are hidden,
// shift the scale bar down into the indicator's old slot so we
// don't leave a weird empty gap above the attribution text.
final scaleBarSpacing = appState.hideZoomControls
? kZoomIndicatorSpacingAboveButtonBar
: kScaleBarSpacingAboveButtonBar;
return CustomScaleBar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(scaleBarSpacing, safeArea.bottom),
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
),
maxWidthPx: 120,
barHeight: 8,
);
},
),
],
),
),
@@ -655,10 +531,8 @@ class MapViewState extends State<MapView> with WidgetsBindingObserver {
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
onSearchPressed: widget.onSearchPressed,
hideZoomControls: appState.hideZoomControls,
),
// Node limit indicator (top-left) - shown when limit is active
Builder(
builder: (context) {
+5 -19
View File
@@ -17,7 +17,6 @@ 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) {
@@ -35,21 +34,14 @@ class NodeProviderWithCache extends ChangeNotifier {
}
/// Fetch and update nodes for the given view, with debouncing for rapid map movement
Future<void> fetchAndUpdate({
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 {
@@ -60,21 +52,15 @@ 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
@@ -122,4 +108,4 @@ class NodeProviderWithCache extends ChangeNotifier {
}
return true;
}
}
}
+74 -558
View File
@@ -2,17 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:collection/collection.dart';
import '../models/osm_node.dart';
import '../models/pending_upload.dart';
import '../services/map_data_provider.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
import 'advanced_edit_options_sheet.dart';
class NodeTagSheet extends StatefulWidget {
class NodeTagSheet extends StatelessWidget {
final OsmNode node;
final VoidCallback? onEditPressed;
final bool isNodeLimitActive;
@@ -24,88 +20,6 @@ class NodeTagSheet extends StatefulWidget {
this.isNodeLimitActive = false,
});
@override
State<NodeTagSheet> createState() => _NodeTagSheetState();
}
class _NodeTagSheetState extends State<NodeTagSheet> {
// The PendingUpload backing this node (if it was in a pending state when the
// sheet was opened). We resolve this once and hold onto the object reference
// itself (not just its id) so we can keep observing its live status via its
// mutable fields even after it's eventually removed from the queue list
// (which happens ~1s after completion). Without this, once the item left
// the queue we'd have no way to tell "finished successfully" apart from
// "still pending" and would show stale/misleading status forever.
PendingUpload? _upload;
bool _uploadResolved = false;
@override
void initState() {
super.initState();
_resolveTrackedUpload();
}
void _resolveTrackedUpload() {
if (_uploadResolved) return;
_uploadResolved = true;
final appState = context.read<AppState>();
if (widget.node.tags['_pending_upload'] == 'true') {
_upload = appState.pendingUploads
.firstWhereOrNull((u) => u.tempNodeId == widget.node.id);
} else if (widget.node.tags['_pending_edit'] == 'true' ||
widget.node.tags['_pending_deletion'] == 'true') {
_upload = appState.pendingUploads
.firstWhereOrNull((u) => u.originalNodeId == widget.node.id);
}
}
/// Resolve the node data that should currently be displayed.
///
/// While an upload/edit/deletion is still in progress (or has failed), this
/// is just the snapshot that was passed in when the sheet was opened -
/// nothing has changed in the cache yet. Once the tracked upload completes,
/// we switch to the live node from the cache using the *real* submitted ID
/// (which can differ from the temporary negative "-epoch" ID for creates
/// and extracts, but matches the original ID for edits) so that tags, id,
/// and editability reflect reality instead of the stale pre-submission
/// snapshot. Returns null if the node has been deleted.
OsmNode? _resolveDisplayNode() {
final upload = _upload;
if (upload == null) {
// No tracked upload - either this node was never pending, or we
// couldn't find a matching queue entry (shouldn't normally happen).
// Try a live cache lookup so we still pick up any external updates,
// but fall back to the passed-in node if it's not (or no longer) cached.
return MapDataProvider().getNodeById(widget.node.id) ?? widget.node;
}
if (!upload.isComplete) {
// Still pending/in-progress/failed - nothing has changed yet.
return widget.node;
}
if (upload.isDeletion) {
return null; // Node has been deleted
}
// Upload completed successfully (create/modify/extract). Look up the
// live node using the real submitted ID (same as originalNodeId for
// modify operations, a brand-new positive ID for create/extract).
final realId = upload.submittedNodeId ?? widget.node.id;
final cached = MapDataProvider().getNodeById(realId);
if (cached != null) return cached;
// Fallback: cache might have already been cleared/replaced; reconstruct
// from the upload data directly so we don't fall back to stale pending
// tags/id.
return OsmNode(
id: realId,
coord: upload.coord,
tags: upload.getCombinedTags(),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -113,30 +27,22 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
builder: (context, child) {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
final upload = _upload;
final isDeleted = upload != null && upload.isComplete && upload.isDeletion;
final displayNode = isDeleted ? null : _resolveDisplayNode();
if (displayNode == null) {
return _buildDeletedSheet(context, locService);
}
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
final isEditable = (!displayNode.tags.containsKey('_pending_upload') ||
displayNode.tags['_pending_upload'] != 'true') &&
(!displayNode.tags.containsKey('_pending_edit') ||
displayNode.tags['_pending_edit'] != 'true') &&
(!displayNode.tags.containsKey('_pending_deletion') ||
displayNode.tags['_pending_deletion'] != 'true');
final isEditable = (!node.tags.containsKey('_pending_upload') ||
node.tags['_pending_upload'] != 'true') &&
(!node.tags.containsKey('_pending_edit') ||
node.tags['_pending_edit'] != 'true') &&
(!node.tags.containsKey('_pending_deletion') ||
node.tags['_pending_deletion'] != 'true');
// Check if this is a real OSM node (not pending) - for "View on OSM" button
final isRealOSMNode = !displayNode.tags.containsKey('_pending_upload') &&
displayNode.id > 0; // Real OSM nodes have positive IDs
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
node.id > 0; // Real OSM nodes have positive IDs
void openEditSheet() {
// Check if node limit is active and warn user
if (widget.isNodeLimitActive) {
if (isNodeLimitActive) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -149,22 +55,22 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
return;
}
if (widget.onEditPressed != null) {
widget.onEditPressed!(); // Use callback if provided
if (onEditPressed != null) {
onEditPressed!(); // Use callback if provided
} else {
// Fallback behavior
Navigator.pop(context); // Close this sheet first
appState.startEditSession(displayNode); // HomeScreen will auto-show the edit sheet
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
}
}
void deleteNode() async {
if (!appState.isLoggedIn) {
final shouldLogIn = await showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
final shouldDelete = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(locService.t('node.confirmDeleteTitle')),
content: Text(locService.t('node.mustBeLoggedInToDelete')),
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
@@ -172,46 +78,17 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(locService.t('actions.logIn')),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(locService.t('actions.delete')),
),
],
),
);
if ((shouldLogIn ?? false) && context.mounted) {
Navigator.pushNamed(context, '/settings/osm-account');
}
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(
nodeId: displayNode.id.toString(),
locService: locService,
),
);
},
);
if ((result?.confirmed ?? false) && context.mounted) {
if (shouldDelete == true && context.mounted) {
Navigator.pop(context); // Close this sheet first
appState.deleteNode(displayNode, changesetComment: result!.comment);
appState.deleteNode(node);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
);
@@ -219,7 +96,7 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
}
void viewOnOSM() async {
final url = 'https://www.openstreetmap.org/node/${displayNode.id}';
final url = 'https://www.openstreetmap.org/node/${node.id}';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
@@ -244,148 +121,10 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: displayNode),
builder: (context) => AdvancedEditOptionsSheet(node: node),
);
}
void viewInQueue() {
Navigator.pop(context); // Close this sheet first
Navigator.pushNamed(context, '/settings/queue');
}
Widget? uploadStatusBanner;
if (!isEditable) {
final IconData statusIcon;
final Color statusColor;
final String statusLabel;
final bool showSpinner;
final bool showQueueButton;
final state = upload?.uploadState;
if (state == null) {
// Fallback: tag says pending but we couldn't find a matching
// queue entry (shouldn't normally happen).
statusIcon = Icons.hourglass_empty;
statusColor = Colors.orange;
statusLabel = locService.t('node.uploadPending');
showSpinner = false;
showQueueButton = true;
} else {
switch (state) {
case UploadState.pending:
statusIcon = Icons.hourglass_empty;
statusColor = Colors.orange;
statusLabel = locService.t('node.uploadPending');
showSpinner = false;
showQueueButton = true;
break;
case UploadState.creatingChangeset:
statusIcon = Icons.cloud_upload_outlined;
statusColor = Colors.orange;
statusLabel = locService.t('node.uploadCreatingChangeset');
showSpinner = true;
showQueueButton = true;
break;
case UploadState.uploading:
statusIcon = Icons.cloud_upload_outlined;
statusColor = Colors.orange;
statusLabel = locService.t('node.uploadUploading');
showSpinner = true;
showQueueButton = true;
break;
case UploadState.closingChangeset:
statusIcon = Icons.cloud_upload_outlined;
statusColor = Colors.orange;
statusLabel = locService.t('node.uploadClosingChangeset');
showSpinner = true;
showQueueButton = true;
break;
case UploadState.error:
statusIcon = Icons.error_outline;
statusColor = Colors.red;
statusLabel = locService.t('node.uploadError');
showSpinner = false;
showQueueButton = true;
break;
case UploadState.complete:
statusIcon = Icons.check_circle_outline;
statusColor = Colors.green;
statusLabel = locService.t('node.uploadComplete');
showSpinner = false;
showQueueButton = false;
break;
}
}
uploadStatusBanner = Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (showSpinner)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: statusColor,
),
)
else
Icon(statusIcon, color: statusColor, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
statusLabel,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (state == UploadState.error &&
(upload?.errorMessage?.isNotEmpty ?? false)) ...[
const SizedBox(height: 4),
SelectableText(
upload!.errorMessage!,
style: TextStyle(
color: statusColor.withValues(alpha: 0.9),
fontSize: 12,
),
),
],
if (showQueueButton) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: viewInQueue,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 28),
foregroundColor: statusColor,
),
child: Text(locService.t('node.viewInQueue')),
),
),
],
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
return SafeArea(
@@ -396,92 +135,78 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', displayNode.id.toString()),
locService.t('node.title').replaceAll('{}', node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
?uploadStatusBanner,
// Tag list with flexible height constraint
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
),
child: SelectionArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...displayNode.tags.entries
.where((e) => !e.key.startsWith('_')) // Hide internal bookkeeping tags
.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...node.tags.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Linkify(
onOpen: (link) async {
final uri = Uri.parse(link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
);
}
},
text: e.value,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(width: 8),
Expanded(
child: Linkify(
onOpen: (link) async {
final uri = Uri.parse(link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
);
}
},
text: e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(humanize: false),
),
],
),
),
],
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
// First row: View and Advanced buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isRealOSMNode) ...[
OutlinedButton.icon(
TextButton.icon(
onPressed: () => viewOnOSM(),
icon: const Icon(Icons.open_in_new, size: 18),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.viewOnOSM')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(width: 8),
],
if (isEditable) ...[
OutlinedButton.icon(
onPressed: openAdvancedEdit,
@@ -510,12 +235,12 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: displayNode.isConstrained ? null : deleteNode,
onPressed: node.isConstrained ? null : deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: displayNode.isConstrained ? null : Colors.red,
foregroundColor: node.isConstrained ? null : Colors.red,
),
),
const SizedBox(width: 12),
@@ -535,213 +260,4 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
},
);
}
/// Shown when the tracked upload was a deletion that has now completed -
/// the underlying node no longer exists, so we can't show tags or offer
/// edit/delete actions on it anymore.
Widget _buildDeletedSheet(BuildContext context, LocalizationService locService) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', widget.node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.green, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('node.deletedSuccessfully'),
style: const TextStyle(
color: Colors.green,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
}
}
enum _DeleteReason {
deviceRemoved,
duplicateNode,
misidentifiedInfra,
spamVandalism,
custom,
}
extension _DeleteReasonLabel on _DeleteReason {
String label(LocalizationService locService) {
switch (this) {
case _DeleteReason.deviceRemoved:
return locService.t('node.deleteReasonDeviceRemoved');
case _DeleteReason.duplicateNode:
return locService.t('node.deleteReasonDuplicateNode');
case _DeleteReason.misidentifiedInfra:
return locService.t('node.deleteReasonMisidentifiedInfra');
case _DeleteReason.spamVandalism:
return locService.t('node.deleteReasonSpamVandalism');
case _DeleteReason.custom:
return locService.t('node.deleteReasonCustom');
}
}
/// The text used to build the changeset comment (kept in English/consistent
/// regardless of UI language, matching prior app behavior for uploads).
String get commentText {
switch (this) {
case _DeleteReason.deviceRemoved:
return 'device removed';
case _DeleteReason.duplicateNode:
return 'duplicate node';
case _DeleteReason.misidentifiedInfra:
return 'misidentified infra';
case _DeleteReason.spamVandalism:
return 'spam/vandalism';
case _DeleteReason.custom:
return '';
}
}
}
class _DeleteNodeDialog extends StatefulWidget {
final String nodeId;
final LocalizationService locService;
const _DeleteNodeDialog({
required this.nodeId,
required this.locService,
});
@override
State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState();
}
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
late final TextEditingController _commentController;
_DeleteReason? _selectedReason;
@override
void initState() {
super.initState();
_commentController = TextEditingController();
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
bool get _canConfirm {
if (_selectedReason == null) return false;
if (_selectedReason == _DeleteReason.custom) {
return _commentController.text.trim().isNotEmpty;
}
return true;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.locService.t('node.confirmDeleteTitle')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])),
const SizedBox(height: 16),
Text(
widget.locService.t('node.deleteReasonLabel'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
DropdownButtonFormField<_DeleteReason>(
initialValue: _selectedReason,
hint: Text(widget.locService.t('node.deleteReasonSelectHint')),
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: _DeleteReason.values
.map((reason) => DropdownMenuItem(
value: reason,
child: Text(reason.label(widget.locService)),
))
.toList(),
onChanged: (reason) {
setState(() {
_selectedReason = reason;
});
},
),
if (_selectedReason == _DeleteReason.custom) ...[
const SizedBox(height: 8),
TextField(
controller: _commentController,
decoration: InputDecoration(
hintText: widget.locService.t('node.deleteReasonHint'),
border: const OutlineInputBorder(),
isDense: true,
),
maxLines: 2,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) => setState(() {}),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
child: Text(widget.locService.cancel),
),
TextButton(
onPressed: _canConfirm
? () {
final reasonText = _selectedReason == _DeleteReason.custom
? _commentController.text.trim()
: _selectedReason!.commentText;
final finalComment = reasonText.isEmpty
? 'Delete a surveillance node'
: 'Delete a surveillance node: $reasonText';
Navigator.of(context).pop((confirmed: true, comment: finalComment));
}
: null,
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(widget.locService.t('actions.delete')),
),
],
);
}
}
}
+69 -144
View File
@@ -4,164 +4,89 @@ 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.
///
/// 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 {
class PositioningTutorialOverlay extends StatelessWidget {
const PositioningTutorialOverlay({
super.key,
required this.visible,
this.onFadeOutComplete,
});
/// 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).
/// Called when the fade-out animation completes (if animated)
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) {
// 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,
),
],
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,
),
),
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,
),
],
),
),
),
),
);
},
),
),
),
);
},
);
}
}
}
@@ -1,54 +0,0 @@
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')),
),
],
);
}
}
+140 -172
View File
@@ -13,10 +13,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe"
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
version: "7.0.0"
app_links_linux:
dependency: transitive
description:
@@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
version: "2.0.2"
app_links_web:
dependency: transitive
description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -109,10 +109,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.0.0"
collection:
dependency: "direct main"
description:
@@ -165,18 +165,18 @@ packages:
dependency: transitive
description:
name: dbus
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.14"
version: "0.7.12"
desktop_webview_window:
dependency: transitive
description:
name: desktop_webview_window
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
url: "https://pub.dev"
source: hosted
version: "0.3.0"
version: "0.2.3"
fake_async:
dependency: "direct dev"
description:
@@ -242,42 +242,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "19.5.0"
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "4.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "7.2.0"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b"
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
url: "https://pub.dev"
source: hosted
version: "8.3.0"
version: "8.2.2"
flutter_map_animations:
dependency: "direct main"
description:
@@ -290,34 +282,34 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev"
source: hosted
version: "2.4.8"
version: "2.4.7"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev"
source: hosted
version: "10.3.1"
version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
version: "0.2.0"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.0"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -330,10 +322,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.0"
flutter_secure_storage_windows:
dependency: transitive
description:
@@ -346,10 +338,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -359,10 +351,10 @@ packages:
dependency: "direct main"
description:
name: flutter_web_auth_2
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
version: "5.0.1"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
@@ -396,18 +388,18 @@ packages:
dependency: transitive
description:
name: geolocator_apple
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
url: "https://pub.dev"
source: hosted
version: "2.3.14"
version: "2.3.13"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
url: "https://pub.dev"
source: hosted
version: "4.2.8"
version: "4.2.6"
geolocator_web:
dependency: transitive
description:
@@ -424,22 +416,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "1.0.1"
html:
dependency: transitive
description:
@@ -480,30 +480,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.12.0"
version: "4.11.0"
latlong2:
dependency: "direct main"
description:
@@ -552,6 +536,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
@@ -564,42 +564,50 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.0.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
version: "1.0.4"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested:
dependency: transitive
description:
@@ -612,34 +620,26 @@ packages:
dependency: "direct main"
description:
name: oauth2_client
sha256: "74d1024ce3bd8c6012d079e075068c94d64d804a40bcb3d4adb7348bf0c278f7"
sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f"
url: "https://pub.dev"
source: hosted
version: "4.3.2"
version: "4.2.3"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "9.3.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.1"
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
@@ -668,18 +668,18 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.6"
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
@@ -700,10 +700,10 @@ packages:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
@@ -748,10 +748,10 @@ packages:
dependency: transitive
description:
name: proj4dart
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.1.0"
provider:
dependency: "direct main"
description:
@@ -776,30 +776,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.5"
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
url: "https://pub.dev"
source: hosted
version: "2.4.26"
version: "2.4.20"
shared_preferences_foundation:
dependency: transitive
description:
@@ -820,10 +812,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
@@ -840,14 +832,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
simple_sparse_list:
dependency: transitive
description:
name: simple_sparse_list
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
url: "https://pub.dev"
source: hosted
version: "0.1.4"
sky_engine:
dependency: transitive
description: flutter
@@ -865,42 +849,42 @@ packages:
dependency: "direct main"
description:
name: sqflite
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.2+2"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.11"
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2"
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -929,10 +913,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.1"
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -945,18 +929,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.10.1"
version: "0.9.4"
typed_data:
dependency: transitive
description:
@@ -969,10 +953,10 @@ packages:
dependency: transitive
description:
name: unicode
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
url: "https://pub.dev"
source: hosted
version: "1.1.9"
version: "0.3.1"
universal_io:
dependency: transitive
description:
@@ -993,10 +977,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.32"
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
@@ -1033,10 +1017,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
@@ -1057,10 +1041,10 @@ packages:
dependency: transitive
description:
name: vector_graphics
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
@@ -1073,10 +1057,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
url: "https://pub.dev"
source: hosted
version: "1.2.6"
version: "1.2.0"
vector_math:
dependency: transitive
description:
@@ -1089,26 +1073,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev"
source: hosted
version: "1.5.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b
url: "https://pub.dev"
source: hosted
version: "1.5.1"
version: "15.0.2"
web:
dependency: transitive
description:
@@ -1129,10 +1097,10 @@ packages:
dependency: transitive
description:
name: window_to_front
sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241"
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee"
url: "https://pub.dev"
source: hosted
version: "0.0.4"
version: "0.0.3"
wkt_parser:
dependency: transitive
description:
@@ -1166,5 +1134,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
+2 -4
View File
@@ -1,11 +1,10 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 2.11.1+62 # The thing after the + is the version code, incremented with each release
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
flutter: ">=3.44.0" # Mandate Flutter 3.44.0 for SPM default
dependencies:
flutter:
@@ -20,7 +19,7 @@ dependencies:
http: ^1.2.1
flutter_svg: ^2.0.10
xml: ^6.4.2
flutter_local_notifications: ^19.0.0
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
flutter_linkify: ^6.0.0
app_links: ^7.0.0
@@ -39,7 +38,6 @@ dependencies:
package_info_plus: ^9.0.0
csv: ^6.0.0
collection: ^1.18.0
wakelock_plus: ^1.5.2
dev_dependencies:
flutter_test:
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more