mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 07:18:22 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed1d7386d | ||
|
|
9268ef2eb2 | ||
|
|
1e2eba53a9 | ||
|
|
d009803215 | ||
|
|
2dc5b6efa1 | ||
|
|
0800f1ed45 | ||
|
|
c844cff67b | ||
|
|
3ff9ff2352 | ||
|
|
12ffe92df8 | ||
|
|
0b812bdaea | ||
|
|
d87a112c0a | ||
|
|
41479e7ee4 | ||
|
|
036ac8c5e6 | ||
|
|
191b5ccae0 | ||
|
|
480f99b360 | ||
|
|
74d8214dda | ||
|
|
8aab359f22 | ||
|
|
b00e44e3bb | ||
|
|
f438c21166 | ||
|
|
e0b2a6a19c | ||
|
|
329974a8f0 | ||
|
|
5b55171080 | ||
|
|
2ba0667a76 | ||
|
|
8a3ece630c | ||
|
|
c2b0f6f9fd | ||
|
|
273bccc5eb | ||
|
|
d7be022a11 | ||
|
|
157e72011f | ||
|
|
bd71f88452 | ||
|
|
6e2fa3a04c | ||
|
|
843d377b87 | ||
|
|
3857023d43 | ||
|
|
ddf7f543ff | ||
|
|
9f72ec9a76 | ||
|
|
16e34b614f | ||
|
|
2d00d7a8fb | ||
|
|
f85db3ca11 | ||
|
|
447f358727 | ||
|
|
08b395214b | ||
|
|
256dd1a43c | ||
|
|
ca7192d3ec | ||
|
|
2833906c68 |
No files matched your search
@@ -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
|
||||||
|
|||||||
+4
-3
@@ -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/*
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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,2 +1 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
@@ -1,2 +1 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
-43
@@ -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
|
|
||||||
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<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
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+46
-27
@@ -1,7 +1,9 @@
|
|||||||
<?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>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -20,18 +22,58 @@
|
|||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<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>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>https</string>
|
||||||
|
</array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<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>
|
<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>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
@@ -45,28 +87,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
</dict>
|
||||||
<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/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
+24
-3
@@ -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;
|
||||||
@@ -173,7 +174,6 @@ class AppState extends ChangeNotifier {
|
|||||||
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;
|
||||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||||
@@ -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();
|
||||||
@@ -415,6 +418,11 @@ class AppState extends ChangeNotifier {
|
|||||||
_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) {
|
||||||
// Clear add session if it references the deleted profile
|
// Clear add session if it references the deleted profile
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -284,6 +291,9 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -132,7 +137,9 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Підозрілі локації успішно оновлено",
|
||||||
|
|||||||
@@ -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": "疑似位置更新成功",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -198,47 +198,6 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
|||||||
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),
|
||||||
|
|||||||
@@ -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,6 +97,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.profile.editable) ...[
|
if (widget.profile.editable) ...[
|
||||||
|
// Direction and FOV configuration - show different UI based on dev config
|
||||||
|
if (kEnableNon360FOVs) ...[
|
||||||
|
// Old UI: direction required + optional custom FOV text field (development mode)
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||||
@@ -116,6 +122,43 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
onChanged: (value) => setState(() {}), // Trigger validation
|
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
|
||||||
|
double? fov;
|
||||||
|
if (kEnableNon360FOVs) {
|
||||||
|
// Old mode: parse from text field
|
||||||
final fovText = _fovCtrl.text.trim();
|
final fovText = _fovCtrl.text.trim();
|
||||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
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,8 +54,24 @@ class OperatorProfilesSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
...appState.operatorProfiles.map(
|
ReorderableListView.builder(
|
||||||
(p) => ListTile(
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: appState.operatorProfiles.length,
|
||||||
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
|
appState.reorderOperatorProfiles(oldIndex, newIndex);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final p = appState.operatorProfiles[index];
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(p.id),
|
||||||
|
leading: ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
title: Text(p.name),
|
title: Text(p.name),
|
||||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||||
trailing: PopupMenuButton(
|
trailing: PopupMenuButton(
|
||||||
@@ -94,7 +110,8 @@ class OperatorProfilesSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -14,6 +21,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 {
|
||||||
_appLinks = AppLinks();
|
_appLinks = AppLinks();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,41 +8,66 @@ 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;
|
||||||
|
final canFallback = _endpointOverride == null;
|
||||||
|
final effectivePolicy = policy ?? _policy;
|
||||||
|
|
||||||
|
return executeWithFallback<List<OsmNode>>(
|
||||||
|
primaryUrl: endpoint,
|
||||||
|
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||||
|
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||||
|
classifyError: _classifyError,
|
||||||
|
policy: effectivePolicy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
|
||||||
|
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
|
||||||
|
debugPrint('[OverpassService] POST $endpoint');
|
||||||
|
|
||||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
try {
|
||||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
|
||||||
|
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
Uri.parse(_endpoint),
|
Uri.parse(endpoint),
|
||||||
body: {'data': query},
|
body: {'data': query},
|
||||||
).timeout(kOverpassQueryTimeout);
|
).timeout(policy.httpTimeout);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return _parseResponse(response.body);
|
return _parseResponse(response.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for specific error types
|
|
||||||
final errorBody = response.body;
|
final errorBody = response.body;
|
||||||
|
|
||||||
// Node limit error - caller should split area
|
// Node limit error - caller should split area
|
||||||
@@ -60,7 +85,7 @@ class OverpassService {
|
|||||||
throw NodeLimitError('Query timed out - area too complex');
|
throw NodeLimitError('Query timed out - area too complex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit - throw immediately, don't retry
|
// Rate limit
|
||||||
if (response.statusCode == 429 ||
|
if (response.statusCode == 429 ||
|
||||||
errorBody.contains('rate limited') ||
|
errorBody.contains('rate limited') ||
|
||||||
errorBody.contains('too many requests')) {
|
errorBody.contains('too many requests')) {
|
||||||
@@ -68,35 +93,19 @@ class OverpassService {
|
|||||||
throw RateLimitError('Rate limited by Overpass API');
|
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');
|
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle specific error types without retry
|
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
|
||||||
if (e is NodeLimitError || e is RateLimitError) {
|
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
throw NetworkError('Network error: $e');
|
||||||
// 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');
|
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
|
||||||
|
|||||||
@@ -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,14 +73,18 @@ 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) {
|
||||||
|
// If non-360 FOVs are disabled, only allow 360° FOV
|
||||||
|
if (kEnableNon360FOVs || fovValue == 360.0) {
|
||||||
fov = fovValue;
|
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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Use semaphore to limit concurrent file I/O operations
|
||||||
|
final result = await _ioSemaphore.execute(() async {
|
||||||
final bytes = await tileFile.readAsBytes();
|
final bytes = await tileFile.readAsBytes();
|
||||||
final metaJson = json.decode(await metaFile.readAsString())
|
final metaJson = json.decode(await metaFile.readAsString())
|
||||||
as Map<String, dynamic>;
|
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.
|
||||||
|
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
|
||||||
|
await _ioSemaphore.execute(() async {
|
||||||
if (bytes != null) {
|
if (bytes != null) {
|
||||||
await tileFile.writeAsBytes(bytes);
|
await tileFile.writeAsBytes(bytes);
|
||||||
}
|
}
|
||||||
await metaFile.writeAsString(metaJson);
|
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 {
|
||||||
|
final deletedBytes = await _ioSemaphore.execute(() async {
|
||||||
await entry.file.delete();
|
await entry.file.delete();
|
||||||
freedBytes += entry.stat.size;
|
var bytes = entry.stat.size;
|
||||||
evictedKeys.add(key);
|
|
||||||
if (await metaFile.exists()) {
|
if (await metaFile.exists()) {
|
||||||
final metaStat = await metaFile.stat();
|
final metaStat = await metaFile.stat();
|
||||||
await metaFile.delete();
|
await metaFile.delete();
|
||||||
freedBytes += metaStat.size;
|
bytes += metaStat.size;
|
||||||
}
|
}
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
freedBytes += deletedBytes;
|
||||||
|
evictedKeys.add(key);
|
||||||
} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ 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;
|
||||||
@@ -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,
|
||||||
@@ -54,7 +67,6 @@ class RoutingService {
|
|||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
final uri = Uri.parse(_baseUrl);
|
|
||||||
final params = {
|
final params = {
|
||||||
'start': {
|
'start': {
|
||||||
'longitude': start.longitude,
|
'longitude': start.longitude,
|
||||||
@@ -66,10 +78,24 @@ 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(
|
||||||
@@ -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,22 +117,23 @@ 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>?)
|
||||||
@@ -138,12 +165,25 @@ 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;
|
||||||
|
final bool isApiError;
|
||||||
|
|
||||||
const RoutingException(this.message);
|
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'RoutingException: $message';
|
String toString() => 'RoutingException: $message';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
final List<OperatorProfile> _profiles = [];
|
static const String _profileOrderPrefsKey = 'operator_profile_order';
|
||||||
|
|
||||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
final List<OperatorProfile> _profiles = [];
|
||||||
|
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -138,6 +142,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)) {
|
||||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -150,8 +151,14 @@ class GpsController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
_positionSub = Geolocator.getPositionStream(
|
_positionSub = Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: defaultTargetPlatform == TargetPlatform.android
|
||||||
accuracy: LocationAccuracy.high, // Request best, accept what we get
|
? AndroidSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
forceLocationManager: true,
|
||||||
|
)
|
||||||
|
: LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
distanceFilter: distanceFilter,
|
distanceFilter: distanceFilter,
|
||||||
),
|
),
|
||||||
).listen(
|
).listen(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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'))),
|
||||||
);
|
);
|
||||||
@@ -261,3 +248,79 @@ 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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user