mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 15:28:23 +02:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c787589205 | ||
|
|
837f59bfde | ||
|
|
8148ad55c2 | ||
|
|
dfcc64e96e | ||
|
|
6f017fd6c1 | ||
|
|
78f1ef58de | ||
|
|
3e2c9c5b9e | ||
|
|
dd93ac64b6 | ||
|
|
096fb5868b | ||
|
|
974204bd91 | ||
|
|
6a0d4cd8f7 | ||
|
|
af2a08bf3d | ||
|
|
dd8c9b88cc | ||
|
|
1cfca663a6 | ||
|
|
b9110c1670 | ||
|
|
36675508e8 | ||
|
|
9a0928d22d | ||
|
|
b3001927c6 | ||
|
|
b9d82d6de7 | ||
|
|
38eee8d125 | ||
|
|
f8632b6a06 | ||
|
|
be2fe84db5 | ||
|
|
2cddcbef7d | ||
|
|
60c17c41bf | ||
|
|
ee8ead6695 | ||
|
|
4d6bea8cfe | ||
|
|
25c1611fd3 | ||
|
|
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 |
No files matched your search
@@ -27,7 +27,7 @@ jobs:
|
|||||||
id: set-info
|
id: set-info
|
||||||
run: |
|
run: |
|
||||||
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
|
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||||
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
|
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
|
||||||
echo "✅ Pre-release - will build and attach assets, no store uploads"
|
echo "✅ Pre-release - will build and attach assets, no store uploads"
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
# import certificate to keychain
|
# import certificate to keychain
|
||||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Set this keychain as the default
|
# Set this keychain as the default
|
||||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
security default-keychain -s $KEYCHAIN_PATH
|
security default-keychain -s $KEYCHAIN_PATH
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
# install provisioning profile
|
# install provisioning profile
|
||||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||||
|
|
||||||
# Also install using the profile's internal UUID for better compatibility
|
# Also install using the profile's internal UUID for better compatibility
|
||||||
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
|
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
|
||||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||||
@@ -329,7 +329,8 @@ jobs:
|
|||||||
--type ios \
|
--type ios \
|
||||||
--file Runner.ipa \
|
--file Runner.ipa \
|
||||||
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
|
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
|
||||||
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
|
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID \
|
||||||
|
--apple-id 6752760780
|
||||||
|
|
||||||
# Clean up sensitive files
|
# Clean up sensitive files
|
||||||
rm -rf ~/private_keys
|
rm -rf ~/private_keys
|
||||||
|
|||||||
+5
-4
@@ -38,13 +38,14 @@ ios/Flutter/Flutter.framework
|
|||||||
ios/Pods/
|
ios/Pods/
|
||||||
ios/.generated/
|
ios/.generated/
|
||||||
ios/.idea/
|
ios/.idea/
|
||||||
ios/Runner.xcworkspace/
|
ios/Runner.xcworkspace/xcuserdata/
|
||||||
|
ios/Runner.xcworkspace/project.xcworkspace/xcuserdata/
|
||||||
macos/Pods/
|
macos/Pods/
|
||||||
macos/.generated/
|
macos/.generated/
|
||||||
macos/Flutter/ephemeral/
|
macos/Flutter/ephemeral/
|
||||||
# CocoaPods – commit Podfile.lock if you need reproducible iOS builds
|
|
||||||
Podfile.lock
|
|
||||||
Pods/
|
Pods/
|
||||||
|
Podfile
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
# Generated icons and splash screens
|
# Generated icons and splash screens
|
||||||
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
|
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
|
||||||
@@ -103,4 +104,4 @@ build_keys.conf
|
|||||||
linux/
|
linux/
|
||||||
macos/
|
macos/
|
||||||
web/
|
web/
|
||||||
windows/
|
windows/
|
||||||
@@ -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."
|
|
||||||
+17
-13
@@ -297,26 +297,23 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
|
|||||||
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
|
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
|
||||||
|
|
||||||
**Smart error handling & splitting:**
|
**Smart error handling & splitting:**
|
||||||
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep
|
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep. This is a deterministic result-set size limit, so shrinking the queried area is the correct fix.
|
||||||
- **Timeout errors**: Also triggers splitting (dense areas with many profiles)
|
- **Timeout errors**: Treated as a plain retryable network error — no splitting. Timeouts usually indicate server load or an expensive query, not "too much area", so firing off more sub-requests would only add load to an already-struggling server.
|
||||||
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
|
- **Rate limiting (HTTP 429)**: Extended backoff (30s), no splitting (would make it worse). Still surfaces the same "slow" status indicator as the node-limit-splitting case, so the user sees consistent feedback either way.
|
||||||
- **Surgical detection**: Only splits on actual limit errors, not network issues
|
- **Surgical detection**: Only splits on the actual 50k node limit, not timeouts or other network issues.
|
||||||
|
|
||||||
**Query optimization & deduplication:**
|
|
||||||
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
|
|
||||||
- **Profile deduplication**: Automatically removes redundant profiles from queries using subsumption analysis
|
|
||||||
- **User-initiated detection**: Only reports loading status for user-facing operations
|
|
||||||
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
|
|
||||||
|
|
||||||
**Profile subsumption optimization (v2.1.1+):**
|
**Query optimization & deduplication (in `OverpassService._buildQuery`):**
|
||||||
To reduce Overpass query complexity, profiles are deduplicated before query generation:
|
Profiles are deduplicated before query generation via subsumption analysis:
|
||||||
- **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values
|
- **Subsumption rule**: Profile A subsumes profile B if all of A's non-empty tags exist in B with identical values
|
||||||
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
|
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
|
||||||
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
|
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
|
||||||
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching
|
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching (see `NodeProviderWithCache._matchesAnyProfile`), independent of which clauses were sent to Overpass
|
||||||
|
- **Safety fallback**: if dedup would remove every profile (e.g. all profiles have only empty tags), the original full list is used instead of sending an empty query
|
||||||
|
|
||||||
**Why this approach:**
|
**Why this approach:**
|
||||||
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Profile deduplication reduces query complexity by ~80% for default setups, while automatic splitting handles remaining edge cases. Surgical error detection avoids unnecessary API load from network issues.
|
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 45s timeouts. Profile deduplication reduces query complexity by ~80% for default setups. Surgical error detection avoids unnecessary API load from network issues.
|
||||||
|
|
||||||
|
|
||||||
### 6. Uploader Service Architecture (Refactored v1.5.3)
|
### 6. Uploader Service Architecture (Refactored v1.5.3)
|
||||||
|
|
||||||
@@ -854,6 +851,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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,45 @@
|
|||||||
{
|
{
|
||||||
|
"2.10.5": {
|
||||||
|
"content": [
|
||||||
|
"• Make node tags selectable / copyable",
|
||||||
|
"• Add status indicator to details sheet for pending nodes",
|
||||||
|
"• Global toggle for offline features",
|
||||||
|
"• Auto-open details sheet for new add/edit/deletes"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2.10.4": {
|
||||||
|
"content": [
|
||||||
|
"• Catch and correct a couple rare map bounds issues",
|
||||||
|
"• Prevent queueing a deletion without being logged in",
|
||||||
|
"• Require a reason for deletion, provide options in a dropdown",
|
||||||
|
"• Add option to hide zoom controls in advanced settings"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2.10.3": {
|
||||||
|
"content": [
|
||||||
|
"• Data loading performance improvements",
|
||||||
|
"• Node marker when submitting now green for visibility"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2.10.2": {
|
||||||
|
"content": [
|
||||||
|
"• Added tap and hold gesture on empty map area to add a node at that location",
|
||||||
|
"• Added ability to accept direct links to a specific node for callbacks from e.g. FlockHopper",
|
||||||
|
"• Fixed app hang during startup when suspected locations database needed updating - refresh now happens in background"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2.10.1": {
|
||||||
|
"content": [
|
||||||
|
"• Operator profiles are now reorderable",
|
||||||
|
"• Support operator profile import via deflockapp:// links",
|
||||||
|
"• Makes operator profile UI consistent with device profiles"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"2.10.0": {
|
||||||
|
"content": [
|
||||||
|
"• Simplified profile FOVs; there is now only a checkbox for '360'"
|
||||||
|
]
|
||||||
|
},
|
||||||
"2.9.2": {
|
"2.9.2": {
|
||||||
"content": [
|
"content": [
|
||||||
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
|
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
|
||||||
|
|||||||
@@ -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>
|
||||||
+70
-3
@@ -64,6 +64,12 @@ class AppState extends ChangeNotifier {
|
|||||||
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
|
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
|
||||||
Timer? _messageCheckTimer;
|
Timer? _messageCheckTimer;
|
||||||
|
|
||||||
|
// Node ID to auto-focus (open the details sheet for) after a submit/edit/delete
|
||||||
|
// completes, when kAutoOpenNodeSheetAfterSubmit is enabled. Consumed (read once
|
||||||
|
// and cleared) by HomeScreen after picking it up.
|
||||||
|
int? _pendingFocusNodeId;
|
||||||
|
|
||||||
|
|
||||||
AppState() {
|
AppState() {
|
||||||
instance = this;
|
instance = this;
|
||||||
_authState = AuthState();
|
_authState = AuthState();
|
||||||
@@ -156,6 +162,11 @@ 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 hideZoomControls => _settingsState.hideZoomControls;
|
||||||
|
bool get offlineFeaturesEnabled => _settingsState.offlineFeaturesEnabled;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||||
@@ -172,7 +183,6 @@ class AppState extends ChangeNotifier {
|
|||||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Upload queue state
|
// Upload queue state
|
||||||
int get pendingCount => _uploadQueueState.pendingCount;
|
int get pendingCount => _uploadQueueState.pendingCount;
|
||||||
@@ -250,6 +260,9 @@ class AppState extends ChangeNotifier {
|
|||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
|
||||||
|
// Start background refresh of suspected locations if needed (non-blocking)
|
||||||
|
_suspectedLocationState.initBackgroundRefresh(offlineMode: _settingsState.offlineMode);
|
||||||
|
|
||||||
// Check for initial deep link after a small delay to let navigation settle
|
// Check for initial deep link after a small delay to let navigation settle
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
DeepLinkService().checkInitialLink();
|
DeepLinkService().checkInitialLink();
|
||||||
@@ -414,6 +427,11 @@ class AppState extends ChangeNotifier {
|
|||||||
void deleteProfile(NodeProfile p) {
|
void deleteProfile(NodeProfile p) {
|
||||||
_profileState.deleteProfile(p);
|
_profileState.deleteProfile(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reload all profiles from storage (useful after migrations modify stored profiles)
|
||||||
|
Future<void> reloadProfiles() async {
|
||||||
|
await _profileState.reloadFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
// Callback when a profile is deleted - clear any stale session references
|
// Callback when a profile is deleted - clear any stale session references
|
||||||
void _onProfileDeleted(NodeProfile deletedProfile) {
|
void _onProfileDeleted(NodeProfile deletedProfile) {
|
||||||
@@ -437,6 +455,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);
|
||||||
@@ -568,24 +590,44 @@ class AppState extends ChangeNotifier {
|
|||||||
void commitSession() {
|
void commitSession() {
|
||||||
final session = _sessionState.commitSession();
|
final session = _sessionState.commitSession();
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
final upload = _uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
|
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||||
|
_pendingFocusNodeId = upload.tempNodeId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void commitEditSession() {
|
void commitEditSession() {
|
||||||
final session = _sessionState.commitEditSession();
|
final session = _sessionState.commitEditSession();
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
final upload = _uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
|
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||||
|
_pendingFocusNodeId = upload.tempNodeId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode(OsmNode node, {String? changesetComment}) {
|
void deleteNode(OsmNode node, {String? changesetComment}) {
|
||||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
|
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||||
|
_pendingFocusNodeId = node.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consume (read once and clear) the node ID pending auto-focus, if any.
|
||||||
|
/// Used by HomeScreen to reopen the node details sheet right after a
|
||||||
|
/// submit/edit/delete completes, when [kAutoOpenNodeSheetAfterSubmit] is
|
||||||
|
/// enabled. Returns null if no focus is pending.
|
||||||
|
int? consumePendingFocusNodeId() {
|
||||||
|
final id = _pendingFocusNodeId;
|
||||||
|
_pendingFocusNodeId = null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---------- Search Methods ----------
|
// ---------- Search Methods ----------
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query) async {
|
||||||
await _searchState.search(query);
|
await _searchState.search(query);
|
||||||
@@ -669,6 +711,31 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setKeepScreenAwake(bool enabled) async {
|
||||||
|
await _settingsState.setKeepScreenAwake(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHideZoomControls(bool enabled) async {
|
||||||
|
await _settingsState.setHideZoomControls(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the master "offline features enabled" toggle. When disabling,
|
||||||
|
/// also forces offlineMode off (if it was on) and cancels any active
|
||||||
|
/// area downloads, so we never end up "in offline mode" with the
|
||||||
|
/// feature disabled. Does NOT delete existing offline area data — that
|
||||||
|
/// is a separate, explicit user choice handled by the UI.
|
||||||
|
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
|
||||||
|
await _settingsState.setOfflineFeaturesEnabled(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
if (offlineMode) {
|
||||||
|
await setOfflineMode(false);
|
||||||
|
}
|
||||||
|
await OfflineAreaService().cancelActiveDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> setPauseQueueProcessing(bool enabled) async {
|
Future<void> setPauseQueueProcessing(bool enabled) async {
|
||||||
await _settingsState.setPauseQueueProcessing(enabled);
|
await _settingsState.setPauseQueueProcessing(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|||||||
+13
-9
@@ -71,7 +71,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
|
|||||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||||
|
|
||||||
// Development/testing features - set to false for production builds
|
// Development/testing features - set to false for production builds
|
||||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
|
||||||
|
|
||||||
// Navigation features - set to false to hide navigation UI elements while in development
|
// Navigation features - set to false to hide navigation UI elements while in development
|
||||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||||
@@ -82,6 +82,12 @@ 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)
|
||||||
|
|
||||||
|
// Auto-open tags sheet after add/edit/delete, so the user can watch upload status update live.
|
||||||
|
const bool kAutoOpenNodeSheetAfterSubmit = true; // Set to true to enable auto-focus behavior
|
||||||
|
|
||||||
|
// Profile FOV features - set to false to restrict profiles to 360° FOV only
|
||||||
|
const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles
|
||||||
|
|
||||||
/// Navigation availability: only dev builds, and only when online
|
/// 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,15 +99,13 @@ const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sa
|
|||||||
const int kMinZoomForNodeEditingSheets = 16; // 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
|
|
||||||
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
|
|
||||||
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
|
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
|
||||||
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
|
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
|
||||||
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
|
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
|
||||||
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
|
|
||||||
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
|
|
||||||
|
|
||||||
// Data refresh configuration
|
// Data refresh configuration
|
||||||
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
|
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
|
||||||
@@ -161,11 +165,11 @@ const int kAbsoluteMaxTileCount = 50000;
|
|||||||
const int kAbsoluteMaxZoom = 23;
|
const int kAbsoluteMaxZoom = 23;
|
||||||
|
|
||||||
// Node icon configuration
|
// Node icon configuration
|
||||||
const double kNodeIconDiameter = 18.0;
|
const double kNodeIconDiameter = 17.0;
|
||||||
const double _kNodeRingThicknessBase = 2.5;
|
const double _kNodeRingThicknessBase = 2.6;
|
||||||
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
|
const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior
|
||||||
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
|
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
|
||||||
const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white
|
const Color kNodeRingColorMock = Color(0xE044BB55); // Add node mock point - white
|
||||||
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
|
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
|
||||||
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
|
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
|
||||||
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
||||||
|
|||||||
@@ -74,8 +74,23 @@
|
|||||||
"advancedSettings": "Erweiterte Einstellungen",
|
"advancedSettings": "Erweiterte Einstellungen",
|
||||||
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
||||||
"proximityAlerts": "Näherungswarnungen",
|
"proximityAlerts": "Näherungswarnungen",
|
||||||
"networkStatusIndicator": "Netzwerkstatus-Anzeige"
|
"keepScreenAwake": "Bildschirm Aktiv Lassen",
|
||||||
|
"networkStatusIndicator": "Netzwerkstatus-Anzeige",
|
||||||
|
"hideZoomControls": "Zoom-Steuerung Ausblenden",
|
||||||
|
"enableOfflineFeatures": "Offline-Funktionen Aktivieren",
|
||||||
|
"enableOfflineFeaturesTitle": "Offline-Funktionen aktivieren",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Herunterladen und Nutzen von Offline-Kartenbereichen erlauben. Wenn deaktiviert, sind Offline-Modus und heruntergeladene Bereiche nicht verfügbar."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Bildschirm während der App-Nutzung aktiv lassen",
|
||||||
|
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Zoom-Steuerung ausblenden",
|
||||||
|
"subtitle": "Blendet die +/- Zoom-Schaltflächen und die Zoomstufen-Anzeige aus. Sie können weiterhin mit Fingergesten zoomen."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -96,9 +111,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Grund für Löschung",
|
||||||
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
|
"deleteReasonSelectHint": "Auswählen...",
|
||||||
|
"deleteReasonDeviceRemoved": "Gerät entfernt",
|
||||||
|
"deleteReasonDuplicateNode": "Doppelter Knoten",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Falsch identifizierte Infrastruktur",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/Vandalismus",
|
||||||
|
"deleteReasonCustom": "Benutzerdefiniert..",
|
||||||
|
"deleteReasonHint": "Bitte Grund beschreiben...",
|
||||||
|
"mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.",
|
||||||
|
"uploadPending": "Ausstehender Upload",
|
||||||
|
"uploadCreatingChangeset": "Changeset wird erstellt...",
|
||||||
|
"uploadUploading": "Wird hochgeladen...",
|
||||||
|
"uploadClosingChangeset": "Changeset wird geschlossen...",
|
||||||
|
"uploadError": "Upload fehlgeschlagen",
|
||||||
|
"uploadComplete": "Upload abgeschlossen",
|
||||||
|
"viewInQueue": "In Warteschlange anzeigen",
|
||||||
|
"deletedSuccessfully": "Dieser Knoten wurde erfolgreich von OpenStreetMap gelöscht."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"selectProfile": "Profil auswählen...",
|
"selectProfile": "Profil auswählen...",
|
||||||
@@ -324,6 +357,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",
|
||||||
@@ -382,8 +417,13 @@
|
|||||||
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
||||||
"startRefresh": "Aktualisierung starten",
|
"startRefresh": "Aktualisierung starten",
|
||||||
"refreshStarted": "Aktualisierung gestartet!",
|
"refreshStarted": "Aktualisierung gestartet!",
|
||||||
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
|
"refreshFailed": "Aktualisierung fehlgeschlagen: {}",
|
||||||
|
"disableConfirmTitle": "Vorhandene Offline-Bereichsdaten löschen?",
|
||||||
|
"disableConfirmMessage": "Sie haben bereits Offline-Bereichsdaten. Das Deaktivieren der Offline-Funktionen verhindert, dass die App diese verwendet. Möchten Sie diese Daten für den Fall behalten, dass Sie die Offline-Funktionen später wieder aktivieren, oder sie jetzt löschen, um Speicherplatz freizugeben?",
|
||||||
|
"keepData": "Daten Behalten",
|
||||||
|
"deleteData": "Daten Löschen"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Tags Verfeinern",
|
"title": "Tags Verfeinern",
|
||||||
"operatorProfile": "Betreiber-Profil",
|
"operatorProfile": "Betreiber-Profil",
|
||||||
@@ -524,7 +564,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,8 +111,23 @@
|
|||||||
"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",
|
||||||
|
"hideZoomControls": "Hide Zoom Controls",
|
||||||
|
"enableOfflineFeatures": "Enable Offline Features",
|
||||||
|
"enableOfflineFeaturesTitle": "Enable offline features",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Allow downloading and using offline map areas. When disabled, offline mode and downloaded areas are unavailable."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Keep screen awake while using app",
|
||||||
|
"subtitle": "Prevents the screen from turning off automatically while using the app"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Hide zoom controls",
|
||||||
|
"subtitle": "Hide the +/- zoom buttons and zoom level indicator. You can still zoom with pinch gestures."
|
||||||
|
},
|
||||||
|
|
||||||
"proximityAlerts": {
|
"proximityAlerts": {
|
||||||
"getNotified": "Get notified when approaching surveillance devices",
|
"getNotified": "Get notified when approaching surveillance devices",
|
||||||
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
||||||
@@ -133,9 +148,26 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Reason for Deletion",
|
||||||
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
|
"deleteReasonSelectHint": "Select...",
|
||||||
|
"deleteReasonDeviceRemoved": "Device removed",
|
||||||
|
"deleteReasonDuplicateNode": "Duplicate node",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Misidentified infra",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalism",
|
||||||
|
"deleteReasonCustom": "Custom..",
|
||||||
|
"deleteReasonHint": "Please describe the reason...",
|
||||||
|
"mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.",
|
||||||
|
"uploadPending": "Pending upload",
|
||||||
|
"uploadCreatingChangeset": "Creating changeset...",
|
||||||
|
"uploadUploading": "Uploading...",
|
||||||
|
"uploadClosingChangeset": "Closing changeset...",
|
||||||
|
"uploadError": "Upload failed",
|
||||||
|
"uploadComplete": "Upload complete",
|
||||||
|
"viewInQueue": "View in Queue",
|
||||||
|
"deletedSuccessfully": "This node was successfully deleted from OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"selectProfile": "Select a profile...",
|
"selectProfile": "Select a profile...",
|
||||||
@@ -361,6 +393,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",
|
||||||
@@ -419,8 +453,13 @@
|
|||||||
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
||||||
"startRefresh": "Start Refresh",
|
"startRefresh": "Start Refresh",
|
||||||
"refreshStarted": "Refresh started!",
|
"refreshStarted": "Refresh started!",
|
||||||
"refreshFailed": "Refresh failed: {}"
|
"refreshFailed": "Refresh failed: {}",
|
||||||
|
"disableConfirmTitle": "Delete Existing Offline Area Data?",
|
||||||
|
"disableConfirmMessage": "You have existing offline area data. Disabling offline features will stop the app from using it. Would you like to keep this data on disk in case you re-enable offline features later, or delete it now to free up storage space?",
|
||||||
|
"keepData": "Keep Data",
|
||||||
|
"deleteData": "Delete Data"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Refine Tags",
|
"title": "Refine Tags",
|
||||||
"operatorProfile": "Operator Profile",
|
"operatorProfile": "Operator Profile",
|
||||||
@@ -524,7 +563,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,23 @@
|
|||||||
"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",
|
||||||
"networkStatusIndicator": "Indicador de Estado de Red"
|
"keepScreenAwake": "Mantener la Pantalla Encendida",
|
||||||
|
"networkStatusIndicator": "Indicador de Estado de Red",
|
||||||
|
"hideZoomControls": "Ocultar Controles de Zoom",
|
||||||
|
"enableOfflineFeatures": "Activar Funciones sin Conexión",
|
||||||
|
"enableOfflineFeaturesTitle": "Activar funciones sin conexión",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Permitir descargar y usar áreas de mapa sin conexión. Si se desactiva, el modo sin conexión y las áreas descargadas no estarán disponibles."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Mantener la pantalla encendida mientras se usa la app",
|
||||||
|
"subtitle": "Evita que la pantalla se apague automáticamente mientras se usa la app"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Ocultar controles de zoom",
|
||||||
|
"subtitle": "Oculta los botones de zoom +/- y el indicador de nivel de zoom. Aún puedes hacer zoom con gestos de pellizco."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Razón para Eliminación",
|
||||||
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
|
"deleteReasonSelectHint": "Seleccionar...",
|
||||||
|
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||||
|
"deleteReasonDuplicateNode": "Nodo duplicado",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Infraestructura mal identificada",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||||
|
"deleteReasonCustom": "Personalizado..",
|
||||||
|
"deleteReasonHint": "Por favor describa la razón...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.",
|
||||||
|
"uploadPending": "Subida pendiente",
|
||||||
|
"uploadCreatingChangeset": "Creando changeset...",
|
||||||
|
"uploadUploading": "Subiendo...",
|
||||||
|
"uploadClosingChangeset": "Cerrando changeset...",
|
||||||
|
"uploadError": "Error al subir",
|
||||||
|
"uploadComplete": "Subida completada",
|
||||||
|
"viewInQueue": "Ver en la Cola",
|
||||||
|
"deletedSuccessfully": "Este nodo fue eliminado con éxito de OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"selectProfile": "Seleccionar un perfil...",
|
"selectProfile": "Seleccionar un perfil...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
||||||
"startRefresh": "Iniciar Actualización",
|
"startRefresh": "Iniciar Actualización",
|
||||||
"refreshStarted": "¡Actualización iniciada!",
|
"refreshStarted": "¡Actualización iniciada!",
|
||||||
"refreshFailed": "Actualización falló: {}"
|
"refreshFailed": "Actualización falló: {}",
|
||||||
|
"disableConfirmTitle": "¿Eliminar Datos de Áreas sin Conexión Existentes?",
|
||||||
|
"disableConfirmMessage": "Ya tiene datos de áreas sin conexión existentes. Desactivar las funciones sin conexión evitará que la app los use. ¿Desea conservar estos datos en el dispositivo por si reactiva las funciones sin conexión más tarde, o eliminarlos ahora para liberar espacio de almacenamiento?",
|
||||||
|
"keepData": "Conservar Datos",
|
||||||
|
"deleteData": "Eliminar Datos"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Refinar Etiquetas",
|
"title": "Refinar Etiquetas",
|
||||||
"operatorProfile": "Perfil de Operador",
|
"operatorProfile": "Perfil de Operador",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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é",
|
||||||
"networkStatusIndicator": "Indicateur de Statut Réseau"
|
"keepScreenAwake": "Maintenir l'écran Allumé",
|
||||||
|
"networkStatusIndicator": "Indicateur de Statut Réseau",
|
||||||
|
"hideZoomControls": "Masquer les Contrôles de Zoom",
|
||||||
|
"enableOfflineFeatures": "Activer les Fonctionnalités Hors Ligne",
|
||||||
|
"enableOfflineFeaturesTitle": "Activer les fonctionnalités hors ligne",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Autoriser le téléchargement et l'utilisation de zones de carte hors ligne. Si désactivé, le mode hors ligne et les zones téléchargées ne seront pas disponibles."
|
||||||
},
|
},
|
||||||
|
"keepScreenAwake": {
|
||||||
|
|
||||||
|
"title": "Maintenir l'écran allumé lors de l'utilisation de l'application",
|
||||||
|
"subtitle": "Empêche l'écran de s'éteindre automatiquement pendant l'utilisation de l'application"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Masquer les contrôles de zoom",
|
||||||
|
"subtitle": "Masque les boutons de zoom +/- et l'indicateur de niveau de zoom. Vous pouvez toujours zoomer avec des gestes de pincement."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Raison de Suppression",
|
||||||
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
|
"deleteReasonSelectHint": "Sélectionner...",
|
||||||
|
"deleteReasonDeviceRemoved": "Appareil retiré",
|
||||||
|
"deleteReasonDuplicateNode": "Nœud dupliqué",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Infrastructure mal identifiée",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||||
|
"deleteReasonCustom": "Personnalisé..",
|
||||||
|
"deleteReasonHint": "Veuillez décrire la raison...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.",
|
||||||
|
"uploadPending": "Téléchargement en attente",
|
||||||
|
"uploadCreatingChangeset": "Création du changeset...",
|
||||||
|
"uploadUploading": "Téléchargement en cours...",
|
||||||
|
"uploadClosingChangeset": "Fermeture du changeset...",
|
||||||
|
"uploadError": "Échec du téléchargement",
|
||||||
|
"uploadComplete": "Téléchargement terminé",
|
||||||
|
"viewInQueue": "Voir dans la File d'Attente",
|
||||||
|
"deletedSuccessfully": "Ce nœud a été supprimé avec succès d'OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"selectProfile": "Sélectionner un profil...",
|
"selectProfile": "Sélectionner un profil...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
||||||
"startRefresh": "Démarrer l'Actualisation",
|
"startRefresh": "Démarrer l'Actualisation",
|
||||||
"refreshStarted": "Actualisation démarrée !",
|
"refreshStarted": "Actualisation démarrée !",
|
||||||
"refreshFailed": "Actualisation échouée : {}"
|
"refreshFailed": "Actualisation échouée : {}",
|
||||||
|
"disableConfirmTitle": "Supprimer les Données de Zones Hors Ligne Existantes ?",
|
||||||
|
"disableConfirmMessage": "Vous avez déjà des données de zones hors ligne. Désactiver les fonctionnalités hors ligne empêchera l'application de les utiliser. Souhaitez-vous conserver ces données sur l'appareil au cas où vous réactiveriez les fonctionnalités hors ligne plus tard, ou les supprimer maintenant pour libérer de l'espace de stockage ?",
|
||||||
|
"keepData": "Conserver les Données",
|
||||||
|
"deleteData": "Supprimer les Données"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Affiner les Étiquettes",
|
"title": "Affiner les Étiquettes",
|
||||||
"operatorProfile": "Profil d'Opérateur",
|
"operatorProfile": "Profil d'Opérateur",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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à",
|
||||||
"networkStatusIndicator": "Indicatore di Stato di Rete"
|
"keepScreenAwake": "Mantieni lo Schermo Attivo",
|
||||||
|
"networkStatusIndicator": "Indicatore di Stato di Rete",
|
||||||
|
"hideZoomControls": "Nascondi Controlli Zoom",
|
||||||
|
"enableOfflineFeatures": "Abilita Funzionalità Offline",
|
||||||
|
"enableOfflineFeaturesTitle": "Abilita funzionalità offline",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Consenti il download e l'uso di aree mappa offline. Se disabilitato, la modalità offline e le aree scaricate non saranno disponibili."
|
||||||
},
|
},
|
||||||
|
"keepScreenAwake": {
|
||||||
|
|
||||||
|
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
|
||||||
|
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Nascondi i controlli dello zoom",
|
||||||
|
"subtitle": "Nasconde i pulsanti di zoom +/- e l'indicatore del livello di zoom. Puoi ancora usare i gesti di pizzico per lo zoom."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Motivo per Eliminazione",
|
||||||
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
|
"deleteReasonSelectHint": "Seleziona...",
|
||||||
|
"deleteReasonDeviceRemoved": "Dispositivo rimosso",
|
||||||
|
"deleteReasonDuplicateNode": "Nodo duplicato",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Infrastruttura identificata erroneamente",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||||
|
"deleteReasonCustom": "Personalizzato..",
|
||||||
|
"deleteReasonHint": "Descrivi il motivo...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.",
|
||||||
|
"uploadPending": "Caricamento in sospeso",
|
||||||
|
"uploadCreatingChangeset": "Creazione changeset...",
|
||||||
|
"uploadUploading": "Caricamento...",
|
||||||
|
"uploadClosingChangeset": "Chiusura changeset...",
|
||||||
|
"uploadError": "Caricamento fallito",
|
||||||
|
"uploadComplete": "Caricamento completato",
|
||||||
|
"viewInQueue": "Visualizza in Coda",
|
||||||
|
"deletedSuccessfully": "Questo nodo è stato eliminato con successo da OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profilo",
|
"profile": "Profilo",
|
||||||
"selectProfile": "Seleziona un profilo...",
|
"selectProfile": "Seleziona un profilo...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
||||||
"startRefresh": "Avvia Aggiornamento",
|
"startRefresh": "Avvia Aggiornamento",
|
||||||
"refreshStarted": "Aggiornamento avviato!",
|
"refreshStarted": "Aggiornamento avviato!",
|
||||||
"refreshFailed": "Aggiornamento fallito: {}"
|
"refreshFailed": "Aggiornamento fallito: {}",
|
||||||
|
"disableConfirmTitle": "Eliminare i Dati delle Aree Offline Esistenti?",
|
||||||
|
"disableConfirmMessage": "Hai già dati di aree offline esistenti. Disabilitare le funzionalità offline impedirà all'app di usarli. Vuoi conservare questi dati sul dispositivo nel caso in cui riattivi le funzionalità offline in seguito, oppure eliminarli ora per liberare spazio di archiviazione?",
|
||||||
|
"keepData": "Conserva Dati",
|
||||||
|
"deleteData": "Elimina Dati"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Affina Tag",
|
"title": "Affina Tag",
|
||||||
"operatorProfile": "Profilo Operatore",
|
"operatorProfile": "Profilo Operatore",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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",
|
||||||
"networkStatusIndicator": "Netwerk Status Indicator"
|
"keepScreenAwake": "Scherm Actief Houden",
|
||||||
|
"networkStatusIndicator": "Netwerk Status Indicator",
|
||||||
|
"hideZoomControls": "Zoomknoppen Verbergen",
|
||||||
|
"enableOfflineFeatures": "Offline Functies Inschakelen",
|
||||||
|
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Houd het scherm actief tijdens het gebruik van de app",
|
||||||
|
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Zoomknoppen verbergen",
|
||||||
|
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Reden voor Verwijdering",
|
||||||
"deleteReasonHint": "bijv. apparaat verwijderd, onjuiste locatie, duplicaat..."
|
"deleteReasonSelectHint": "Selecteer...",
|
||||||
|
"deleteReasonDeviceRemoved": "Apparaat verwijderd",
|
||||||
|
"deleteReasonDuplicateNode": "Dubbele node",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Verkeerd geïdentificeerde infrastructuur",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||||
|
"deleteReasonCustom": "Aangepast..",
|
||||||
|
"deleteReasonHint": "Beschrijf de reden...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.",
|
||||||
|
"uploadPending": "Upload in behandeling",
|
||||||
|
"uploadCreatingChangeset": "Changeset maken...",
|
||||||
|
"uploadUploading": "Uploaden...",
|
||||||
|
"uploadClosingChangeset": "Changeset sluiten...",
|
||||||
|
"uploadError": "Upload mislukt",
|
||||||
|
"uploadComplete": "Upload voltooid",
|
||||||
|
"viewInQueue": "Bekijk in Wachtrij",
|
||||||
|
"deletedSuccessfully": "Deze node is succesvol verwijderd van OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profiel",
|
"profile": "Profiel",
|
||||||
"selectProfile": "Selecteer een profiel...",
|
"selectProfile": "Selecteer een profiel...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
||||||
"startRefresh": "Start Verversen",
|
"startRefresh": "Start Verversen",
|
||||||
"refreshStarted": "Verversen gestart!",
|
"refreshStarted": "Verversen gestart!",
|
||||||
"refreshFailed": "Verversen mislukt: {}"
|
"refreshFailed": "Verversen mislukt: {}",
|
||||||
|
"disableConfirmTitle": "Bestaande Offline Gebiedsgegevens Verwijderen?",
|
||||||
|
"disableConfirmMessage": "U heeft al bestaande offline gebiedsgegevens. Het uitschakelen van offline functies zal voorkomen dat de app deze gebruikt. Wilt u deze gegevens op het apparaat bewaren voor het geval u offline functies later weer inschakelt, of ze nu verwijderen om opslagruimte vrij te maken?",
|
||||||
|
"keepData": "Gegevens Behouden",
|
||||||
|
"deleteData": "Gegevens Verwijderen"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Verfijn Tags",
|
"title": "Verfijn Tags",
|
||||||
"operatorProfile": "Operator Profiel",
|
"operatorProfile": "Operator Profiel",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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",
|
||||||
"networkStatusIndicator": "Wskaźnik Stanu Sieci"
|
"keepScreenAwake": "Nie Wygaszaj Ekranu",
|
||||||
|
"networkStatusIndicator": "Wskaźnik Stanu Sieci",
|
||||||
|
"hideZoomControls": "Ukryj Kontrolki Zoomu",
|
||||||
|
"enableOfflineFeatures": "Włącz Funkcje Offline",
|
||||||
|
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
|
||||||
|
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Ukryj kontrolki zoomu",
|
||||||
|
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Powód Usunięcia",
|
||||||
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
|
"deleteReasonSelectHint": "Wybierz...",
|
||||||
|
"deleteReasonDeviceRemoved": "Urządzenie usunięte",
|
||||||
|
"deleteReasonDuplicateNode": "Zduplikowany węzeł",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Błędnie zidentyfikowana infrastruktura",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/wandalizm",
|
||||||
|
"deleteReasonCustom": "Niestandardowy..",
|
||||||
|
"deleteReasonHint": "Opisz powód...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
|
||||||
|
"uploadPending": "Przesyłanie oczekujące",
|
||||||
|
"uploadCreatingChangeset": "Tworzenie zestawu zmian...",
|
||||||
|
"uploadUploading": "Przesyłanie...",
|
||||||
|
"uploadClosingChangeset": "Zamykanie zestawu zmian...",
|
||||||
|
"uploadError": "Przesyłanie nie powiodło się",
|
||||||
|
"uploadComplete": "Przesyłanie zakończone",
|
||||||
|
"viewInQueue": "Zobacz w Kolejce",
|
||||||
|
"deletedSuccessfully": "Ten węzeł został pomyślnie usunięty z OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"selectProfile": "Wybierz profil...",
|
"selectProfile": "Wybierz profil...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
||||||
"startRefresh": "Rozpocznij Odświeżanie",
|
"startRefresh": "Rozpocznij Odświeżanie",
|
||||||
"refreshStarted": "Odświeżanie rozpoczęte!",
|
"refreshStarted": "Odświeżanie rozpoczęte!",
|
||||||
"refreshFailed": "Odświeżanie nie powiodło się: {}"
|
"refreshFailed": "Odświeżanie nie powiodło się: {}",
|
||||||
|
"disableConfirmTitle": "Usunąć Istniejące Dane Obszarów Offline?",
|
||||||
|
"disableConfirmMessage": "Masz już istniejące dane obszarów offline. Wyłączenie funkcji offline uniemożliwi aplikacji ich używanie. Czy chcesz zachować te dane na urządzeniu na wypadek ponownego włączenia funkcji offline później, czy usunąć je teraz, aby zwolnić miejsce na dysku?",
|
||||||
|
"keepData": "Zachowaj Dane",
|
||||||
|
"deleteData": "Usuń Dane"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Doprecyzuj Tagi",
|
"title": "Doprecyzuj Tagi",
|
||||||
"operatorProfile": "Profil Operatora",
|
"operatorProfile": "Profil Operatora",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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",
|
||||||
"networkStatusIndicator": "Indicador de Status de Rede"
|
"keepScreenAwake": "Manter Ecrã Ativa",
|
||||||
|
"networkStatusIndicator": "Indicador de Status de Rede",
|
||||||
|
"hideZoomControls": "Ocultar Controlos de Zoom",
|
||||||
|
"enableOfflineFeatures": "Ativar Funcionalidades Offline",
|
||||||
|
"enableOfflineFeaturesTitle": "Ativar funcionalidades offline",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Permitir descarregar e usar áreas de mapa offline. Se desativado, o modo offline e as áreas descarregadas não estarão disponíveis."
|
||||||
},
|
},
|
||||||
|
"keepScreenAwake": {
|
||||||
|
|
||||||
|
"title": "Manter o ecrã ativo durante o uso do app",
|
||||||
|
"subtitle": "Impede que o ecrã se desligue automaticamente enquanto a app estiver em uso"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Ocultar controlos de zoom",
|
||||||
|
"subtitle": "Oculta os botões de zoom +/- e o indicador de nível de zoom. Ainda pode ampliar com gestos de pinça."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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)",
|
"deleteReasonLabel": "Motivo para Exclusão",
|
||||||
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
|
"deleteReasonSelectHint": "Selecionar...",
|
||||||
|
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||||
|
"deleteReasonDuplicateNode": "Nó duplicado",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Infraestrutura mal identificada",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||||
|
"deleteReasonCustom": "Personalizado..",
|
||||||
|
"deleteReasonHint": "Descreva o motivo...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.",
|
||||||
|
"uploadPending": "Envio pendente",
|
||||||
|
"uploadCreatingChangeset": "Criando changeset...",
|
||||||
|
"uploadUploading": "Enviando...",
|
||||||
|
"uploadClosingChangeset": "Fechando changeset...",
|
||||||
|
"uploadError": "Falha no envio",
|
||||||
|
"uploadComplete": "Envio concluído",
|
||||||
|
"viewInQueue": "Ver na Fila",
|
||||||
|
"deletedSuccessfully": "Este nó foi excluído com sucesso do OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"selectProfile": "Selecionar um perfil...",
|
"selectProfile": "Selecionar um perfil...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
||||||
"startRefresh": "Iniciar Atualização",
|
"startRefresh": "Iniciar Atualização",
|
||||||
"refreshStarted": "Atualização iniciada!",
|
"refreshStarted": "Atualização iniciada!",
|
||||||
"refreshFailed": "Atualização falhou: {}"
|
"refreshFailed": "Atualização falhou: {}",
|
||||||
|
"disableConfirmTitle": "Eliminar Dados de Áreas Offline Existentes?",
|
||||||
|
"disableConfirmMessage": "Já tem dados de áreas offline existentes. Desativar as funcionalidades offline impedirá que a app os utilize. Deseja manter estes dados no dispositivo caso reative as funcionalidades offline mais tarde, ou eliminá-los agora para libertar espaço de armazenamento?",
|
||||||
|
"keepData": "Manter Dados",
|
||||||
|
"deleteData": "Eliminar Dados"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Refinar Tags",
|
"title": "Refinar Tags",
|
||||||
"operatorProfile": "Perfil de Operador",
|
"operatorProfile": "Perfil de Operador",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"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ı",
|
||||||
"networkStatusIndicator": "Ağ Durumu Göstergesi"
|
"keepScreenAwake": "Ekranı Açık Tut",
|
||||||
|
"networkStatusIndicator": "Ağ Durumu Göstergesi",
|
||||||
|
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle",
|
||||||
|
"enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir",
|
||||||
|
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Uygulamayı kullanırken ekranı açık tut",
|
||||||
|
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Yakınlaştırma kontrollerini gizle",
|
||||||
|
"subtitle": "+/- yakınlaştırma düğmelerini ve yakınlaştırma seviyesi göstergesini gizler. Yine de kıstırma hareketleriyle yakınlaştırabilirsiniz."
|
||||||
|
},
|
||||||
|
|
||||||
"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",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"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ı)",
|
"deleteReasonLabel": "Silme Nedeni",
|
||||||
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
|
"deleteReasonSelectHint": "Seçin...",
|
||||||
|
"deleteReasonDeviceRemoved": "Cihaz kaldırıldı",
|
||||||
|
"deleteReasonDuplicateNode": "Yinelenen düğüm",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Yanlış tanımlanmış altyapı",
|
||||||
|
"deleteReasonSpamVandalism": "Spam/vandalizm",
|
||||||
|
"deleteReasonCustom": "Özel..",
|
||||||
|
"deleteReasonHint": "Lütfen nedeni açıklayın...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.",
|
||||||
|
"uploadPending": "Yükleme bekliyor",
|
||||||
|
"uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...",
|
||||||
|
"uploadUploading": "Yükleniyor...",
|
||||||
|
"uploadClosingChangeset": "Değişiklik seti kapatılıyor...",
|
||||||
|
"uploadError": "Yükleme başarısız oldu",
|
||||||
|
"uploadComplete": "Yükleme tamamlandı",
|
||||||
|
"viewInQueue": "Sırada Görüntüle",
|
||||||
|
"deletedSuccessfully": "Bu düğüm OpenStreetMap'ten başarıyla silindi."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"selectProfile": "Bir profil seçin...",
|
"selectProfile": "Bir profil seçin...",
|
||||||
@@ -361,6 +394,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",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
||||||
"startRefresh": "Yenilemeyi Başlat",
|
"startRefresh": "Yenilemeyi Başlat",
|
||||||
"refreshStarted": "Yenileme başladı!",
|
"refreshStarted": "Yenileme başladı!",
|
||||||
"refreshFailed": "Yenileme başarısız: {}"
|
"refreshFailed": "Yenileme başarısız: {}",
|
||||||
|
"disableConfirmTitle": "Mevcut Çevrimdışı Alan Verileri Silinsin mi?",
|
||||||
|
"disableConfirmMessage": "Zaten mevcut çevrimdışı alan verileriniz var. Çevrimdışı özellikleri devre dışı bırakmak, uygulamanın bunları kullanmasını engelleyecektir. Daha sonra çevrimdışı özellikleri tekrar etkinleştirme ihtimaline karşı bu verileri cihazda saklamak mı istersiniz, yoksa depolama alanını boşaltmak için şimdi silmek mi istersiniz?",
|
||||||
|
"keepData": "Verileri Sakla",
|
||||||
|
"deleteData": "Verileri Sil"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Etiketleri Düzenle",
|
"title": "Etiketleri Düzenle",
|
||||||
"operatorProfile": "Operatör Profili",
|
"operatorProfile": "Operatör Profili",
|
||||||
@@ -524,7 +564,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,23 @@
|
|||||||
"advancedSettings": "Розширені Налаштування",
|
"advancedSettings": "Розширені Налаштування",
|
||||||
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
||||||
"proximityAlerts": "Сповіщення Про Близькість",
|
"proximityAlerts": "Сповіщення Про Близькість",
|
||||||
"networkStatusIndicator": "Індикатор Стану Мережі"
|
"keepScreenAwake": "Тримати екран активним",
|
||||||
|
"networkStatusIndicator": "Індикатор Стану Мережі",
|
||||||
|
"hideZoomControls": "Приховати Елементи Керування Масштабом",
|
||||||
|
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
|
||||||
|
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
|
||||||
|
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "Тримати екран активним під час використання програми",
|
||||||
|
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "Приховати елементи керування масштабом",
|
||||||
|
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
|
||||||
|
},
|
||||||
|
|
||||||
"proximityAlerts": {
|
"proximityAlerts": {
|
||||||
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
||||||
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||||
"confirmDeleteTitle": "Видалити Вузол",
|
"confirmDeleteTitle": "Видалити Вузол",
|
||||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||||
"deleteReasonLabel": "Причина Видалення (Опціонально)",
|
"deleteReasonLabel": "Причина Видалення",
|
||||||
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
|
"deleteReasonSelectHint": "Вибрати...",
|
||||||
|
"deleteReasonDeviceRemoved": "Пристрій видалено",
|
||||||
|
"deleteReasonDuplicateNode": "Дубльований вузол",
|
||||||
|
"deleteReasonMisidentifiedInfra": "Неправильно ідентифікована інфраструктура",
|
||||||
|
"deleteReasonSpamVandalism": "Спам/вандалізм",
|
||||||
|
"deleteReasonCustom": "Власний..",
|
||||||
|
"deleteReasonHint": "Опишіть причину...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
|
||||||
|
"uploadPending": "Завантаження очікує",
|
||||||
|
"uploadCreatingChangeset": "Створення набору змін...",
|
||||||
|
"uploadUploading": "Завантаження...",
|
||||||
|
"uploadClosingChangeset": "Закриття набору змін...",
|
||||||
|
"uploadError": "Помилка завантаження",
|
||||||
|
"uploadComplete": "Завантаження завершено",
|
||||||
|
"viewInQueue": "Переглянути в Черзі",
|
||||||
|
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "Профіль",
|
"profile": "Профіль",
|
||||||
"selectProfile": "Оберіть профіль...",
|
"selectProfile": "Оберіть профіль...",
|
||||||
@@ -361,6 +394,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 Теги",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
||||||
"startRefresh": "Почати Оновлення",
|
"startRefresh": "Почати Оновлення",
|
||||||
"refreshStarted": "Оновлення розпочато!",
|
"refreshStarted": "Оновлення розпочато!",
|
||||||
"refreshFailed": "Оновлення не вдалося: {}"
|
"refreshFailed": "Оновлення не вдалося: {}",
|
||||||
|
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
|
||||||
|
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
|
||||||
|
"keepData": "Зберегти Дані",
|
||||||
|
"deleteData": "Видалити Дані"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "Уточнити Теги",
|
"title": "Уточнити Теги",
|
||||||
"operatorProfile": "Профіль Оператора",
|
"operatorProfile": "Профіль Оператора",
|
||||||
@@ -524,7 +564,7 @@
|
|||||||
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
||||||
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
||||||
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
||||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
|
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
|
||||||
"updating": "Оновлення Підозрілих Локацій",
|
"updating": "Оновлення Підозрілих Локацій",
|
||||||
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
||||||
"updateSuccess": "Підозрілі локації успішно оновлено",
|
"updateSuccess": "Підозрілі локації успішно оновлено",
|
||||||
|
|||||||
@@ -111,8 +111,23 @@
|
|||||||
"advancedSettings": "高级设置",
|
"advancedSettings": "高级设置",
|
||||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||||
"proximityAlerts": "邻近警报",
|
"proximityAlerts": "邻近警报",
|
||||||
"networkStatusIndicator": "网络状态指示器"
|
"keepScreenAwake": "保持屏幕常亮",
|
||||||
|
"networkStatusIndicator": "网络状态指示器",
|
||||||
|
"hideZoomControls": "隐藏缩放控件",
|
||||||
|
"enableOfflineFeatures": "启用离线功能",
|
||||||
|
"enableOfflineFeaturesTitle": "启用离线功能",
|
||||||
|
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"keepScreenAwake": {
|
||||||
|
"title": "使用应用时保持屏幕常亮",
|
||||||
|
"subtitle": "防止在使用应用期间屏幕自动熄灭"
|
||||||
|
},
|
||||||
|
"hideZoomControls": {
|
||||||
|
"title": "隐藏缩放控件",
|
||||||
|
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
|
||||||
|
},
|
||||||
|
|
||||||
"proximityAlerts": {
|
"proximityAlerts": {
|
||||||
"getNotified": "接近监控设备时接收通知",
|
"getNotified": "接近监控设备时接收通知",
|
||||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||||
@@ -133,9 +148,27 @@
|
|||||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||||
"confirmDeleteTitle": "删除节点",
|
"confirmDeleteTitle": "删除节点",
|
||||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
||||||
"deleteReasonLabel": "删除原因(可选)",
|
"deleteReasonLabel": "删除原因",
|
||||||
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
|
"deleteReasonSelectHint": "请选择...",
|
||||||
|
"deleteReasonDeviceRemoved": "设备已移除",
|
||||||
|
"deleteReasonDuplicateNode": "重复节点",
|
||||||
|
"deleteReasonMisidentifiedInfra": "识别错误的基础设施",
|
||||||
|
"deleteReasonSpamVandalism": "垃圾信息/破坏",
|
||||||
|
"deleteReasonCustom": "自定义..",
|
||||||
|
"deleteReasonHint": "请描述原因...",
|
||||||
|
|
||||||
|
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
|
||||||
|
"uploadPending": "上传待处理",
|
||||||
|
"uploadCreatingChangeset": "正在创建变更集...",
|
||||||
|
"uploadUploading": "正在上传...",
|
||||||
|
"uploadClosingChangeset": "正在关闭变更集...",
|
||||||
|
"uploadError": "上传失败",
|
||||||
|
"uploadComplete": "上传完成",
|
||||||
|
"viewInQueue": "在队列中查看",
|
||||||
|
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
"addNode": {
|
"addNode": {
|
||||||
"profile": "配置文件",
|
"profile": "配置文件",
|
||||||
"selectProfile": "选择配置文件...",
|
"selectProfile": "选择配置文件...",
|
||||||
@@ -361,6 +394,8 @@
|
|||||||
"fovHint": "视场角度数(留空使用默认值)",
|
"fovHint": "视场角度数(留空使用默认值)",
|
||||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||||
"fovInvalid": "视场角必须在1到360度之间",
|
"fovInvalid": "视场角必须在1到360度之间",
|
||||||
|
"fov360": "360°视场角",
|
||||||
|
"fov360Subtitle": "摄像头具有360度全方向视场角",
|
||||||
"submittable": "可提交",
|
"submittable": "可提交",
|
||||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||||
"osmTags": "OSM 标签",
|
"osmTags": "OSM 标签",
|
||||||
@@ -419,8 +454,13 @@
|
|||||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||||
"startRefresh": "开始刷新",
|
"startRefresh": "开始刷新",
|
||||||
"refreshStarted": "刷新已开始!",
|
"refreshStarted": "刷新已开始!",
|
||||||
"refreshFailed": "刷新失败:{}"
|
"refreshFailed": "刷新失败:{}",
|
||||||
|
"disableConfirmTitle": "删除现有的离线区域数据?",
|
||||||
|
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
|
||||||
|
"keepData": "保留数据",
|
||||||
|
"deleteData": "删除数据"
|
||||||
},
|
},
|
||||||
|
|
||||||
"refineTagsSheet": {
|
"refineTagsSheet": {
|
||||||
"title": "细化标签",
|
"title": "细化标签",
|
||||||
"operatorProfile": "运营商配置文件",
|
"operatorProfile": "运营商配置文件",
|
||||||
@@ -524,7 +564,7 @@
|
|||||||
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
||||||
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
||||||
"minimumDistance": "与真实节点的最小距离",
|
"minimumDistance": "与真实节点的最小距离",
|
||||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
|
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
|
||||||
"updating": "正在更新疑似位置",
|
"updating": "正在更新疑似位置",
|
||||||
"downloadingAndProcessing": "正在下载和处理数据...",
|
"downloadingAndProcessing": "正在下载和处理数据...",
|
||||||
"updateSuccess": "疑似位置更新成功",
|
"updateSuccess": "疑似位置更新成功",
|
||||||
|
|||||||
+42
-1
@@ -17,12 +17,44 @@ import 'services/localization_service.dart';
|
|||||||
import 'services/provider_tile_cache_manager.dart';
|
import 'services/provider_tile_cache_manager.dart';
|
||||||
import 'services/version_service.dart';
|
import 'services/version_service.dart';
|
||||||
import 'services/deep_link_service.dart';
|
import 'services/deep_link_service.dart';
|
||||||
|
import 'services/deflock_tile_provider.dart' show TileLoadCancelledException, TileNotAvailableOfflineException;
|
||||||
|
|
||||||
|
/// Suppress console noise for expected, already-handled tile load errors.
|
||||||
|
///
|
||||||
|
/// [TileLoadCancelledException] (tile scrolled off screen) and
|
||||||
|
/// [TileNotAvailableOfflineException] (no cached data available) are thrown
|
||||||
|
/// deliberately by DeflockOfflineTileImageProvider and are already handled —
|
||||||
|
/// TileLayerManager.onTileLoadError() specifically skips retry logic for
|
||||||
|
/// both. Flutter's image pipeline reports them via
|
||||||
|
/// MultiFrameImageStreamCompleter with `silent: true`, but
|
||||||
|
/// FlutterError.dumpErrorToConsole has a debug-mode assert() that ignores
|
||||||
|
/// the silent flag and always prints to console anyway. This filters those
|
||||||
|
/// two specific, expected exception types back out while leaving all other
|
||||||
|
/// errors (including genuinely silent-but-unexpected ones) reported as
|
||||||
|
/// normal.
|
||||||
|
void _installTileErrorFilter() {
|
||||||
|
final defaultOnError = FlutterError.onError;
|
||||||
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
|
final exception = details.exception;
|
||||||
|
if (details.silent &&
|
||||||
|
(exception is TileLoadCancelledException ||
|
||||||
|
exception is TileNotAvailableOfflineException)) {
|
||||||
|
return; // Already handled — don't spam the console.
|
||||||
|
}
|
||||||
|
if (defaultOnError != null) {
|
||||||
|
defaultOnError(details);
|
||||||
|
} else {
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
_installTileErrorFilter();
|
||||||
|
|
||||||
|
|
||||||
// Initialize version service
|
// Initialize version service
|
||||||
await VersionService().init();
|
await VersionService().init();
|
||||||
|
|
||||||
@@ -92,6 +124,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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'app_state.dart';
|
import 'app_state.dart';
|
||||||
|
import 'services/offline_area_service.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 +145,76 @@ class OneTimeMigrations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear non-360 FOV values from all profiles (v2.10.0)
|
||||||
|
static Future<void> migrate_2_10_0(AppState appState) async {
|
||||||
|
try {
|
||||||
|
// Only perform this migration if non-360 FOVs are disabled
|
||||||
|
if (kEnableNon360FOVs) {
|
||||||
|
debugPrint('[Migration] 2.10.0: Non-360 FOVs enabled, skipping FOV cleanup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all profiles from storage
|
||||||
|
final profiles = await ProfileService().load();
|
||||||
|
bool anyProfileChanged = false;
|
||||||
|
int profilesCleared = 0;
|
||||||
|
|
||||||
|
// Clear non-360 FOV values from all profiles
|
||||||
|
final updatedProfiles = profiles.map((profile) {
|
||||||
|
if (profile.fov != null) {
|
||||||
|
// Use approximation to handle floating point precision issues
|
||||||
|
final fovValue = profile.fov!;
|
||||||
|
final is360 = (fovValue - 360.0).abs() < 0.01; // Within 0.01 degrees of 360
|
||||||
|
|
||||||
|
if (!is360) {
|
||||||
|
debugPrint('[Migration] 2.10.0: Clearing FOV $fovValue from profile: ${profile.name}');
|
||||||
|
anyProfileChanged = true;
|
||||||
|
profilesCleared++;
|
||||||
|
return profile.copyWith(fov: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Save updated profiles back to storage if any changes were made
|
||||||
|
if (anyProfileChanged) {
|
||||||
|
await ProfileService().save(updatedProfiles);
|
||||||
|
await appState.reloadProfiles();
|
||||||
|
debugPrint('[Migration] 2.10.0: Cleared FOV from $profilesCleared profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[Migration] 2.10.0 completed: cleared non-360 FOV values from profiles');
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint('[Migration] 2.10.0 ERROR: Failed to clear non-360 FOV values: $e');
|
||||||
|
debugPrint('[Migration] 2.10.0 ERROR: Stack trace: $stackTrace');
|
||||||
|
// Don't rethrow - this is non-critical, FOV restrictions will still apply going forward
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable offline features for existing users who already have offline
|
||||||
|
/// area data (v2.10.5). New installs and existing users who never used
|
||||||
|
/// offline areas will keep the new "offline features enabled" setting at
|
||||||
|
/// its default of false.
|
||||||
|
static Future<void> migrate_2_10_5(AppState appState) async {
|
||||||
|
try {
|
||||||
|
final offlineAreaService = OfflineAreaService();
|
||||||
|
await offlineAreaService.ensureInitialized();
|
||||||
|
|
||||||
|
if (offlineAreaService.offlineAreas.isNotEmpty) {
|
||||||
|
await appState.setOfflineFeaturesEnabled(true);
|
||||||
|
debugPrint('[Migration] 2.10.5: Existing offline area data found - enabled offline features');
|
||||||
|
} else {
|
||||||
|
debugPrint('[Migration] 2.10.5: No existing offline area data - leaving offline features disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[Migration] 2.10.5 completed: offline features migration done');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[Migration] 2.10.5 ERROR: Failed to migrate offline features setting: $e');
|
||||||
|
// Don't rethrow - this is non-critical, worst case is the user needs
|
||||||
|
// to manually re-enable the toggle to see their existing offline areas.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the migration function for a specific version
|
/// 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,11 +230,16 @@ 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;
|
||||||
|
case '2.10.5':
|
||||||
|
return migrate_2_10_5;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Run migration for a specific version with nuclear reset on failure
|
/// Run migration for a specific version with nuclear reset on failure
|
||||||
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -83,6 +86,13 @@ class AboutScreen extends StatelessWidget {
|
|||||||
_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),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -203,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,9 @@ 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 'settings/sections/hide_zoom_controls_section.dart';
|
||||||
|
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
class AdvancedSettingsScreen extends StatelessWidget {
|
class AdvancedSettingsScreen extends StatelessWidget {
|
||||||
@@ -32,6 +35,11 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
|||||||
Divider(),
|
Divider(),
|
||||||
SuspectedLocationsSection(),
|
SuspectedLocationsSection(),
|
||||||
Divider(),
|
Divider(),
|
||||||
|
KeepScreenAwakeSection(),
|
||||||
|
Divider(),
|
||||||
|
HideZoomControlsSection(),
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+114
-54
@@ -1,12 +1,15 @@
|
|||||||
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';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import '../widgets/map_view.dart';
|
import '../widgets/map_view.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
|
import '../services/map_data_provider.dart';
|
||||||
|
|
||||||
|
|
||||||
import '../widgets/node_tag_sheet.dart';
|
import '../widgets/node_tag_sheet.dart';
|
||||||
import '../widgets/download_area_dialog.dart';
|
import '../widgets/download_area_dialog.dart';
|
||||||
@@ -19,6 +22,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 +61,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 +292,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(
|
||||||
@@ -417,6 +457,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-focus a node's details sheet right after it was submitted/edited/deleted,
|
||||||
|
// behind kAutoOpenNodeSheetAfterSubmit (pending A/B testing/team feedback).
|
||||||
|
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||||
|
final focusNodeId = appState.consumePendingFocusNodeId();
|
||||||
|
if (focusNodeId != null && !_sheetCoordinator.hasActiveNodeSheet) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final node = MapDataProvider().getNodeById(focusNodeId);
|
||||||
|
if (node != null) {
|
||||||
|
openNodeTagSheet(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Pass the active sheet height directly to the map
|
// Pass the active sheet height directly to the map
|
||||||
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
|
final activeSheetHeight = _sheetCoordinator.activeSheetHeight;
|
||||||
|
|
||||||
@@ -498,6 +554,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
|
||||||
@@ -556,67 +613,70 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
child: Row(
|
child: AnimatedBuilder(
|
||||||
children: [
|
animation: LocalizationService.instance,
|
||||||
Expanded(
|
builder: (context, child) {
|
||||||
flex: 7, // 70% for primary action
|
final appState = context.watch<AppState>();
|
||||||
child: AnimatedBuilder(
|
final showDownloadButton = appState.offlineFeaturesEnabled;
|
||||||
animation: LocalizationService.instance,
|
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||||
builder: (context, child) => ElevatedButton.icon(
|
|
||||||
icon: Icon(Icons.add_location_alt),
|
return Row(
|
||||||
label: Text(LocalizationService.instance.tagNode),
|
children: [
|
||||||
onPressed: _openAddNodeSheet,
|
Expanded(
|
||||||
style: ElevatedButton.styleFrom(
|
// Take full width when the download button is hidden
|
||||||
minimumSize: Size(0, 48),
|
flex: showDownloadButton ? 7 : 10,
|
||||||
textStyle: TextStyle(fontSize: 16),
|
child: ElevatedButton.icon(
|
||||||
|
icon: Icon(Icons.add_location_alt),
|
||||||
|
label: Text(LocalizationService.instance.tagNode),
|
||||||
|
onPressed: _openAddNodeSheet,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: Size(0, 48),
|
||||||
|
textStyle: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (showDownloadButton) ...[
|
||||||
),
|
SizedBox(width: 12),
|
||||||
SizedBox(width: 12),
|
Expanded(
|
||||||
Expanded(
|
flex: 3, // 30% for secondary action
|
||||||
flex: 3, // 30% for secondary action
|
child: FittedBox(
|
||||||
child: AnimatedBuilder(
|
fit: BoxFit.scaleDown,
|
||||||
animation: LocalizationService.instance,
|
child: ElevatedButton.icon(
|
||||||
builder: (context, child) {
|
icon: Icon(Icons.download_for_offline),
|
||||||
final appState = context.watch<AppState>();
|
label: Text(LocalizationService.instance.download),
|
||||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
onPressed: canDownload ? () {
|
||||||
return FittedBox(
|
// Check minimum zoom level before opening download dialog
|
||||||
fit: BoxFit.scaleDown,
|
final currentZoom = _mapController.mapController.camera.zoom;
|
||||||
child: ElevatedButton.icon(
|
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||||
icon: Icon(Icons.download_for_offline),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
label: Text(LocalizationService.instance.download),
|
SnackBar(
|
||||||
onPressed: canDownload ? () {
|
content: Text(
|
||||||
// Check minimum zoom level before opening download dialog
|
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||||
final currentZoom = _mapController.mapController.camera.zoom;
|
params: [kMinZoomForOfflineDownload.toString()])
|
||||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
|
||||||
params: [kMinZoomForOfflineDownload.toString()])
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||||
);
|
);
|
||||||
} : null,
|
} : null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: Size(0, 48),
|
minimumSize: Size(0, 48),
|
||||||
textStyle: TextStyle(fontSize: 16),
|
textStyle: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../app_state.dart';
|
||||||
|
import 'settings/sections/enable_offline_features_section.dart';
|
||||||
import 'settings/sections/offline_mode_section.dart';
|
import 'settings/sections/offline_mode_section.dart';
|
||||||
import 'settings/sections/offline_areas_section.dart';
|
import 'settings/sections/offline_areas_section.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
@@ -16,20 +19,42 @@ class OfflineSettingsScreen extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(locService.t('settings.offlineSettings')),
|
title: Text(locService.t('settings.offlineSettings')),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: Consumer<AppState>(
|
||||||
padding: EdgeInsets.fromLTRB(
|
builder: (context, appState, child) {
|
||||||
16,
|
final offlineFeaturesEnabled = appState.offlineFeaturesEnabled;
|
||||||
16,
|
|
||||||
16,
|
return ListView(
|
||||||
16 + MediaQuery.of(context).padding.bottom,
|
padding: EdgeInsets.fromLTRB(
|
||||||
),
|
16,
|
||||||
children: const [
|
16,
|
||||||
OfflineModeSection(),
|
16,
|
||||||
Divider(),
|
16 + MediaQuery.of(context).padding.bottom,
|
||||||
OfflineAreasSection(),
|
),
|
||||||
],
|
children: [
|
||||||
|
const EnableOfflineFeaturesSection(),
|
||||||
|
const Divider(),
|
||||||
|
// Grey out and disable interaction with the rest of the
|
||||||
|
// offline settings when the master toggle is off, while
|
||||||
|
// keeping them visible/discoverable.
|
||||||
|
Opacity(
|
||||||
|
opacity: offlineFeaturesEnabled ? 1.0 : 0.4,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !offlineFeaturesEnabled,
|
||||||
|
child: const Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
OfflineModeSection(),
|
||||||
|
Divider(),
|
||||||
|
OfflineAreasSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/node_profile.dart';
|
|||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
import '../widgets/nsi_tag_value_field.dart';
|
import '../widgets/nsi_tag_value_field.dart';
|
||||||
|
import '../dev_config.dart';
|
||||||
|
|
||||||
class ProfileEditor extends StatefulWidget {
|
class ProfileEditor extends StatefulWidget {
|
||||||
const ProfileEditor({super.key, required this.profile});
|
const ProfileEditor({super.key, required this.profile});
|
||||||
@@ -22,6 +23,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
late bool _requiresDirection;
|
late bool _requiresDirection;
|
||||||
late bool _submittable;
|
late bool _submittable;
|
||||||
late TextEditingController _fovCtrl;
|
late TextEditingController _fovCtrl;
|
||||||
|
late bool _is360Fov;
|
||||||
|
|
||||||
static const _defaultTags = [
|
static const _defaultTags = [
|
||||||
MapEntry('man_made', 'surveillance'),
|
MapEntry('man_made', 'surveillance'),
|
||||||
@@ -41,6 +43,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
_requiresDirection = widget.profile.requiresDirection;
|
_requiresDirection = widget.profile.requiresDirection;
|
||||||
_submittable = widget.profile.submittable;
|
_submittable = widget.profile.submittable;
|
||||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||||
|
_is360Fov = widget.profile.fov == 360.0;
|
||||||
|
|
||||||
if (widget.profile.tags.isEmpty) {
|
if (widget.profile.tags.isEmpty) {
|
||||||
// New profile → start with sensible defaults
|
// New profile → start with sensible defaults
|
||||||
@@ -94,28 +97,68 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.profile.editable) ...[
|
if (widget.profile.editable) ...[
|
||||||
CheckboxListTile(
|
// Direction and FOV configuration - show different UI based on dev config
|
||||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
if (kEnableNon360FOVs) ...[
|
||||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
// Old UI: direction required + optional custom FOV text field (development mode)
|
||||||
value: _requiresDirection,
|
CheckboxListTile(
|
||||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||||
),
|
value: _requiresDirection,
|
||||||
if (_requiresDirection) Padding(
|
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
child: TextField(
|
|
||||||
controller: _fovCtrl,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: locService.t('profileEditor.fov'),
|
|
||||||
hintText: locService.t('profileEditor.fovHint'),
|
|
||||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
|
||||||
errorText: _validateFov(),
|
|
||||||
suffixText: '°',
|
|
||||||
),
|
|
||||||
onChanged: (value) => setState(() {}), // Trigger validation
|
|
||||||
),
|
),
|
||||||
),
|
if (_requiresDirection) Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _fovCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: locService.t('profileEditor.fov'),
|
||||||
|
hintText: locService.t('profileEditor.fovHint'),
|
||||||
|
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||||
|
errorText: _validateFov(),
|
||||||
|
suffixText: '°',
|
||||||
|
),
|
||||||
|
onChanged: (value) => setState(() {}), // Trigger validation
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// New UI: mutually exclusive direction vs 360° FOV checkboxes (production mode)
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||||
|
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||||
|
value: _requiresDirection,
|
||||||
|
onChanged: widget.profile.editable
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_requiresDirection = value ?? false;
|
||||||
|
// Make mutually exclusive with 360° FOV
|
||||||
|
if (_requiresDirection) {
|
||||||
|
_is360Fov = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text(locService.t('profileEditor.fov360')),
|
||||||
|
subtitle: Text(locService.t('profileEditor.fov360Subtitle')),
|
||||||
|
value: _is360Fov,
|
||||||
|
onChanged: widget.profile.editable
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_is360Fov = value ?? false;
|
||||||
|
// Make mutually exclusive with direction requirement
|
||||||
|
if (_is360Fov) {
|
||||||
|
_requiresDirection = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
],
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(locService.t('profileEditor.submittable')),
|
title: Text(locService.t('profileEditor.submittable')),
|
||||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||||
@@ -199,6 +242,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _validateFov() {
|
String? _validateFov() {
|
||||||
|
// Only validate when using text field mode
|
||||||
|
if (!kEnableNon360FOVs) return null;
|
||||||
|
|
||||||
final text = _fovCtrl.text.trim();
|
final text = _fovCtrl.text.trim();
|
||||||
if (text.isEmpty) return null; // Optional field
|
if (text.isEmpty) return null; // Optional field
|
||||||
|
|
||||||
@@ -219,14 +265,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate FOV if provided
|
// Validate FOV if using text field mode
|
||||||
if (_validateFov() != null) {
|
if (kEnableNon360FOVs && _validateFov() != null) {
|
||||||
return; // Don't save if FOV validation fails
|
return; // Don't save if FOV validation fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse FOV
|
// Parse FOV based on dev config mode
|
||||||
final fovText = _fovCtrl.text.trim();
|
double? fov;
|
||||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
if (kEnableNon360FOVs) {
|
||||||
|
// Old mode: parse from text field
|
||||||
|
final fovText = _fovCtrl.text.trim();
|
||||||
|
fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||||
|
} else {
|
||||||
|
// New mode: use checkbox state
|
||||||
|
fov = _is360Fov ? 360.0 : null;
|
||||||
|
}
|
||||||
|
|
||||||
final tagMap = <String, String>{};
|
final tagMap = <String, String>{};
|
||||||
for (final e in _tags) {
|
for (final e in _tags) {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../app_state.dart';
|
||||||
|
import '../../../services/offline_area_service.dart';
|
||||||
|
import '../../../services/localization_service.dart';
|
||||||
|
|
||||||
|
/// Master toggle for all offline-area functionality (downloading, browsing,
|
||||||
|
/// and using cached tiles/nodes offline). When disabled, the rest of the
|
||||||
|
/// Offline Settings screen (offline mode + offline areas list) is greyed out
|
||||||
|
/// and the download button on the map screen is hidden. Functionally, the
|
||||||
|
/// app will behave as if no offline area data exists at all while this is
|
||||||
|
/// off, regardless of whether area files still exist on disk.
|
||||||
|
class EnableOfflineFeaturesSection extends StatelessWidget {
|
||||||
|
const EnableOfflineFeaturesSection({super.key});
|
||||||
|
|
||||||
|
Future<void> _handleToggle(BuildContext context, AppState appState, bool value) async {
|
||||||
|
if (value) {
|
||||||
|
// Enabling requires no confirmation.
|
||||||
|
await appState.setOfflineFeaturesEnabled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling: if offline area data already exists, ask whether to keep
|
||||||
|
// or delete it. Either choice proceeds to disable the toggle - there is
|
||||||
|
// no "cancel" path once this dialog is shown.
|
||||||
|
final offlineService = OfflineAreaService();
|
||||||
|
await offlineService.ensureInitialized();
|
||||||
|
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
|
||||||
|
|
||||||
|
if (!hasExistingAreas) {
|
||||||
|
await appState.setOfflineFeaturesEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
final shouldDelete = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(locService.t('offlineAreas.disableConfirmTitle')),
|
||||||
|
content: Text(locService.t('offlineAreas.disableConfirmMessage')),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(locService.t('offlineAreas.keepData')),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text(locService.t('offlineAreas.deleteData')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldDelete == true) {
|
||||||
|
for (final area in offlineService.offlineAreas.toList()) {
|
||||||
|
offlineService.deleteArea(area.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable regardless of the user's data choice.
|
||||||
|
await appState.setOfflineFeaturesEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: LocalizationService.instance,
|
||||||
|
builder: (context, child) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
final appState = context.watch<AppState>();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('settings.enableOfflineFeatures'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(locService.t('settings.enableOfflineFeaturesTitle')),
|
||||||
|
subtitle: Text(
|
||||||
|
locService.t('settings.enableOfflineFeaturesSubtitle'),
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
value: appState.offlineFeaturesEnabled,
|
||||||
|
onChanged: (value) => _handleToggle(context, appState, value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../app_state.dart';
|
||||||
|
import '../../../services/localization_service.dart';
|
||||||
|
|
||||||
|
class HideZoomControlsSection extends StatelessWidget {
|
||||||
|
const HideZoomControlsSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Standard Pattern: Use AnimatedBuilder to respond to language/localization changes
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: LocalizationService.instance,
|
||||||
|
builder: (context, child) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
final appState = context.watch<AppState>();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Bold Section Header (matches Keep Screen Awake, Proximity, etc.)
|
||||||
|
Text(
|
||||||
|
locService.t('settings.hideZoomControls'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// The Functional Toggle
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero, // Aligns switch text with the bold header
|
||||||
|
title: Text(locService.t('hideZoomControls.title')),
|
||||||
|
subtitle: Text(
|
||||||
|
locService.t('hideZoomControls.subtitle'),
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
value: appState.hideZoomControls,
|
||||||
|
onChanged: (bool value) {
|
||||||
|
appState.setHideZoomControls(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../app_state.dart';
|
||||||
|
import '../../../services/localization_service.dart';
|
||||||
|
|
||||||
|
class KeepScreenAwakeSection extends StatelessWidget {
|
||||||
|
const KeepScreenAwakeSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Standard Pattern: Use AnimatedBuilder to respond to language/localization changes
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: LocalizationService.instance,
|
||||||
|
builder: (context, child) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
final appState = context.watch<AppState>();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Bold Section Header (matches Max Nodes, Proximity, etc.)
|
||||||
|
Text(
|
||||||
|
locService.t('settings.keepScreenAwake'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// The Functional Toggle
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero, // Aligns switch text with the bold header
|
||||||
|
title: Text(locService.t('keepScreenAwake.title')),
|
||||||
|
subtitle: Text(
|
||||||
|
locService.t('keepScreenAwake.subtitle'),
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
value: appState.keepScreenAwake,
|
||||||
|
onChanged: (bool value) {
|
||||||
|
appState.setKeepScreenAwake(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class NodeProfilesSection extends StatelessWidget {
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: appState.profiles.length,
|
itemCount: appState.profiles.length,
|
||||||
onReorder: (oldIndex, newIndex) {
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
appState.reorderProfiles(oldIndex, newIndex);
|
appState.reorderProfiles(oldIndex, newIndex);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
@@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
...appState.operatorProfiles.map(
|
ReorderableListView.builder(
|
||||||
(p) => ListTile(
|
shrinkWrap: true,
|
||||||
title: Text(p.name),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
itemCount: appState.operatorProfiles.length,
|
||||||
trailing: PopupMenuButton(
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
itemBuilder: (context) => [
|
appState.reorderOperatorProfiles(oldIndex, newIndex);
|
||||||
PopupMenuItem(
|
},
|
||||||
value: 'edit',
|
itemBuilder: (context, index) {
|
||||||
child: Row(
|
final p = appState.operatorProfiles[index];
|
||||||
children: [
|
return ListTile(
|
||||||
const Icon(Icons.edit),
|
key: ValueKey(p.id),
|
||||||
const SizedBox(width: 8),
|
leading: ReorderableDragStartListener(
|
||||||
Text(locService.t('actions.edit')),
|
index: index,
|
||||||
],
|
child: const Icon(
|
||||||
),
|
Icons.drag_handle,
|
||||||
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
),
|
||||||
value: 'delete',
|
title: Text(p.name),
|
||||||
child: Row(
|
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||||
children: [
|
trailing: PopupMenuButton(
|
||||||
const Icon(Icons.delete, color: Colors.red),
|
itemBuilder: (context) => [
|
||||||
const SizedBox(width: 8),
|
PopupMenuItem(
|
||||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
value: 'edit',
|
||||||
],
|
child: Row(
|
||||||
),
|
children: [
|
||||||
),
|
const Icon(Icons.edit),
|
||||||
],
|
const SizedBox(width: 8),
|
||||||
onSelected: (value) {
|
Text(locService.t('actions.edit')),
|
||||||
if (value == 'edit') {
|
],
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => OperatorProfileEditor(profile: p),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
} else if (value == 'delete') {
|
PopupMenuItem(
|
||||||
_showDeleteProfileDialog(context, p);
|
value: 'delete',
|
||||||
}
|
child: Row(
|
||||||
},
|
children: [
|
||||||
),
|
const Icon(Icons.delete, color: Colors.red),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
|
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'edit') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OperatorProfileEditor(profile: p),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (value == 'delete') {
|
||||||
|
_showDeleteProfileDialog(context, p);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,11 +225,28 @@ 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
|
||||||
|
versionsNeedingMigration.add('2.10.5');
|
||||||
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Shared validation helpers for geographic coordinates and zoom levels.
|
||||||
|
///
|
||||||
|
/// Used wherever a coordinate or zoom level comes from an external source
|
||||||
|
/// (GPS fixes, persisted preferences, live map camera state) that isn't
|
||||||
|
/// guaranteed to be finite/in-range.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import '../dev_config.dart' show kAbsoluteMaxZoom;
|
||||||
|
|
||||||
|
/// Validate that a latitude value is finite and within -90 to 90.
|
||||||
|
bool isValidLatitude(double value) {
|
||||||
|
return !value.isNaN && !value.isInfinite && value >= -90.0 && value <= 90.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a longitude value is finite and within -180 to 180.
|
||||||
|
bool isValidLongitude(double value) {
|
||||||
|
return !value.isNaN && !value.isInfinite && value >= -180.0 && value <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that a zoom level is finite and within a sane range.
|
||||||
|
///
|
||||||
|
/// Defaults to 1.0 to [kAbsoluteMaxZoom]. `num` is used (rather than
|
||||||
|
/// `double`) so the `int` constant [kAbsoluteMaxZoom] can be used directly
|
||||||
|
/// as a default parameter value.
|
||||||
|
bool isValidZoom(double zoom, {num min = 1.0, num max = kAbsoluteMaxZoom}) {
|
||||||
|
return !zoom.isNaN && !zoom.isInfinite && zoom >= min && zoom <= max;
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:app_links/app_links.dart';
|
import 'package:app_links/app_links.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
|
import '../models/operator_profile.dart';
|
||||||
|
import '../models/osm_node.dart';
|
||||||
|
import 'http_client.dart';
|
||||||
import 'profile_import_service.dart';
|
import 'profile_import_service.dart';
|
||||||
|
import 'operator_profile_import_service.dart';
|
||||||
import '../screens/profile_editor.dart';
|
import '../screens/profile_editor.dart';
|
||||||
|
import '../screens/operator_profile_editor.dart';
|
||||||
|
|
||||||
class DeepLinkService {
|
class DeepLinkService {
|
||||||
static final DeepLinkService _instance = DeepLinkService._internal();
|
static final DeepLinkService _instance = DeepLinkService._internal();
|
||||||
@@ -13,6 +20,9 @@ class DeepLinkService {
|
|||||||
|
|
||||||
late AppLinks _appLinks;
|
late AppLinks _appLinks;
|
||||||
StreamSubscription<Uri>? _linkSubscription;
|
StreamSubscription<Uri>? _linkSubscription;
|
||||||
|
|
||||||
|
/// Callback for HomeScreen to receive node deep links
|
||||||
|
void Function(OsmNode node)? onNodeDeepLink;
|
||||||
|
|
||||||
/// Initialize deep link handling (sets up stream listener only)
|
/// Initialize deep link handling (sets up stream listener only)
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
@@ -42,6 +52,9 @@ class DeepLinkService {
|
|||||||
case 'profiles':
|
case 'profiles':
|
||||||
_handleProfilesLink(uri);
|
_handleProfilesLink(uri);
|
||||||
break;
|
break;
|
||||||
|
case 'node':
|
||||||
|
_handleNodeLink(uri);
|
||||||
|
break;
|
||||||
case 'auth':
|
case 'auth':
|
||||||
// OAuth links are handled by flutter_web_auth_2
|
// OAuth links are handled by flutter_web_auth_2
|
||||||
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
|
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
|
||||||
@@ -68,6 +81,59 @@ class DeepLinkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle node deep link: `deflockapp://node?id=<nodeId>`
|
||||||
|
Future<void> _handleNodeLink(Uri uri) async {
|
||||||
|
final idStr = uri.queryParameters['id'];
|
||||||
|
final nodeId = int.tryParse(idStr ?? '');
|
||||||
|
if (nodeId == null) {
|
||||||
|
_showError('Invalid node link: missing or invalid ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final node = await _fetchNodeById(nodeId);
|
||||||
|
if (node == null) {
|
||||||
|
_showError('Node $nodeId not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onNodeDeepLink != null) {
|
||||||
|
onNodeDeepLink!(node);
|
||||||
|
} else {
|
||||||
|
debugPrint('[DeepLinkService] No node deep link handler registered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch an OSM node by ID from the OpenStreetMap API
|
||||||
|
Future<OsmNode?> _fetchNodeById(int nodeId) async {
|
||||||
|
try {
|
||||||
|
final url = Uri.parse('https://api.openstreetmap.org/api/0.6/node/$nodeId.json');
|
||||||
|
final client = UserAgentClient();
|
||||||
|
final response = await client.get(url);
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
final elements = json['elements'] as List?;
|
||||||
|
if (elements == null || elements.isEmpty) return null;
|
||||||
|
|
||||||
|
final e = elements[0];
|
||||||
|
final tags = <String, String>{};
|
||||||
|
if (e['tags'] != null) {
|
||||||
|
(e['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||||
|
tags[k] = v.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return OsmNode(
|
||||||
|
id: e['id'] as int,
|
||||||
|
coord: LatLng((e['lat'] as num).toDouble(), (e['lon'] as num).toDouble()),
|
||||||
|
tags: tags,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[DeepLinkService] Failed to fetch node $nodeId: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle profile-related deep links
|
/// Handle profile-related deep links
|
||||||
void _handleProfilesLink(Uri uri) {
|
void _handleProfilesLink(Uri uri) {
|
||||||
final segments = uri.pathSegments;
|
final segments = uri.pathSegments;
|
||||||
@@ -86,8 +152,16 @@ class DeepLinkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>`
|
/// Handle profile add deep link: `deflockapp://profiles/add?p=<base64>` or `deflockapp://profiles/add?op=<base64>`
|
||||||
void _handleAddProfileLink(Uri uri) {
|
void _handleAddProfileLink(Uri uri) {
|
||||||
|
// Check for operator profile parameter first
|
||||||
|
final operatorBase64Data = uri.queryParameters['op'];
|
||||||
|
if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) {
|
||||||
|
_handleOperatorProfileImport(operatorBase64Data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise check for device profile parameter
|
||||||
final base64Data = uri.queryParameters['p'];
|
final base64Data = uri.queryParameters['p'];
|
||||||
|
|
||||||
if (base64Data == null || base64Data.isEmpty) {
|
if (base64Data == null || base64Data.isEmpty) {
|
||||||
@@ -107,6 +181,20 @@ class DeepLinkService {
|
|||||||
_navigateToProfileEditor(profile);
|
_navigateToProfileEditor(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle operator profile import from deep link
|
||||||
|
void _handleOperatorProfileImport(String base64Data) {
|
||||||
|
// Parse operator profile from base64
|
||||||
|
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
|
||||||
|
|
||||||
|
if (operatorProfile == null) {
|
||||||
|
_showError('Invalid operator profile data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to operator profile editor with the imported profile
|
||||||
|
_navigateToOperatorProfileEditor(operatorProfile);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigate to profile editor with pre-filled profile data
|
/// Navigate to profile editor with pre-filled profile data
|
||||||
void _navigateToProfileEditor(NodeProfile profile) {
|
void _navigateToProfileEditor(NodeProfile profile) {
|
||||||
final context = _navigatorKey?.currentContext;
|
final context = _navigatorKey?.currentContext;
|
||||||
@@ -124,6 +212,23 @@ class DeepLinkService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigate to operator profile editor with pre-filled operator profile data
|
||||||
|
void _navigateToOperatorProfileEditor(OperatorProfile operatorProfile) {
|
||||||
|
final context = _navigatorKey?.currentContext;
|
||||||
|
|
||||||
|
if (context == null) {
|
||||||
|
debugPrint('[DeepLinkService] No navigator context available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OperatorProfileEditor(profile: operatorProfile),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Show error message to user
|
/// Show error message to user
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
final context = _navigatorKey?.currentContext;
|
final context = _navigatorKey?.currentContext;
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class MapDataProvider {
|
|||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
int maxResults = 0, // 0 = no limit for offline downloads
|
int maxResults = 0, // 0 = no limit for offline downloads
|
||||||
int maxTries = 3,
|
|
||||||
}) async {
|
}) async {
|
||||||
|
|
||||||
if (AppState.instance.offlineMode) {
|
if (AppState.instance.offlineMode) {
|
||||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,18 @@ class NodeDataManager extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch nodes with automatic area splitting if needed
|
/// Fetch nodes, splitting the query area into quadrants only when Overpass
|
||||||
|
/// reports the query would exceed its hard 50k node limit ([NodeLimitError]).
|
||||||
|
///
|
||||||
|
/// Splitting is deliberately NOT used for other failure modes:
|
||||||
|
/// - [RateLimitError] (HTTP 429): the server explicitly asked us to slow
|
||||||
|
/// down, so firing off up to 4x more requests would be counterproductive.
|
||||||
|
/// We back off instead, while still surfacing the "slow" status
|
||||||
|
/// indicator to the user (same UI feedback as the splitting case).
|
||||||
|
/// - Timeouts / other transient failures now surface as [NetworkError]
|
||||||
|
/// (see [OverpassService]) and are not caught here at all — they're
|
||||||
|
/// handled by the normal retry/backoff/fallback logic and reported as a
|
||||||
|
/// plain timeout/error status by the caller.
|
||||||
Future<List<OsmNode>> fetchWithSplitting(
|
Future<List<OsmNode>> fetchWithSplitting(
|
||||||
LatLngBounds bounds,
|
LatLngBounds bounds,
|
||||||
List<NodeProfile> profiles, {
|
List<NodeProfile> profiles, {
|
||||||
@@ -238,7 +249,7 @@ class NodeDataManager extends ChangeNotifier {
|
|||||||
return nodes;
|
return nodes;
|
||||||
|
|
||||||
} on NodeLimitError {
|
} on NodeLimitError {
|
||||||
// Hit node limit or timeout - split area if not too deep
|
// Hit the deterministic 50k node limit - split area if not too deep
|
||||||
if (splitDepth >= maxSplitDepth) {
|
if (splitDepth >= maxSplitDepth) {
|
||||||
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
||||||
return [];
|
return [];
|
||||||
@@ -254,13 +265,20 @@ class NodeDataManager extends ChangeNotifier {
|
|||||||
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
|
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
|
||||||
|
|
||||||
} on RateLimitError {
|
} on RateLimitError {
|
||||||
// Rate limited - wait and return empty
|
// Rate limited - back off without splitting the area. Splitting here
|
||||||
debugPrint('[NodeDataManager] Rate limited, backing off');
|
// would multiply request volume against a server that just told us to
|
||||||
|
// slow down. We still show the same "slow" status indicator so the
|
||||||
|
// user gets feedback, but there is no recursive quadrant fetching.
|
||||||
|
debugPrint('[NodeDataManager] Rate limited, backing off (no area splitting)');
|
||||||
|
if (isUserInitiated) {
|
||||||
|
NetworkStatus.instance.setSplitting();
|
||||||
|
}
|
||||||
await Future.delayed(const Duration(seconds: 30));
|
await Future.delayed(const Duration(seconds: 30));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Fetch data by splitting area into quadrants
|
/// Fetch data by splitting area into quadrants
|
||||||
Future<List<OsmNode>> _fetchSplitAreas(
|
Future<List<OsmNode>> _fetchSplitAreas(
|
||||||
LatLngBounds bounds,
|
LatLngBounds bounds,
|
||||||
@@ -372,8 +390,13 @@ class NodeDataManager extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Load all offline nodes into cache (call at app startup)
|
/// Load all offline nodes into cache (call at app startup)
|
||||||
Future<void> preloadOfflineNodes() async {
|
Future<void> preloadOfflineNodes() async {
|
||||||
|
// Skip entirely when the user has disabled offline features — this call
|
||||||
|
// isn't gated by OfflineAreaService.hasOfflineAreasForProvider*() since
|
||||||
|
// it fetches directly via fetchLocalNodes, so we guard it explicitly.
|
||||||
|
if (!AppState.instance.offlineFeaturesEnabled) return;
|
||||||
try {
|
try {
|
||||||
final offlineAreaService = OfflineAreaService();
|
final offlineAreaService = OfflineAreaService();
|
||||||
|
|
||||||
|
|
||||||
for (final area in offlineAreaService.offlineAreas) {
|
for (final area in offlineAreaService.offlineAreas) {
|
||||||
if (area.status != OfflineAreaStatus.complete) continue;
|
if (area.status != OfflineAreaStatus.complete) continue;
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ class OfflineAreaService {
|
|||||||
/// Fast check: do we have any completed offline areas for a specific provider/type?
|
/// Fast check: do we have any completed offline areas for a specific provider/type?
|
||||||
/// This allows smart cache routing without expensive filesystem searches.
|
/// This allows smart cache routing without expensive filesystem searches.
|
||||||
/// Safe to call before initialization - returns false if not yet initialized.
|
/// Safe to call before initialization - returns false if not yet initialized.
|
||||||
|
///
|
||||||
|
/// Returns false immediately if the user has disabled offline features via
|
||||||
|
/// the master "Enable Offline Features" toggle, regardless of whether area
|
||||||
|
/// data still exists on disk — this fully gates the offline-first tile
|
||||||
|
/// pipeline without needing to touch fetchLocalTile/fetchLocalNodes.
|
||||||
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
|
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
|
||||||
|
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
return false; // No offline areas loaded yet
|
return false; // No offline areas loaded yet
|
||||||
}
|
}
|
||||||
@@ -44,7 +50,11 @@ class OfflineAreaService {
|
|||||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||||
/// offline-first path for tiles that will never be found locally.
|
/// offline-first path for tiles that will never be found locally.
|
||||||
|
///
|
||||||
|
/// Also returns false immediately when offline features are disabled (see
|
||||||
|
/// [hasOfflineAreasForProvider] for rationale).
|
||||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||||
|
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||||
if (!_initialized) return false;
|
if (!_initialized) return false;
|
||||||
return _areas.any((area) =>
|
return _areas.any((area) =>
|
||||||
area.status == OfflineAreaStatus.complete &&
|
area.status == OfflineAreaStatus.complete &&
|
||||||
@@ -54,6 +64,7 @@ class OfflineAreaService {
|
|||||||
zoom <= area.maxZoom
|
zoom <= area.maxZoom
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Reset service state and inject areas for unit tests.
|
/// Reset service state and inject areas for unit tests.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/// Exception thrown when Overpass API returns an error indicating too many nodes were requested.
|
|
||||||
/// This typically happens when querying large areas that would return more than 50k nodes.
|
|
||||||
class OverpassNodeLimitException implements Exception {
|
|
||||||
final String message;
|
|
||||||
final String? serverResponse;
|
|
||||||
|
|
||||||
OverpassNodeLimitException(this.message, {this.serverResponse});
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'OverpassNodeLimitException: $message';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exception thrown when Overpass API rate limits the request.
|
|
||||||
/// Should trigger longer backoff delays, not area splitting.
|
|
||||||
class OverpassRateLimitException implements Exception {
|
|
||||||
final String message;
|
|
||||||
final String? serverResponse;
|
|
||||||
|
|
||||||
OverpassRateLimitException(this.message, {this.serverResponse});
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'OverpassRateLimitException: $message';
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,20 @@ class OverpassService {
|
|||||||
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
|
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
|
||||||
|
|
||||||
/// Fetch surveillance nodes from Overpass API with retry and fallback.
|
/// Fetch surveillance nodes from Overpass API with retry and fallback.
|
||||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
///
|
||||||
|
/// Throws:
|
||||||
|
/// - [NodeLimitError] when the query would exceed Overpass's hard 50k node
|
||||||
|
/// limit. This is the *only* case where the caller should split the
|
||||||
|
/// query area into smaller regions, since it's a deterministic function
|
||||||
|
/// of how much data lives in the requested bounds.
|
||||||
|
/// - [RateLimitError] when rate limited (HTTP 429 or equivalent). Callers
|
||||||
|
/// should back off, not split/retry — splitting would only amplify load
|
||||||
|
/// on a server that just told us to slow down.
|
||||||
|
/// - [NetworkError] for timeouts and other retryable HTTP/network failures.
|
||||||
|
/// Timeouts are usually a sign of server load or an expensive query, not
|
||||||
|
/// "too much area" — retrying with a hail of smaller sub-requests makes
|
||||||
|
/// the problem worse, so timeouts are treated like any other transient
|
||||||
|
/// network failure (handled by the existing retry/backoff/fallback logic).
|
||||||
Future<List<OsmNode>> fetchNodes({
|
Future<List<OsmNode>> fetchNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
@@ -70,22 +83,16 @@ class OverpassService {
|
|||||||
|
|
||||||
final errorBody = response.body;
|
final errorBody = response.body;
|
||||||
|
|
||||||
// Node limit error - caller should split area
|
// Node limit error - a deterministic result-set size limit. This is
|
||||||
|
// the one case where splitting the query area into smaller regions is
|
||||||
|
// the correct fix, since it directly reduces nodes-per-request.
|
||||||
if (response.statusCode == 400 &&
|
if (response.statusCode == 400 &&
|
||||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
debugPrint('[OverpassService] Node limit exceeded (50k), area should be split');
|
||||||
throw NodeLimitError('Query exceeded 50k node limit');
|
throw NodeLimitError('Query exceeded 50k node limit');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout error - also try splitting (complex query)
|
// Rate limit - back off, don't split or hammer the server with more requests.
|
||||||
if (errorBody.contains('timeout') ||
|
|
||||||
errorBody.contains('runtime limit exceeded') ||
|
|
||||||
errorBody.contains('Query timed out')) {
|
|
||||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
|
||||||
throw NodeLimitError('Query timed out - area too complex');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limit
|
|
||||||
if (response.statusCode == 429 ||
|
if (response.statusCode == 429 ||
|
||||||
errorBody.contains('rate limited') ||
|
errorBody.contains('rate limited') ||
|
||||||
errorBody.contains('too many requests')) {
|
errorBody.contains('too many requests')) {
|
||||||
@@ -93,6 +100,17 @@ class OverpassService {
|
|||||||
throw RateLimitError('Rate limited by Overpass API');
|
throw RateLimitError('Rate limited by Overpass API');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timeout / runtime limit exceeded - treat as a plain retryable network
|
||||||
|
// error. This is usually server load or query cost, not "area too
|
||||||
|
// big" — splitting into up to 64 sub-requests would only make load
|
||||||
|
// on the server worse.
|
||||||
|
if (errorBody.contains('timeout') ||
|
||||||
|
errorBody.contains('runtime limit exceeded') ||
|
||||||
|
errorBody.contains('Query timed out')) {
|
||||||
|
debugPrint('[OverpassService] Query timed out');
|
||||||
|
throw NetworkError('Query timed out');
|
||||||
|
}
|
||||||
|
|
||||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
|
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
|
||||||
@@ -108,9 +126,24 @@ class OverpassService {
|
|||||||
return ErrorDisposition.retry;
|
return ErrorDisposition.retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build Overpass QL query for given bounds and profiles
|
/// Build Overpass QL query for given bounds and profiles.
|
||||||
|
///
|
||||||
|
/// Profiles are deduplicated first: if one profile's non-empty tags are a
|
||||||
|
/// subset of another's (with identical values), the broader profile's query
|
||||||
|
/// clause already returns every node the narrower one would, so the
|
||||||
|
/// narrower clause is redundant and can be dropped. This directly reduces
|
||||||
|
/// query size/complexity without changing the result set — client-side
|
||||||
|
/// filtering (matching returned nodes against the full profile list) still
|
||||||
|
/// happens independently, so nothing is lost.
|
||||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||||
final nodeClauses = profiles.map((profile) {
|
final profilesToQuery = _deduplicateProfilesForQuery(profiles);
|
||||||
|
|
||||||
|
if (profilesToQuery.length < profiles.length) {
|
||||||
|
debugPrint(
|
||||||
|
'[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency');
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodeClauses = profilesToQuery.map((profile) {
|
||||||
// Convert profile tags to Overpass filter format, excluding empty values
|
// Convert profile tags to Overpass filter format, excluding empty values
|
||||||
final tagFilters = profile.tags.entries
|
final tagFilters = profile.tags.entries
|
||||||
.where((entry) => entry.value.trim().isNotEmpty)
|
.where((entry) => entry.value.trim().isNotEmpty)
|
||||||
@@ -126,14 +159,79 @@ class OverpassService {
|
|||||||
$nodeClauses
|
$nodeClauses
|
||||||
);
|
);
|
||||||
out body;
|
out body;
|
||||||
(
|
<;
|
||||||
way(bn);
|
out ids;
|
||||||
rel(bn);
|
|
||||||
);
|
|
||||||
out skel;
|
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deduplicate profiles for Overpass queries by removing profiles that are
|
||||||
|
/// subsumed by others. A profile A subsumes profile B if all of A's
|
||||||
|
/// non-empty tags exist in B with identical values — meaning every node
|
||||||
|
/// matching B's filter also matches A's filter, so B's clause is redundant.
|
||||||
|
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
|
||||||
|
if (profiles.length <= 1) return profiles;
|
||||||
|
|
||||||
|
final result = <NodeProfile>[];
|
||||||
|
|
||||||
|
for (final candidate in profiles) {
|
||||||
|
// Skip profiles that only have empty tags - they would match
|
||||||
|
// everything and shouldn't be treated as subsuming or subsumable.
|
||||||
|
final candidateNonEmptyTags = candidate.tags.entries
|
||||||
|
.where((entry) => entry.value.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (candidateNonEmptyTags.isEmpty) continue;
|
||||||
|
|
||||||
|
// Check if any existing profile in our result subsumes this candidate
|
||||||
|
bool isSubsumed = false;
|
||||||
|
for (final existing in result) {
|
||||||
|
if (_profileSubsumes(existing, candidate)) {
|
||||||
|
isSubsumed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSubsumed) {
|
||||||
|
// This candidate is not subsumed, so add it. But first, remove any
|
||||||
|
// existing profiles that this candidate subsumes (candidate is
|
||||||
|
// broader, so those clauses are now redundant).
|
||||||
|
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
|
||||||
|
result.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check: if dedup removed everything (e.g. all-empty-tag
|
||||||
|
// profiles), fall back to the original list rather than sending an
|
||||||
|
// empty query.
|
||||||
|
return result.isNotEmpty ? result : profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if [broaderProfile] subsumes [specificProfile]: true if all
|
||||||
|
/// non-empty tags in [broaderProfile] exist in [specificProfile] with
|
||||||
|
/// identical values.
|
||||||
|
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
|
||||||
|
final broaderTags = Map.fromEntries(
|
||||||
|
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
|
||||||
|
);
|
||||||
|
final specificTags = Map.fromEntries(
|
||||||
|
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If broader has no non-empty tags, it doesn't subsume anything (it
|
||||||
|
// would match everything, which isn't a meaningful "broader filter").
|
||||||
|
if (broaderTags.isEmpty) return false;
|
||||||
|
|
||||||
|
// If broader has more non-empty tags than specific, it can't subsume it.
|
||||||
|
if (broaderTags.length > specificTags.length) return false;
|
||||||
|
|
||||||
|
for (final entry in broaderTags.entries) {
|
||||||
|
if (specificTags[entry.key] != entry.value) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Parse Overpass JSON response into OsmNode objects
|
/// Parse Overpass JSON response into OsmNode objects
|
||||||
List<OsmNode> _parseResponse(String responseBody) {
|
List<OsmNode> _parseResponse(String responseBody) {
|
||||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||||
@@ -176,7 +274,9 @@ out skel;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error thrown when query exceeds node limits or is too complex - area should be split
|
/// Error thrown when a query would exceed Overpass's 50k node limit.
|
||||||
|
/// The caller should split the query area into smaller regions to resolve this
|
||||||
|
/// — it's a deterministic function of how much data lives within the bounds.
|
||||||
class NodeLimitError extends Error {
|
class NodeLimitError extends Error {
|
||||||
final String message;
|
final String message;
|
||||||
NodeLimitError(this.message);
|
NodeLimitError(this.message);
|
||||||
@@ -192,7 +292,7 @@ class RateLimitError extends Error {
|
|||||||
String toString() => 'RateLimitError: $message';
|
String toString() => 'RateLimitError: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error thrown for network/HTTP issues - retryable
|
/// Error thrown for network/HTTP issues (including timeouts) - retryable
|
||||||
class NetworkError extends Error {
|
class NetworkError extends Error {
|
||||||
final String message;
|
final String message;
|
||||||
NetworkError(this.message);
|
NetworkError(this.message);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
|
import '../dev_config.dart';
|
||||||
|
|
||||||
class ProfileImportService {
|
class ProfileImportService {
|
||||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||||
@@ -72,13 +73,17 @@ class ProfileImportService {
|
|||||||
final requiresDirection = data['requiresDirection'] ?? true;
|
final requiresDirection = data['requiresDirection'] ?? true;
|
||||||
final submittable = data['submittable'] ?? true;
|
final submittable = data['submittable'] ?? true;
|
||||||
|
|
||||||
// Parse FOV if provided
|
// Parse FOV if provided, applying restrictions based on dev config
|
||||||
double? fov;
|
double? fov;
|
||||||
if (data['fov'] != null) {
|
if (data['fov'] != null) {
|
||||||
if (data['fov'] is num) {
|
if (data['fov'] is num) {
|
||||||
final fovValue = (data['fov'] as num).toDouble();
|
final fovValue = (data['fov'] as num).toDouble();
|
||||||
if (fovValue > 0 && fovValue <= 360) {
|
if (fovValue > 0 && fovValue <= 360) {
|
||||||
fov = fovValue;
|
// If non-360 FOVs are disabled, only allow 360° FOV
|
||||||
|
if (kEnableNon360FOVs || fovValue == 360.0) {
|
||||||
|
fov = fovValue;
|
||||||
|
}
|
||||||
|
// Otherwise, clear non-360 FOV values (set fov to null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,18 @@ class SuspectedLocationDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create database tables
|
/// Create database tables
|
||||||
Future<void> _createTables(Database db, int version) async {
|
///
|
||||||
|
/// Takes a [DatabaseExecutor] (rather than [Database]) so this can be
|
||||||
|
/// called either directly on the database (e.g. from [onCreate]) or inside
|
||||||
|
/// a transaction (see [insertBatch]).
|
||||||
|
Future<void> _createTables(DatabaseExecutor db, int version) async {
|
||||||
debugPrint('[SuspectedLocationDatabase] Creating tables...');
|
debugPrint('[SuspectedLocationDatabase] Creating tables...');
|
||||||
|
|
||||||
// Main suspected locations table
|
// Main suspected locations table
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE $_tableName (
|
CREATE TABLE $_tableName (
|
||||||
$_columnTicketNo TEXT PRIMARY KEY,
|
$_columnTicketNo TEXT PRIMARY KEY,
|
||||||
|
|
||||||
$_columnCentroidLat REAL NOT NULL,
|
$_columnCentroidLat REAL NOT NULL,
|
||||||
$_columnCentroidLng REAL NOT NULL,
|
$_columnCentroidLng REAL NOT NULL,
|
||||||
$_columnBounds TEXT,
|
$_columnBounds TEXT,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class Uploader {
|
|||||||
// Create new node
|
// Create new node
|
||||||
final mergedTags = p.getCombinedTags();
|
final mergedTags = p.getCombinedTags();
|
||||||
final tagsXml = mergedTags.entries.map((e) =>
|
final tagsXml = mergedTags.entries.map((e) =>
|
||||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||||
final nodeXml = '''
|
final nodeXml = '''
|
||||||
<osm>
|
<osm>
|
||||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||||
@@ -141,7 +141,7 @@ class Uploader {
|
|||||||
// Update existing node with version
|
// Update existing node with version
|
||||||
final mergedTags = p.getCombinedTags();
|
final mergedTags = p.getCombinedTags();
|
||||||
final tagsXml = mergedTags.entries.map((e) =>
|
final tagsXml = mergedTags.entries.map((e) =>
|
||||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||||
final nodeXml = '''
|
final nodeXml = '''
|
||||||
<osm>
|
<osm>
|
||||||
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||||
@@ -190,7 +190,7 @@ class Uploader {
|
|||||||
// Extract creates a new node with tags from the original node
|
// Extract creates a new node with tags from the original node
|
||||||
final mergedTags = p.getCombinedTags();
|
final mergedTags = p.getCombinedTags();
|
||||||
final tagsXml = mergedTags.entries.map((e) =>
|
final tagsXml = mergedTags.entries.map((e) =>
|
||||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||||
final nodeXml = '''
|
final nodeXml = '''
|
||||||
<osm>
|
<osm>
|
||||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/operator_profile.dart';
|
import '../models/operator_profile.dart';
|
||||||
import '../services/operator_profile_service.dart';
|
import '../services/operator_profile_service.dart';
|
||||||
|
|
||||||
class OperatorProfileState extends ChangeNotifier {
|
class OperatorProfileState extends ChangeNotifier {
|
||||||
|
static const String _profileOrderPrefsKey = 'operator_profile_order';
|
||||||
|
|
||||||
final List<OperatorProfile> _profiles = [];
|
final List<OperatorProfile> _profiles = [];
|
||||||
|
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||||
|
|
||||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||||
|
|
||||||
Future<void> init({bool addDefaults = false}) async {
|
Future<void> init({bool addDefaults = false}) async {
|
||||||
_profiles.addAll(await OperatorProfileService().load());
|
_profiles.addAll(await OperatorProfileService().load());
|
||||||
@@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier {
|
|||||||
_profiles.addAll(OperatorProfile.getDefaults());
|
_profiles.addAll(OperatorProfile.getDefaults());
|
||||||
await OperatorProfileService().save(_profiles);
|
await OperatorProfileService().save(_profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load custom order from prefs
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
void addOrUpdateProfile(OperatorProfile p) {
|
void addOrUpdateProfile(OperatorProfile p) {
|
||||||
@@ -34,4 +42,53 @@ class OperatorProfileState extends ChangeNotifier {
|
|||||||
OperatorProfileService().save(_profiles);
|
OperatorProfileService().save(_profiles);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reorder profiles (for drag-and-drop in settings)
|
||||||
|
void reorderProfiles(int oldIndex, int newIndex) {
|
||||||
|
final orderedProfiles = _getOrderedProfiles();
|
||||||
|
|
||||||
|
// With onReorderItem callback, newIndex is already adjusted
|
||||||
|
final item = orderedProfiles.removeAt(oldIndex);
|
||||||
|
orderedProfiles.insert(newIndex, item);
|
||||||
|
|
||||||
|
// Update custom order with new sequence
|
||||||
|
_customOrder = orderedProfiles.map((p) => p.id).toList();
|
||||||
|
_saveCustomOrder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get profiles in custom order, with unordered profiles at the end
|
||||||
|
List<OperatorProfile> _getOrderedProfiles() {
|
||||||
|
if (_customOrder.isEmpty) {
|
||||||
|
return List.from(_profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ordered = <OperatorProfile>[];
|
||||||
|
final profilesById = {for (final p in _profiles) p.id: p};
|
||||||
|
|
||||||
|
// Add profiles in custom order
|
||||||
|
for (final id in _customOrder) {
|
||||||
|
final profile = profilesById[id];
|
||||||
|
if (profile != null) {
|
||||||
|
ordered.add(profile);
|
||||||
|
profilesById.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any remaining profiles that weren't in the custom order
|
||||||
|
ordered.addAll(profilesById.values);
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save custom order to disk
|
||||||
|
Future<void> _saveCustomOrder() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
|
||||||
|
} catch (e) {
|
||||||
|
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||||
|
debugPrint('[OperatorProfileState] Failed to save custom order: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,16 @@ 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';
|
||||||
|
static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls';
|
||||||
|
static const String _offlineFeaturesEnabledPrefsKey = 'offline_features_enabled';
|
||||||
|
|
||||||
bool _offlineMode = false;
|
bool _offlineMode = false;
|
||||||
bool _pauseQueueProcessing = false;
|
bool _pauseQueueProcessing = false;
|
||||||
|
bool _keepScreenAwake = false;
|
||||||
|
bool _hideZoomControls = false;
|
||||||
|
bool _offlineFeaturesEnabled = 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;
|
||||||
@@ -65,7 +72,12 @@ 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;
|
||||||
|
bool get hideZoomControls => _hideZoomControls;
|
||||||
|
bool get offlineFeaturesEnabled => _offlineFeaturesEnabled;
|
||||||
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;
|
||||||
DistanceUnit get distanceUnit => _distanceUnit;
|
DistanceUnit get distanceUnit => _distanceUnit;
|
||||||
@@ -138,7 +150,20 @@ 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 hide zoom controls setting
|
||||||
|
_hideZoomControls = prefs.getBool(_hideZoomControlsPrefsKey) ?? false;
|
||||||
|
|
||||||
|
// Load offline features enabled setting (defaults to false; migration
|
||||||
|
// for existing users with pre-existing offline area data lives in
|
||||||
|
// migrations.dart / migrate_2_10_5)
|
||||||
|
_offlineFeaturesEnabled = prefs.getBool(_offlineFeaturesEnabledPrefsKey) ?? false;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Load upload mode (including migration from old test_mode bool)
|
// 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;
|
||||||
@@ -397,7 +422,44 @@ 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 hide zoom controls enabled/disabled
|
||||||
|
Future<void> setHideZoomControls(bool enabled) async {
|
||||||
|
if (_hideZoomControls != enabled) {
|
||||||
|
_hideZoomControls = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_hideZoomControlsPrefsKey, enabled);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the master "offline features enabled" toggle. When this is off,
|
||||||
|
/// offline areas (downloading, browsing, and using cached tiles/nodes) are
|
||||||
|
/// disabled entirely. Note: any cascading side effects (forcing offline
|
||||||
|
/// mode off, cancelling active downloads, prompting to delete existing
|
||||||
|
/// area data) are handled by the caller (see AppState.setOfflineFeaturesEnabled)
|
||||||
|
/// since those require access to services not owned by SettingsState.
|
||||||
|
Future<void> setOfflineFeaturesEnabled(bool enabled) async {
|
||||||
|
if (_offlineFeaturesEnabled != enabled) {
|
||||||
|
_offlineFeaturesEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_offlineFeaturesEnabledPrefsKey, enabled);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/// Set distance for avoidance of nodes during navigation
|
||||||
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);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a completed session to the upload queue
|
// Add a completed session to the upload queue
|
||||||
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
PendingUpload addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
|
||||||
final upload = PendingUpload(
|
final upload = PendingUpload(
|
||||||
coord: session.target!,
|
coord: session.target!,
|
||||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||||
@@ -159,10 +159,11 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
NodeProviderWithCache.instance.notifyListeners();
|
NodeProviderWithCache.instance.notifyListeners();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return upload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a completed edit session to the upload queue
|
// Add a completed edit session to the upload queue
|
||||||
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
PendingUpload addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
|
||||||
// Determine operation type and coordinates
|
// Determine operation type and coordinates
|
||||||
final UploadOperation operation;
|
final UploadOperation operation;
|
||||||
final LatLng coordToUse;
|
final LatLng coordToUse;
|
||||||
@@ -250,10 +251,11 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
NodeProviderWithCache.instance.notifyListeners();
|
NodeProviderWithCache.instance.notifyListeners();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return upload;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a node deletion to the upload queue
|
// Add a node deletion to the upload queue
|
||||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
PendingUpload 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
|
||||||
@@ -282,6 +284,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
NodeProviderWithCache.instance.notifyListeners();
|
NodeProviderWithCache.instance.notifyListeners();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return upload;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ 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;
|
||||||
import '../../services/proximity_alert_service.dart';
|
import '../../services/proximity_alert_service.dart';
|
||||||
|
import '../../services/coordinate_validation.dart';
|
||||||
import '../../models/osm_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../../models/node_profile.dart';
|
import '../../models/node_profile.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Simple GPS controller that handles precise location permissions only.
|
/// Simple GPS controller that handles precise location permissions only.
|
||||||
/// Key principles:
|
/// Key principles:
|
||||||
/// - Respect "denied forever" - stop trying
|
/// - Respect "denied forever" - stop trying
|
||||||
@@ -145,15 +148,21 @@ class GpsController {
|
|||||||
void _startPositionStream() {
|
void _startPositionStream() {
|
||||||
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
|
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
|
||||||
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
|
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
|
||||||
|
|
||||||
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
|
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_positionSub = Geolocator.getPositionStream(
|
_positionSub = Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: defaultTargetPlatform == TargetPlatform.android
|
||||||
accuracy: LocationAccuracy.high, // Request best, accept what we get
|
? AndroidSettings(
|
||||||
distanceFilter: distanceFilter,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
distanceFilter: distanceFilter,
|
||||||
|
forceLocationManager: true,
|
||||||
|
)
|
||||||
|
: LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.high,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
),
|
||||||
).listen(
|
).listen(
|
||||||
_onPositionReceived,
|
_onPositionReceived,
|
||||||
onError: _onPositionError,
|
onError: _onPositionError,
|
||||||
@@ -180,6 +189,18 @@ class GpsController {
|
|||||||
|
|
||||||
/// Handle incoming GPS position
|
/// Handle incoming GPS position
|
||||||
void _onPositionReceived(Position position) {
|
void _onPositionReceived(Position position) {
|
||||||
|
// Reject malformed fixes (occasionally NaN/Infinite from the platform
|
||||||
|
// location stack) before they reach map state.
|
||||||
|
if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) {
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'[GpsController] Ignoring invalid GPS position: '
|
||||||
|
'lat=${position.latitude}, lng=${position.longitude}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
final newLocation = LatLng(position.latitude, position.longitude);
|
final newLocation = LatLng(position.latitude, position.longitude);
|
||||||
_currentLocation = newLocation;
|
_currentLocation = newLocation;
|
||||||
|
|
||||||
@@ -202,6 +223,7 @@ class GpsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle GPS stream errors
|
/// Handle GPS stream errors
|
||||||
|
|
||||||
void _onPositionError(dynamic error) {
|
void _onPositionError(dynamic error) {
|
||||||
debugPrint('[GpsController] Position stream error: $error');
|
debugPrint('[GpsController] Position stream error: $error');
|
||||||
if (_hasLocation) {
|
if (_hasLocation) {
|
||||||
@@ -358,4 +380,4 @@ class GpsController {
|
|||||||
_onMapMovedProgrammatically = null;
|
_onMapMovedProgrammatically = null;
|
||||||
_isUserInteracting = null;
|
_isUserInteracting = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ class MapOverlays extends StatelessWidget {
|
|||||||
final EditNodeSession? editSession;
|
final EditNodeSession? editSession;
|
||||||
final String? attribution; // Attribution for current tile provider
|
final String? attribution; // Attribution for current tile provider
|
||||||
final VoidCallback? onSearchPressed; // Callback for search button
|
final VoidCallback? onSearchPressed; // Callback for search button
|
||||||
|
final bool hideZoomControls; // Hide zoom indicator + zoom in/out buttons
|
||||||
const MapOverlays({
|
const MapOverlays({
|
||||||
super.key,
|
super.key,
|
||||||
required this.mapController,
|
required this.mapController,
|
||||||
@@ -25,8 +26,10 @@ class MapOverlays extends StatelessWidget {
|
|||||||
this.editSession,
|
this.editSession,
|
||||||
this.attribution,
|
this.attribution,
|
||||||
this.onSearchPressed,
|
this.onSearchPressed,
|
||||||
|
this.hideZoomControls = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/// Show full attribution text in a dialog with license link.
|
/// Show full attribution text in a dialog with license link.
|
||||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||||
final locService = LocalizationService.instance;
|
final locService = LocalizationService.instance;
|
||||||
@@ -138,35 +141,37 @@ class MapOverlays extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Zoom indicator, positioned relative to button bar with left safe area
|
// Zoom indicator, positioned relative to button bar with left safe area
|
||||||
Positioned(
|
if (!hideZoomControls)
|
||||||
left: leftPositionWithSafeArea(10, safeArea),
|
Positioned(
|
||||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
left: leftPositionWithSafeArea(10, safeArea),
|
||||||
child: Container(
|
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
color: Colors.black.withValues(alpha: 0.52),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(7),
|
color: Colors.black.withValues(alpha: 0.52),
|
||||||
),
|
borderRadius: BorderRadius.circular(7),
|
||||||
child: Builder(
|
),
|
||||||
builder: (context) {
|
child: Builder(
|
||||||
double zoom = 15.0; // fallback
|
builder: (context) {
|
||||||
try {
|
double zoom = 15.0; // fallback
|
||||||
zoom = mapController.mapController.camera.zoom;
|
try {
|
||||||
} catch (_) {
|
zoom = mapController.mapController.camera.zoom;
|
||||||
// Map controller not ready yet
|
} catch (_) {
|
||||||
}
|
// Map controller not ready yet
|
||||||
return Text(
|
}
|
||||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
return Text(
|
||||||
style: const TextStyle(
|
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||||
color: Colors.white,
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w600,
|
fontSize: 12,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Attribution overlay, positioned relative to button bar with left safe area
|
// Attribution overlay, positioned relative to button bar with left safe area
|
||||||
if (attribution != null)
|
if (attribution != null)
|
||||||
@@ -228,37 +233,40 @@ class MapOverlays extends StatelessWidget {
|
|||||||
|
|
||||||
// Layer selector button
|
// Layer selector button
|
||||||
const LayerSelectorButton(),
|
const LayerSelectorButton(),
|
||||||
const SizedBox(height: 8),
|
if (!hideZoomControls) ...[
|
||||||
// Zoom in button
|
const SizedBox(height: 8),
|
||||||
FloatingActionButton(
|
// Zoom in button
|
||||||
mini: true,
|
FloatingActionButton(
|
||||||
heroTag: "zoom_in",
|
mini: true,
|
||||||
onPressed: () {
|
heroTag: "zoom_in",
|
||||||
try {
|
onPressed: () {
|
||||||
final zoom = mapController.mapController.camera.zoom;
|
try {
|
||||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
final zoom = mapController.mapController.camera.zoom;
|
||||||
} catch (_) {
|
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||||
// Map controller not ready yet
|
} catch (_) {
|
||||||
}
|
// Map controller not ready yet
|
||||||
},
|
}
|
||||||
child: const Icon(Icons.add),
|
},
|
||||||
),
|
child: const Icon(Icons.add),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
// Zoom out button
|
const SizedBox(height: 8),
|
||||||
FloatingActionButton(
|
// Zoom out button
|
||||||
mini: true,
|
FloatingActionButton(
|
||||||
heroTag: "zoom_out",
|
mini: true,
|
||||||
onPressed: () {
|
heroTag: "zoom_out",
|
||||||
try {
|
onPressed: () {
|
||||||
final zoom = mapController.mapController.camera.zoom;
|
try {
|
||||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
final zoom = mapController.mapController.camera.zoom;
|
||||||
} catch (_) {
|
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||||
// Map controller not ready yet
|
} catch (_) {
|
||||||
}
|
// Map controller not ready yet
|
||||||
},
|
}
|
||||||
child: const Icon(Icons.remove),
|
},
|
||||||
),
|
child: const Icon(Icons.remove),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../services/coordinate_validation.dart';
|
||||||
|
|
||||||
/// Manages map position persistence and initial positioning.
|
/// Manages map position persistence and initial positioning.
|
||||||
/// Handles saving/loading last map position and moving to initial locations.
|
/// Handles saving/loading last map position and moving to initial locations.
|
||||||
@@ -29,9 +30,9 @@ class MapPositionManager {
|
|||||||
final lng = prefs.getDouble('last_map_longitude');
|
final lng = prefs.getDouble('last_map_longitude');
|
||||||
final zoom = prefs.getDouble('last_map_zoom');
|
final zoom = prefs.getDouble('last_map_zoom');
|
||||||
|
|
||||||
if (lat != null && lng != null &&
|
if (lat != null && lng != null &&
|
||||||
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
|
isValidLatitude(lat) && isValidLongitude(lng)) {
|
||||||
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
|
final validZoom = zoom != null && isValidZoom(zoom) ? zoom : 15.0;
|
||||||
_initialLocation = LatLng(lat, lng);
|
_initialLocation = LatLng(lat, lng);
|
||||||
_initialZoom = validZoom;
|
_initialZoom = validZoom;
|
||||||
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
||||||
@@ -50,9 +51,9 @@ class MapPositionManager {
|
|||||||
try {
|
try {
|
||||||
final zoom = _initialZoom ?? 15.0;
|
final zoom = _initialZoom ?? 15.0;
|
||||||
// Double-check coordinates are valid before moving
|
// Double-check coordinates are valid before moving
|
||||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
if (isValidLatitude(_initialLocation!.latitude) &&
|
||||||
_isValidCoordinate(_initialLocation!.longitude) &&
|
isValidLongitude(_initialLocation!.longitude) &&
|
||||||
_isValidZoom(zoom)) {
|
isValidZoom(zoom)) {
|
||||||
controller.mapController.move(_initialLocation!, zoom);
|
controller.mapController.move(_initialLocation!, zoom);
|
||||||
_hasMovedToInitialLocation = true;
|
_hasMovedToInitialLocation = true;
|
||||||
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
||||||
@@ -70,9 +71,9 @@ class MapPositionManager {
|
|||||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||||
try {
|
try {
|
||||||
// Validate coordinates and zoom before saving
|
// Validate coordinates and zoom before saving
|
||||||
if (!_isValidCoordinate(location.latitude) ||
|
if (!isValidLatitude(location.latitude) ||
|
||||||
!_isValidCoordinate(location.longitude) ||
|
!isValidLongitude(location.longitude) ||
|
||||||
!_isValidZoom(zoom)) {
|
!isValidZoom(zoom)) {
|
||||||
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,8 +88,6 @@ class MapPositionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Clear any stored map position (useful for recovery from invalid data)
|
/// Clear any stored map position (useful for recovery from invalid data)
|
||||||
static Future<void> clearStoredMapPosition() async {
|
static Future<void> clearStoredMapPosition() async {
|
||||||
try {
|
try {
|
||||||
@@ -101,20 +100,4 @@ class MapPositionManager {
|
|||||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
|
|
||||||
bool _isValidCoordinate(double value) {
|
|
||||||
return !value.isNaN &&
|
|
||||||
!value.isInfinite &&
|
|
||||||
value >= -180.0 &&
|
|
||||||
value <= 180.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate that a zoom level is valid
|
|
||||||
bool _isValidZoom(double zoom) {
|
|
||||||
return !zoom.isNaN &&
|
|
||||||
!zoom.isInfinite &&
|
|
||||||
zoom >= 1.0 &&
|
|
||||||
zoom <= 25.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+100
-2
@@ -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';
|
||||||
@@ -25,9 +26,12 @@ import 'node_limit_indicator.dart';
|
|||||||
import 'proximity_alert_banner.dart';
|
import 'proximity_alert_banner.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import '../services/proximity_alert_service.dart';
|
import '../services/proximity_alert_service.dart';
|
||||||
|
import '../services/coordinate_validation.dart';
|
||||||
import 'sheet_aware_map.dart';
|
import 'sheet_aware_map.dart';
|
||||||
|
|
||||||
import 'custom_scale_bar.dart';
|
import 'custom_scale_bar.dart';
|
||||||
|
|
||||||
|
|
||||||
class MapView extends StatefulWidget {
|
class MapView extends StatefulWidget {
|
||||||
final AnimatedMapController controller;
|
final AnimatedMapController controller;
|
||||||
const MapView({
|
const MapView({
|
||||||
@@ -42,6 +46,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 +58,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();
|
||||||
@@ -77,6 +83,13 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Track map center to clear queue on significant panning
|
// Track map center to clear queue on significant panning
|
||||||
LatLng? _lastCenter;
|
LatLng? _lastCenter;
|
||||||
|
|
||||||
|
// Last known-good camera center/zoom, used to self-heal if the camera
|
||||||
|
// ever gets pushed into an invalid state.
|
||||||
|
LatLng? _lastValidCenter;
|
||||||
|
double? _lastValidZoom;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// State for proximity alert banner
|
// State for proximity alert banner
|
||||||
bool _showProximityBanner = false;
|
bool _showProximityBanner = false;
|
||||||
@@ -84,7 +97,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 +239,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();
|
||||||
}
|
}
|
||||||
@@ -258,6 +285,27 @@ class MapViewState extends State<MapView> {
|
|||||||
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Known-good fallback location for [initialCenter]: validated GPS
|
||||||
|
/// location, then validated persisted position, then a hardcoded default.
|
||||||
|
LatLng get _safeInitialCenter {
|
||||||
|
final gpsLocation = _gpsController.currentLocation;
|
||||||
|
if (gpsLocation != null &&
|
||||||
|
isValidLatitude(gpsLocation.latitude) &&
|
||||||
|
isValidLongitude(gpsLocation.longitude)) {
|
||||||
|
return gpsLocation;
|
||||||
|
}
|
||||||
|
final persisted = _positionManager.initialLocation;
|
||||||
|
if (persisted != null &&
|
||||||
|
isValidLatitude(persisted.latitude) &&
|
||||||
|
isValidLongitude(persisted.longitude)) {
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
return LatLng(37.7749, -122.4194);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant MapView oldWidget) {
|
void didUpdateWidget(covariant MapView oldWidget) {
|
||||||
@@ -278,6 +326,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,
|
||||||
@@ -394,18 +445,47 @@ class MapViewState extends State<MapView> {
|
|||||||
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
||||||
mapController: _controller.mapController,
|
mapController: _controller.mapController,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
initialCenter: _safeInitialCenter,
|
||||||
initialZoom: _positionManager.initialZoom ?? 15,
|
initialZoom: _positionManager.initialZoom ?? 15,
|
||||||
|
|
||||||
minZoom: 1.0,
|
minZoom: 1.0,
|
||||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||||
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
||||||
onPositionChanged: (pos, gesture) {
|
onPositionChanged: (pos, gesture) {
|
||||||
|
// Self-heal if the camera center/zoom ever becomes invalid
|
||||||
|
// (observed to happen silently during ordinary panning,
|
||||||
|
// leaving a blank grey map with no tiles/markers). Snap back
|
||||||
|
// to the last known-good position immediately.
|
||||||
|
if (!isValidLatitude(pos.center.latitude) ||
|
||||||
|
!isValidLongitude(pos.center.longitude) ||
|
||||||
|
!isValidZoom(pos.zoom)) {
|
||||||
|
debugPrint(
|
||||||
|
|
||||||
|
'[MapView] Detected invalid camera position '
|
||||||
|
'(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) '
|
||||||
|
'- snapping back to last known-good position',
|
||||||
|
);
|
||||||
|
final recoveryCenter = _lastValidCenter ?? _safeInitialCenter;
|
||||||
|
final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
try {
|
||||||
|
_controller.mapController.move(recoveryCenter, recoveryZoom);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MapView] Failed to recover camera position: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // Don't process this invalid position further
|
||||||
|
}
|
||||||
|
_lastValidCenter = pos.center;
|
||||||
|
_lastValidZoom = pos.zoom;
|
||||||
|
|
||||||
setState(() {}); // Instant UI update for zoom, etc.
|
setState(() {}); // Instant UI update for zoom, etc.
|
||||||
if (gesture) {
|
if (gesture) {
|
||||||
widget.onUserGesture();
|
widget.onUserGesture();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||||
|
|
||||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||||
_controller.animateTo(
|
_controller.animateTo(
|
||||||
@@ -496,6 +576,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(
|
||||||
@@ -507,17 +596,24 @@ class MapViewState extends State<MapView> {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final safeArea = MediaQuery.of(context).padding;
|
final safeArea = MediaQuery.of(context).padding;
|
||||||
|
// When zoom controls (and the zoom level indicator) are hidden,
|
||||||
|
// shift the scale bar down into the indicator's old slot so we
|
||||||
|
// don't leave a weird empty gap above the attribution text.
|
||||||
|
final scaleBarSpacing = appState.hideZoomControls
|
||||||
|
? kZoomIndicatorSpacingAboveButtonBar
|
||||||
|
: kScaleBarSpacingAboveButtonBar;
|
||||||
return CustomScaleBar(
|
return CustomScaleBar(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: leftPositionWithSafeArea(8, safeArea),
|
left: leftPositionWithSafeArea(8, safeArea),
|
||||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
|
bottom: bottomPositionFromButtonBar(scaleBarSpacing, safeArea.bottom),
|
||||||
),
|
),
|
||||||
maxWidthPx: 120,
|
maxWidthPx: 120,
|
||||||
barHeight: 8,
|
barHeight: 8,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -531,8 +627,10 @@ class MapViewState extends State<MapView> {
|
|||||||
editSession: editSession,
|
editSession: editSession,
|
||||||
attribution: appState.selectedTileType?.attribution,
|
attribution: appState.selectedTileType?.attribution,
|
||||||
onSearchPressed: widget.onSearchPressed,
|
onSearchPressed: widget.onSearchPressed,
|
||||||
|
hideZoomControls: appState.hideZoomControls,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
// Node limit indicator (top-left) - shown when limit is active
|
// Node limit indicator (top-left) - shown when limit is active
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|||||||
+484
-80
@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import '../models/osm_node.dart';
|
import '../models/osm_node.dart';
|
||||||
|
import '../models/pending_upload.dart';
|
||||||
|
import '../services/map_data_provider.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import 'advanced_edit_options_sheet.dart';
|
import 'advanced_edit_options_sheet.dart';
|
||||||
|
|
||||||
class NodeTagSheet extends StatelessWidget {
|
|
||||||
|
class NodeTagSheet extends StatefulWidget {
|
||||||
final OsmNode node;
|
final OsmNode node;
|
||||||
final VoidCallback? onEditPressed;
|
final VoidCallback? onEditPressed;
|
||||||
final bool isNodeLimitActive;
|
final bool isNodeLimitActive;
|
||||||
@@ -20,6 +24,88 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
this.isNodeLimitActive = false,
|
this.isNodeLimitActive = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NodeTagSheet> createState() => _NodeTagSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NodeTagSheetState extends State<NodeTagSheet> {
|
||||||
|
// The PendingUpload backing this node (if it was in a pending state when the
|
||||||
|
// sheet was opened). We resolve this once and hold onto the object reference
|
||||||
|
// itself (not just its id) so we can keep observing its live status via its
|
||||||
|
// mutable fields even after it's eventually removed from the queue list
|
||||||
|
// (which happens ~1s after completion). Without this, once the item left
|
||||||
|
// the queue we'd have no way to tell "finished successfully" apart from
|
||||||
|
// "still pending" and would show stale/misleading status forever.
|
||||||
|
PendingUpload? _upload;
|
||||||
|
bool _uploadResolved = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_resolveTrackedUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resolveTrackedUpload() {
|
||||||
|
if (_uploadResolved) return;
|
||||||
|
_uploadResolved = true;
|
||||||
|
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
if (widget.node.tags['_pending_upload'] == 'true') {
|
||||||
|
_upload = appState.pendingUploads
|
||||||
|
.firstWhereOrNull((u) => u.tempNodeId == widget.node.id);
|
||||||
|
} else if (widget.node.tags['_pending_edit'] == 'true' ||
|
||||||
|
widget.node.tags['_pending_deletion'] == 'true') {
|
||||||
|
_upload = appState.pendingUploads
|
||||||
|
.firstWhereOrNull((u) => u.originalNodeId == widget.node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the node data that should currently be displayed.
|
||||||
|
///
|
||||||
|
/// While an upload/edit/deletion is still in progress (or has failed), this
|
||||||
|
/// is just the snapshot that was passed in when the sheet was opened -
|
||||||
|
/// nothing has changed in the cache yet. Once the tracked upload completes,
|
||||||
|
/// we switch to the live node from the cache using the *real* submitted ID
|
||||||
|
/// (which can differ from the temporary negative "-epoch" ID for creates
|
||||||
|
/// and extracts, but matches the original ID for edits) so that tags, id,
|
||||||
|
/// and editability reflect reality instead of the stale pre-submission
|
||||||
|
/// snapshot. Returns null if the node has been deleted.
|
||||||
|
OsmNode? _resolveDisplayNode() {
|
||||||
|
final upload = _upload;
|
||||||
|
if (upload == null) {
|
||||||
|
// No tracked upload - either this node was never pending, or we
|
||||||
|
// couldn't find a matching queue entry (shouldn't normally happen).
|
||||||
|
// Try a live cache lookup so we still pick up any external updates,
|
||||||
|
// but fall back to the passed-in node if it's not (or no longer) cached.
|
||||||
|
return MapDataProvider().getNodeById(widget.node.id) ?? widget.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upload.isComplete) {
|
||||||
|
// Still pending/in-progress/failed - nothing has changed yet.
|
||||||
|
return widget.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload.isDeletion) {
|
||||||
|
return null; // Node has been deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload completed successfully (create/modify/extract). Look up the
|
||||||
|
// live node using the real submitted ID (same as originalNodeId for
|
||||||
|
// modify operations, a brand-new positive ID for create/extract).
|
||||||
|
final realId = upload.submittedNodeId ?? widget.node.id;
|
||||||
|
final cached = MapDataProvider().getNodeById(realId);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
// Fallback: cache might have already been cleared/replaced; reconstruct
|
||||||
|
// from the upload data directly so we don't fall back to stale pending
|
||||||
|
// tags/id.
|
||||||
|
return OsmNode(
|
||||||
|
id: realId,
|
||||||
|
coord: upload.coord,
|
||||||
|
tags: upload.getCombinedTags(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
@@ -27,22 +113,30 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
final locService = LocalizationService.instance;
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
|
final upload = _upload;
|
||||||
|
final isDeleted = upload != null && upload.isComplete && upload.isDeletion;
|
||||||
|
final displayNode = isDeleted ? null : _resolveDisplayNode();
|
||||||
|
|
||||||
|
if (displayNode == null) {
|
||||||
|
return _buildDeletedSheet(context, locService);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
|
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
|
||||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
final isEditable = (!displayNode.tags.containsKey('_pending_upload') ||
|
||||||
node.tags['_pending_upload'] != 'true') &&
|
displayNode.tags['_pending_upload'] != 'true') &&
|
||||||
(!node.tags.containsKey('_pending_edit') ||
|
(!displayNode.tags.containsKey('_pending_edit') ||
|
||||||
node.tags['_pending_edit'] != 'true') &&
|
displayNode.tags['_pending_edit'] != 'true') &&
|
||||||
(!node.tags.containsKey('_pending_deletion') ||
|
(!displayNode.tags.containsKey('_pending_deletion') ||
|
||||||
node.tags['_pending_deletion'] != 'true');
|
displayNode.tags['_pending_deletion'] != 'true');
|
||||||
|
|
||||||
// Check if this is a real OSM node (not pending) - for "View on OSM" button
|
// Check if this is a real OSM node (not pending) - for "View on OSM" button
|
||||||
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
|
final isRealOSMNode = !displayNode.tags.containsKey('_pending_upload') &&
|
||||||
node.id > 0; // Real OSM nodes have positive IDs
|
displayNode.id > 0; // Real OSM nodes have positive IDs
|
||||||
|
|
||||||
void openEditSheet() {
|
void openEditSheet() {
|
||||||
// Check if node limit is active and warn user
|
// Check if node limit is active and warn user
|
||||||
if (isNodeLimitActive) {
|
if (widget.isNodeLimitActive) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -55,27 +149,52 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onEditPressed != null) {
|
if (widget.onEditPressed != null) {
|
||||||
onEditPressed!(); // Use callback if provided
|
widget.onEditPressed!(); // Use callback if provided
|
||||||
} else {
|
} else {
|
||||||
// Fallback behavior
|
// Fallback behavior
|
||||||
Navigator.pop(context); // Close this sheet first
|
Navigator.pop(context); // Close this sheet first
|
||||||
appState.startEditSession(node); // HomeScreen will auto-show the edit sheet
|
appState.startEditSession(displayNode); // HomeScreen will auto-show the edit sheet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteNode() async {
|
void deleteNode() async {
|
||||||
|
if (!appState.isLoggedIn) {
|
||||||
|
final shouldLogIn = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) => AlertDialog(
|
||||||
|
title: Text(locService.t('node.confirmDeleteTitle')),
|
||||||
|
content: Text(locService.t('node.mustBeLoggedInToDelete')),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(locService.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(locService.t('actions.logIn')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((shouldLogIn ?? false) && context.mounted) {
|
||||||
|
Navigator.pushNamed(context, '/settings/osm-account');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final result = await showDialog<({bool confirmed, String comment})>(
|
final result = await showDialog<({bool confirmed, String comment})>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) => _DeleteNodeDialog(
|
builder: (BuildContext context) => _DeleteNodeDialog(
|
||||||
nodeId: node.id.toString(),
|
nodeId: displayNode.id.toString(),
|
||||||
locService: locService,
|
locService: locService,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((result?.confirmed ?? false) && 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, changesetComment: result!.comment);
|
appState.deleteNode(displayNode, 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'))),
|
||||||
);
|
);
|
||||||
@@ -83,7 +202,7 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void viewOnOSM() async {
|
void viewOnOSM() async {
|
||||||
final url = 'https://www.openstreetmap.org/node/${node.id}';
|
final url = 'https://www.openstreetmap.org/node/${displayNode.id}';
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
@@ -108,10 +227,148 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => AdvancedEditOptionsSheet(node: node),
|
builder: (context) => AdvancedEditOptionsSheet(node: displayNode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void viewInQueue() {
|
||||||
|
Navigator.pop(context); // Close this sheet first
|
||||||
|
Navigator.pushNamed(context, '/settings/queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? uploadStatusBanner;
|
||||||
|
if (!isEditable) {
|
||||||
|
final IconData statusIcon;
|
||||||
|
final Color statusColor;
|
||||||
|
final String statusLabel;
|
||||||
|
final bool showSpinner;
|
||||||
|
final bool showQueueButton;
|
||||||
|
|
||||||
|
final state = upload?.uploadState;
|
||||||
|
|
||||||
|
if (state == null) {
|
||||||
|
// Fallback: tag says pending but we couldn't find a matching
|
||||||
|
// queue entry (shouldn't normally happen).
|
||||||
|
statusIcon = Icons.hourglass_empty;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = locService.t('node.uploadPending');
|
||||||
|
showSpinner = false;
|
||||||
|
showQueueButton = true;
|
||||||
|
} else {
|
||||||
|
switch (state) {
|
||||||
|
case UploadState.pending:
|
||||||
|
statusIcon = Icons.hourglass_empty;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = locService.t('node.uploadPending');
|
||||||
|
showSpinner = false;
|
||||||
|
showQueueButton = true;
|
||||||
|
break;
|
||||||
|
case UploadState.creatingChangeset:
|
||||||
|
statusIcon = Icons.cloud_upload_outlined;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = locService.t('node.uploadCreatingChangeset');
|
||||||
|
showSpinner = true;
|
||||||
|
showQueueButton = true;
|
||||||
|
break;
|
||||||
|
case UploadState.uploading:
|
||||||
|
statusIcon = Icons.cloud_upload_outlined;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = locService.t('node.uploadUploading');
|
||||||
|
showSpinner = true;
|
||||||
|
showQueueButton = true;
|
||||||
|
break;
|
||||||
|
case UploadState.closingChangeset:
|
||||||
|
statusIcon = Icons.cloud_upload_outlined;
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
statusLabel = locService.t('node.uploadClosingChangeset');
|
||||||
|
showSpinner = true;
|
||||||
|
showQueueButton = true;
|
||||||
|
break;
|
||||||
|
case UploadState.error:
|
||||||
|
statusIcon = Icons.error_outline;
|
||||||
|
statusColor = Colors.red;
|
||||||
|
statusLabel = locService.t('node.uploadError');
|
||||||
|
showSpinner = false;
|
||||||
|
showQueueButton = true;
|
||||||
|
break;
|
||||||
|
case UploadState.complete:
|
||||||
|
statusIcon = Icons.check_circle_outline;
|
||||||
|
statusColor = Colors.green;
|
||||||
|
statusLabel = locService.t('node.uploadComplete');
|
||||||
|
showSpinner = false;
|
||||||
|
showQueueButton = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadStatusBanner = Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withValues(alpha: 0.1),
|
||||||
|
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (showSpinner)
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: statusColor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(statusIcon, color: statusColor, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
statusLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (state == UploadState.error &&
|
||||||
|
(upload?.errorMessage?.isNotEmpty ?? false)) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
upload!.errorMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor.withValues(alpha: 0.9),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (showQueueButton) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: viewInQueue,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
minimumSize: const Size(0, 28),
|
||||||
|
foregroundColor: statusColor,
|
||||||
|
),
|
||||||
|
child: Text(locService.t('node.viewInQueue')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -122,78 +379,92 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
locService.t('node.title').replaceAll('{}', displayNode.id.toString()),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
?uploadStatusBanner,
|
||||||
|
|
||||||
// Tag list with flexible height constraint
|
// Tag list with flexible height constraint
|
||||||
|
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SelectionArea(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
...node.tags.entries.map(
|
children: [
|
||||||
(e) => Padding(
|
...displayNode.tags.entries
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
.where((e) => !e.key.startsWith('_')) // Hide internal bookkeeping tags
|
||||||
child: Row(
|
.map(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
(e) => Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
Text(
|
child: Row(
|
||||||
e.key,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontWeight: FontWeight.w500,
|
Text(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
e.key,
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Linkify(
|
|
||||||
onOpen: (link) async {
|
|
||||||
final uri = Uri.parse(link.url);
|
|
||||||
if (await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
} else if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text: e.value,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
linkStyle: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
options: const LinkifyOptions(humanize: false),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
],
|
Expanded(
|
||||||
|
child: Linkify(
|
||||||
|
onOpen: (link) async {
|
||||||
|
final uri = Uri.parse(link.url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} else if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text: e.value,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
linkStyle: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
options: const LinkifyOptions(humanize: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// First row: View and Advanced buttons
|
// First row: View and Advanced buttons
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (isRealOSMNode) ...[
|
if (isRealOSMNode) ...[
|
||||||
TextButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => viewOnOSM(),
|
onPressed: () => viewOnOSM(),
|
||||||
icon: const Icon(Icons.open_in_new, size: 16),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
label: Text(locService.t('actions.viewOnOSM')),
|
label: Text(locService.t('actions.viewOnOSM')),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (isEditable) ...[
|
if (isEditable) ...[
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: openAdvancedEdit,
|
onPressed: openAdvancedEdit,
|
||||||
@@ -222,12 +493,12 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: node.isConstrained ? null : deleteNode,
|
onPressed: displayNode.isConstrained ? null : deleteNode,
|
||||||
icon: const Icon(Icons.delete, size: 18),
|
icon: const Icon(Icons.delete, size: 18),
|
||||||
label: Text(locService.t('actions.delete')),
|
label: Text(locService.t('actions.delete')),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
foregroundColor: node.isConstrained ? null : Colors.red,
|
foregroundColor: displayNode.isConstrained ? null : Colors.red,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -247,6 +518,103 @@ class NodeTagSheet extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown when the tracked upload was a deletion that has now completed -
|
||||||
|
/// the underlying node no longer exists, so we can't show tags or offer
|
||||||
|
/// edit/delete actions on it anymore.
|
||||||
|
Widget _buildDeletedSheet(BuildContext context, LocalizationService locService) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('node.title').replaceAll('{}', widget.node.id.toString()),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.1),
|
||||||
|
border: Border.all(color: Colors.green.withValues(alpha: 0.4)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle_outline, color: Colors.green, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
locService.t('node.deletedSuccessfully'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(locService.t('actions.close')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _DeleteReason {
|
||||||
|
deviceRemoved,
|
||||||
|
duplicateNode,
|
||||||
|
misidentifiedInfra,
|
||||||
|
spamVandalism,
|
||||||
|
custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _DeleteReasonLabel on _DeleteReason {
|
||||||
|
String label(LocalizationService locService) {
|
||||||
|
switch (this) {
|
||||||
|
case _DeleteReason.deviceRemoved:
|
||||||
|
return locService.t('node.deleteReasonDeviceRemoved');
|
||||||
|
case _DeleteReason.duplicateNode:
|
||||||
|
return locService.t('node.deleteReasonDuplicateNode');
|
||||||
|
case _DeleteReason.misidentifiedInfra:
|
||||||
|
return locService.t('node.deleteReasonMisidentifiedInfra');
|
||||||
|
case _DeleteReason.spamVandalism:
|
||||||
|
return locService.t('node.deleteReasonSpamVandalism');
|
||||||
|
case _DeleteReason.custom:
|
||||||
|
return locService.t('node.deleteReasonCustom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text used to build the changeset comment (kept in English/consistent
|
||||||
|
/// regardless of UI language, matching prior app behavior for uploads).
|
||||||
|
String get commentText {
|
||||||
|
switch (this) {
|
||||||
|
case _DeleteReason.deviceRemoved:
|
||||||
|
return 'device removed';
|
||||||
|
case _DeleteReason.duplicateNode:
|
||||||
|
return 'duplicate node';
|
||||||
|
case _DeleteReason.misidentifiedInfra:
|
||||||
|
return 'misidentified infra';
|
||||||
|
case _DeleteReason.spamVandalism:
|
||||||
|
return 'spam/vandalism';
|
||||||
|
case _DeleteReason.custom:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeleteNodeDialog extends StatefulWidget {
|
class _DeleteNodeDialog extends StatefulWidget {
|
||||||
@@ -264,6 +632,7 @@ class _DeleteNodeDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||||
late final TextEditingController _commentController;
|
late final TextEditingController _commentController;
|
||||||
|
_DeleteReason? _selectedReason;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -277,6 +646,14 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _canConfirm {
|
||||||
|
if (_selectedReason == null) return false;
|
||||||
|
if (_selectedReason == _DeleteReason.custom) {
|
||||||
|
return _commentController.text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
@@ -292,16 +669,39 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
|||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
DropdownButtonFormField<_DeleteReason>(
|
||||||
controller: _commentController,
|
initialValue: _selectedReason,
|
||||||
decoration: InputDecoration(
|
hint: Text(widget.locService.t('node.deleteReasonSelectHint')),
|
||||||
hintText: widget.locService.t('node.deleteReasonHint'),
|
decoration: const InputDecoration(
|
||||||
border: const OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
items: _DeleteReason.values
|
||||||
textCapitalization: TextCapitalization.sentences,
|
.map((reason) => DropdownMenuItem(
|
||||||
|
value: reason,
|
||||||
|
child: Text(reason.label(widget.locService)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (reason) {
|
||||||
|
setState(() {
|
||||||
|
_selectedReason = reason;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
if (_selectedReason == _DeleteReason.custom) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.locService.t('node.deleteReasonHint'),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -310,17 +710,21 @@ class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
|||||||
child: Text(widget.locService.cancel),
|
child: Text(widget.locService.cancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: _canConfirm
|
||||||
final comment = _commentController.text.trim();
|
? () {
|
||||||
final finalComment = comment.isEmpty
|
final reasonText = _selectedReason == _DeleteReason.custom
|
||||||
? 'Delete a surveillance node'
|
? _commentController.text.trim()
|
||||||
: 'Delete a surveillance node: $comment';
|
: _selectedReason!.commentText;
|
||||||
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
final finalComment = reasonText.isEmpty
|
||||||
},
|
? 'Delete a surveillance node'
|
||||||
|
: 'Delete a surveillance node: $reasonText';
|
||||||
|
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
||||||
|
}
|
||||||
|
: null,
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: Text(widget.locService.t('actions.delete')),
|
child: Text(widget.locService.t('actions.delete')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+166
-134
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -580,34 +580,26 @@ packages:
|
|||||||
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: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
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.2+53 # The thing after the + is the version code, incremented with each release
|
version: 2.10.5+60 # 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', () {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:deflockapp/services/coordinate_validation.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('isValidLatitude', () {
|
||||||
|
test('accepts in-range values', () {
|
||||||
|
expect(isValidLatitude(0), isTrue);
|
||||||
|
expect(isValidLatitude(37.7749), isTrue);
|
||||||
|
expect(isValidLatitude(90), isTrue);
|
||||||
|
expect(isValidLatitude(-90), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NaN and Infinite', () {
|
||||||
|
expect(isValidLatitude(double.nan), isFalse);
|
||||||
|
expect(isValidLatitude(double.infinity), isFalse);
|
||||||
|
expect(isValidLatitude(double.negativeInfinity), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range values', () {
|
||||||
|
expect(isValidLatitude(90.1), isFalse);
|
||||||
|
expect(isValidLatitude(-90.1), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isValidLongitude', () {
|
||||||
|
test('accepts in-range values', () {
|
||||||
|
expect(isValidLongitude(0), isTrue);
|
||||||
|
expect(isValidLongitude(-122.4194), isTrue);
|
||||||
|
expect(isValidLongitude(180), isTrue);
|
||||||
|
expect(isValidLongitude(-180), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NaN and Infinite', () {
|
||||||
|
expect(isValidLongitude(double.nan), isFalse);
|
||||||
|
expect(isValidLongitude(double.infinity), isFalse);
|
||||||
|
expect(isValidLongitude(double.negativeInfinity), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range values', () {
|
||||||
|
expect(isValidLongitude(180.1), isFalse);
|
||||||
|
expect(isValidLongitude(-180.1), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isValidZoom', () {
|
||||||
|
test('accepts in-range values (default bounds: 1.0 to kAbsoluteMaxZoom)', () {
|
||||||
|
expect(isValidZoom(1), isTrue);
|
||||||
|
expect(isValidZoom(15), isTrue);
|
||||||
|
expect(isValidZoom(23), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NaN and Infinite', () {
|
||||||
|
expect(isValidZoom(double.nan), isFalse);
|
||||||
|
expect(isValidZoom(double.infinity), isFalse);
|
||||||
|
expect(isValidZoom(double.negativeInfinity), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects out-of-range values with default bounds', () {
|
||||||
|
expect(isValidZoom(0), isFalse);
|
||||||
|
expect(isValidZoom(0.5), isFalse);
|
||||||
|
expect(isValidZoom(24), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects custom min/max bounds', () {
|
||||||
|
expect(isValidZoom(0.5, min: 1.0, max: 20.0), isFalse);
|
||||||
|
expect(isValidZoom(1.0, min: 1.0, max: 20.0), isTrue);
|
||||||
|
expect(isValidZoom(20.0, min: 1.0, max: 20.0), isTrue);
|
||||||
|
expect(isValidZoom(20.1, min: 1.0, max: 20.0), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,8 +41,11 @@ void main() {
|
|||||||
mockAppState = MockAppState();
|
mockAppState = MockAppState();
|
||||||
AppState.instance = mockAppState;
|
AppState.instance = mockAppState;
|
||||||
|
|
||||||
// Default stubs: online, no offline areas
|
// Default stubs: online, no offline areas, offline features enabled
|
||||||
|
// (so tests exercise the same behavior as before this setting existed)
|
||||||
when(() => mockAppState.offlineMode).thenReturn(false);
|
when(() => mockAppState.offlineMode).thenReturn(false);
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||||
|
|
||||||
|
|
||||||
provider = DeflockTileProvider(
|
provider = DeflockTileProvider(
|
||||||
providerId: 'openstreetmap',
|
providerId: 'openstreetmap',
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import 'package:deflockapp/app_state.dart';
|
||||||
import 'package:deflockapp/services/offline_area_service.dart';
|
import 'package:deflockapp/services/offline_area_service.dart';
|
||||||
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
import 'package:deflockapp/services/offline_areas/offline_area_models.dart';
|
||||||
|
|
||||||
|
class MockAppState extends Mock implements AppState {}
|
||||||
|
|
||||||
|
|
||||||
OfflineArea _makeArea({
|
OfflineArea _makeArea({
|
||||||
String providerId = 'osm',
|
String providerId = 'osm',
|
||||||
String tileTypeId = 'standard',
|
String tileTypeId = 'standard',
|
||||||
@@ -26,11 +31,19 @@ OfflineArea _makeArea({
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final service = OfflineAreaService();
|
final service = OfflineAreaService();
|
||||||
|
late MockAppState mockAppState;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
service.setAreasForTesting([]);
|
service.setAreasForTesting([]);
|
||||||
|
mockAppState = MockAppState();
|
||||||
|
AppState.instance = mockAppState;
|
||||||
|
// These tests exercise the underlying zoom/provider matching logic, so
|
||||||
|
// offline features are enabled by default here — the "disabled" gating
|
||||||
|
// itself is covered separately below.
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
group('hasOfflineAreasForProviderAtZoom', () {
|
group('hasOfflineAreasForProviderAtZoom', () {
|
||||||
test('returns true for zoom within range', () {
|
test('returns true for zoom within range', () {
|
||||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||||
@@ -90,4 +103,30 @@ void main() {
|
|||||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('offlineFeaturesEnabled gating', () {
|
||||||
|
test('hasOfflineAreasForProvider returns false when feature disabled, even with matching areas', () {
|
||||||
|
service.setAreasForTesting([_makeArea(providerId: 'osm', tileTypeId: 'standard')]);
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||||
|
|
||||||
|
expect(service.hasOfflineAreasForProvider('osm', 'standard'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasOfflineAreasForProviderAtZoom returns false when feature disabled, even within zoom range', () {
|
||||||
|
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||||
|
|
||||||
|
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-enabling offlineFeaturesEnabled restores normal matching', () {
|
||||||
|
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false);
|
||||||
|
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||||
|
|
||||||
|
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||||
|
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group('query building', () {
|
group('query building', () {
|
||||||
test('uses out skel for way/relation pass, out body for node pass',
|
test('uses out ids for way/relation pass, out body for node pass',
|
||||||
() async {
|
() async {
|
||||||
stubOverpassResponse([]);
|
stubOverpassResponse([]);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ void main() {
|
|||||||
|
|
||||||
final query = (captured.last as Map<String, String>)['data']!;
|
final query = (captured.last as Map<String, String>)['data']!;
|
||||||
expect(query, contains('out body;'));
|
expect(query, contains('out body;'));
|
||||||
expect(query, contains('out skel;'));
|
expect(query, contains('out ids;'));
|
||||||
expect(query, isNot(contains('out meta;')));
|
expect(query, isNot(contains('out meta;')));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +99,129 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('profile deduplication (subsumption)', () {
|
||||||
|
test('a specific profile subsumed by a generic one is dropped', () async {
|
||||||
|
final generic = NodeProfile(
|
||||||
|
id: 'generic-alpr',
|
||||||
|
name: 'Generic ALPR',
|
||||||
|
tags: const {
|
||||||
|
'man_made': 'surveillance',
|
||||||
|
'surveillance:type': 'ALPR',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final specific = NodeProfile(
|
||||||
|
id: 'flock',
|
||||||
|
name: 'Flock',
|
||||||
|
tags: const {
|
||||||
|
'man_made': 'surveillance',
|
||||||
|
'surveillance': 'public',
|
||||||
|
'surveillance:type': 'ALPR',
|
||||||
|
'surveillance:zone': 'traffic',
|
||||||
|
'camera:type': 'fixed',
|
||||||
|
'manufacturer': 'Flock Safety',
|
||||||
|
'manufacturer:wikidata': 'Q108485435',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stubOverpassResponse([]);
|
||||||
|
|
||||||
|
await service.fetchNodes(bounds: bounds, profiles: [generic, specific]);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||||
|
).captured;
|
||||||
|
final query = (captured.last as Map<String, String>)['data']!;
|
||||||
|
|
||||||
|
// Only the generic clause should remain.
|
||||||
|
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]'));
|
||||||
|
expect(query, isNot(contains('manufacturer')));
|
||||||
|
expect(query, isNot(contains('Flock Safety')));
|
||||||
|
|
||||||
|
// Only one node clause total.
|
||||||
|
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
|
||||||
|
expect(nodeClauseCount, equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('unrelated profiles are all kept', () async {
|
||||||
|
final alprGeneric = NodeProfile(
|
||||||
|
id: 'generic-alpr',
|
||||||
|
name: 'Generic ALPR',
|
||||||
|
tags: const {
|
||||||
|
'man_made': 'surveillance',
|
||||||
|
'surveillance:type': 'ALPR',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final gunshotGeneric = NodeProfile(
|
||||||
|
id: 'generic-gunshot',
|
||||||
|
name: 'Generic Gunshot Detector',
|
||||||
|
tags: const {
|
||||||
|
'man_made': 'surveillance',
|
||||||
|
'surveillance:type': 'gunshot_detector',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
stubOverpassResponse([]);
|
||||||
|
|
||||||
|
await service.fetchNodes(bounds: bounds, profiles: [alprGeneric, gunshotGeneric]);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||||
|
).captured;
|
||||||
|
final query = (captured.last as Map<String, String>)['data']!;
|
||||||
|
|
||||||
|
expect(query, contains('["surveillance:type"="ALPR"]'));
|
||||||
|
expect(query, contains('["surveillance:type"="gunshot_detector"]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full default profile set reduces to the two generic clauses', () async {
|
||||||
|
final profiles = NodeProfile.getDefaults();
|
||||||
|
|
||||||
|
stubOverpassResponse([]);
|
||||||
|
|
||||||
|
await service.fetchNodes(bounds: bounds, profiles: profiles);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||||
|
).captured;
|
||||||
|
final query = (captured.last as Map<String, String>)['data']!;
|
||||||
|
|
||||||
|
final nodeClauseCount = RegExp(r'node\[').allMatches(query).length;
|
||||||
|
expect(nodeClauseCount, equals(2));
|
||||||
|
|
||||||
|
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="ALPR"]'));
|
||||||
|
expect(query, contains('["man_made"="surveillance"]["surveillance:type"="gunshot_detector"]'));
|
||||||
|
// Brand-specific tags should not appear anywhere in the reduced query.
|
||||||
|
expect(query, isNot(contains('manufacturer')));
|
||||||
|
expect(query, isNot(contains('ShotSpotter')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profiles with only empty tags do not subsume or break the query', () async {
|
||||||
|
final emptyTagsProfile = NodeProfile(
|
||||||
|
id: 'empty',
|
||||||
|
name: 'Empty',
|
||||||
|
tags: const {'camera:mount': ''},
|
||||||
|
);
|
||||||
|
final normalProfile = NodeProfile(
|
||||||
|
id: 'normal',
|
||||||
|
name: 'Normal',
|
||||||
|
tags: const {'man_made': 'surveillance'},
|
||||||
|
);
|
||||||
|
|
||||||
|
stubOverpassResponse([]);
|
||||||
|
|
||||||
|
await service.fetchNodes(bounds: bounds, profiles: [emptyTagsProfile, normalProfile]);
|
||||||
|
|
||||||
|
final captured = verify(
|
||||||
|
() => mockClient.post(any(), body: captureAny(named: 'body')),
|
||||||
|
).captured;
|
||||||
|
final query = (captured.last as Map<String, String>)['data']!;
|
||||||
|
|
||||||
|
expect(query, contains('["man_made"="surveillance"]'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
group('response parsing — constraint detection', () {
|
group('response parsing — constraint detection', () {
|
||||||
test('nodes referenced by a way are constrained', () async {
|
test('nodes referenced by a way are constrained', () async {
|
||||||
stubOverpassResponse([
|
stubOverpassResponse([
|
||||||
@@ -255,27 +378,28 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('response with "timeout" throws NodeLimitError', () async {
|
test('response with "timeout" throws NetworkError (not split)', () async {
|
||||||
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
stubErrorResponse(400, 'runtime error: timeout in query execution');
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NodeLimitError>()),
|
throwsA(isA<NetworkError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('response with "runtime limit exceeded" throws NodeLimitError',
|
test('response with "runtime limit exceeded" throws NetworkError (not split)',
|
||||||
() async {
|
() async {
|
||||||
stubErrorResponse(400, 'runtime limit exceeded');
|
stubErrorResponse(400, 'runtime limit exceeded');
|
||||||
|
|
||||||
await expectLater(
|
await expectLater(
|
||||||
() => service.fetchNodes(
|
() => service.fetchNodes(
|
||||||
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
|
||||||
throwsA(isA<NodeLimitError>()),
|
throwsA(isA<NetworkError>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('HTTP 429 throws RateLimitError', () async {
|
test('HTTP 429 throws RateLimitError', () async {
|
||||||
stubErrorResponse(429, 'Too Many Requests');
|
stubErrorResponse(429, 'Too Many Requests');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user