Compare commits

...
Author SHA1 Message Date
stopflock 7ed1d7386d specify apple app ID 2026-06-29 18:37:33 -05:00
stopflock 9268ef2eb2 bump version identifier 2026-06-29 17:37:54 -05:00
stopflock 1e2eba53a9 Merge pull request #169 from reb1995/deintegrate-CocoaPods
Update Flutter to minumum 3.44.0. Deintegrate CocoaPods from build completely
2026-06-29 14:47:13 -05:00
Ryan Brown d009803215 Update Flutter to minumum 3.44.0. Deintegrate CocoaPods from build compepletely. 2026-06-27 21:19:17 +00:00
stopflock 2dc5b6efa1 Merge pull request #168 from reb1995/update-local-notifications-version
Update flutter_local_notifications to min v19.0.0. Update app to use Swift Package Manager build process for all packages.
2026-06-27 12:32:04 -05:00
Ryan Brown 0800f1ed45 Update NDK version to clear 'jni requires Android NDK 28.2.13676358' build error. Appeared after updating other dependencies and updating flutter_local_notifications minimum version. 2026-06-26 06:56:25 +00:00
Ryan Brown c844cff67b Update flutter_local_notifications to min v19.0.0. Update app to use Swift Package Manager build process for all packages. 2026-06-24 03:59:20 +00:00
stopflock 3ff9ff2352 fix profile reordering test 2026-06-15 16:30:41 -05:00
stopflock 12ffe92df8 suspected locations background refresh 2026-06-14 21:40:22 -05:00
stopflock 0b812bdaea long press to add node 2026-06-14 21:20:17 -05:00
stopflock d87a112c0a silence useless deeplink exception 2026-06-14 19:36:08 -05:00
stopflock 41479e7ee4 2.10.2 fixes reorderlist deprecation 2026-06-14 19:02:54 -05:00
stopflock 036ac8c5e6 Merge pull request #166 from FoggedLens/feature/node-deeplink
Feature/node deeplink
2026-06-14 15:11:27 -05:00
flockhopperdevandClaude Opus 4.6 191b5ccae0 refactor: use UserAgentClient for OSM API fetch, remove redundant comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 17:39:30 -06:00
flockhopperdevandClaude Sonnet 4.6 480f99b360 feat: wire HomeScreen to handle node deep links
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:39:30 -06:00
flockhopperdevandClaude Opus 4.6 74d8214dda feat: add node deep link handling to DeepLinkService
Adds `deflockapp://node?id=<nodeId>` deep link support: parses the node
ID, fetches the OSM node via the public API, and delivers it via an
`onNodeDeepLink` callback for HomeScreen to register. Includes URL
parsing unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 17:39:30 -06:00
flockhopperdevandClaude Opus 4.6 8aab359f22 feat: add node deep link intent filter
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 17:39:30 -06:00
stopflock b00e44e3bb Merge pull request #163 from reb1995/keep-screen-awake-while-using-app
Keep screen awake while using app. Update translations for new settings.
2026-05-12 15:18:32 -05:00
Ryan Brown f438c21166 Add translations. Translated by Google Translate. 2026-05-09 23:35:19 +00:00
Ryan Brown e0b2a6a19c Update and confirm english localizations. 2026-05-09 22:34:04 +00:00
Ryan Brown 329974a8f0 Feature: Keep screen on while using app. Initial working. 2026-05-09 22:02:36 +00:00
stopflock 5b55171080 Merge pull request #155 from reb1995/fix-android-gps-location-accuracy-requirement
Update gps_controller to not require location accuracy in android.
2026-05-04 21:38:32 -05:00
reb1995 2ba0667a76 Update gps_controller.dart
Remove extra import that isn't required. Now passes flutter analyze.
2026-04-12 21:07:26 -04:00
reb1995 8a3ece630c Merge branch 'FoggedLens:main' into fix-android-gps-location-accuracy-requirement 2026-04-12 19:39:37 -04:00
stopflock c2b0f6f9fd Merge pull request #156 from reb1995/Update-OAuth2-with-Application-Permissions
Update DEVELOPER.md to include OSM OAuth2 Application Permissions
2026-04-12 16:25:48 -05:00
reb1995 273bccc5eb Update DEVELOPER.md
Updates DEVELOPER.md instructions with the OSM OAuth2 Application Permissions and Redirect URIs. Adds needed clarity for developers to make sure the app has the required permissions if compiling on their own.
2026-04-09 00:33:39 -04:00
reb1995 d7be022a11 Update gps_controller to not require location accuracy in android.
Adds basic target matching. If Android Do... Else use old code.
2026-04-06 13:09:41 -04:00
stopflock 157e72011f devibe changelog 2026-03-23 13:15:26 -05:00
stopflock bd71f88452 operator profiles - import, reorderable 2026-03-23 12:58:50 -05:00
stopflock 6e2fa3a04c typo 2026-03-23 11:45:13 -05:00
stopflock 843d377b87 Nuclear reset button now appears when kEnableDevelopmentModes is true as well 2026-03-15 14:31:39 -05:00
stopflock 3857023d43 Fix deprecation warning 2026-03-14 18:11:43 -05:00
stopflock ddf7f543ff Button to trigger nuke reset in dev mode 2026-03-14 18:04:57 -05:00
stopflock 9f72ec9a76 Fix lint warnings, clean up debug logging 2026-03-14 17:31:01 -05:00
stopflock 16e34b614f Rework profile FOVs - 360 checkbox 2026-03-14 17:18:33 -05:00
stopflock 2d00d7a8fb bump ver 2026-03-12 22:30:16 -05:00
stopflock f85db3ca11 add "report osm map issue" link to info/about page 2026-03-12 22:19:21 -05:00
stopflock 447f358727 Limit max files open to prevent OS error for too many files open related to tile storage 2026-03-12 19:58:07 -05:00
stopflock 08b395214b Move "about osm" to about page, add option to clear tile caches, option to add reason when deleting, min zoom 16 for edits/submissions, 300km nav distance warning 2026-03-12 18:59:48 -05:00
stopflock 256dd1a43c Merge pull request #145 from dougborg/feat/resilience-policy
Add endpoint migration with centralized retry/fallback policy
2026-03-12 11:42:28 -05:00
Doug BorgandClaude Opus 4.6 ca7192d3ec Add changelog entry for retry/fallback feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:20:08 -06:00
Doug BorgandClaude Opus 4.6 2833906c68 Add centralized retry/fallback policy with hard-coded endpoints
Extract duplicated retry logic from OverpassService and RoutingService
into a shared resilience framework in service_policy.dart:

- ResiliencePolicy: configurable retries, backoff, and HTTP timeout
- executeWithFallback: retry loop with primary→fallback endpoint chain
- ErrorDisposition enum: abort / fallback / retry classification
- ServicePolicy + ServicePolicyResolver: per-service compliance rules
  (rate limits, caching, concurrency) for OSMF and third-party services
- ServiceRateLimiter: async semaphore-based concurrency and rate control

OverpassService now hits overpass.deflock.org first, falls back to
overpass-api.de. RoutingService hits api.dontgetflocked.com first,
falls back to alprwatch.org. Both use per-service error classifiers
to determine retry vs fallback vs abort behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:13:52 -06:00
69 changed files with 2775 additions and 856 deletions

No files matched your search

+5 -4
View File
@@ -27,7 +27,7 @@ jobs:
id: set-info id: set-info
run: | run: |
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
if [ "${{ github.event.release.prerelease }}" = "true" ]; then if [ "${{ github.event.release.prerelease }}" = "true" ]; then
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
echo "✅ Pre-release - will build and attach assets, no store uploads" echo "✅ Pre-release - will build and attach assets, no store uploads"
@@ -187,7 +187,7 @@ jobs:
# import certificate to keychain # import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH 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 security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Set this keychain as the default # Set this keychain as the default
security list-keychain -d user -s $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH
security default-keychain -s $KEYCHAIN_PATH security default-keychain -s $KEYCHAIN_PATH
@@ -195,7 +195,7 @@ jobs:
# install provisioning profile # install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
# Also install using the profile's internal UUID for better compatibility # 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()" -) 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 cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
@@ -329,7 +329,8 @@ jobs:
--type ios \ --type ios \
--file Runner.ipa \ --file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \ --apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID --apiIssuer $APP_STORE_CONNECT_ISSUER_ID \
--apple-id 6752760780
# Clean up sensitive files # Clean up sensitive files
rm -rf ~/private_keys rm -rf ~/private_keys
+5 -4
View File
@@ -38,13 +38,14 @@ ios/Flutter/Flutter.framework
ios/Pods/ ios/Pods/
ios/.generated/ ios/.generated/
ios/.idea/ ios/.idea/
ios/Runner.xcworkspace/ ios/Runner.xcworkspace/xcuserdata/
ios/Runner.xcworkspace/project.xcworkspace/xcuserdata/
macos/Pods/ macos/Pods/
macos/.generated/ macos/.generated/
macos/Flutter/ephemeral/ macos/Flutter/ephemeral/
# CocoaPods – commit Podfile.lock if you need reproducible iOS builds
Podfile.lock
Pods/ Pods/
Podfile
Podfile.lock
# Generated icons and splash screens # Generated icons and splash screens
ios/Runner/Assets.xcassets/AppIcon.appiconset/* ios/Runner/Assets.xcassets/AppIcon.appiconset/*
@@ -103,4 +104,4 @@ build_keys.conf
linux/ linux/
macos/ macos/
web/ web/
windows/ windows/
-11
View File
@@ -1,11 +0,0 @@
---
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."
+7
View File
@@ -854,6 +854,13 @@ To run the app with working OSM authentication, register OAuth2 applications:
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications 1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
2. **Sandbox OSM**: https://master.apis.dev.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): For local builds, create `build_keys.conf` (gitignored):
```bash ```bash
+1 -1
View File
@@ -20,7 +20,7 @@ android {
compileSdk = 36 compileSdk = 36
// NDK only needed if you build native plugins; keep your pinned version // NDK only needed if you build native plugins; keep your pinned version
ndkVersion = "27.0.12077973" ndkVersion = "28.2.13676358"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
+1
View File
@@ -42,6 +42,7 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="deflockapp" android:host="profiles"/> <data android:scheme="deflockapp" android:host="profiles"/>
<data android:scheme="deflockapp" android:host="node"/>
</intent-filter> </intent-filter>
</activity> </activity>
+29 -1
View File
@@ -1,7 +1,35 @@
{ {
"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": { "2.9.1": {
"content": [ "content": [
"• When hitting node render limit, only render nodes closest to center of viewport." "• 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."
] ]
}, },
"2.9.0": { "2.9.0": {
-2
View File
@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
-1
View File
@@ -1,2 +1 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
-43
View File
@@ -1,43 +0,0 @@
# 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
+24 -104
View File
@@ -10,9 +10,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
80E9445C8798AC7271AAC8BA /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 015ABBACED80649D4AFF850D /* Pods_Runner.framework */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -42,20 +41,15 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; 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>"; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/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>"; }; 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>"; }; 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; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -63,8 +57,6 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -72,7 +64,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5B624583B69E961F78575427 /* Pods_RunnerTests.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -80,27 +71,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
80E9445C8798AC7271AAC8BA /* Pods_Runner.framework in Frameworks */, 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup 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 */ = { 331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -109,18 +86,10 @@
path = RunnerTests; path = RunnerTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
968347D6C1E8FCB1D43AE8AF /* Frameworks */ = {
isa = PBXGroup;
children = (
015ABBACED80649D4AFF850D /* Pods_Runner.framework */,
C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -136,8 +105,6 @@
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
2C653E01607E349213D63C91 /* Pods */,
968347D6C1E8FCB1D43AE8AF /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -172,7 +139,6 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = ( buildPhases = (
4C9A553A911C8019A2A14C90 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */, 331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */, 331C807F294A63A400263BE5 /* Resources */,
1789E739C02C24F676C90644 /* Frameworks */, 1789E739C02C24F676C90644 /* Frameworks */,
@@ -191,20 +157,21 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
EF132DD72459C72ECA32FCAD /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */, 9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */, 97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */, 97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */, 97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
1553428839027C2A5102CCB3 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -238,6 +205,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -270,23 +240,6 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase 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 */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -303,28 +256,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; 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 */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -340,28 +271,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; 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 */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -491,7 +400,6 @@
}; };
331C8088294A63A400263BE5 /* Debug */ = { 331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -509,7 +417,6 @@
}; };
331C8089294A63A400263BE5 /* Release */ = { 331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -525,7 +432,6 @@
}; };
331C808A294A63A400263BE5 /* Profile */ = { 331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
@@ -735,6 +641,20 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }
@@ -5,6 +5,24 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "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> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
+10
View File
@@ -0,0 +1,10 @@
<?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>
+5 -2
View File
@@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }
+84 -65
View File
@@ -1,72 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict>
<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>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> <dict>
<key>CFBundleDevelopmentRegion</key> <key>UIApplicationSupportsMultipleScenes</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/> <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> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<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> </plist>
+24 -3
View File
@@ -156,6 +156,7 @@ class AppState extends ChangeNotifier {
int get maxNodes => _settingsState.maxNodes; int get maxNodes => _settingsState.maxNodes;
UploadMode get uploadMode => _settingsState.uploadMode; UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode; FollowMeMode get followMeMode => _settingsState.followMeMode;
bool get keepScreenAwake => _settingsState.keepScreenAwake;
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
int get proximityAlertDistance => _settingsState.proximityAlertDistance; int get proximityAlertDistance => _settingsState.proximityAlertDistance;
@@ -172,7 +173,6 @@ class AppState extends ChangeNotifier {
TileType? get selectedTileType => _settingsState.selectedTileType; TileType? get selectedTileType => _settingsState.selectedTileType;
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider; TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
// Upload queue state // Upload queue state
int get pendingCount => _uploadQueueState.pendingCount; int get pendingCount => _uploadQueueState.pendingCount;
@@ -250,6 +250,9 @@ class AppState extends ChangeNotifier {
_isInitialized = true; _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 // Check for initial deep link after a small delay to let navigation settle
Future.delayed(const Duration(milliseconds: 500), () { Future.delayed(const Duration(milliseconds: 500), () {
DeepLinkService().checkInitialLink(); DeepLinkService().checkInitialLink();
@@ -414,6 +417,11 @@ class AppState extends ChangeNotifier {
void deleteProfile(NodeProfile p) { void deleteProfile(NodeProfile p) {
_profileState.deleteProfile(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 // Callback when a profile is deleted - clear any stale session references
void _onProfileDeleted(NodeProfile deletedProfile) { void _onProfileDeleted(NodeProfile deletedProfile) {
@@ -437,6 +445,10 @@ class AppState extends ChangeNotifier {
_operatorProfileState.deleteProfile(p); _operatorProfileState.deleteProfile(p);
} }
void reorderOperatorProfiles(int oldIndex, int newIndex) {
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
}
// ---------- Session Methods ---------- // ---------- Session Methods ----------
void startAddSession() { void startAddSession() {
_sessionState.startAddSession(enabledProfiles); _sessionState.startAddSession(enabledProfiles);
@@ -581,8 +593,8 @@ class AppState extends ChangeNotifier {
} }
} }
void deleteNode(OsmNode node) { void deleteNode(OsmNode node, {String? changesetComment}) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode); _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
_startUploader(); _startUploader();
} }
@@ -669,6 +681,10 @@ class AppState extends ChangeNotifier {
} }
} }
Future<void> setKeepScreenAwake(bool enabled) async {
await _settingsState.setKeepScreenAwake(enabled);
}
Future<void> setPauseQueueProcessing(bool enabled) async { Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled); await _settingsState.setPauseQueueProcessing(enabled);
if (!enabled) { if (!enabled) {
@@ -717,6 +733,11 @@ class AppState extends ChangeNotifier {
await _settingsState.deleteTileProvider(providerId); 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 /// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async { Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode); await _settingsState.setFollowMeMode(mode);
+7 -6
View File
@@ -64,9 +64,6 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0; const double kChangesetCloseBackoffMultiplier = 2.0;
// Navigation routing configuration
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
// Overpass API configuration // Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
@@ -85,6 +82,9 @@ const bool kEnableNodeEdits = true; // Set to false to temporarily disable node
// Node extraction features - set to false to hide extract functionality for constrained nodes // 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) const bool kEnableNodeExtraction = false; // Set to true to enable extract from way/relation feature (WIP)
// 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 /// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) { bool enableNavigationFeatures({required bool offlineMode}) {
return kEnableNavigationFeatures && !offlineMode; return kEnableNavigationFeatures && !offlineMode;
@@ -93,9 +93,10 @@ bool enableNavigationFeatures({required bool offlineMode}) {
// Marker/node interaction // Marker/node interaction
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) 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 kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes) const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250); 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); const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
// Pre-fetch area configuration // Pre-fetch area configuration
@@ -131,7 +132,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node 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) // Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
// Positioning tutorial configuration // Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
@@ -139,7 +140,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
// Navigation route planning configuration // Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km) const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
// Node display configuration // Node display configuration
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
+14 -2
View File
@@ -74,8 +74,13 @@
"advancedSettings": "Erweiterte Einstellungen", "advancedSettings": "Erweiterte Einstellungen",
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
"proximityAlerts": "Näherungswarnungen", "proximityAlerts": "Näherungswarnungen",
"keepScreenAwake": "Bildschirm Aktiv Lassen",
"networkStatusIndicator": "Netzwerkstatus-Anzeige" "networkStatusIndicator": "Netzwerkstatus-Anzeige"
}, },
"keepScreenAwake": {
"title": "Bildschirm während der App-Nutzung aktiv lassen",
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte", "getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung", "batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
@@ -95,7 +100,9 @@
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht", "editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht", "deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen", "confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden." "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 (Optional)",
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -247,6 +254,9 @@
"addProvider": "Anbieter Hinzufügen", "addProvider": "Anbieter Hinzufügen",
"deleteProvider": "Anbieter Löschen", "deleteProvider": "Anbieter Löschen",
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?", "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", "providerName": "Anbieter-Name",
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH", "providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
"providerNameRequired": "Anbieter-Name ist erforderlich", "providerNameRequired": "Anbieter-Name ist erforderlich",
@@ -319,6 +329,8 @@
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)", "fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat", "fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen", "fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
"fov360": "360° Sichtfeld",
"fov360Subtitle": "Kamera hat 360-Grad omnidirektionales Sichtfeld",
"submittable": "Übertragbar", "submittable": "Übertragbar",
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann", "submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
"osmTags": "OSM-Tags", "osmTags": "OSM-Tags",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen", "dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org", "dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
"minimumDistance": "Mindestabstand zu echten Geräten", "minimumDistance": "Mindestabstand zu echten Geräten",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden", "minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {} vorhandener Überwachungsgeräte ausblenden",
"updating": "Verdächtige Standorte werden aktualisiert", "updating": "Verdächtige Standorte werden aktualisiert",
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...", "downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert", "updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
+16 -4
View File
@@ -111,7 +111,12 @@
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
"proximityAlerts": "Proximity Alerts", "proximityAlerts": "Proximity Alerts",
"networkStatusIndicator": "Network Status Indicator" "networkStatusIndicator": "Network Status Indicator",
"keepScreenAwake": "Keep Screen Awake"
},
"keepScreenAwake": {
"title": "Keep screen awake while using app",
"subtitle": "Prevents the screen from turning off automatically while using the app"
}, },
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices", "getNotified": "Get notified when approaching surveillance devices",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Node edit queued for upload", "editQueuedForUpload": "Node edit queued for upload",
"deleteQueuedForUpload": "Node deletion queued for upload", "deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node", "confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone." "confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
"deleteReasonLabel": "Reason for Deletion (Optional)",
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
}, },
"addNode": { "addNode": {
"profile": "Profile", "profile": "Profile",
@@ -282,8 +289,11 @@
"needsApiKey": "Needs API key", "needsApiKey": "Needs API key",
"editProvider": "Edit Provider", "editProvider": "Edit Provider",
"addProvider": "Add Provider", "addProvider": "Add Provider",
"deleteProvider": "Delete Provider", "deleteProvider": "Delete Provider",
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?", "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", "providerName": "Provider Name",
"providerNameHint": "e.g., Custom Maps Inc.", "providerNameHint": "e.g., Custom Maps Inc.",
"providerNameRequired": "Provider name is required", "providerNameRequired": "Provider name is required",
@@ -356,6 +366,8 @@
"fovHint": "FOV in degrees (leave empty for default)", "fovHint": "FOV in degrees (leave empty for default)",
"fovSubtitle": "Camera field of view - used for cone width and range submission format", "fovSubtitle": "Camera field of view - used for cone width and range submission format",
"fovInvalid": "FOV must be between 1 and 360 degrees", "fovInvalid": "FOV must be between 1 and 360 degrees",
"fov360": "360 FOV",
"fov360Subtitle": "Camera has 360-degree omnidirectional field of view",
"submittable": "Submittable", "submittable": "Submittable",
"submittableSubtitle": "Whether this profile can be used for camera submissions", "submittableSubtitle": "Whether this profile can be used for camera submissions",
"osmTags": "OSM Tags", "osmTags": "OSM Tags",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites", "dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org", "dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
"minimumDistance": "Minimum Distance from Real Nodes", "minimumDistance": "Minimum Distance from Real Nodes",
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices", "minimumDistanceSubtitle": "Hide suspected locations within {} of existing surveillance devices",
"updating": "Updating Suspected Locations", "updating": "Updating Suspected Locations",
"downloadingAndProcessing": "Downloading and processing data...", "downloadingAndProcessing": "Downloading and processing data...",
"updateSuccess": "Suspected locations updated successfully", "updateSuccess": "Suspected locations updated successfully",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Configuración Avanzada", "advancedSettings": "Configuración Avanzada",
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
"proximityAlerts": "Alertas de Proximidad", "proximityAlerts": "Alertas de Proximidad",
"keepScreenAwake": "Mantener la Pantalla Encendida",
"networkStatusIndicator": "Indicador de Estado de Red" "networkStatusIndicator": "Indicador de Estado de Red"
}, },
"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"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia", "getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación", "batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Edición de nodo en cola para subir", "editQueuedForUpload": "Edición de nodo en cola para subir",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir", "deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo", "confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer." "confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
"deleteReasonLabel": "Razón para Eliminación (Opcional)",
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
}, },
"addNode": { "addNode": {
"profile": "Perfil", "profile": "Perfil",
@@ -284,6 +291,9 @@
"addProvider": "Agregar Proveedor", "addProvider": "Agregar Proveedor",
"deleteProvider": "Eliminar Proveedor", "deleteProvider": "Eliminar Proveedor",
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?", "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", "providerName": "Nombre del Proveedor",
"providerNameHint": "ej., Mapas Personalizados Inc.", "providerNameHint": "ej., Mapas Personalizados Inc.",
"providerNameRequired": "El nombre del proveedor es requerido", "providerNameRequired": "El nombre del proveedor es requerido",
@@ -356,6 +366,8 @@
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)", "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", "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", "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", "submittable": "Envíable",
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras", "submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
"osmTags": "Etiquetas OSM", "osmTags": "Etiquetas OSM",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia", "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", "dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
"minimumDistance": "Distancia Mínima de Nodos Reales", "minimumDistance": "Distancia Mínima de Nodos Reales",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes", "minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {} de dispositivos de vigilancia existentes",
"updating": "Actualizando Ubicaciones Sospechosas", "updating": "Actualizando Ubicaciones Sospechosas",
"downloadingAndProcessing": "Descargando y procesando datos...", "downloadingAndProcessing": "Descargando y procesando datos...",
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente", "updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Paramètres Avancés", "advancedSettings": "Paramètres Avancés",
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
"proximityAlerts": "Alertes de Proximité", "proximityAlerts": "Alertes de Proximité",
"keepScreenAwake": "Maintenir l'écran Allumé",
"networkStatusIndicator": "Indicateur de Statut Réseau" "networkStatusIndicator": "Indicateur de Statut Réseau"
}, },
"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"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance", "getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation", "batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Modification de nœud mise en file pour envoi", "editQueuedForUpload": "Modification de nœud mise en file pour envoi",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi", "deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud", "confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée." "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
"deleteReasonLabel": "Raison de Suppression (Optionnel)",
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +291,9 @@
"addProvider": "Ajouter Fournisseur", "addProvider": "Ajouter Fournisseur",
"deleteProvider": "Supprimer Fournisseur", "deleteProvider": "Supprimer Fournisseur",
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?", "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", "providerName": "Nom du Fournisseur",
"providerNameHint": "ex., Cartes Personnalisées Inc.", "providerNameHint": "ex., Cartes Personnalisées Inc.",
"providerNameRequired": "Le nom du fournisseur est requis", "providerNameRequired": "Le nom du fournisseur est requis",
@@ -356,6 +366,8 @@
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)", "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", "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", "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", "submittable": "Soumissible",
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras", "submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
"osmTags": "Balises OSM", "osmTags": "Balises OSM",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance", "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", "dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
"minimumDistance": "Distance Minimale des Nœuds Réels", "minimumDistance": "Distance Minimale des Nœuds Réels",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants", "minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {} des dispositifs de surveillance existants",
"updating": "Mise à Jour des Emplacements Suspects", "updating": "Mise à Jour des Emplacements Suspects",
"downloadingAndProcessing": "Téléchargement et traitement des données...", "downloadingAndProcessing": "Téléchargement et traitement des données...",
"updateSuccess": "Emplacements suspects mis à jour avec succès", "updateSuccess": "Emplacements suspects mis à jour avec succès",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Impostazioni Avanzate", "advancedSettings": "Impostazioni Avanzate",
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
"proximityAlerts": "Avvisi di Prossimità", "proximityAlerts": "Avvisi di Prossimità",
"keepScreenAwake": "Mantieni lo Schermo Attivo",
"networkStatusIndicator": "Indicatore di Stato di Rete" "networkStatusIndicator": "Indicatore di Stato di Rete"
}, },
"keepScreenAwake": {
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza", "getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione", "batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Modifica nodo in coda per il caricamento", "editQueuedForUpload": "Modifica nodo in coda per il caricamento",
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento", "deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
"confirmDeleteTitle": "Elimina Nodo", "confirmDeleteTitle": "Elimina Nodo",
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata." "confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
"deleteReasonLabel": "Motivo per Eliminazione (Opzionale)",
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
}, },
"addNode": { "addNode": {
"profile": "Profilo", "profile": "Profilo",
@@ -284,6 +291,9 @@
"addProvider": "Aggiungi Fornitore", "addProvider": "Aggiungi Fornitore",
"deleteProvider": "Elimina Fornitore", "deleteProvider": "Elimina Fornitore",
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?", "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", "providerName": "Nome Fornitore",
"providerNameHint": "es., Mappe Personalizzate Inc.", "providerNameHint": "es., Mappe Personalizzate Inc.",
"providerNameRequired": "Il nome del fornitore è obbligatorio", "providerNameRequired": "Il nome del fornitore è obbligatorio",
@@ -356,6 +366,8 @@
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)", "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", "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", "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", "submittable": "Inviabile",
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere", "submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
"osmTags": "Tag OSM", "osmTags": "Tag OSM",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza", "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", "dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
"minimumDistance": "Distanza Minima dai Nodi Reali", "minimumDistance": "Distanza Minima dai Nodi Reali",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti", "minimumDistanceSubtitle": "Nascondi posizioni sospette entro {} dai dispositivi di sorveglianza esistenti",
"updating": "Aggiornamento Posizioni Sospette", "updating": "Aggiornamento Posizioni Sospette",
"downloadingAndProcessing": "Scaricamento e elaborazione dati...", "downloadingAndProcessing": "Scaricamento e elaborazione dati...",
"updateSuccess": "Posizioni sospette aggiornate con successo", "updateSuccess": "Posizioni sospette aggiornate con successo",
+15 -3
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Geavanceerde Instellingen", "advancedSettings": "Geavanceerde Instellingen",
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
"proximityAlerts": "Nabijheids Waarschuwingen", "proximityAlerts": "Nabijheids Waarschuwingen",
"keepScreenAwake": "Scherm Actief Houden",
"networkStatusIndicator": "Netwerk Status Indicator" "networkStatusIndicator": "Netwerk Status Indicator"
}, },
"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"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert", "getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring", "batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
@@ -131,8 +136,10 @@
"queuedForUpload": "Node in wachtrij geplaatst voor upload", "queuedForUpload": "Node in wachtrij geplaatst voor upload",
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload", "editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload", "deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
"confirmDeleteTitle": "Verwijder Node", "confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." "confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteReasonLabel": "Reden voor Verwijdering (Optioneel)",
"deleteReasonHint": "bijv. apparaat verwijderd, onjuiste locatie, duplicaat..."
}, },
"addNode": { "addNode": {
"profile": "Profiel", "profile": "Profiel",
@@ -284,6 +291,9 @@
"addProvider": "Voeg Provider Toe", "addProvider": "Voeg Provider Toe",
"deleteProvider": "Verwijder Provider", "deleteProvider": "Verwijder Provider",
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?", "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", "providerName": "Provider Naam",
"providerNameHint": "bijv., Aangepaste Kaarten B.V.", "providerNameHint": "bijv., Aangepaste Kaarten B.V.",
"providerNameRequired": "Provider naam is vereist", "providerNameRequired": "Provider naam is vereist",
@@ -356,6 +366,8 @@
"fovHint": "FOV in graden (laat leeg voor standaard)", "fovHint": "FOV in graden (laat leeg voor standaard)",
"fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat", "fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat",
"fovInvalid": "FOV moet tussen 1 en 360 graden zijn", "fovInvalid": "FOV moet tussen 1 en 360 graden zijn",
"fov360": "360° Gezichtsveld",
"fov360Subtitle": "Camera heeft een 360-graden omnidirectioneel gezichtsveld",
"submittable": "Indienbaar", "submittable": "Indienbaar",
"submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen", "submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen",
"osmTags": "OSM Tags", "osmTags": "OSM Tags",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft", "dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org", "dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
"minimumDistance": "Minimum Afstand van Echte Nodes", "minimumDistance": "Minimum Afstand van Echte Nodes",
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten", "minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
"updating": "Verdachte Locaties Bijwerken", "updating": "Verdachte Locaties Bijwerken",
"downloadingAndProcessing": "Data downloaden en verwerken...", "downloadingAndProcessing": "Data downloaden en verwerken...",
"updateSuccess": "Verdachte locaties succesvol bijgewerkt", "updateSuccess": "Verdachte locaties succesvol bijgewerkt",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Ustawienia Zaawansowane", "advancedSettings": "Ustawienia Zaawansowane",
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
"proximityAlerts": "Alerty Bliskości", "proximityAlerts": "Alerty Bliskości",
"keepScreenAwake": "Nie Wygaszaj Ekranu",
"networkStatusIndicator": "Wskaźnik Stanu Sieci" "networkStatusIndicator": "Wskaźnik Stanu Sieci"
}, },
"keepScreenAwake": {
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru", "getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji", "batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania", "editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania", "deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
"confirmDeleteTitle": "Usuń Węzeł", "confirmDeleteTitle": "Usuń Węzeł",
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć." "confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
"deleteReasonLabel": "Powód Usunięcia (Opcjonalny)",
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +291,9 @@
"addProvider": "Dodaj Dostawcę", "addProvider": "Dodaj Dostawcę",
"deleteProvider": "Usuń Dostawcę", "deleteProvider": "Usuń Dostawcę",
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?", "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", "providerName": "Nazwa Dostawcy",
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.", "providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
"providerNameRequired": "Nazwa dostawcy jest wymagana", "providerNameRequired": "Nazwa dostawcy jest wymagana",
@@ -356,6 +366,8 @@
"fovHint": "FOV w stopniach (zostaw puste dla domyślnego)", "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", "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", "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", "submittable": "Możliwy do Zgłoszenia",
"submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer", "submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer",
"osmTags": "Tagi OSM", "osmTags": "Tagi OSM",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru", "dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org", "dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów", "minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru", "minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
"updating": "Aktualizowanie Podejrzanych Lokalizacji", "updating": "Aktualizowanie Podejrzanych Lokalizacji",
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...", "downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie", "updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Configurações Avançadas", "advancedSettings": "Configurações Avançadas",
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
"proximityAlerts": "Alertas de Proximidade", "proximityAlerts": "Alertas de Proximidade",
"keepScreenAwake": "Manter Ecrã Ativa",
"networkStatusIndicator": "Indicador de Status de Rede" "networkStatusIndicator": "Indicador de Status de Rede"
}, },
"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"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância", "getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização", "batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Edição de nó na fila para envio", "editQueuedForUpload": "Edição de nó na fila para envio",
"deleteQueuedForUpload": "Exclusão de nó na fila para envio", "deleteQueuedForUpload": "Exclusão de nó na fila para envio",
"confirmDeleteTitle": "Excluir Nó", "confirmDeleteTitle": "Excluir Nó",
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita." "confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
"deleteReasonLabel": "Motivo para Exclusão (Opcional)",
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
}, },
"addNode": { "addNode": {
"profile": "Perfil", "profile": "Perfil",
@@ -284,6 +291,9 @@
"addProvider": "Adicionar Provedor", "addProvider": "Adicionar Provedor",
"deleteProvider": "Excluir Provedor", "deleteProvider": "Excluir Provedor",
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?", "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", "providerName": "Nome do Provedor",
"providerNameHint": "ex., Mapas Personalizados Inc.", "providerNameHint": "ex., Mapas Personalizados Inc.",
"providerNameRequired": "Nome do provedor é obrigatório", "providerNameRequired": "Nome do provedor é obrigatório",
@@ -356,6 +366,8 @@
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)", "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", "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", "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", "submittable": "Enviável",
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras", "submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
"osmTags": "Tags OSM", "osmTags": "Tags OSM",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância", "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", "dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
"minimumDistance": "Distância Mínima de Nós Reais", "minimumDistance": "Distância Mínima de Nós Reais",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes", "minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {} de dispositivos de vigilância existentes",
"updating": "Atualizando Localizações Suspeitas", "updating": "Atualizando Localizações Suspeitas",
"downloadingAndProcessing": "Baixando e processando dados...", "downloadingAndProcessing": "Baixando e processando dados...",
"updateSuccess": "Localizações suspeitas atualizadas com sucesso", "updateSuccess": "Localizações suspeitas atualizadas com sucesso",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Gelişmiş Ayarlar", "advancedSettings": "Gelişmiş Ayarlar",
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
"proximityAlerts": "Yakınlık Uyarıları", "proximityAlerts": "Yakınlık Uyarıları",
"keepScreenAwake": "Ekranı Açık Tut",
"networkStatusIndicator": "Ağ Durumu Göstergesi" "networkStatusIndicator": "Ağ Durumu Göstergesi"
}, },
"keepScreenAwake": {
"title": "Uygulamayı kullanırken ekranı açık tut",
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al", "getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır", "batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı", "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ı", "deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
"confirmDeleteTitle": "Düğümü Sil", "confirmDeleteTitle": "Düğümü Sil",
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." "confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteReasonLabel": "Silme Nedeni (İsteğe Bağlı)",
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +291,9 @@
"addProvider": "Sağlayıcı Ekle", "addProvider": "Sağlayıcı Ekle",
"deleteProvider": "Sağlayıcıyı Sil", "deleteProvider": "Sağlayıcıyı Sil",
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?", "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ı", "providerName": "Sağlayıcı Adı",
"providerNameHint": "örn., Özel Haritalar A.Ş.", "providerNameHint": "örn., Özel Haritalar A.Ş.",
"providerNameRequired": "Sağlayıcı adı gerekli", "providerNameRequired": "Sağlayıcı adı gerekli",
@@ -356,6 +366,8 @@
"fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)", "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", "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", "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", "submittable": "Gönderilebilir",
"submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı", "submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı",
"osmTags": "OSM Etiketleri", "osmTags": "OSM Etiketleri",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri", "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", "dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe", "minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}m yakınındaki şüpheli konumları gizle", "minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {} yakınındaki şüpheli konumları gizle",
"updating": "Şüpheli Konumlar Güncelleniyor", "updating": "Şüpheli Konumlar Güncelleniyor",
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...", "downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi", "updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "Розширені Налаштування", "advancedSettings": "Розширені Налаштування",
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
"proximityAlerts": "Сповіщення Про Близькість", "proximityAlerts": "Сповіщення Про Близькість",
"keepScreenAwake": "Тримати екран активним",
"networkStatusIndicator": "Індикатор Стану Мережі" "networkStatusIndicator": "Індикатор Стану Мережі"
}, },
"keepScreenAwake": {
"title": "Тримати екран активним під час використання програми",
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження", "getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження", "batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження", "editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження", "deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
"confirmDeleteTitle": "Видалити Вузол", "confirmDeleteTitle": "Видалити Вузол",
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати." "confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
"deleteReasonLabel": "Причина Видалення (Опціонально)",
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
}, },
"addNode": { "addNode": {
"profile": "Профіль", "profile": "Профіль",
@@ -284,6 +291,9 @@
"addProvider": "Додати Постачальника", "addProvider": "Додати Постачальника",
"deleteProvider": "Видалити Постачальника", "deleteProvider": "Видалити Постачальника",
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?", "deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
"clearCaches": "Очистити Кеш",
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
"cachesCleared": "Кеш очищено для \"{}\"",
"providerName": "Назва Постачальника", "providerName": "Назва Постачальника",
"providerNameHint": "напр., Кастомні Карти ТОВ", "providerNameHint": "напр., Кастомні Карти ТОВ",
"providerNameRequired": "Назва постачальника обов'язкова", "providerNameRequired": "Назва постачальника обов'язкова",
@@ -356,6 +366,8 @@
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)", "fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону", "fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
"fovInvalid": "FOV повинно бути між 1 і 360 градусами", "fovInvalid": "FOV повинно бути між 1 і 360 градусами",
"fov360": "Поле Зору 360°",
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
"submittable": "Можна Подавати", "submittable": "Можна Подавати",
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер", "submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
"osmTags": "OSM Теги", "osmTags": "OSM Теги",
@@ -519,7 +531,7 @@
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження", "dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org", "dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів", "minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження", "minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
"updating": "Оновлення Підозрілих Локацій", "updating": "Оновлення Підозрілих Локацій",
"downloadingAndProcessing": "Завантаження та обробка даних...", "downloadingAndProcessing": "Завантаження та обробка даних...",
"updateSuccess": "Підозрілі локації успішно оновлено", "updateSuccess": "Підозрілі локації успішно оновлено",
+14 -2
View File
@@ -111,8 +111,13 @@
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"advancedSettingsSubtitle": "性能、警报和地图提供商设置", "advancedSettingsSubtitle": "性能、警报和地图提供商设置",
"proximityAlerts": "邻近警报", "proximityAlerts": "邻近警报",
"keepScreenAwake": "保持屏幕常亮",
"networkStatusIndicator": "网络状态指示器" "networkStatusIndicator": "网络状态指示器"
}, },
"keepScreenAwake": {
"title": "使用应用时保持屏幕常亮",
"subtitle": "防止在使用应用期间屏幕自动熄灭"
},
"proximityAlerts": { "proximityAlerts": {
"getNotified": "接近监控设备时接收通知", "getNotified": "接近监控设备时接收通知",
"batteryUsage": "使用额外电量进行连续位置监控", "batteryUsage": "使用额外电量进行连续位置监控",
@@ -132,7 +137,9 @@
"editQueuedForUpload": "节点编辑已排队上传", "editQueuedForUpload": "节点编辑已排队上传",
"deleteQueuedForUpload": "节点删除已排队上传", "deleteQueuedForUpload": "节点删除已排队上传",
"confirmDeleteTitle": "删除节点", "confirmDeleteTitle": "删除节点",
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。" "confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
"deleteReasonLabel": "删除原因(可选)",
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
}, },
"addNode": { "addNode": {
"profile": "配置文件", "profile": "配置文件",
@@ -284,6 +291,9 @@
"addProvider": "添加提供商", "addProvider": "添加提供商",
"deleteProvider": "删除提供商", "deleteProvider": "删除提供商",
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?", "deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
"clearCaches": "清除缓存",
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
"cachesCleared": "已清除 \"{}\" 的缓存",
"providerName": "提供商名称", "providerName": "提供商名称",
"providerNameHint": "例如,自定义地图公司", "providerNameHint": "例如,自定义地图公司",
"providerNameRequired": "提供商名称为必填项", "providerNameRequired": "提供商名称为必填项",
@@ -356,6 +366,8 @@
"fovHint": "视场角度数(留空使用默认值)", "fovHint": "视场角度数(留空使用默认值)",
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式", "fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
"fovInvalid": "视场角必须在1到360度之间", "fovInvalid": "视场角必须在1到360度之间",
"fov360": "360°视场角",
"fov360Subtitle": "摄像头具有360度全方向视场角",
"submittable": "可提交", "submittable": "可提交",
"submittableSubtitle": "此配置文件是否可用于摄像头提交", "submittableSubtitle": "此配置文件是否可用于摄像头提交",
"osmTags": "OSM 标签", "osmTags": "OSM 标签",
@@ -519,7 +531,7 @@
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点", "dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供", "dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
"minimumDistance": "与真实节点的最小距离", "minimumDistance": "与真实节点的最小距离",
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置", "minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
"updating": "正在更新疑似位置", "updating": "正在更新疑似位置",
"downloadingAndProcessing": "正在下载和处理数据...", "downloadingAndProcessing": "正在下载和处理数据...",
"updateSuccess": "疑似位置更新成功", "updateSuccess": "疑似位置更新成功",
+9
View File
@@ -92,6 +92,15 @@ class DeFlockApp extends StatelessWidget {
'/settings/release-notes': (context) => const ReleaseNotesScreen(), '/settings/release-notes': (context) => const ReleaseNotesScreen(),
}, },
initialRoute: '/', initialRoute: '/',
onUnknownRoute: (settings) {
// Handle deep link routes that Flutter tries to process before app_links
// This catches routes like "/?id=13939398601" from "deflockapp://node?id=13939398601"
debugPrint('[Navigation] Unknown route intercepted: ${settings.name} (likely deep link, will be handled by DeepLinkService)');
return MaterialPageRoute(
settings: settings,
builder: (_) => const HomeScreen(),
);
},
); );
} }
+49
View File
@@ -6,6 +6,7 @@ import 'app_state.dart';
import 'services/profile_service.dart'; import 'services/profile_service.dart';
import 'services/suspected_location_cache.dart'; import 'services/suspected_location_cache.dart';
import 'widgets/nuclear_reset_dialog.dart'; import 'widgets/nuclear_reset_dialog.dart';
import 'dev_config.dart';
/// One-time migrations that run when users upgrade to specific versions. /// One-time migrations that run when users upgrade to specific versions.
/// Each migration function is named after the version where it should run. /// Each migration function is named after the version where it should run.
@@ -142,6 +143,52 @@ 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
}
}
/// Get the migration function for a specific version /// Get the migration function for a specific version
static Future<void> Function(AppState)? getMigrationForVersion(String version) { static Future<void> Function(AppState)? getMigrationForVersion(String version) {
switch (version) { switch (version) {
@@ -157,6 +204,8 @@ class OneTimeMigrations {
return migrate_2_1_0; return migrate_2_1_0;
case '2.7.3': case '2.7.3':
return migrate_2_7_3; return migrate_2_7_3;
case '2.10.0':
return migrate_2_10_0;
default: default:
return null; return null;
} }
+274
View File
@@ -1,6 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../dev_config.dart';
import '../services/localization_service.dart'; import '../services/localization_service.dart';
import '../services/nuclear_reset_service.dart';
import '../widgets/welcome_dialog.dart'; import '../widgets/welcome_dialog.dart';
import '../widgets/submission_guide_dialog.dart'; import '../widgets/submission_guide_dialog.dart';
@@ -76,10 +79,20 @@ class AboutScreen extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// About OpenStreetMap section
_buildAboutOSMSection(context),
const SizedBox(height: 24),
// Information dialogs section // Information dialogs section
_buildDialogButtons(context), _buildDialogButtons(context),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildHelpLinks(context), _buildHelpLinks(context),
// Dev-only nuclear reset button at very bottom
if (kDebugMode || kEnableDevelopmentModes) ...[
const SizedBox(height: 32),
_buildDevNuclearResetButton(context),
const SizedBox(height: 16),
],
], ],
), ),
), ),
@@ -96,6 +109,8 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'), _buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
const SizedBox(height: 8), 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'), _buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'), _buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
@@ -121,6 +136,39 @@ 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) { Widget _buildDialogButtons(BuildContext context) {
final locService = LocalizationService.instance; final locService = LocalizationService.instance;
@@ -165,5 +213,231 @@ 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,6 +3,7 @@ import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/suspected_locations_section.dart'; import 'settings/sections/suspected_locations_section.dart';
import 'settings/sections/tile_provider_section.dart'; import 'settings/sections/tile_provider_section.dart';
import 'settings/sections/keep_screen_awake_section.dart';
import '../services/localization_service.dart'; import '../services/localization_service.dart';
class AdvancedSettingsScreen extends StatelessWidget { class AdvancedSettingsScreen extends StatelessWidget {
@@ -32,6 +33,8 @@ class AdvancedSettingsScreen extends StatelessWidget {
Divider(), Divider(),
SuspectedLocationsSection(), SuspectedLocationsSection(),
Divider(), Divider(),
KeepScreenAwakeSection(),
Divider(),
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
// Divider(), // Divider(),
TileProviderSection(), TileProviderSection(),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../app_state.dart'; import '../../app_state.dart';
@@ -125,4 +126,50 @@ class MapInteractionHandler {
// Clear suspected location selection // Clear suspected location selection
appState.clearSuspectedLocationSelection(); 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();
});
}
} }
+39
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../app_state.dart'; import '../app_state.dart';
@@ -19,6 +20,7 @@ import '../models/osm_node.dart';
import '../models/suspected_location.dart'; import '../models/suspected_location.dart';
import '../models/search_result.dart'; import '../models/search_result.dart';
import '../services/changelog_service.dart'; import '../services/changelog_service.dart';
import '../services/deep_link_service.dart';
import 'coordinators/sheet_coordinator.dart'; import 'coordinators/sheet_coordinator.dart';
import 'coordinators/navigation_coordinator.dart'; import 'coordinators/navigation_coordinator.dart';
import 'coordinators/map_interaction_handler.dart'; import 'coordinators/map_interaction_handler.dart';
@@ -57,10 +59,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_sheetCoordinator = SheetCoordinator(); _sheetCoordinator = SheetCoordinator();
_navigationCoordinator = NavigationCoordinator(); _navigationCoordinator = NavigationCoordinator();
_mapInteractionHandler = MapInteractionHandler(); _mapInteractionHandler = MapInteractionHandler();
DeepLinkService().onNodeDeepLink = _handleNodeDeepLink;
} }
@override @override
void dispose() { void dispose() {
DeepLinkService().onNodeDeepLink = null;
_mapController.dispose(); _mapController.dispose();
super.dispose(); super.dispose();
} }
@@ -286,6 +290,40 @@ 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) { void openNodeTagSheet(OsmNode node) {
// Handle the map interaction (centering and follow-me disable) // Handle the map interaction (centering and follow-me disable)
_mapInteractionHandler.handleNodeTap( _mapInteractionHandler.handleNodeTap(
@@ -498,6 +536,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Re-render when location status changes (for follow-me button state) // Re-render when location status changes (for follow-me button state)
setState(() {}); setState(() {});
}, },
onMapLongPress: _onMapLongPress,
onUserGesture: () { onUserGesture: () {
// Only clear selected node if tag sheet is not open // 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 // This prevents nodes from losing their grey-out when map is moved while viewing tags
+7 -2
View File
@@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
final tagMap = <String, String>{}; final tagMap = <String, String>{};
for (final e in _tags) { for (final e in _tags) {
if (e.key.trim().isEmpty) continue; // Skip only if key is empty final key = e.key.trim();
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement 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;
} }
final newProfile = widget.profile.copyWith( final newProfile = widget.profile.copyWith(
+1 -42
View File
@@ -195,50 +195,9 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
child: UploadModeSection(), 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 // Account deletion section - only show when logged in and not in simulate mode
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[ if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
+79 -26
View File
@@ -6,6 +6,7 @@ import '../models/node_profile.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../services/localization_service.dart'; import '../services/localization_service.dart';
import '../widgets/nsi_tag_value_field.dart'; import '../widgets/nsi_tag_value_field.dart';
import '../dev_config.dart';
class ProfileEditor extends StatefulWidget { class ProfileEditor extends StatefulWidget {
const ProfileEditor({super.key, required this.profile}); const ProfileEditor({super.key, required this.profile});
@@ -22,6 +23,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
late bool _requiresDirection; late bool _requiresDirection;
late bool _submittable; late bool _submittable;
late TextEditingController _fovCtrl; late TextEditingController _fovCtrl;
late bool _is360Fov;
static const _defaultTags = [ static const _defaultTags = [
MapEntry('man_made', 'surveillance'), MapEntry('man_made', 'surveillance'),
@@ -41,6 +43,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
_requiresDirection = widget.profile.requiresDirection; _requiresDirection = widget.profile.requiresDirection;
_submittable = widget.profile.submittable; _submittable = widget.profile.submittable;
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? ''); _fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
_is360Fov = widget.profile.fov == 360.0;
if (widget.profile.tags.isEmpty) { if (widget.profile.tags.isEmpty) {
// New profile → start with sensible defaults // New profile → start with sensible defaults
@@ -94,28 +97,68 @@ class _ProfileEditorState extends State<ProfileEditor> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (widget.profile.editable) ...[ if (widget.profile.editable) ...[
CheckboxListTile( // Direction and FOV configuration - show different UI based on dev config
title: Text(locService.t('profileEditor.requiresDirection')), if (kEnableNon360FOVs) ...[
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')), // Old UI: direction required + optional custom FOV text field (development mode)
value: _requiresDirection, CheckboxListTile(
onChanged: (value) => setState(() => _requiresDirection = value ?? true), title: Text(locService.t('profileEditor.requiresDirection')),
controlAffinity: ListTileControlAffinity.leading, subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
), value: _requiresDirection,
if (_requiresDirection) Padding( onChanged: (value) => setState(() => _requiresDirection = value ?? true),
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), controlAffinity: ListTileControlAffinity.leading,
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
), ),
), 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( CheckboxListTile(
title: Text(locService.t('profileEditor.submittable')), title: Text(locService.t('profileEditor.submittable')),
subtitle: Text(locService.t('profileEditor.submittableSubtitle')), subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
@@ -199,6 +242,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
} }
String? _validateFov() { String? _validateFov() {
// Only validate when using text field mode
if (!kEnableNon360FOVs) return null;
final text = _fovCtrl.text.trim(); final text = _fovCtrl.text.trim();
if (text.isEmpty) return null; // Optional field if (text.isEmpty) return null; // Optional field
@@ -219,14 +265,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
return; return;
} }
// Validate FOV if provided // Validate FOV if using text field mode
if (_validateFov() != null) { if (kEnableNon360FOVs && _validateFov() != null) {
return; // Don't save if FOV validation fails return; // Don't save if FOV validation fails
} }
// Parse FOV // Parse FOV based on dev config mode
final fovText = _fovCtrl.text.trim(); double? fov;
final fov = fovText.isEmpty ? null : double.tryParse(fovText); 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;
}
final tagMap = <String, String>{}; final tagMap = <String, String>{};
for (final e in _tags) { for (final e in _tags) {
@@ -0,0 +1,46 @@
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, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: appState.profiles.length, itemCount: appState.profiles.length,
onReorder: (oldIndex, newIndex) { onReorderItem: (oldIndex, newIndex) {
appState.reorderProfiles(oldIndex, newIndex); appState.reorderProfiles(oldIndex, newIndex);
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget {
), ),
) )
else else
...appState.operatorProfiles.map( ReorderableListView.builder(
(p) => ListTile( shrinkWrap: true,
title: Text(p.name), physics: const NeverScrollableScrollPhysics(),
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])), itemCount: appState.operatorProfiles.length,
trailing: PopupMenuButton( onReorderItem: (oldIndex, newIndex) {
itemBuilder: (context) => [ appState.reorderOperatorProfiles(oldIndex, newIndex);
PopupMenuItem( },
value: 'edit', itemBuilder: (context, index) {
child: Row( final p = appState.operatorProfiles[index];
children: [ return ListTile(
const Icon(Icons.edit), key: ValueKey(p.id),
const SizedBox(width: 8), leading: ReorderableDragStartListener(
Text(locService.t('actions.edit')), index: index,
], child: const Icon(
), Icons.drag_handle,
color: Colors.grey,
), ),
PopupMenuItem( ),
value: 'delete', title: Text(p.name),
child: Row( subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
children: [ trailing: PopupMenuButton(
const Icon(Icons.delete, color: Colors.red), itemBuilder: (context) => [
const SizedBox(width: 8), PopupMenuItem(
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)), value: 'edit',
], child: Row(
), children: [
), const Icon(Icons.edit),
], const SizedBox(width: 8),
onSelected: (value) { Text(locService.t('actions.edit')),
if (value == 'edit') { ],
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => OperatorProfileEditor(profile: p),
), ),
); ),
} else if (value == 'delete') { PopupMenuItem(
_showDeleteProfileDialog(context, p); 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),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
);
},
), ),
], ],
); );
@@ -104,6 +104,9 @@ class TileProviderSection extends StatelessWidget {
case 'edit': case 'edit':
_editProvider(context, provider); _editProvider(context, provider);
break; break;
case 'clear_cache':
_clearProviderCaches(context, provider);
break;
case 'delete': case 'delete':
_deleteProvider(context, provider); _deleteProvider(context, provider);
break; break;
@@ -120,6 +123,16 @@ 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( PopupMenuItem(
value: 'delete', value: 'delete',
child: Row( child: Row(
@@ -154,6 +167,37 @@ 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) { void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance; final locService = LocalizationService.instance;
showDialog( showDialog(
+2 -2
View File
@@ -86,12 +86,12 @@ class AuthService {
} }
try { try {
final token = await _helper.getToken(); final token = await _helper.getToken();
if (token?.accessToken == null) { if (token.accessToken == null) {
log('OAuth error: token null or missing accessToken'); log('OAuth error: token null or missing accessToken');
return null; return null;
} }
final tokenMap = { final tokenMap = {
'accessToken': token!.accessToken, 'accessToken': token.accessToken,
'refreshToken': token.refreshToken, 'refreshToken': token.refreshToken,
}; };
final tokenJson = jsonEncode(tokenMap); final tokenJson = jsonEncode(tokenMap);
+12
View File
@@ -225,10 +225,22 @@ class ChangelogService {
versionsNeedingMigration.add('1.6.3'); 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')) { if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
versionsNeedingMigration.add('2.7.3'); versionsNeedingMigration.add('2.7.3');
} }
if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) {
versionsNeedingMigration.add('2.10.0');
}
// Future versions can be added here // Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0'); // versionsNeedingMigration.add('2.0.0');
+106 -1
View File
@@ -1,10 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/node_profile.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 'profile_import_service.dart';
import 'operator_profile_import_service.dart';
import '../screens/profile_editor.dart'; import '../screens/profile_editor.dart';
import '../screens/operator_profile_editor.dart';
class DeepLinkService { class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal(); static final DeepLinkService _instance = DeepLinkService._internal();
@@ -13,6 +20,9 @@ class DeepLinkService {
late AppLinks _appLinks; late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription; 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) /// Initialize deep link handling (sets up stream listener only)
Future<void> init() async { Future<void> init() async {
@@ -42,6 +52,9 @@ class DeepLinkService {
case 'profiles': case 'profiles':
_handleProfilesLink(uri); _handleProfilesLink(uri);
break; break;
case 'node':
_handleNodeLink(uri);
break;
case 'auth': case 'auth':
// OAuth links are handled by flutter_web_auth_2 // OAuth links are handled by flutter_web_auth_2
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2'); debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
@@ -68,6 +81,59 @@ 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 /// Handle profile-related deep links
void _handleProfilesLink(Uri uri) { void _handleProfilesLink(Uri uri) {
final segments = uri.pathSegments; final segments = uri.pathSegments;
@@ -86,8 +152,16 @@ class DeepLinkService {
} }
} }
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` /// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
void _handleAddProfileLink(Uri uri) { 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']; final base64Data = uri.queryParameters['p'];
if (base64Data == null || base64Data.isEmpty) { if (base64Data == null || base64Data.isEmpty) {
@@ -107,6 +181,20 @@ class DeepLinkService {
_navigateToProfileEditor(profile); _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 /// Navigate to profile editor with pre-filled profile data
void _navigateToProfileEditor(NodeProfile profile) { void _navigateToProfileEditor(NodeProfile profile) {
final context = _navigatorKey?.currentContext; final context = _navigatorKey?.currentContext;
@@ -124,6 +212,23 @@ 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 /// Show error message to user
void _showError(String message) { void _showError(String message) {
final context = _navigatorKey?.currentContext; final context = _navigatorKey?.currentContext;
@@ -0,0 +1,100 @@
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;
}
}
+96 -87
View File
@@ -8,97 +8,106 @@ import '../models/node_profile.dart';
import '../models/osm_node.dart'; import '../models/osm_node.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'service_policy.dart';
/// Simple Overpass API client with proper HTTP retry logic. /// Simple Overpass API client with retry and fallback logic.
/// Single responsibility: Make requests, handle network errors, return data. /// Single responsibility: Make requests, handle network errors, return data.
class OverpassService { class OverpassService {
static const String _endpoint = 'https://overpass-api.de/api/interpreter'; 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),
);
final http.Client _client; final http.Client _client;
/// Optional override endpoint. When null, uses [defaultEndpoint].
final String? _endpointOverride;
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); OverpassService({http.Client? client, String? endpoint})
: _client = client ?? UserAgentClient(),
_endpointOverride = endpoint;
/// Resolve the primary endpoint: constructor override or default.
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
/// Fetch surveillance nodes from Overpass API with proper retry logic. /// Fetch surveillance nodes from Overpass API with retry and fallback.
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
Future<List<OsmNode>> fetchNodes({ Future<List<OsmNode>> fetchNodes({
required LatLngBounds bounds, required LatLngBounds bounds,
required List<NodeProfile> profiles, required List<NodeProfile> profiles,
int maxRetries = 3, ResiliencePolicy? policy,
}) async { }) async {
if (profiles.isEmpty) return []; if (profiles.isEmpty) return [];
final query = _buildQuery(bounds, profiles); final query = _buildQuery(bounds, profiles);
final endpoint = _primaryEndpoint;
for (int attempt = 0; attempt <= maxRetries; attempt++) { final canFallback = _endpointOverride == null;
try { final effectivePolicy = policy ?? _policy;
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
return executeWithFallback<List<OsmNode>>(
final response = await _client.post( primaryUrl: endpoint,
Uri.parse(_endpoint), fallbackUrl: canFallback ? fallbackEndpoint : null,
body: {'data': query}, execute: (url) => _attemptFetch(url, query, effectivePolicy),
).timeout(kOverpassQueryTimeout); classifyError: _classifyError,
policy: effectivePolicy,
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');
}
}
throw NetworkError('Max retries exceeded');
} }
/// 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);
}
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
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');
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) {
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
rethrow;
}
throw NetworkError('Network error: $e');
}
}
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 /// Build Overpass QL query for given bounds and profiles
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) { String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
final nodeClauses = profiles.map((profile) { final nodeClauses = profiles.map((profile) {
@@ -107,7 +116,7 @@ class OverpassService {
.where((entry) => entry.value.trim().isNotEmpty) .where((entry) => entry.value.trim().isNotEmpty)
.map((entry) => '["${entry.key}"="${entry.value}"]') .map((entry) => '["${entry.key}"="${entry.value}"]')
.join(); .join();
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});'; return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
}).join('\n '); }).join('\n ');
@@ -119,38 +128,38 @@ class OverpassService {
out body; out body;
( (
way(bn); way(bn);
rel(bn); rel(bn);
); );
out skel; out skel;
'''; ''';
} }
/// Parse Overpass JSON response into OsmNode objects /// Parse Overpass JSON response into OsmNode objects
List<OsmNode> _parseResponse(String responseBody) { List<OsmNode> _parseResponse(String responseBody) {
final data = jsonDecode(responseBody) as Map<String, dynamic>; final data = jsonDecode(responseBody) as Map<String, dynamic>;
final elements = data['elements'] as List<dynamic>; final elements = data['elements'] as List<dynamic>;
final nodeElements = <Map<String, dynamic>>[]; final nodeElements = <Map<String, dynamic>>[];
final constrainedNodeIds = <int>{}; final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes and identify constrained nodes // First pass: collect surveillance nodes and identify constrained nodes
for (final element in elements.whereType<Map<String, dynamic>>()) { for (final element in elements.whereType<Map<String, dynamic>>()) {
final type = element['type'] as String?; final type = element['type'] as String?;
if (type == 'node') { if (type == 'node') {
nodeElements.add(element); nodeElements.add(element);
} else if (type == 'way' || type == 'relation') { } else if (type == 'way' || type == 'relation') {
// Mark referenced nodes as constrained // Mark referenced nodes as constrained
final refs = element['nodes'] as List<dynamic>? ?? final refs = element['nodes'] as List<dynamic>? ??
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? []; element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
for (final ref in refs) { for (final ref in refs) {
final nodeId = ref is int ? ref : int.tryParse(ref.toString()); final nodeId = ref is int ? ref : int.tryParse(ref.toString());
if (nodeId != null) constrainedNodeIds.add(nodeId); if (nodeId != null) constrainedNodeIds.add(nodeId);
} }
} }
} }
// Second pass: create OsmNode objects // Second pass: create OsmNode objects
final nodes = nodeElements.map((element) { final nodes = nodeElements.map((element) {
final nodeId = element['id'] as int; final nodeId = element['id'] as int;
@@ -161,7 +170,7 @@ out skel;
isConstrained: constrainedNodeIds.contains(nodeId), isConstrained: constrainedNodeIds.contains(nodeId),
); );
}).toList(); }).toList();
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained'); debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
return nodes; return nodes;
} }
@@ -189,4 +198,4 @@ class NetworkError extends Error {
NetworkError(this.message); NetworkError(this.message);
@override @override
String toString() => 'NetworkError: $message'; String toString() => 'NetworkError: $message';
} }
+7 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/node_profile.dart'; import '../models/node_profile.dart';
import '../dev_config.dart';
class ProfileImportService { class ProfileImportService {
// Maximum size for base64 encoded profile data (approx 50KB decoded) // Maximum size for base64 encoded profile data (approx 50KB decoded)
@@ -72,13 +73,17 @@ class ProfileImportService {
final requiresDirection = data['requiresDirection'] ?? true; final requiresDirection = data['requiresDirection'] ?? true;
final submittable = data['submittable'] ?? true; final submittable = data['submittable'] ?? true;
// Parse FOV if provided // Parse FOV if provided, applying restrictions based on dev config
double? fov; double? fov;
if (data['fov'] != null) { if (data['fov'] != null) {
if (data['fov'] is num) { if (data['fov'] is num) {
final fovValue = (data['fov'] as num).toDouble(); final fovValue = (data['fov'] as num).toDouble();
if (fovValue > 0 && fovValue <= 360) { if (fovValue > 0 && fovValue <= 360) {
fov = fovValue; // 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)
} }
} }
} }
@@ -61,10 +61,13 @@ class ProviderTileCacheManager {
/// Delete a specific provider's cache directory and remove the store. /// Delete a specific provider's cache directory and remove the store.
static Future<void> deleteCache(String providerId, String tileTypeId) async { static Future<void> deleteCache(String providerId, String tileTypeId) async {
final key = '$providerId/$tileTypeId'; final key = '$providerId/$tileTypeId';
final store = _stores.remove(key); final store = _stores[key];
if (store != null) { if (store != null) {
// Use the store's clear method to properly reset its internal state
await store.clear(); await store.clear();
// Don't remove from registry - let it be reused with clean state
} else if (_baseCacheDir != null) { } else if (_baseCacheDir != null) {
// Fallback for stores not in registry
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
if (await cacheDir.exists()) { if (await cacheDir.exists()) {
await cacheDir.delete(recursive: true); await cacheDir.delete(recursive: true);
+75 -14
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -27,6 +28,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
/// [putTile] call to avoid blocking construction. /// [putTile] call to avoid blocking construction.
int? _estimatedSize; 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. /// Throttle: don't re-scan more than once per minute.
DateTime? _lastPruneCheck; DateTime? _lastPruneCheck;
@@ -52,9 +57,16 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta')); final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try { try {
final bytes = await tileFile.readAsBytes(); // Use semaphore to limit concurrent file I/O operations
final metaJson = json.decode(await metaFile.readAsString()) final result = await _ioSemaphore.execute(() async {
as Map<String, dynamic>; 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 metadata = CachedMapTileMetadata( final metadata = CachedMapTileMetadata(
staleAt: DateTime.fromMillisecondsSinceEpoch( staleAt: DateTime.fromMillisecondsSinceEpoch(
@@ -120,10 +132,13 @@ class ProviderTileCacheStore implements MapCachingProvider {
// Write .tile before .meta: if we crash between the two writes, the // 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. // read path's both-must-exist check sees a miss rather than an orphan .meta.
if (bytes != null) { // Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
await tileFile.writeAsBytes(bytes); await _ioSemaphore.execute(() async {
} if (bytes != null) {
await metaFile.writeAsString(metaJson); await tileFile.writeAsBytes(bytes);
}
await metaFile.writeAsString(metaJson);
});
// Reset size estimate so it resyncs from disk on next check. // Reset size estimate so it resyncs from disk on next check.
// This avoids drift from overwrites where the old size isn't subtracted. // This avoids drift from overwrites where the old size isn't subtracted.
@@ -250,14 +265,19 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta')); final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try { try {
await entry.file.delete(); final deletedBytes = await _ioSemaphore.execute(() async {
freedBytes += entry.stat.size; 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;
evictedKeys.add(key); evictedKeys.add(key);
if (await metaFile.exists()) {
final metaStat = await metaFile.stat();
await metaFile.delete();
freedBytes += metaStat.size;
}
} catch (e) { } catch (e) {
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
} }
@@ -313,3 +333,44 @@ class ProviderTileCacheStore implements MapCachingProvider {
@visibleForTesting @visibleForTesting
Future<void> forceEviction() => _evictIfNeeded(); 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();
}
}
}
+70 -30
View File
@@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../dev_config.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'service_policy.dart';
class RouteResult { class RouteResult {
final List<LatLng> waypoints; final List<LatLng> waypoints;
final double distanceMeters; final double distanceMeters;
final double durationSeconds; final double durationSeconds;
const RouteResult({ const RouteResult({
required this.waypoints, required this.waypoints,
required this.distanceMeters, required this.distanceMeters,
required this.durationSeconds, required this.durationSeconds,
}); });
@override @override
String toString() { String toString() {
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)'; return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
@@ -26,14 +26,27 @@ class RouteResult {
} }
class RoutingService { class RoutingService {
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions'; static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
final http.Client _client; static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions';
static const _policy = ResiliencePolicy(
maxRetries: 1,
httpTimeout: Duration(seconds: 30),
);
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient(); 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;
void close() => _client.close(); void close() => _client.close();
// Calculate route between two points using alprwatch /// Resolve the primary URL to use: constructor override or default.
String get _primaryUrl => _baseUrlOverride ?? defaultUrl;
// Calculate route between two points
Future<RouteResult> calculateRoute({ Future<RouteResult> calculateRoute({
required LatLng start, required LatLng start,
required LatLng end, required LatLng end,
@@ -53,8 +66,7 @@ class RoutingService {
'tags': tags, 'tags': tags,
}; };
}).toList(); }).toList();
final uri = Uri.parse(_baseUrl);
final params = { final params = {
'start': { 'start': {
'longitude': start.longitude, 'longitude': start.longitude,
@@ -66,11 +78,25 @@ class RoutingService {
}, },
'avoidance_distance': avoidanceDistance, 'avoidance_distance': avoidanceDistance,
'enabled_profiles': enabledProfiles, 'enabled_profiles': enabledProfiles,
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route 'show_exclusion_zone': false,
}; };
debugPrint('[RoutingService] alprwatch request: $uri $params'); 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');
try { try {
final response = await _client.post( final response = await _client.post(
uri, uri,
@@ -78,7 +104,7 @@ class RoutingService {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: json.encode(params) body: json.encode(params)
).timeout(kNavigationRoutingTimeout); ).timeout(_policy.httpTimeout);
if (response.statusCode != 200) { if (response.statusCode != 200) {
if (kDebugMode) { if (kDebugMode) {
@@ -91,24 +117,25 @@ class RoutingService {
: body; : body;
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated'); debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
} }
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}',
statusCode: response.statusCode);
} }
final data = json.decode(response.body) as Map<String, dynamic>; final data = json.decode(response.body) as Map<String, dynamic>;
debugPrint('[RoutingService] alprwatch response data: $data'); debugPrint('[RoutingService] response data: $data');
// Check alprwatch response status // Check response status
final ok = data['ok'] as bool? ?? false; final ok = data['ok'] as bool? ?? false;
if ( ! ok ) { if ( ! ok ) {
final message = data['error'] as String? ?? 'Unknown routing error'; final message = data['error'] as String? ?? 'Unknown routing error';
throw RoutingException('alprwatch error: $message'); throw RoutingException('API error: $message', isApiError: true);
} }
final route = data['result']['route'] as Map<String, dynamic>?; final route = data['result']['route'] as Map<String, dynamic>?;
if (route == null) { if (route == null) {
throw RoutingException('No route found between these points'); throw RoutingException('No route found between these points', isApiError: true);
} }
final waypoints = (route['coordinates'] as List<dynamic>?) final waypoints = (route['coordinates'] as List<dynamic>?)
?.map((inner) { ?.map((inner) {
final pair = inner as List<dynamic>; final pair = inner as List<dynamic>;
@@ -116,19 +143,19 @@ class RoutingService {
final lng = (pair[0] as num).toDouble(); final lng = (pair[0] as num).toDouble();
final lat = (pair[1] as num).toDouble(); final lat = (pair[1] as num).toDouble();
return LatLng(lat, lng); return LatLng(lat, lng);
}).whereType<LatLng>().toList() ?? []; }).whereType<LatLng>().toList() ?? [];
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0; final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0; final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
final result = RouteResult( final result = RouteResult(
waypoints: waypoints, waypoints: waypoints,
distanceMeters: distance, distanceMeters: distance,
durationSeconds: duration, durationSeconds: duration,
); );
debugPrint('[RoutingService] Route calculated: $result'); debugPrint('[RoutingService] Route calculated: $result');
return result; return result;
} catch (e) { } catch (e) {
debugPrint('[RoutingService] Route calculation failed: $e'); debugPrint('[RoutingService] Route calculation failed: $e');
if (e is RoutingException) { if (e is RoutingException) {
@@ -138,13 +165,26 @@ 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 { class RoutingException implements Exception {
final String message; final String message;
final int? statusCode;
const RoutingException(this.message); final bool isApiError;
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
@override @override
String toString() => 'RoutingException: $message'; String toString() => 'RoutingException: $message';
} }
+94 -47
View File
@@ -152,11 +152,10 @@ class ServicePolicy {
'attributionUrl: $attributionUrl)'; 'attributionUrl: $attributionUrl)';
} }
/// Resolves URLs and tile providers to their applicable [ServicePolicy]. /// Resolves service URLs to their applicable [ServicePolicy].
/// ///
/// Built-in patterns cover all OSMF official services and common third-party /// Built-in patterns cover all OSMF official services and common third-party
/// tile providers. Custom overrides can be registered for self-hosted endpoints /// tile providers. Falls back to permissive defaults for unrecognized hosts.
/// via [registerCustomPolicy].
class ServicePolicyResolver { class ServicePolicyResolver {
/// Host → ServiceType mapping for known services. /// Host → ServiceType mapping for known services.
static final Map<String, ServiceType> _hostPatterns = { static final Map<String, ServiceType> _hostPatterns = {
@@ -166,6 +165,7 @@ class ServicePolicyResolver {
'tile.openstreetmap.org': ServiceType.osmTileServer, 'tile.openstreetmap.org': ServiceType.osmTileServer,
'nominatim.openstreetmap.org': ServiceType.nominatim, 'nominatim.openstreetmap.org': ServiceType.nominatim,
'overpass-api.de': ServiceType.overpass, 'overpass-api.de': ServiceType.overpass,
'overpass.deflock.org': ServiceType.overpass,
'taginfo.openstreetmap.org': ServiceType.tagInfo, 'taginfo.openstreetmap.org': ServiceType.tagInfo,
'tiles.virtualearth.net': ServiceType.bingTiles, 'tiles.virtualearth.net': ServiceType.bingTiles,
'api.mapbox.com': ServiceType.mapboxTiles, 'api.mapbox.com': ServiceType.mapboxTiles,
@@ -183,25 +183,14 @@ class ServicePolicyResolver {
ServiceType.custom: const ServicePolicy(), 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]. /// Resolve a URL to its applicable [ServicePolicy].
/// ///
/// Checks custom overrides first, then built-in host patterns. Falls back /// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
/// to [ServicePolicy.custom] for unrecognized hosts. /// for unrecognized hosts.
static ServicePolicy resolve(String url) { static ServicePolicy resolve(String url) {
final host = _extractHost(url); final host = _extractHost(url);
if (host == null) return const ServicePolicy(); 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) { for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) { if (host == entry.key || host.endsWith('.${entry.key}')) {
return _policies[entry.value] ?? const ServicePolicy(); return _policies[entry.value] ?? const ServicePolicy();
@@ -218,14 +207,6 @@ class ServicePolicyResolver {
final host = _extractHost(url); final host = _extractHost(url);
if (host == null) return ServiceType.custom; 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) { for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) { if (host == entry.key || host.endsWith('.${entry.key}')) {
return entry.value; return entry.value;
@@ -239,29 +220,6 @@ class ServicePolicyResolver {
static ServicePolicy resolveByType(ServiceType type) => static ServicePolicy resolveByType(ServiceType type) =>
_policies[type] ?? const ServicePolicy(); _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. /// Extract the host from a URL or URL template.
static String? _extractHost(String url) { static String? _extractHost(String url) {
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
@@ -283,6 +241,95 @@ 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. /// Reusable per-service rate limiter and concurrency controller.
/// ///
/// Enforces the rate limits and concurrency constraints defined in each /// Enforces the rate limits and concurrency constraints defined in each
+20 -5
View File
@@ -35,16 +35,31 @@ class SuspectedLocationService {
// Load cache data // Load cache data
await _cache.loadFromStorage(); await _cache.loadFromStorage();
// Only auto-fetch if enabled, data is stale or missing, and we are not offline // Note: Auto-refresh logic moved to initBackgroundRefresh() to avoid blocking app startup
if (_isEnabled && (await _shouldRefresh()) && !offlineMode) { if (_isEnabled && (await _shouldRefresh()) && offlineMode) {
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; final lastFetch = await _cache.lastFetchTime;
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}'); 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');
});
}
}
/// Enable or disable suspected locations /// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async { Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled; _isEnabled = enabled;
+3 -3
View File
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
_calculateRoute(); _calculateRoute();
} }
/// Calculate route using alprwatch /// Calculate route via RoutingService (primary + fallback endpoints).
void _calculateRoute() { void _calculateRoute() {
if (_routeStart == null || _routeEnd == null) return; if (_routeStart == null || _routeEnd == null) return;
debugPrint('[NavigationState] Calculating route with alprwatch...'); debugPrint('[NavigationState] Calculating route...');
_isCalculating = true; _isCalculating = true;
_routingError = null; _routingError = null;
notifyListeners(); notifyListeners();
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
_showingOverview = true; _showingOverview = true;
_provisionalPinLocation = null; // Hide provisional pin _provisionalPinLocation = null; // Hide provisional pin
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}'); debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
notifyListeners(); notifyListeners();
}).catchError((error) { }).catchError((error) {
+58 -1
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/operator_profile.dart'; import '../models/operator_profile.dart';
import '../services/operator_profile_service.dart'; import '../services/operator_profile_service.dart';
class OperatorProfileState extends ChangeNotifier { class OperatorProfileState extends ChangeNotifier {
static const String _profileOrderPrefsKey = 'operator_profile_order';
final List<OperatorProfile> _profiles = []; final List<OperatorProfile> _profiles = [];
List<String> _customOrder = []; // List of profile IDs in user's preferred order
List<OperatorProfile> get profiles => List.unmodifiable(_profiles); List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
Future<void> init({bool addDefaults = false}) async { Future<void> init({bool addDefaults = false}) async {
_profiles.addAll(await OperatorProfileService().load()); _profiles.addAll(await OperatorProfileService().load());
@@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier {
_profiles.addAll(OperatorProfile.getDefaults()); _profiles.addAll(OperatorProfile.getDefaults());
await OperatorProfileService().save(_profiles); await OperatorProfileService().save(_profiles);
} }
// Load custom order from prefs
final prefs = await SharedPreferences.getInstance();
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
} }
void addOrUpdateProfile(OperatorProfile p) { void addOrUpdateProfile(OperatorProfile p) {
@@ -34,4 +42,53 @@ class OperatorProfileState extends ChangeNotifier {
OperatorProfileService().save(_profiles); OperatorProfileService().save(_profiles);
notifyListeners(); 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');
}
}
} }
+23 -4
View File
@@ -57,6 +57,28 @@ class ProfileState extends ChangeNotifier {
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? []; _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) { void toggleProfile(NodeProfile p, bool e) {
if (e) { if (e) {
_enabled.add(p); _enabled.add(p);
@@ -107,10 +129,7 @@ class ProfileState extends ChangeNotifier {
void reorderProfiles(int oldIndex, int newIndex) { void reorderProfiles(int oldIndex, int newIndex) {
final orderedProfiles = _getOrderedProfiles(); final orderedProfiles = _getOrderedProfiles();
// Standard Flutter reordering logic // With onReorderItem callback, newIndex is already adjusted
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = orderedProfiles.removeAt(oldIndex); final item = orderedProfiles.removeAt(oldIndex);
orderedProfiles.insert(newIndex, item); orderedProfiles.insert(newIndex, item);
+29 -1
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../models/tile_provider.dart'; import '../models/tile_provider.dart';
import '../services/provider_tile_cache_manager.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import '../keys.dart'; import '../keys.dart';
@@ -38,9 +39,11 @@ class SettingsState extends ChangeNotifier {
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
static const String _distanceUnitPrefsKey = 'distance_unit'; static const String _distanceUnitPrefsKey = 'distance_unit';
static const String _keepScreenAwakePrefsKey = 'keep_screen_awake';
bool _offlineMode = false; bool _offlineMode = false;
bool _pauseQueueProcessing = false; bool _pauseQueueProcessing = false;
bool _keepScreenAwake = false;
int _maxNodes = kDefaultMaxNodes; int _maxNodes = kDefaultMaxNodes;
// Default must account for missing secrets (preview builds) even before init() runs // Default must account for missing secrets (preview builds) even before init() runs
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
@@ -64,6 +67,7 @@ class SettingsState extends ChangeNotifier {
int get proximityAlertDistance => _proximityAlertDistance; int get proximityAlertDistance => _proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled; bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _suspectedLocationMinDistance; int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
bool get keepScreenAwake => _keepScreenAwake;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders); List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId; String get selectedTileTypeId => _selectedTileTypeId;
int get navigationAvoidanceDistance => _navigationAvoidanceDistance; int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
@@ -137,6 +141,9 @@ class SettingsState extends ChangeNotifier {
// Load suspected location minimum distance // Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100; _suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
// Load keep screen awake setting
_keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false;
// Load upload mode (including migration from old test_mode bool) // Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) { if (prefs.containsKey(_uploadModePrefsKey)) {
@@ -332,6 +339,17 @@ class SettingsState extends ChangeNotifier {
notifyListeners(); 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 /// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async { Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) { if (_followMeMode != mode) {
@@ -385,7 +403,17 @@ class SettingsState extends ChangeNotifier {
} }
} }
// Set distance for avoidance of nodes during navigation /// 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 distance for avoidance of nodes during navigation
Future<void> setNavigationAvoidanceDistance(int distance) async { Future<void> setNavigationAvoidanceDistance(int distance) async {
if (_navigationAvoidanceDistance != distance) { if (_navigationAvoidanceDistance != distance) {
_navigationAvoidanceDistance = distance; _navigationAvoidanceDistance = distance;
+5
View File
@@ -61,6 +61,11 @@ class SuspectedLocationState extends ChangeNotifier {
notifyListeners(); 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 /// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async { Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled); await _service.setEnabled(enabled);
+2 -2
View File
@@ -253,12 +253,12 @@ class UploadQueueState extends ChangeNotifier {
} }
// Add a node deletion to the upload queue // Add a node deletion to the upload queue
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) { void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
final upload = PendingUpload( final upload = PendingUpload(
coord: node.coord, coord: node.coord,
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API 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 profile: null, // No profile needed for deletions - just delete by node ID
changesetComment: 'Delete a surveillance node', // Default comment for deletions changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
uploadMode: uploadMode, uploadMode: uploadMode,
operation: UploadOperation.delete, operation: UploadOperation.delete,
originalNodeId: node.id, originalNodeId: node.id,
+14 -7
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
import '../../dev_config.dart'; import '../../dev_config.dart';
import '../../app_state.dart' show FollowMeMode; import '../../app_state.dart' show FollowMeMode;
@@ -145,15 +146,21 @@ class GpsController {
void _startPositionStream() { void _startPositionStream() {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off; final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)'); debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
try { try {
_positionSub = Geolocator.getPositionStream( _positionSub = Geolocator.getPositionStream(
locationSettings: LocationSettings( locationSettings: defaultTargetPlatform == TargetPlatform.android
accuracy: LocationAccuracy.high, // Request best, accept what we get ? AndroidSettings(
distanceFilter: distanceFilter, accuracy: LocationAccuracy.high,
), distanceFilter: distanceFilter,
forceLocationManager: true,
)
: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
),
).listen( ).listen(
_onPositionReceived, _onPositionReceived,
onError: _onPositionError, onError: _onPositionError,
@@ -358,4 +365,4 @@ class GpsController {
_onMapMovedProgrammatically = null; _onMapMovedProgrammatically = null;
_isUserInteracting = null; _isUserInteracting = null;
} }
} }
+29
View File
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../app_state.dart' show AppState, FollowMeMode; import '../app_state.dart' show AppState, FollowMeMode;
import '../services/offline_area_service.dart'; import '../services/offline_area_service.dart';
@@ -42,6 +43,7 @@ class MapView extends StatefulWidget {
this.onSearchPressed, this.onSearchPressed,
this.onNodeLimitChanged, this.onNodeLimitChanged,
this.onLocationStatusChanged, this.onLocationStatusChanged,
this.onMapLongPress,
}); });
final FollowMeMode followMeMode; final FollowMeMode followMeMode;
@@ -53,6 +55,7 @@ class MapView extends StatefulWidget {
final VoidCallback? onSearchPressed; final VoidCallback? onSearchPressed;
final void Function(bool isLimited)? onNodeLimitChanged; final void Function(bool isLimited)? onNodeLimitChanged;
final VoidCallback? onLocationStatusChanged; final VoidCallback? onLocationStatusChanged;
final void Function(LatLng)? onMapLongPress;
@override @override
State<MapView> createState() => MapViewState(); State<MapView> createState() => MapViewState();
@@ -84,7 +87,20 @@ class MapViewState extends State<MapView> {
// Track active pointers to suppress follow-me animations during touch // Track active pointers to suppress follow-me animations during touch
int _activePointers = 0; 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 @override
void initState() { void initState() {
@@ -213,6 +229,7 @@ class MapViewState extends State<MapView> {
_nodeController.dispose(); _nodeController.dispose();
_tileManager.dispose(); _tileManager.dispose();
_gpsController.dispose(); _gpsController.dispose();
WakelockPlus.disable();
// PrefetchAreaService no longer used - replaced with NodeDataManager // PrefetchAreaService no longer used - replaced with NodeDataManager
super.dispose(); super.dispose();
} }
@@ -278,6 +295,9 @@ class MapViewState extends State<MapView> {
final session = appState.session; final session = appState.session;
final editSession = appState.editSession; final editSession = appState.editSession;
// Keep screen awake based on user setting
_updateWakelock(appState.keepScreenAwake);
// Check if enabled profiles changed and refresh nodes if needed // Check if enabled profiles changed and refresh nodes if needed
_nodeController.checkAndHandleProfileChanges( _nodeController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles, currentEnabledProfiles: appState.enabledProfiles,
@@ -496,6 +516,15 @@ class MapViewState extends State<MapView> {
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode); _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: [ children: [
_tileManager.buildTileLayer( _tileManager.buildTileLayer(
+83 -20
View File
@@ -65,30 +65,17 @@ class NodeTagSheet extends StatelessWidget {
} }
void deleteNode() async { void deleteNode() async {
final shouldDelete = await showDialog<bool>( final result = await showDialog<({bool confirmed, String comment})>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) => _DeleteNodeDialog(
return AlertDialog( nodeId: node.id.toString(),
title: Text(locService.t('node.confirmDeleteTitle')), locService: locService,
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locService.cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(locService.t('actions.delete')),
),
],
);
},
); );
if (shouldDelete == true && context.mounted) { if ((result?.confirmed ?? false) && context.mounted) {
Navigator.pop(context); // Close this sheet first Navigator.pop(context); // Close this sheet first
appState.deleteNode(node); appState.deleteNode(node, changesetComment: result!.comment);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))), SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
); );
@@ -260,4 +247,80 @@ class NodeTagSheet extends StatelessWidget {
}, },
); );
} }
}
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;
@override
void initState() {
super.initState();
_commentController = TextEditingController();
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
@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),
TextField(
controller: _commentController,
decoration: InputDecoration(
hintText: widget.locService.t('node.deleteReasonHint'),
border: const OutlineInputBorder(),
isDense: true,
),
maxLines: 2,
textCapitalization: TextCapitalization.sentences,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
child: Text(widget.locService.cancel),
),
TextButton(
onPressed: () {
final comment = _commentController.text.trim();
final finalComment = comment.isEmpty
? 'Delete a surveillance node'
: 'Delete a surveillance node: $comment';
Navigator.of(context).pop((confirmed: true, comment: finalComment));
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(widget.locService.t('actions.delete')),
),
],
);
}
} }
+172 -140
View File
@@ -13,10 +13,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: app_links name: app_links
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.2.0"
app_links_linux: app_links_linux:
dependency: transitive dependency: transitive
description: description:
@@ -29,10 +29,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: app_links_platform_interface name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.0.3"
app_links_web: app_links_web:
dependency: transitive dependency: transitive
description: description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -109,10 +109,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: code_assets name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.2.1"
collection: collection:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -165,18 +165,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.14"
desktop_webview_window: desktop_webview_window:
dependency: transitive dependency: transitive
description: description:
name: desktop_webview_window name: desktop_webview_window
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.3" version: "0.3.0"
fake_async: fake_async:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -242,34 +242,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "17.2.4" version: "19.5.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.1" version: "6.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.2.0" 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"
flutter_map: flutter_map:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_map name: flutter_map
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.2.2" version: "8.3.0"
flutter_map_animations: flutter_map_animations:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -282,34 +290,34 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_native_splash name: flutter_native_splash
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.7" version: "2.4.8"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.0" version: "10.3.1"
flutter_secure_storage_darwin: flutter_secure_storage_darwin:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_darwin name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.3.2"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -322,10 +330,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
@@ -338,10 +346,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_svg name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.3.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -351,10 +359,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_web_auth_2 name: flutter_web_auth_2
sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3" sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.1" version: "5.0.3"
flutter_web_auth_2_platform_interface: flutter_web_auth_2_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -388,18 +396,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_apple name: geolocator_apple
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.13" version: "2.3.14"
geolocator_platform_interface: geolocator_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: geolocator_platform_interface name: geolocator_platform_interface
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.6" version: "4.2.8"
geolocator_web: geolocator_web:
dependency: transitive dependency: transitive
description: description:
@@ -416,30 +424,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gtk: gtk:
dependency: transitive dependency: transitive
description: description:
name: gtk name: gtk
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.2.0"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "2.0.2"
html: html:
dependency: transitive dependency: transitive
description: description:
@@ -480,14 +480,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" 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: json_annotation:
dependency: transitive dependency: transitive
description: description:
name: json_annotation name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.11.0" version: "4.12.0"
latlong2: latlong2:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -536,22 +552,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" 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: logging:
dependency: transitive dependency: transitive
description: description:
@@ -564,50 +564,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mgrs_dart: mgrs_dart:
dependency: transitive dependency: transitive
description: description:
name: mgrs_dart name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "3.0.0"
mocktail: mocktail:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: mocktail name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -620,26 +612,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: oauth2_client name: oauth2_client
sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f" sha256: "74d1024ce3bd8c6012d079e075068c94d64d804a40bcb3d4adb7348bf0c278f7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.3" version: "4.3.2"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.3.0" version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.0" version: "9.0.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -668,18 +668,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.6"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.22" version: "2.3.1"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -700,10 +700,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_platform_interface name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
@@ -748,10 +748,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: proj4dart name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "3.0.0"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -776,22 +776,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4" version: "2.5.5"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.20" version: "2.4.26"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -812,10 +820,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@@ -832,6 +840,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -849,42 +865,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: sqflite name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
sqflite_android: sqflite_android:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+2" version: "2.4.3"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.11"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
name: sqflite_darwin name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
sqflite_platform_interface: sqflite_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: sqflite_platform_interface name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -913,10 +929,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0" version: "3.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -929,18 +945,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.11"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
name: timezone name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.4" version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -953,10 +969,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: unicode name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.1" version: "1.1.9"
universal_io: universal_io:
dependency: transitive dependency: transitive
description: description:
@@ -977,10 +993,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.28" version: "6.3.32"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@@ -1017,10 +1033,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.4.3"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
@@ -1041,10 +1057,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_graphics name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.19" version: "1.2.2"
vector_graphics_codec: vector_graphics_codec:
dependency: transitive dependency: transitive
description: description:
@@ -1057,10 +1073,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_graphics_compiler name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.6"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -1073,10 +1089,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" 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"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1097,10 +1129,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: window_to_front name: window_to_front
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.3" version: "0.0.4"
wkt_parser: wkt_parser:
dependency: transitive dependency: transitive
description: description:
@@ -1134,5 +1166,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.3 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.44.0"
+4 -2
View File
@@ -1,10 +1,11 @@
name: deflockapp name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none" publish_to: "none"
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release version: 2.10.2+57 # The thing after the + is the version code, incremented with each release
environment: environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) 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: dependencies:
flutter: flutter:
@@ -19,7 +20,7 @@ dependencies:
http: ^1.2.1 http: ^1.2.1
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10
xml: ^6.4.2 xml: ^6.4.2
flutter_local_notifications: ^17.2.2 flutter_local_notifications: ^19.0.0
url_launcher: ^6.3.0 url_launcher: ^6.3.0
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
app_links: ^7.0.0 app_links: ^7.0.0
@@ -38,6 +39,7 @@ dependencies:
package_info_plus: ^9.0.0 package_info_plus: ^9.0.0
csv: ^6.0.0 csv: ^6.0.0
collection: ^1.18.0 collection: ^1.18.0
wakelock_plus: ^1.5.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Node deep link URL parsing', () {
test('extracts node ID from valid link', () {
final uri = Uri.parse('deflockapp://node?id=1234567890');
expect(uri.host, 'node');
expect(uri.queryParameters['id'], '1234567890');
});
test('returns null for missing id param', () {
final uri = Uri.parse('deflockapp://node');
expect(uri.queryParameters['id'], isNull);
});
test('returns null for empty id param', () {
final uri = Uri.parse('deflockapp://node?id=');
expect(uri.queryParameters['id'], '');
});
test('returns null for non-numeric id', () {
final uri = Uri.parse('deflockapp://node?id=abc');
expect(int.tryParse(uri.queryParameters['id'] ?? ''), isNull);
});
});
}
+4 -4
View File
@@ -221,13 +221,13 @@ void main() {
// Initial order should be A, B, C // Initial order should be A, B, C
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
// Move profile at index 0 (A) to index 2 (should become B, A, C due to Flutter's reorder logic) // Move profile at index 0 (A) to index 2 (with onReorderItem, newIndex is pre-adjusted)
profileState.reorderProfiles(0, 2); profileState.reorderProfiles(0, 2);
expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c'])); expect(profileState.profiles.map((p) => p.id), equals(['b', 'c', 'a']));
// Move profile at index 1 (A) to index 0 (should become A, B, C) // Move profile at index 1 (C) to index 0 (should become C, B, A)
profileState.reorderProfiles(1, 0); profileState.reorderProfiles(1, 0);
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); expect(profileState.profiles.map((p) => p.id), equals(['c', 'b', 'a']));
}); });
test('should maintain enabled status after reordering', () { test('should maintain enabled status after reordering', () {
+188 -12
View File
@@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/models/node_profile.dart'; import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/services/overpass_service.dart'; import 'package:deflockapp/services/overpass_service.dart';
import 'package:deflockapp/services/service_policy.dart';
class MockHttpClient extends Mock implements http.Client {} class MockHttpClient extends Mock implements http.Client {}
@@ -36,6 +37,7 @@ void main() {
setUp(() { setUp(() {
mockClient = MockHttpClient(); mockClient = MockHttpClient();
// Initialize OverpassService with a mock HTTP client for testing
service = OverpassService(client: mockClient); service = OverpassService(client: mockClient);
}); });
@@ -246,9 +248,9 @@ void main() {
stubErrorResponse( stubErrorResponse(
400, 'Error: too many nodes (limit is 50000) in query'); 400, 'Error: too many nodes (limit is 50000) in query');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -256,9 +258,9 @@ void main() {
test('response with "timeout" throws NodeLimitError', () async { test('response with "timeout" throws NodeLimitError', () async {
stubErrorResponse(400, 'runtime error: timeout in query execution'); stubErrorResponse(400, 'runtime error: timeout in query execution');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -267,9 +269,9 @@ void main() {
() async { () async {
stubErrorResponse(400, 'runtime limit exceeded'); stubErrorResponse(400, 'runtime limit exceeded');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -277,9 +279,9 @@ void main() {
test('HTTP 429 throws RateLimitError', () async { test('HTTP 429 throws RateLimitError', () async {
stubErrorResponse(429, 'Too Many Requests'); stubErrorResponse(429, 'Too Many Requests');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()), throwsA(isA<RateLimitError>()),
); );
}); });
@@ -287,9 +289,9 @@ void main() {
test('response with "rate limited" throws RateLimitError', () async { test('response with "rate limited" throws RateLimitError', () async {
stubErrorResponse(503, 'You are rate limited'); stubErrorResponse(503, 'You are rate limited');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()), throwsA(isA<RateLimitError>()),
); );
}); });
@@ -298,9 +300,9 @@ void main() {
() async { () async {
stubErrorResponse(500, 'Internal Server Error'); stubErrorResponse(500, 'Internal Server Error');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()), throwsA(isA<NetworkError>()),
); );
}); });
@@ -313,4 +315,178 @@ void main() {
verifyNever(() => mockClient.post(any(), body: any(named: 'body'))); verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
}); });
}); });
group('fallback behavior', () {
test('falls back to overpass-api.de on NetworkError after retries', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Internal Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0));
expect(nodes, hasLength(1));
// primary (1 attempt, 0 retries) + fallback (1 attempt) = 2
expect(callCount, equals(2));
});
test('does NOT fallback on NodeLimitError', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async => http.Response(
'Error: too many nodes (limit is 50000) in query',
400,
));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
// Only one call — no fallback (abort disposition)
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('RateLimitError triggers fallback without retrying primary', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Too Many Requests', 429);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 1 primary (no retry on fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// primary + fallback
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(2);
});
test('does NOT fallback when using custom endpoint', () async {
final customService = OverpassService(
client: mockClient,
endpoint: 'https://custom.example.com/api/interpreter',
);
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => customService.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// Only one call - no fallback with custom endpoint
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('retries exhaust before fallback kicks in', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(callCount, equals(4));
});
});
group('default endpoints', () {
test('default endpoint is overpass.deflock.org', () {
expect(OverpassService.defaultEndpoint,
equals('https://overpass.deflock.org/api/interpreter'));
});
test('fallback endpoint is overpass-api.de', () {
expect(OverpassService.fallbackEndpoint,
equals('https://overpass-api.de/api/interpreter'));
});
});
} }
+324 -27
View File
@@ -41,6 +41,30 @@ void main() {
AppState.instance = MockAppState(); AppState.instance = MockAppState();
}); });
/// Helper: stub a successful routing response
void stubSuccessResponse() {
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
));
}
group('RoutingService', () { group('RoutingService', () {
test('empty tags are filtered from request body', () async { test('empty tags are filtered from request body', () async {
// Profile with empty tag values (like builtin-flock has camera:mount: '') // Profile with empty tag values (like builtin-flock has camera:mount: '')
@@ -57,29 +81,7 @@ void main() {
]; ];
when(() => mockAppState.enabledProfiles).thenReturn(profiles); when(() => mockAppState.enabledProfiles).thenReturn(profiles);
// Capture the request body stubSuccessResponse();
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
);
});
await service.calculateRoute(start: start, end: end); await service.calculateRoute(start: start, end: end);
@@ -147,7 +149,7 @@ void main() {
reasonPhrase: 'Bad Request', reasonPhrase: 'Bad Request',
)); ));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -166,7 +168,7 @@ void main() {
body: any(named: 'body'), body: any(named: 'body'),
)).thenThrow(http.ClientException('Connection refused')); )).thenThrow(http.ClientException('Connection refused'));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -176,7 +178,7 @@ void main() {
); );
}); });
test('API-level error surfaces alprwatch message', () async { test('API-level error surfaces message', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]); when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post( when(() => mockClient.post(
@@ -191,7 +193,7 @@ void main() {
200, 200,
)); ));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -201,4 +203,299 @@ void main() {
); );
}); });
}); });
group('fallback behavior', () {
test('falls back to secondary on server error (500) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Internal Server Error', 500,
reasonPhrase: 'Internal Server Error');
}
// Fallback succeeds
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 5000.0,
'duration': 300.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(5000.0));
// 2 primary attempts (1 + 1 retry) + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on 502 (GraphHopper unavailable) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on network error after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
throw http.ClientException('Connection refused');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('429 triggers fallback without retrying primary', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Too Many Requests', 429,
reasonPhrase: 'Too Many Requests');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 200.0,
'duration': 120.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(200.0));
// 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('does NOT fallback on 400 (validation error)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Bad Request: missing start', 400,
reasonPhrase: 'Bad Request'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 400)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on 403 (all 4xx except 429 abort)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Forbidden', 403,
reasonPhrase: 'Forbidden'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 403)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on API-level business logic errors', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': false,
'error': 'No route found',
}),
200,
));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.isApiError, 'isApiError', true)),
);
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Internal Server Error', 500,
reasonPhrase: 'Internal Server Error'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 500)),
);
// 2 primary attempts + 2 fallback attempts = 4
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(4);
});
test('does NOT fallback when using custom baseUrl', () async {
final customService = RoutingService(
client: mockClient,
baseUrl: 'https://custom.example.com/route',
);
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Service Unavailable', 503,
reasonPhrase: 'Service Unavailable'));
await expectLater(
() => customService.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>()),
);
// 2 attempts (1 + 1 retry), no fallback with custom URL
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(2);
});
});
group('RoutingException', () {
test('statusCode is preserved', () {
const e = RoutingException('test', statusCode: 502);
expect(e.statusCode, 502);
expect(e.isApiError, false);
});
test('isApiError flag works', () {
const e = RoutingException('test', isApiError: true);
expect(e.isApiError, true);
expect(e.statusCode, isNull);
});
});
} }
+193 -61
View File
@@ -5,10 +5,6 @@ import 'package:deflockapp/services/service_policy.dart';
void main() { void main() {
group('ServicePolicyResolver', () { group('ServicePolicyResolver', () {
setUp(() {
ServicePolicyResolver.clearCustomPolicies();
});
group('resolveType', () { group('resolveType', () {
test('resolves OSM editing API from production URL', () { test('resolves OSM editing API from production URL', () {
expect( expect(
@@ -200,63 +196,6 @@ void main() {
}); });
}); });
group('custom policy overrides', () {
test('custom override takes precedence over built-in', () {
ServicePolicyResolver.registerCustomPolicy(
'overpass-api.de',
const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true),
);
final policy = ServicePolicyResolver.resolve(
'https://overpass-api.de/api/interpreter',
);
expect(policy.maxConcurrentRequests, 20);
});
test('custom policy for self-hosted tiles allows offline', () {
ServicePolicyResolver.registerCustomPolicy(
'tiles.myserver.com',
const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16),
);
final policy = ServicePolicyResolver.resolve(
'https://tiles.myserver.com/{z}/{x}/{y}.png',
);
expect(policy.allowsOfflineDownload, true);
expect(policy.maxConcurrentRequests, 16);
});
test('removing custom override restores built-in policy', () {
ServicePolicyResolver.registerCustomPolicy(
'overpass-api.de',
const ServicePolicy.custom(maxConcurrent: 20),
);
expect(
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
20,
);
ServicePolicyResolver.removeCustomPolicy('overpass-api.de');
// Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere)
expect(
ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests,
0,
);
});
test('clearCustomPolicies removes all overrides', () {
ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1));
ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2));
ServicePolicyResolver.clearCustomPolicies();
// Both should now return custom (default) policy
expect(
ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests,
8, // default custom maxConcurrent
);
});
});
}); });
group('ServiceRateLimiter', () { group('ServiceRateLimiter', () {
@@ -423,4 +362,197 @@ void main() {
expect(policy.attributionUrl, 'https://example.com/license'); expect(policy.attributionUrl, 'https://example.com/license');
}); });
}); });
group('ResiliencePolicy', () {
test('retryDelay uses exponential backoff', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 100),
retryBackoffMaxMs: 2000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 100));
expect(policy.retryDelay(1), const Duration(milliseconds: 200));
expect(policy.retryDelay(2), const Duration(milliseconds: 400));
});
test('retryDelay clamps to max', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 1000),
retryBackoffMaxMs: 3000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 1000));
expect(policy.retryDelay(1), const Duration(milliseconds: 2000));
expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped
expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped
});
});
group('executeWithFallback', () {
const policy = ResiliencePolicy(
maxRetries: 2,
retryBackoffBase: Duration.zero, // no delay in tests
);
test('abort error stops immediately, no fallback', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
throw Exception('bad request');
},
classifyError: (_) => ErrorDisposition.abort,
policy: policy,
),
throwsA(isA<Exception>()),
);
expect(callCount, 1); // no retries, no fallback
});
test('fallback error skips retries, goes to fallback', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('rate limited');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
);
expect(result, 'ok from fallback');
// 1 primary (no retries) + 1 fallback = 2
expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']);
});
test('retry error retries N times then falls back', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('server error');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'ok from fallback');
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(urlsSeen.where((u) => u.contains('primary')).length, 3);
expect(urlsSeen.where((u) => u.contains('fallback')).length, 1);
});
test('no fallback URL rethrows after retries', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('server error');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>()),
);
// 3 attempts (1 + 2 retries), then rethrow
expect(callCount, 3);
});
test('fallback disposition with no fallback URL rethrows immediately', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('rate limited');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
),
throwsA(isA<Exception>()),
);
// Only 1 attempt — fallback disposition skips retries, and no fallback URL
expect(callCount, 1);
});
test('both fail propagates last error', () async {
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
if (url.contains('fallback')) {
throw Exception('fallback also failed');
}
throw Exception('primary failed');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>().having(
(e) => e.toString(), 'message', contains('fallback also failed'))),
);
});
test('success on first try returns immediately', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
return Future.value('success');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'success');
expect(callCount, 1);
});
test('success after retry does not try fallback', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
if (callCount == 1) throw Exception('transient');
return Future.value('recovered');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'recovered');
expect(callCount, 2); // 1 fail + 1 success, no fallback
});
});
} }