mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 15:28:23 +02:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1909a3b5f1 | ||
|
|
7c5d066362 | ||
|
|
b231b48ac1 | ||
|
|
0e1e9cb2ff | ||
|
|
a808a4f37e | ||
|
|
0887d1ef44 | ||
|
|
0c6cf467fe | ||
|
|
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 | ||
|
|
2d00d7a8fb | ||
|
|
f85db3ca11 | ||
|
|
447f358727 | ||
|
|
08b395214b | ||
|
|
256dd1a43c | ||
|
|
ca7192d3ec | ||
|
|
2833906c68 | ||
|
|
4d1032e56d | ||
|
|
834861bcaf | ||
|
|
ba80b88595 | ||
|
|
ebb7fd090f | ||
|
|
fe401cc04b | ||
|
|
de65cecc6a | ||
|
|
122b303378 | ||
|
|
91e5177056 | ||
|
|
f3f40f36ef | ||
|
|
2d92214bed | ||
|
|
be446fbcbc | ||
|
|
5728b4f70f | ||
|
|
aeb1903bbc | ||
|
|
57df8e83a7 | ||
|
|
bc671c4efe | ||
|
|
4941c2726d | ||
|
|
b56e9325b3 | ||
|
|
30f546be29 |
No files matched your search
@@ -27,7 +27,7 @@ jobs:
|
||||
id: set-info
|
||||
run: |
|
||||
echo "is_prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
echo "should_upload_to_stores=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ Pre-release - will build and attach assets, no store uploads"
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
# import certificate to keychain
|
||||
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
|
||||
# Set this keychain as the default
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security default-keychain -s $KEYCHAIN_PATH
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
# install provisioning profile
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||
|
||||
|
||||
# Also install using the profile's internal UUID for better compatibility
|
||||
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
@@ -329,7 +329,8 @@ jobs:
|
||||
--type ios \
|
||||
--file Runner.ipa \
|
||||
--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
|
||||
rm -rf ~/private_keys
|
||||
|
||||
+7
-5
@@ -38,13 +38,14 @@ ios/Flutter/Flutter.framework
|
||||
ios/Pods/
|
||||
ios/.generated/
|
||||
ios/.idea/
|
||||
ios/Runner.xcworkspace/
|
||||
ios/Runner.xcworkspace/xcuserdata/
|
||||
ios/Runner.xcworkspace/project.xcworkspace/xcuserdata/
|
||||
macos/Pods/
|
||||
macos/.generated/
|
||||
macos/Flutter/ephemeral/
|
||||
# CocoaPods – commit Podfile.lock if you need reproducible iOS builds
|
||||
Podfile.lock
|
||||
Pods/
|
||||
Podfile
|
||||
Podfile.lock
|
||||
|
||||
# Generated icons and splash screens
|
||||
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
|
||||
@@ -73,12 +74,13 @@ fuchsia/build/
|
||||
web/build/
|
||||
|
||||
# ───────────────────────────────
|
||||
# IDE / Editor Settings
|
||||
# IDE / Editor / AI Tool Settings
|
||||
# ───────────────────────────────
|
||||
.idea/
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
# Swap files
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -102,4 +104,4 @@ build_keys.conf
|
||||
linux/
|
||||
macos/
|
||||
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)
|
||||
|
||||
**Smart error handling & splitting:**
|
||||
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep
|
||||
- **Timeout errors**: Also triggers splitting (dense areas with many profiles)
|
||||
- **Rate limiting**: Extended backoff (30s), no splitting (would make it worse)
|
||||
- **Surgical detection**: Only splits on actual limit errors, not network issues
|
||||
- **50k node limit**: Automatically splits query into 4 quadrants, recursively up to 3 levels deep. This is a deterministic result-set size limit, so shrinking the queried area is the correct fix.
|
||||
- **Timeout errors**: Treated as a plain retryable network error — no splitting. Timeouts usually indicate server load or an expensive query, not "too much area", so firing off more sub-requests would only add load to an already-struggling server.
|
||||
- **Rate limiting (HTTP 429)**: Extended backoff (30s), no splitting (would make it worse). Still surfaces the same "slow" status indicator as the node-limit-splitting case, so the user sees consistent feedback either way.
|
||||
- **Surgical detection**: Only splits on the actual 50k node limit, not timeouts or other network issues.
|
||||
|
||||
**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+):**
|
||||
To reduce Overpass query complexity, profiles are deduplicated before query generation:
|
||||
**Query optimization & deduplication (in `OverpassService._buildQuery`):**
|
||||
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
|
||||
- **Example**: `Generic ALPR` (tags: `man_made=surveillance, surveillance:type=ALPR`) subsumes `Flock` (same tags + `manufacturer=Flock Safety`)
|
||||
- **Result**: Default profile set reduces from ~11 to ~2 query clauses (Generic ALPR + Generic Gunshot)
|
||||
- **UI unchanged**: All enabled profiles still used for post-query filtering and display matching
|
||||
- **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:**
|
||||
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)
|
||||
|
||||
@@ -854,6 +851,13 @@ To run the app with working OSM authentication, register OAuth2 applications:
|
||||
|
||||
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
|
||||
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
|
||||
3. **OAuth2 Application Permissions**
|
||||
* Uncheck "Confidential application?"
|
||||
* Read user preferences (read_prefs)
|
||||
* Modify the map (write_api)
|
||||
* Read, update status and delete user messages (consume_messages)
|
||||
4. **Redirect URIs**
|
||||
* deflockapp://auth
|
||||
|
||||
For local builds, create `build_keys.conf` (gitignored):
|
||||
```bash
|
||||
|
||||
@@ -20,7 +20,7 @@ android {
|
||||
compileSdk = 36
|
||||
|
||||
// NDK only needed if you build native plugins; keep your pinned version
|
||||
ndkVersion = "27.0.12077973"
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
|
||||
@@ -30,6 +30,19 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<!-- Disable Flutter's implicit deep-link routing so that
|
||||
app_links/DeepLinkService is the sole handler of incoming
|
||||
deflockapp:// URIs. Without this, Flutter's engine also
|
||||
tries to auto-convert the URI into a Navigator route
|
||||
(e.g. "/add?p=...") which conflicts with the navigation
|
||||
DeepLinkService performs, causing screens pushed by
|
||||
DeepLinkService to be immediately buried by a duplicate
|
||||
HomeScreen route. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
|
||||
|
||||
<!-- Launcher intent -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
@@ -42,6 +55,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="deflockapp" android:host="profiles"/>
|
||||
<data android:scheme="deflockapp" android:host="node"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
+80
-1
@@ -1,7 +1,86 @@
|
||||
{
|
||||
"2.11.0": {
|
||||
"content": [
|
||||
"• Further improve node loading performance",
|
||||
"• Fix node constrainment when part of a way/relation",
|
||||
"• Show submission status in details sheet",
|
||||
"• Fix profile import links not opening"
|
||||
]
|
||||
},
|
||||
"2.10.5": {
|
||||
"content": [
|
||||
"• Make node tags selectable / copyable",
|
||||
"• Add status indicator to details sheet for pending nodes",
|
||||
"• Global toggle for offline features",
|
||||
"• Auto-open details sheet for new add/edit/deletes"
|
||||
]
|
||||
},
|
||||
"2.10.4": {
|
||||
"content": [
|
||||
"• Catch and correct a couple rare map bounds issues",
|
||||
"• Prevent queueing a deletion without being logged in",
|
||||
"• Require a reason for deletion, provide options in a dropdown",
|
||||
"• Add option to hide zoom controls in advanced settings"
|
||||
]
|
||||
},
|
||||
"2.10.3": {
|
||||
"content": [
|
||||
"• Data loading performance improvements",
|
||||
"• Node marker when submitting now green for visibility"
|
||||
]
|
||||
},
|
||||
"2.10.2": {
|
||||
"content": [
|
||||
"• Added tap and hold gesture on empty map area to add a node at that location",
|
||||
"• Added ability to accept direct links to a specific node for callbacks from e.g. FlockHopper",
|
||||
"• Fixed app hang during startup when suspected locations database needed updating - refresh now happens in background"
|
||||
]
|
||||
},
|
||||
"2.10.1": {
|
||||
"content": [
|
||||
"• Operator profiles are now reorderable",
|
||||
"• Support operator profile import via deflockapp:// links",
|
||||
"• Makes operator profile UI consistent with device profiles"
|
||||
]
|
||||
},
|
||||
"2.10.0": {
|
||||
"content": [
|
||||
"• Simplified profile FOVs; there is now only a checkbox for '360'"
|
||||
]
|
||||
},
|
||||
"2.9.2": {
|
||||
"content": [
|
||||
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
|
||||
"• Added 'Clear Caches' option to tile provider menus - easily free up storage space by clearing cached tiles for specific providers",
|
||||
"• Enhanced node deletion workflow - users can now provide an informative reason when deleting surveillance devices",
|
||||
"• Added 'Report issue with OSM base map' link in About screen help section - easily report issues with the underlying OpenStreetMap data"
|
||||
]
|
||||
},
|
||||
"2.9.1": {
|
||||
"content": [
|
||||
"• When hitting node render limit, only render nodes closest to center of viewport.",
|
||||
"• Moved to our own infrastructure for Overpass and routing services, with automatic fallback to public servers."
|
||||
]
|
||||
},
|
||||
"2.9.0": {
|
||||
"content": [
|
||||
"• Caching, tile retries, offline areas, now working properly. Map imagery should load correctly."
|
||||
]
|
||||
},
|
||||
"2.8.1": {
|
||||
"content": [
|
||||
"• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission",
|
||||
"• Added drag handles so profiles can be reordered to customize dropdown order when submitting"
|
||||
]
|
||||
},
|
||||
"2.8.0": {
|
||||
"content": [
|
||||
"• Update dependencies and build chain tools; no code changes"
|
||||
]
|
||||
},
|
||||
"2.7.2": {
|
||||
"content": [
|
||||
"• Now following OSM UserAgent guidelines."
|
||||
"• Now following OSM UserAgent guidelines"
|
||||
]
|
||||
},
|
||||
"2.7.1": {
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ read_from_file() {
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(cat "$file"; echo)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1,2 +1 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.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 */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
5B624583B69E961F78575427 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
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 */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -42,20 +41,15 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
015ABBACED80649D4AFF850D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
570CEB9EF97E9EE9B3436119 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -63,8 +57,6 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F596A88B00A1B31C43A2B089 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -72,7 +64,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5B624583B69E961F78575427 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -80,27 +71,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
80E9445C8798AC7271AAC8BA /* Pods_Runner.framework in Frameworks */,
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2C653E01607E349213D63C91 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F596A88B00A1B31C43A2B089 /* Pods-Runner.debug.xcconfig */,
|
||||
7884B7AB945386E2395EFF39 /* Pods-Runner.release.xcconfig */,
|
||||
570CEB9EF97E9EE9B3436119 /* Pods-Runner.profile.xcconfig */,
|
||||
8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */,
|
||||
731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -109,18 +86,10 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
968347D6C1E8FCB1D43AE8AF /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
015ABBACED80649D4AFF850D /* Pods_Runner.framework */,
|
||||
C9D0FF4AAF30A10DCC7546BD /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -136,8 +105,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
2C653E01607E349213D63C91 /* Pods */,
|
||||
968347D6C1E8FCB1D43AE8AF /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -172,7 +139,6 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
4C9A553A911C8019A2A14C90 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
1789E739C02C24F676C90644 /* Frameworks */,
|
||||
@@ -191,20 +157,21 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
EF132DD72459C72ECA32FCAD /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
1553428839027C2A5102CCB3 /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -238,6 +205,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -270,23 +240,6 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
1553428839027C2A5102CCB3 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -303,28 +256,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
4C9A553A911C8019A2A14C90 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -340,28 +271,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
EF132DD72459C72ECA32FCAD /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -491,7 +400,6 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8DDE785A4593E6CA5CA43172 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -509,7 +417,6 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 045F89E14FC4F02010A83367 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -525,7 +432,6 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 731D4782D191731330541A7E /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -735,6 +641,20 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "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>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -2,12 +2,15 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
+95
-65
@@ -1,72 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Disable Flutter's implicit deep-link routing so that
|
||||
app_links/DeepLinkService is the sole handler of incoming
|
||||
deflockapp:// URIs. Without this, Flutter's engine also tries
|
||||
to auto-convert the URI into a Navigator route (e.g.
|
||||
"/add?p=...") which conflicts with the navigation
|
||||
DeepLinkService performs, causing screens pushed by
|
||||
DeepLinkService to be immediately buried by a duplicate
|
||||
HomeScreen route. -->
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<!-- OAuth2 redirect handler -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- (Optional) allow opening the system browser and returning -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+88
-5
@@ -64,6 +64,12 @@ class AppState extends ChangeNotifier {
|
||||
VoidCallback? _tutorialCompletionCallback; // Callback when tutorial is completed
|
||||
Timer? _messageCheckTimer;
|
||||
|
||||
// Node ID to auto-focus (open the details sheet for) after a submit/edit/delete
|
||||
// completes, when kAutoOpenNodeSheetAfterSubmit is enabled. Consumed (read once
|
||||
// and cleared) by HomeScreen after picking it up.
|
||||
int? _pendingFocusNodeId;
|
||||
|
||||
|
||||
AppState() {
|
||||
instance = this;
|
||||
_authState = AuthState();
|
||||
@@ -156,6 +162,11 @@ class AppState extends ChangeNotifier {
|
||||
int get maxNodes => _settingsState.maxNodes;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
bool get keepScreenAwake => _settingsState.keepScreenAwake;
|
||||
bool get hideZoomControls => _settingsState.hideZoomControls;
|
||||
bool get offlineFeaturesEnabled => _settingsState.offlineFeaturesEnabled;
|
||||
|
||||
|
||||
|
||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||
@@ -172,12 +183,18 @@ class AppState extends ChangeNotifier {
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
/// True if any queue item currently has an open changeset that hasn't
|
||||
/// been closed yet (creating changeset, uploading node, or closing
|
||||
/// changeset). Used to gate enabling offline mode / pausing the upload
|
||||
/// queue so in-flight submissions can finish first.
|
||||
bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing);
|
||||
|
||||
|
||||
// Suspected location state
|
||||
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
|
||||
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
|
||||
@@ -250,6 +267,9 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
// Start background refresh of suspected locations if needed (non-blocking)
|
||||
_suspectedLocationState.initBackgroundRefresh(offlineMode: _settingsState.offlineMode);
|
||||
|
||||
// Check for initial deep link after a small delay to let navigation settle
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
DeepLinkService().checkInitialLink();
|
||||
@@ -407,9 +427,18 @@ class AppState extends ChangeNotifier {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
_profileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
/// Reload all profiles from storage (useful after migrations modify stored profiles)
|
||||
Future<void> reloadProfiles() async {
|
||||
await _profileState.reloadFromStorage();
|
||||
}
|
||||
|
||||
// Callback when a profile is deleted - clear any stale session references
|
||||
void _onProfileDeleted(NodeProfile deletedProfile) {
|
||||
@@ -433,6 +462,10 @@ class AppState extends ChangeNotifier {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
void reorderOperatorProfiles(int oldIndex, int newIndex) {
|
||||
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
@@ -564,24 +597,44 @@ class AppState extends ChangeNotifier {
|
||||
void commitSession() {
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
final upload = _uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||
_pendingFocusNodeId = upload.tempNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void commitEditSession() {
|
||||
final session = _sessionState.commitEditSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
final upload = _uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit && upload.tempNodeId != null) {
|
||||
_pendingFocusNodeId = upload.tempNodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode(OsmNode node) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||
void deleteNode(OsmNode node, {String? changesetComment}) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
|
||||
_startUploader();
|
||||
if (kAutoOpenNodeSheetAfterSubmit) {
|
||||
_pendingFocusNodeId = node.id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume (read once and clear) the node ID pending auto-focus, if any.
|
||||
/// Used by HomeScreen to reopen the node details sheet right after a
|
||||
/// submit/edit/delete completes, when [kAutoOpenNodeSheetAfterSubmit] is
|
||||
/// enabled. Returns null if no focus is pending.
|
||||
int? consumePendingFocusNodeId() {
|
||||
final id = _pendingFocusNodeId;
|
||||
_pendingFocusNodeId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Search Methods ----------
|
||||
Future<void> search(String query) async {
|
||||
await _searchState.search(query);
|
||||
@@ -665,6 +718,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 {
|
||||
await _settingsState.setPauseQueueProcessing(enabled);
|
||||
if (!enabled) {
|
||||
@@ -713,6 +791,11 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
/// Clear all tile caches for a specific provider
|
||||
Future<void> clearTileProviderCaches(String providerId) async {
|
||||
await _settingsState.clearTileProviderCaches(providerId);
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
|
||||
+22
-14
@@ -64,8 +64,12 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
|
||||
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
|
||||
const double kChangesetCloseBackoffMultiplier = 2.0;
|
||||
|
||||
// Navigation routing configuration
|
||||
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
|
||||
// How long to keep the "Submission Complete" confirmation visible in the
|
||||
// in-flight upload wait dialog (shown when the user enables offline mode or
|
||||
// pauses the upload queue while a submission is actively in progress) before
|
||||
// automatically dismissing and applying the requested setting change.
|
||||
const Duration kInFlightUploadCompleteDisplayDuration = Duration(seconds: 2);
|
||||
|
||||
|
||||
// Overpass API configuration
|
||||
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
|
||||
@@ -74,7 +78,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// 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
|
||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||
@@ -85,6 +89,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
|
||||
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
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
return kEnableNavigationFeatures && !offlineMode;
|
||||
@@ -93,18 +103,16 @@ bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
// Marker/node interaction
|
||||
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
|
||||
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
|
||||
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
|
||||
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
|
||||
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
|
||||
const Duration kMapLongPressTimeout = Duration(milliseconds: 600); // Duration to trigger "add node here" on empty map area
|
||||
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
||||
|
||||
// Pre-fetch area configuration
|
||||
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
|
||||
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
|
||||
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
|
||||
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
|
||||
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
|
||||
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
|
||||
|
||||
|
||||
// Data refresh configuration
|
||||
const int kDataRefreshIntervalSeconds = 60; // Refresh cached data after this many seconds
|
||||
@@ -131,7 +139,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
|
||||
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning
|
||||
const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
|
||||
|
||||
// Positioning tutorial configuration
|
||||
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
|
||||
@@ -139,7 +147,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
|
||||
|
||||
// Navigation route planning configuration
|
||||
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
|
||||
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km)
|
||||
const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
|
||||
|
||||
// Node display configuration
|
||||
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
|
||||
@@ -164,11 +172,11 @@ const int kAbsoluteMaxTileCount = 50000;
|
||||
const int kAbsoluteMaxZoom = 23;
|
||||
|
||||
// Node icon configuration
|
||||
const double kNodeIconDiameter = 18.0;
|
||||
const double _kNodeRingThicknessBase = 2.5;
|
||||
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
|
||||
const double kNodeIconDiameter = 17.0;
|
||||
const double _kNodeRingThicknessBase = 2.6;
|
||||
const double kNodeDotOpacity = 0.28; // Opacity for the grey dot interior
|
||||
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 kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
|
||||
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
||||
|
||||
+10
-6
@@ -1,16 +1,20 @@
|
||||
// OpenStreetMap OAuth client IDs for this app.
|
||||
// These must be provided via --dart-define at build time.
|
||||
|
||||
/// Whether OSM OAuth secrets were provided at build time.
|
||||
/// When false, the app should force simulate mode.
|
||||
bool get kHasOsmSecrets {
|
||||
const prod = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
return prod.isNotEmpty && sandbox.isNotEmpty;
|
||||
}
|
||||
|
||||
String get kOsmProdClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
|
||||
String get kOsmSandboxClientId {
|
||||
const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
|
||||
if (fromBuild.isNotEmpty) return fromBuild;
|
||||
|
||||
throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id');
|
||||
return fromBuild;
|
||||
}
|
||||
+68
-12
@@ -74,8 +74,23 @@
|
||||
"advancedSettings": "Erweiterte Einstellungen",
|
||||
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
||||
"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": {
|
||||
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
|
||||
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
|
||||
@@ -95,8 +110,28 @@
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
||||
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
||||
"confirmDeleteTitle": "Knoten löschen",
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteReasonLabel": "Grund für Löschung",
|
||||
"deleteReasonSelectHint": "Auswählen...",
|
||||
"deleteReasonDeviceRemoved": "Gerät entfernt",
|
||||
"deleteReasonDuplicateNode": "Doppelter Knoten",
|
||||
"deleteReasonMisidentifiedInfra": "Falsch identifizierte Infrastruktur",
|
||||
"deleteReasonSpamVandalism": "Spam/Vandalismus",
|
||||
"deleteReasonCustom": "Benutzerdefiniert..",
|
||||
"deleteReasonHint": "Bitte Grund beschreiben...",
|
||||
"mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.",
|
||||
"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": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Profil auswählen...",
|
||||
@@ -144,7 +179,10 @@
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}",
|
||||
"offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).",
|
||||
"currentTileProvider": "aktuelle Kachel",
|
||||
"noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download gestartet",
|
||||
@@ -232,8 +270,13 @@
|
||||
"closingChangeset": " (Changeset schließen...)",
|
||||
"processingPaused": "Warteschlangenverarbeitung pausiert",
|
||||
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert."
|
||||
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert.",
|
||||
"inFlightWaitTitle": "Übermittlung wird abgeschlossen",
|
||||
"inFlightWaitMessage": "Eine Übermittlung läuft gerade und muss abgeschlossen werden, bevor diese Einstellung angewendet werden kann. Dies sollte nur einen Moment dauern.",
|
||||
"inFlightCompleteTitle": "Übermittlung Abgeschlossen",
|
||||
"inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
|
||||
@@ -244,6 +287,9 @@
|
||||
"addProvider": "Anbieter Hinzufügen",
|
||||
"deleteProvider": "Anbieter Löschen",
|
||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"clearCaches": "Caches Leeren",
|
||||
"clearCachesConfirm": "Alle Kachel-Caches für \"{}\" löschen? Dies wird Speicherplatz freigeben, aber die Kacheln müssen beim Betrachten dieser Bereiche erneut heruntergeladen werden.",
|
||||
"cachesCleared": "Caches für \"{}\" geleert",
|
||||
"providerName": "Anbieter-Name",
|
||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||
@@ -292,13 +338,16 @@
|
||||
"addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?",
|
||||
"createCustomProfile": "Benutzerdefiniertes Profil Erstellen",
|
||||
"createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsite": "Von Webseite Importieren",
|
||||
"importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten",
|
||||
"attribution": "Karten-Zuschreibung"
|
||||
"attribution": "Karten-Zuschreibung",
|
||||
"mapAttribution": "Kartenquelle: {}",
|
||||
"couldNotOpenLink": "Link konnte nicht geöffnet werden",
|
||||
"openLicense": "Lizenz öffnen: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
@@ -313,6 +362,8 @@
|
||||
"fovHint": "Sichtfeld in Grad (leer lassen für Standard)",
|
||||
"fovSubtitle": "Kamera-Sichtfeld - verwendet für Kegelbreite und Bereichsübertragungsformat",
|
||||
"fovInvalid": "Sichtfeld muss zwischen 1 und 360 Grad liegen",
|
||||
"fov360": "360° Sichtfeld",
|
||||
"fov360Subtitle": "Kamera hat 360-Grad omnidirektionales Sichtfeld",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
@@ -325,7 +376,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
@@ -371,8 +422,13 @@
|
||||
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
||||
"startRefresh": "Aktualisierung starten",
|
||||
"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": {
|
||||
"title": "Tags Verfeinern",
|
||||
"operatorProfile": "Betreiber-Profil",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
|
||||
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
|
||||
"minimumDistance": "Mindestabstand zu echten Geräten",
|
||||
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
|
||||
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {} vorhandener Überwachungsgeräte ausblenden",
|
||||
"updating": "Verdächtige Standorte werden aktualisiert",
|
||||
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
|
||||
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
|
||||
"updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen",
|
||||
"neverFetched": "Nie abgerufen",
|
||||
"daysAgo": "vor {} Tagen",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"hoursAgo": "vor {} Stunden",
|
||||
"minutesAgo": "vor {} Minuten",
|
||||
"justNow": "Gerade eben"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "Verdächtiger Standort #{}",
|
||||
"ticketNo": "Ticket-Nr.",
|
||||
"address": "Adresse",
|
||||
"street": "Straße",
|
||||
"street": "Straße",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland",
|
||||
"intersectingStreet": "Kreuzende Straße",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-15
@@ -70,7 +70,7 @@
|
||||
"submitAnyway": "Submit Anyway",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"publicCamera": "Public Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Device",
|
||||
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
|
||||
"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": {
|
||||
"getNotified": "Get notified when approaching surveillance devices",
|
||||
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
||||
@@ -132,8 +147,27 @@
|
||||
"editQueuedForUpload": "Node edit queued for upload",
|
||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||
"confirmDeleteTitle": "Delete Node",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
|
||||
"deleteReasonLabel": "Reason for Deletion",
|
||||
"deleteReasonSelectHint": "Select...",
|
||||
"deleteReasonDeviceRemoved": "Device removed",
|
||||
"deleteReasonDuplicateNode": "Duplicate node",
|
||||
"deleteReasonMisidentifiedInfra": "Misidentified infra",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalism",
|
||||
"deleteReasonCustom": "Custom..",
|
||||
"deleteReasonHint": "Please describe the reason...",
|
||||
"mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.",
|
||||
"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": {
|
||||
"profile": "Profile",
|
||||
"selectProfile": "Select a profile...",
|
||||
@@ -181,7 +215,10 @@
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
"downloadFailed": "Failed to start download: {}",
|
||||
"offlineNotPermitted": "The {} server does not permit offline downloads. Switch to a tile provider that allows offline use (e.g., Bing Maps, Mapbox, or a self-hosted tile server).",
|
||||
"currentTileProvider": "current tile",
|
||||
"noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Started",
|
||||
@@ -269,8 +306,13 @@
|
||||
"closingChangeset": " (Closing changeset...)",
|
||||
"processingPaused": "Queue Processing Paused",
|
||||
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
|
||||
"pausedByUser": "Upload processing is manually paused."
|
||||
"pausedByUser": "Upload processing is manually paused.",
|
||||
"inFlightWaitTitle": "Finishing Submission",
|
||||
"inFlightWaitMessage": "A submission is currently in progress and needs to finish before this setting can be applied. This should only take a moment.",
|
||||
"inFlightCompleteTitle": "Submission Complete",
|
||||
"inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "No tile providers configured",
|
||||
@@ -279,8 +321,11 @@
|
||||
"needsApiKey": "Needs API key",
|
||||
"editProvider": "Edit Provider",
|
||||
"addProvider": "Add Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"clearCaches": "Clear Caches",
|
||||
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
|
||||
"cachesCleared": "Caches cleared for \"{}\"",
|
||||
"providerName": "Provider Name",
|
||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||
"providerNameRequired": "Provider name is required",
|
||||
@@ -329,13 +374,16 @@
|
||||
"addProfileChoiceMessage": "How would you like to add a profile?",
|
||||
"createCustomProfile": "Create Custom Profile",
|
||||
"createCustomProfileDescription": "Build a profile from scratch with your own tags",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsite": "Import from Website",
|
||||
"importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers",
|
||||
"attribution": "Map Attribution"
|
||||
"attribution": "Map Attribution",
|
||||
"mapAttribution": "Map attribution: {}",
|
||||
"couldNotOpenLink": "Could not open link",
|
||||
"openLicense": "Open license: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
@@ -350,6 +398,8 @@
|
||||
"fovHint": "FOV in degrees (leave empty for default)",
|
||||
"fovSubtitle": "Camera field of view - used for cone width and range submission format",
|
||||
"fovInvalid": "FOV must be between 1 and 360 degrees",
|
||||
"fov360": "360 FOV",
|
||||
"fov360Subtitle": "Camera has 360-degree omnidirectional field of view",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -362,7 +412,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
@@ -408,8 +458,13 @@
|
||||
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
||||
"startRefresh": "Start Refresh",
|
||||
"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": {
|
||||
"title": "Refine Tags",
|
||||
"operatorProfile": "Operator Profile",
|
||||
@@ -443,7 +498,7 @@
|
||||
"mobileEditors": "Mobile Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Full-featured web editor - always works",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Advanced Android OSM editor",
|
||||
@@ -513,14 +568,14 @@
|
||||
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
|
||||
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
|
||||
"minimumDistance": "Minimum Distance from Real Nodes",
|
||||
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
|
||||
"minimumDistanceSubtitle": "Hide suspected locations within {} of existing surveillance devices",
|
||||
"updating": "Updating Suspected Locations",
|
||||
"downloadingAndProcessing": "Downloading and processing data...",
|
||||
"updateSuccess": "Suspected locations updated successfully",
|
||||
"updateFailed": "Failed to update suspected locations",
|
||||
"neverFetched": "Never fetched",
|
||||
"daysAgo": "{} days ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"hoursAgo": "{} hours ago",
|
||||
"minutesAgo": "{} minutes ago",
|
||||
"justNow": "Just now"
|
||||
},
|
||||
@@ -528,7 +583,7 @@
|
||||
"title": "Suspected Location #{}",
|
||||
"ticketNo": "Ticket No",
|
||||
"address": "Address",
|
||||
"street": "Street",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"intersectingStreet": "Intersecting Street",
|
||||
@@ -552,4 +607,4 @@
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Configuración Avanzada",
|
||||
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
|
||||
"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": {
|
||||
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
|
||||
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
||||
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
||||
"confirmDeleteTitle": "Eliminar Nodo",
|
||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
|
||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
|
||||
"deleteReasonLabel": "Razón para Eliminación",
|
||||
"deleteReasonSelectHint": "Seleccionar...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||
"deleteReasonDuplicateNode": "Nodo duplicado",
|
||||
"deleteReasonMisidentifiedInfra": "Infraestructura mal identificada",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizado..",
|
||||
"deleteReasonHint": "Por favor describa la razón...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.",
|
||||
"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": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Seleccionar un perfil...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
"downloadFailed": "Error al iniciar la descarga: {}",
|
||||
"offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).",
|
||||
"currentTileProvider": "mosaico actual",
|
||||
"noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Descarga Iniciada",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Cerrando changeset...)",
|
||||
"processingPaused": "Procesamiento de Cola Pausado",
|
||||
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente."
|
||||
"pausedByUser": "El procesamiento de subida está pausado manualmente.",
|
||||
"inFlightWaitTitle": "Finalizando Envío",
|
||||
"inFlightWaitMessage": "Actualmente hay un envío en curso que debe finalizar antes de que se pueda aplicar esta configuración. Esto solo debería tomar un momento.",
|
||||
"inFlightCompleteTitle": "Envío Completado",
|
||||
"inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
"noProvidersConfigured": "No hay proveedores de tiles configurados",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Agregar Proveedor",
|
||||
"deleteProvider": "Eliminar Proveedor",
|
||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"clearCaches": "Limpiar Caché",
|
||||
"clearCachesConfirm": "¿Limpiar todo el caché de mosaicos para \"{}\"? Esto liberará espacio de almacenamiento pero los mosaicos deberán descargarse nuevamente al ver esas áreas.",
|
||||
"cachesCleared": "Caché limpiado para \"{}\"",
|
||||
"providerName": "Nombre del Proveedor",
|
||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||
@@ -329,13 +375,16 @@
|
||||
"addProfileChoiceMessage": "¿Cómo desea añadir un perfil?",
|
||||
"createCustomProfile": "Crear Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsite": "Importar desde Sitio Web",
|
||||
"importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores",
|
||||
"attribution": "Atribución del Mapa"
|
||||
"attribution": "Atribución del Mapa",
|
||||
"mapAttribution": "Atribución del mapa: {}",
|
||||
"couldNotOpenLink": "No se pudo abrir el enlace",
|
||||
"openLicense": "Abrir licencia: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "Campo de visión en grados (dejar vacío para el predeterminado)",
|
||||
"fovSubtitle": "Campo de visión de la cámara - usado para el ancho del cono y formato de envío por rango",
|
||||
"fovInvalid": "El campo de visión debe estar entre 1 y 360 grados",
|
||||
"fov360": "Campo de Visión 360°",
|
||||
"fov360Subtitle": "La cámara tiene un campo de visión omnidireccional de 360 grados",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
@@ -362,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
||||
"startRefresh": "Iniciar Actualización",
|
||||
"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": {
|
||||
"title": "Refinar Etiquetas",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
|
||||
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
|
||||
"minimumDistance": "Distancia Mínima de Nodos Reales",
|
||||
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
|
||||
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {} de dispositivos de vigilancia existentes",
|
||||
"updating": "Actualizando Ubicaciones Sospechosas",
|
||||
"downloadingAndProcessing": "Descargando y procesando datos...",
|
||||
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
|
||||
"updateFailed": "Error al actualizar ubicaciones sospechosas",
|
||||
"neverFetched": "Nunca obtenido",
|
||||
"daysAgo": "hace {} días",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"hoursAgo": "hace {} horas",
|
||||
"minutesAgo": "hace {} minutos",
|
||||
"justNow": "Ahora mismo"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "Ubicación Sospechosa #{}",
|
||||
"ticketNo": "No. de Ticket",
|
||||
"address": "Dirección",
|
||||
"street": "Calle",
|
||||
"street": "Calle",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Calle que Intersecta",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Paramètres Avancés",
|
||||
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
|
||||
"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": {
|
||||
"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",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
||||
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
||||
"confirmDeleteTitle": "Supprimer le Nœud",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
|
||||
"deleteReasonLabel": "Raison de Suppression",
|
||||
"deleteReasonSelectHint": "Sélectionner...",
|
||||
"deleteReasonDeviceRemoved": "Appareil retiré",
|
||||
"deleteReasonDuplicateNode": "Nœud dupliqué",
|
||||
"deleteReasonMisidentifiedInfra": "Infrastructure mal identifiée",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||
"deleteReasonCustom": "Personnalisé..",
|
||||
"deleteReasonHint": "Veuillez décrire la raison...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.",
|
||||
"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": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Sélectionner un profil...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.",
|
||||
"downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}",
|
||||
"offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).",
|
||||
"currentTileProvider": "tuile actuelle",
|
||||
"noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Téléchargement Démarré",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Fermeture du changeset...)",
|
||||
"processingPaused": "Traitement de la File d'Attente Interrompu",
|
||||
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement."
|
||||
"pausedByUser": "Le traitement des téléversements est interrompu manuellement.",
|
||||
"inFlightWaitTitle": "Finalisation de la Soumission",
|
||||
"inFlightWaitMessage": "Une soumission est actuellement en cours et doit se terminer avant que ce paramètre puisse être appliqué. Cela ne devrait prendre qu'un instant.",
|
||||
"inFlightCompleteTitle": "Soumission Terminée",
|
||||
"inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Ajouter Fournisseur",
|
||||
"deleteProvider": "Supprimer Fournisseur",
|
||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"clearCaches": "Vider les Caches",
|
||||
"clearCachesConfirm": "Vider tous les caches de tuiles pour \"{}\"? Cela libérera de l'espace de stockage mais les tuiles devront être téléchargées à nouveau lors de la consultation de ces zones.",
|
||||
"cachesCleared": "Caches vidés pour \"{}\"",
|
||||
"providerName": "Nom du Fournisseur",
|
||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||
@@ -329,13 +375,16 @@
|
||||
"addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?",
|
||||
"createCustomProfile": "Créer Profil Personnalisé",
|
||||
"createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsite": "Importer depuis Site Web",
|
||||
"importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs",
|
||||
"attribution": "Attribution de Carte"
|
||||
"attribution": "Attribution de Carte",
|
||||
"mapAttribution": "Attribution de la carte : {}",
|
||||
"couldNotOpenLink": "Impossible d'ouvrir le lien",
|
||||
"openLicense": "Ouvrir la licence : {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "Champ de vision en degrés (laisser vide pour la valeur par défaut)",
|
||||
"fovSubtitle": "Champ de vision de la caméra - utilisé pour la largeur du cône et le format de soumission par plage",
|
||||
"fovInvalid": "Le champ de vision doit être entre 1 et 360 degrés",
|
||||
"fov360": "Champ de Vision 360°",
|
||||
"fov360Subtitle": "La caméra a un champ de vision omnidirectionnel de 360 degrés",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
@@ -362,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
||||
"startRefresh": "Démarrer l'Actualisation",
|
||||
"refreshStarted": "Actualisation démarrée !",
|
||||
"refreshFailed": "Actualisation échouée : {}"
|
||||
"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": {
|
||||
"title": "Affiner les Étiquettes",
|
||||
"operatorProfile": "Profil d'Opérateur",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
|
||||
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
|
||||
"minimumDistance": "Distance Minimale des Nœuds Réels",
|
||||
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}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",
|
||||
"downloadingAndProcessing": "Téléchargement et traitement des données...",
|
||||
"updateSuccess": "Emplacements suspects mis à jour avec succès",
|
||||
"updateFailed": "Échec de la mise à jour des emplacements suspects",
|
||||
"neverFetched": "Jamais récupéré",
|
||||
"daysAgo": "il y a {} jours",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"hoursAgo": "il y a {} heures",
|
||||
"minutesAgo": "il y a {} minutes",
|
||||
"justNow": "À l'instant"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "Emplacement Suspect #{}",
|
||||
"ticketNo": "N° de Ticket",
|
||||
"address": "Adresse",
|
||||
"street": "Rue",
|
||||
"street": "Rue",
|
||||
"city": "Ville",
|
||||
"state": "État",
|
||||
"intersectingStreet": "Rue Transversale",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Impostazioni Avanzate",
|
||||
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
|
||||
"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": {
|
||||
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
|
||||
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||
"confirmDeleteTitle": "Elimina Nodo",
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
|
||||
"deleteReasonLabel": "Motivo per Eliminazione",
|
||||
"deleteReasonSelectHint": "Seleziona...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo rimosso",
|
||||
"deleteReasonDuplicateNode": "Nodo duplicato",
|
||||
"deleteReasonMisidentifiedInfra": "Infrastruttura identificata erroneamente",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizzato..",
|
||||
"deleteReasonHint": "Descrivi il motivo...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.",
|
||||
"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": {
|
||||
"profile": "Profilo",
|
||||
"selectProfile": "Seleziona un profilo...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
"downloadFailed": "Impossibile avviare il download: {}",
|
||||
"offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).",
|
||||
"currentTileProvider": "tile attuale",
|
||||
"noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Avviato",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Chiusura changeset...)",
|
||||
"processingPaused": "Elaborazione Coda Sospesa",
|
||||
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente."
|
||||
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.",
|
||||
"inFlightWaitTitle": "Completamento Invio",
|
||||
"inFlightWaitMessage": "Un invio è attualmente in corso e deve terminare prima che questa impostazione possa essere applicata. Questo dovrebbe richiedere solo un momento.",
|
||||
"inFlightCompleteTitle": "Invio Completato",
|
||||
"inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
"noProvidersConfigured": "Nessun fornitore di tile configurato",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Aggiungi Fornitore",
|
||||
"deleteProvider": "Elimina Fornitore",
|
||||
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"clearCaches": "Svuota Cache",
|
||||
"clearCachesConfirm": "Svuotare tutte le cache delle tessere per \"{}\"? Questo libererà spazio di archiviazione ma le tessere dovranno essere scaricate nuovamente quando si visualizzano quelle aree.",
|
||||
"cachesCleared": "Cache svuotate per \"{}\"",
|
||||
"providerName": "Nome Fornitore",
|
||||
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||
@@ -329,13 +375,16 @@
|
||||
"addProfileChoiceMessage": "Come desideri aggiungere un profilo?",
|
||||
"createCustomProfile": "Crea Profilo Personalizzato",
|
||||
"createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsite": "Importa da Sito Web",
|
||||
"importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
"manageProviders": "Gestisci Fornitori",
|
||||
"attribution": "Attribuzione Mappa"
|
||||
"attribution": "Attribuzione Mappa",
|
||||
"mapAttribution": "Attribuzione mappa: {}",
|
||||
"couldNotOpenLink": "Impossibile aprire il link",
|
||||
"openLicense": "Apri licenza: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Visualizza Profilo",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "Campo visivo in gradi (lasciare vuoto per il valore predefinito)",
|
||||
"fovSubtitle": "Campo visivo della telecamera - utilizzato per la larghezza del cono e il formato di invio per intervallo",
|
||||
"fovInvalid": "Il campo visivo deve essere tra 1 e 360 gradi",
|
||||
"fov360": "Campo Visivo 360°",
|
||||
"fov360Subtitle": "La telecamera ha un campo visivo omnidirezionale di 360 gradi",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
@@ -362,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"operatorName": "Nome operatore",
|
||||
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
||||
"startRefresh": "Avvia Aggiornamento",
|
||||
"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": {
|
||||
"title": "Affina Tag",
|
||||
"operatorProfile": "Profilo Operatore",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
|
||||
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
|
||||
"minimumDistance": "Distanza Minima dai Nodi Reali",
|
||||
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
|
||||
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {} dai dispositivi di sorveglianza esistenti",
|
||||
"updating": "Aggiornamento Posizioni Sospette",
|
||||
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
|
||||
"updateSuccess": "Posizioni sospette aggiornate con successo",
|
||||
"updateFailed": "Aggiornamento posizioni sospette fallito",
|
||||
"neverFetched": "Mai recuperato",
|
||||
"daysAgo": "{} giorni fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"hoursAgo": "{} ore fa",
|
||||
"minutesAgo": "{} minuti fa",
|
||||
"justNow": "Proprio ora"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "Posizione Sospetta #{}",
|
||||
"ticketNo": "N. Ticket",
|
||||
"address": "Indirizzo",
|
||||
"street": "Via",
|
||||
"street": "Via",
|
||||
"city": "Città",
|
||||
"state": "Stato",
|
||||
"intersectingStreet": "Via che Interseca",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Geavanceerde Instellingen",
|
||||
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
|
||||
"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": {
|
||||
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
|
||||
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
|
||||
@@ -131,9 +146,29 @@
|
||||
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
|
||||
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
|
||||
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
|
||||
"confirmDeleteTitle": "Verwijder Node",
|
||||
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt."
|
||||
"confirmDeleteTitle": "Verwijder Node",
|
||||
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"deleteReasonLabel": "Reden voor Verwijdering",
|
||||
"deleteReasonSelectHint": "Selecteer...",
|
||||
"deleteReasonDeviceRemoved": "Apparaat verwijderd",
|
||||
"deleteReasonDuplicateNode": "Dubbele node",
|
||||
"deleteReasonMisidentifiedInfra": "Verkeerd geïdentificeerde infrastructuur",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalisme",
|
||||
"deleteReasonCustom": "Aangepast..",
|
||||
"deleteReasonHint": "Beschrijf de reden...",
|
||||
|
||||
"mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.",
|
||||
"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": {
|
||||
"profile": "Profiel",
|
||||
"selectProfile": "Selecteer een profiel...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.",
|
||||
"areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.",
|
||||
"downloadStarted": "Download gestart! Tiles en nodes ophalen...",
|
||||
"downloadFailed": "Download starten mislukt: {}"
|
||||
"downloadFailed": "Download starten mislukt: {}",
|
||||
"offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).",
|
||||
"currentTileProvider": "huidige tegel",
|
||||
"noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Gestart",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Changeset sluiten...)",
|
||||
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
|
||||
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
|
||||
"pausedByUser": "Upload verwerking is handmatig gepauzeerd."
|
||||
"pausedByUser": "Upload verwerking is handmatig gepauzeerd.",
|
||||
"inFlightWaitTitle": "Indiening Wordt Afgerond",
|
||||
"inFlightWaitMessage": "Er is momenteel een indiening bezig die moet worden afgerond voordat deze instelling kan worden toegepast. Dit zou maar een moment moeten duren.",
|
||||
"inFlightCompleteTitle": "Indiening Voltooid",
|
||||
"inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "Geen tile providers geconfigureerd",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Voeg Provider Toe",
|
||||
"deleteProvider": "Verwijder Provider",
|
||||
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"clearCaches": "Cache Wissen",
|
||||
"clearCachesConfirm": "Alle tegelcaches wissen voor \"{}\"? Dit zal opslagruimte vrijmaken maar tegels moeten opnieuw worden gedownload bij het bekijken van die gebieden.",
|
||||
"cachesCleared": "Caches gewist voor \"{}\"",
|
||||
"providerName": "Provider Naam",
|
||||
"providerNameHint": "bijv., Aangepaste Kaarten B.V.",
|
||||
"providerNameRequired": "Provider naam is vereist",
|
||||
@@ -335,7 +381,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Kaart Tiles",
|
||||
"manageProviders": "Beheer Providers",
|
||||
"attribution": "Kaart Attributie"
|
||||
"attribution": "Kaart Attributie",
|
||||
"mapAttribution": "Kaartbron: {}",
|
||||
"couldNotOpenLink": "Kon link niet openen",
|
||||
"openLicense": "Open licentie: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Bekijk Profiel",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "FOV in graden (laat leeg voor standaard)",
|
||||
"fovSubtitle": "Camera gezichtsveld - gebruikt voor kegel breedte en bereik inzending formaat",
|
||||
"fovInvalid": "FOV moet tussen 1 en 360 graden zijn",
|
||||
"fov360": "360° Gezichtsveld",
|
||||
"fov360Subtitle": "Camera heeft een 360-graden omnidirectioneel gezichtsveld",
|
||||
"submittable": "Indienbaar",
|
||||
"submittableSubtitle": "Of dit profiel gebruikt kan worden voor camera inzendingen",
|
||||
"osmTags": "OSM Tags",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
||||
"startRefresh": "Start Verversen",
|
||||
"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": {
|
||||
"title": "Verfijn Tags",
|
||||
"operatorProfile": "Operator Profiel",
|
||||
@@ -513,7 +569,7 @@
|
||||
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
|
||||
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
|
||||
"minimumDistance": "Minimum Afstand van Echte Nodes",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {}m van bestaande surveillance apparaten",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
|
||||
"updating": "Verdachte Locaties Bijwerken",
|
||||
"downloadingAndProcessing": "Data downloaden en verwerken...",
|
||||
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperiaal (mijl, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Ustawienia Zaawansowane",
|
||||
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
|
||||
"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": {
|
||||
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
|
||||
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
||||
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
||||
"confirmDeleteTitle": "Usuń Węzeł",
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć."
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
|
||||
"deleteReasonLabel": "Powód Usunięcia",
|
||||
"deleteReasonSelectHint": "Wybierz...",
|
||||
"deleteReasonDeviceRemoved": "Urządzenie usunięte",
|
||||
"deleteReasonDuplicateNode": "Zduplikowany węzeł",
|
||||
"deleteReasonMisidentifiedInfra": "Błędnie zidentyfikowana infrastruktura",
|
||||
"deleteReasonSpamVandalism": "Spam/wandalizm",
|
||||
"deleteReasonCustom": "Niestandardowy..",
|
||||
"deleteReasonHint": "Opisz powód...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
|
||||
"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": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Wybierz profil...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.",
|
||||
"areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.",
|
||||
"downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...",
|
||||
"downloadFailed": "Nie udało się rozpocząć pobierania: {}"
|
||||
"downloadFailed": "Nie udało się rozpocząć pobierania: {}",
|
||||
"offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).",
|
||||
"currentTileProvider": "bieżący kafelek",
|
||||
"noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Pobieranie Rozpoczęte",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Zamykanie zestawu zmian...)",
|
||||
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
|
||||
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
|
||||
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane."
|
||||
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.",
|
||||
"inFlightWaitTitle": "Kończenie Przesyłania",
|
||||
"inFlightWaitMessage": "Przesyłanie jest obecnie w toku i musi zostać zakończone, zanim to ustawienie będzie mogło zostać zastosowane. To powinno zająć tylko chwilę.",
|
||||
"inFlightCompleteTitle": "Przesyłanie Zakończone",
|
||||
"inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Dostawcy Kafelków",
|
||||
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Dodaj Dostawcę",
|
||||
"deleteProvider": "Usuń Dostawcę",
|
||||
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"clearCaches": "Wyczyść Cache",
|
||||
"clearCachesConfirm": "Wyczyścić wszystkie cache kafelków dla \"{}\"? To zwolni miejsce na dysku, ale kafelki będą musiały zostać ponownie pobrane podczas przeglądania tych obszarów.",
|
||||
"cachesCleared": "Cache wyczyszczone dla \"{}\"",
|
||||
"providerName": "Nazwa Dostawcy",
|
||||
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
|
||||
"providerNameRequired": "Nazwa dostawcy jest wymagana",
|
||||
@@ -335,7 +381,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Kafelki Mapy",
|
||||
"manageProviders": "Zarządzaj Dostawcami",
|
||||
"attribution": "Atrybucja Mapy"
|
||||
"attribution": "Atrybucja Mapy",
|
||||
"mapAttribution": "Źródło mapy: {}",
|
||||
"couldNotOpenLink": "Nie udało się otworzyć linku",
|
||||
"openLicense": "Otwórz licencję: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Zobacz Profil",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "FOV w stopniach (zostaw puste dla domyślnego)",
|
||||
"fovSubtitle": "Pole widzenia kamery - używane dla szerokości stożka i formatu zgłaszania zasięgu",
|
||||
"fovInvalid": "FOV musi być między 1 a 360 stopniami",
|
||||
"fov360": "Pole Widzenia 360°",
|
||||
"fov360Subtitle": "Kamera ma wielokierunkowe pole widzenia o zasięgu 360 stopni",
|
||||
"submittable": "Możliwy do Zgłoszenia",
|
||||
"submittableSubtitle": "Czy ten profil może być używany do zgłoszeń kamer",
|
||||
"osmTags": "Tagi OSM",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
||||
"startRefresh": "Rozpocznij Odświeżanie",
|
||||
"refreshStarted": "Odświeżanie rozpoczęte!",
|
||||
"refreshFailed": "Odświeżanie nie powiodło się: {}"
|
||||
"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": {
|
||||
"title": "Doprecyzuj Tagi",
|
||||
"operatorProfile": "Profil Operatora",
|
||||
@@ -513,7 +569,7 @@
|
||||
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
|
||||
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
|
||||
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {}m od istniejących urządzeń nadzoru",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
|
||||
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
|
||||
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
|
||||
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Metryczny (km, m)",
|
||||
"imperialDescription": "Imperialny (mila, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Configurações Avançadas",
|
||||
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
|
||||
"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": {
|
||||
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
|
||||
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||
"confirmDeleteTitle": "Excluir Nó",
|
||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
|
||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
|
||||
"deleteReasonLabel": "Motivo para Exclusão",
|
||||
"deleteReasonSelectHint": "Selecionar...",
|
||||
"deleteReasonDeviceRemoved": "Dispositivo removido",
|
||||
"deleteReasonDuplicateNode": "Nó duplicado",
|
||||
"deleteReasonMisidentifiedInfra": "Infraestrutura mal identificada",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalismo",
|
||||
"deleteReasonCustom": "Personalizado..",
|
||||
"deleteReasonHint": "Descreva o motivo...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.",
|
||||
"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": {
|
||||
"profile": "Perfil",
|
||||
"selectProfile": "Selecionar um perfil...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
"downloadFailed": "Falha ao iniciar o download: {}",
|
||||
"offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).",
|
||||
"currentTileProvider": "tile atual",
|
||||
"noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Download Iniciado",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Fechando changeset...)",
|
||||
"processingPaused": "Processamento da Fila Pausado",
|
||||
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente."
|
||||
"pausedByUser": "O processamento de upload está pausado manualmente.",
|
||||
"inFlightWaitTitle": "Finalizando Envio",
|
||||
"inFlightWaitMessage": "Atualmente há um envio em andamento que precisa terminar antes que esta configuração possa ser aplicada. Isso deve levar apenas um momento.",
|
||||
"inFlightCompleteTitle": "Envio Concluído",
|
||||
"inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Adicionar Provedor",
|
||||
"deleteProvider": "Excluir Provedor",
|
||||
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"clearCaches": "Limpar Caches",
|
||||
"clearCachesConfirm": "Limpar todos os caches de mapas para \"{}\"? Isso liberará espaço de armazenamento, mas os mapas precisarão ser baixados novamente ao visualizar essas áreas.",
|
||||
"cachesCleared": "Caches limpos para \"{}\"",
|
||||
"providerName": "Nome do Provedor",
|
||||
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||
@@ -329,13 +375,16 @@
|
||||
"addProfileChoiceMessage": "Como gostaria de adicionar um perfil?",
|
||||
"createCustomProfile": "Criar Perfil Personalizado",
|
||||
"createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsite": "Importar do Site",
|
||||
"importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
"manageProviders": "Gerenciar Provedores",
|
||||
"attribution": "Atribuição do Mapa"
|
||||
"attribution": "Atribuição do Mapa",
|
||||
"mapAttribution": "Atribuição do mapa: {}",
|
||||
"couldNotOpenLink": "Não foi possível abrir o link",
|
||||
"openLicense": "Abrir licença: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "Campo de visão em graus (deixar vazio para o padrão)",
|
||||
"fovSubtitle": "Campo de visão da câmera - usado para largura do cone e formato de envio por intervalo",
|
||||
"fovInvalid": "Campo de visão deve estar entre 1 e 360 graus",
|
||||
"fov360": "Campo de Visão 360°",
|
||||
"fov360Subtitle": "A câmera tem um campo de visão omnidirecional de 360 graus",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
@@ -362,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Novo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nome do operador",
|
||||
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
||||
"startRefresh": "Iniciar Atualização",
|
||||
"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": {
|
||||
"title": "Refinar Tags",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
|
||||
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
|
||||
"minimumDistance": "Distância Mínima de Nós Reais",
|
||||
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}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",
|
||||
"downloadingAndProcessing": "Baixando e processando dados...",
|
||||
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",
|
||||
"updateFailed": "Falha ao atualizar localizações suspeitas",
|
||||
"neverFetched": "Nunca buscado",
|
||||
"daysAgo": "{} dias atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"hoursAgo": "{} horas atrás",
|
||||
"minutesAgo": "{} minutos atrás",
|
||||
"justNow": "Agora mesmo"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "Localização Suspeita #{}",
|
||||
"ticketNo": "N° do Ticket",
|
||||
"address": "Endereço",
|
||||
"street": "Rua",
|
||||
"street": "Rua",
|
||||
"city": "Cidade",
|
||||
"state": "Estado",
|
||||
"intersectingStreet": "Rua que Cruza",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Gelişmiş Ayarlar",
|
||||
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
|
||||
"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": {
|
||||
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
|
||||
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||
"confirmDeleteTitle": "Düğümü Sil",
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleteReasonLabel": "Silme Nedeni",
|
||||
"deleteReasonSelectHint": "Seçin...",
|
||||
"deleteReasonDeviceRemoved": "Cihaz kaldırıldı",
|
||||
"deleteReasonDuplicateNode": "Yinelenen düğüm",
|
||||
"deleteReasonMisidentifiedInfra": "Yanlış tanımlanmış altyapı",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalizm",
|
||||
"deleteReasonCustom": "Özel..",
|
||||
"deleteReasonHint": "Lütfen nedeni açıklayın...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.",
|
||||
"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": {
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Bir profil seçin...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.",
|
||||
"areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.",
|
||||
"downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...",
|
||||
"downloadFailed": "İndirme başlatılamadı: {}"
|
||||
"downloadFailed": "İndirme başlatılamadı: {}",
|
||||
"offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).",
|
||||
"currentTileProvider": "mevcut döşeme",
|
||||
"noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "İndirme Başladı",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Değişiklik seti kapatılıyor...)",
|
||||
"processingPaused": "Kuyruk İşleme Duraklatıldı",
|
||||
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
|
||||
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı."
|
||||
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.",
|
||||
"inFlightWaitTitle": "Gönderim Tamamlanıyor",
|
||||
"inFlightWaitMessage": "Şu anda devam eden bir gönderim var ve bu ayar uygulanmadan önce tamamlanması gerekiyor. Bu sadece bir an sürmelidir.",
|
||||
"inFlightCompleteTitle": "Gönderim Tamamlandı",
|
||||
"inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Döşeme Sağlayıcıları",
|
||||
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Sağlayıcı Ekle",
|
||||
"deleteProvider": "Sağlayıcıyı Sil",
|
||||
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"clearCaches": "Önbellekleri Temizle",
|
||||
"clearCachesConfirm": "\"{}\" için tüm döşeme önbelleklerini temizle? Bu depolama alanını boşaltacak ancak bu alanları görüntülerken döşemelerin tekrar indirilmesi gerekecek.",
|
||||
"cachesCleared": "\"{}\" için önbellekler temizlendi",
|
||||
"providerName": "Sağlayıcı Adı",
|
||||
"providerNameHint": "örn., Özel Haritalar A.Ş.",
|
||||
"providerNameRequired": "Sağlayıcı adı gerekli",
|
||||
@@ -335,7 +381,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Harita Döşemeleri",
|
||||
"manageProviders": "Sağlayıcıları Yönet",
|
||||
"attribution": "Harita Atfı"
|
||||
"attribution": "Harita Atfı",
|
||||
"mapAttribution": "Harita kaynağı: {}",
|
||||
"couldNotOpenLink": "Bağlantı açılamadı",
|
||||
"openLicense": "Lisansı aç: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profili Görüntüle",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "FOV derece cinsinden (varsayılan için boş bırakın)",
|
||||
"fovSubtitle": "Kamera görüş alanı - koni genişliği ve aralık gönderim formatı için kullanılır",
|
||||
"fovInvalid": "FOV 1 ile 360 derece arasında olmalıdır",
|
||||
"fov360": "360° Görüş Alanı",
|
||||
"fov360Subtitle": "Kamera 360 derece çok yönlü görüş alanına sahiptir",
|
||||
"submittable": "Gönderilebilir",
|
||||
"submittableSubtitle": "Bu profilin kamera gönderimlerinde kullanılıp kullanılamayacağı",
|
||||
"osmTags": "OSM Etiketleri",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
||||
"startRefresh": "Yenilemeyi Başlat",
|
||||
"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": {
|
||||
"title": "Etiketleri Düzenle",
|
||||
"operatorProfile": "Operatör Profili",
|
||||
@@ -513,7 +569,7 @@
|
||||
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
|
||||
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
|
||||
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
|
||||
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {}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",
|
||||
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
|
||||
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Metrik (km, m)",
|
||||
"imperialDescription": "İmperial (mil, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "Розширені Налаштування",
|
||||
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
||||
"proximityAlerts": "Сповіщення Про Близькість",
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі"
|
||||
"keepScreenAwake": "Тримати екран активним",
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі",
|
||||
"hideZoomControls": "Приховати Елементи Керування Масштабом",
|
||||
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
|
||||
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
|
||||
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Тримати екран активним під час використання програми",
|
||||
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Приховати елементи керування масштабом",
|
||||
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
||||
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||
"confirmDeleteTitle": "Видалити Вузол",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати."
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||
"deleteReasonLabel": "Причина Видалення",
|
||||
"deleteReasonSelectHint": "Вибрати...",
|
||||
"deleteReasonDeviceRemoved": "Пристрій видалено",
|
||||
"deleteReasonDuplicateNode": "Дубльований вузол",
|
||||
"deleteReasonMisidentifiedInfra": "Неправильно ідентифікована інфраструктура",
|
||||
"deleteReasonSpamVandalism": "Спам/вандалізм",
|
||||
"deleteReasonCustom": "Власний..",
|
||||
"deleteReasonHint": "Опишіть причину...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
|
||||
"uploadPending": "Завантаження очікує",
|
||||
"uploadCreatingChangeset": "Створення набору змін...",
|
||||
"uploadUploading": "Завантаження...",
|
||||
"uploadClosingChangeset": "Закриття набору змін...",
|
||||
"uploadError": "Помилка завантаження",
|
||||
"uploadComplete": "Завантаження завершено",
|
||||
"viewInQueue": "Переглянути в Черзі",
|
||||
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.",
|
||||
"areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.",
|
||||
"downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}"
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}",
|
||||
"offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).",
|
||||
"currentTileProvider": "поточна плитка",
|
||||
"noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Завантаження Почалося",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (Закриття набору змін...)",
|
||||
"processingPaused": "Обробка Черги Призупинена",
|
||||
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну."
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну.",
|
||||
"inFlightWaitTitle": "Завершення Подання",
|
||||
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
|
||||
"inFlightCompleteTitle": "Подання Завершено",
|
||||
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Постачальники Плиток",
|
||||
"noProvidersConfigured": "Постачальників плиток не налаштовано",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "Додати Постачальника",
|
||||
"deleteProvider": "Видалити Постачальника",
|
||||
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"clearCaches": "Очистити Кеш",
|
||||
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
|
||||
"cachesCleared": "Кеш очищено для \"{}\"",
|
||||
"providerName": "Назва Постачальника",
|
||||
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
||||
"providerNameRequired": "Назва постачальника обов'язкова",
|
||||
@@ -335,7 +381,10 @@
|
||||
"mapTiles": {
|
||||
"title": "Плитки Карти",
|
||||
"manageProviders": "Управляти Постачальниками",
|
||||
"attribution": "Атрибуція Карти"
|
||||
"attribution": "Атрибуція Карти",
|
||||
"mapAttribution": "Джерело карти: {}",
|
||||
"couldNotOpenLink": "Не вдалося відкрити посилання",
|
||||
"openLicense": "Відкрити ліцензію: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Переглянути Профіль",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
|
||||
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
|
||||
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
|
||||
"fov360": "Поле Зору 360°",
|
||||
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
|
||||
"submittable": "Можна Подавати",
|
||||
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
|
||||
"osmTags": "OSM Теги",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
||||
"startRefresh": "Почати Оновлення",
|
||||
"refreshStarted": "Оновлення розпочато!",
|
||||
"refreshFailed": "Оновлення не вдалося: {}"
|
||||
"refreshFailed": "Оновлення не вдалося: {}",
|
||||
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
|
||||
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
|
||||
"keepData": "Зберегти Дані",
|
||||
"deleteData": "Видалити Дані"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Уточнити Теги",
|
||||
"operatorProfile": "Профіль Оператора",
|
||||
@@ -513,7 +569,7 @@
|
||||
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
||||
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
||||
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {}м від існуючих пристроїв спостереження",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
|
||||
"updating": "Оновлення Підозрілих Локацій",
|
||||
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
||||
"updateSuccess": "Підозрілі локації успішно оновлено",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "Метричні (км, м)",
|
||||
"imperialDescription": "Імперські (миля, фут)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -111,8 +111,23 @@
|
||||
"advancedSettings": "高级设置",
|
||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||
"proximityAlerts": "邻近警报",
|
||||
"networkStatusIndicator": "网络状态指示器"
|
||||
"keepScreenAwake": "保持屏幕常亮",
|
||||
"networkStatusIndicator": "网络状态指示器",
|
||||
"hideZoomControls": "隐藏缩放控件",
|
||||
"enableOfflineFeatures": "启用离线功能",
|
||||
"enableOfflineFeaturesTitle": "启用离线功能",
|
||||
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "使用应用时保持屏幕常亮",
|
||||
"subtitle": "防止在使用应用期间屏幕自动熄灭"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "隐藏缩放控件",
|
||||
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "接近监控设备时接收通知",
|
||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||
@@ -132,8 +147,28 @@
|
||||
"editQueuedForUpload": "节点编辑已排队上传",
|
||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||
"confirmDeleteTitle": "删除节点",
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
|
||||
"deleteReasonLabel": "删除原因",
|
||||
"deleteReasonSelectHint": "请选择...",
|
||||
"deleteReasonDeviceRemoved": "设备已移除",
|
||||
"deleteReasonDuplicateNode": "重复节点",
|
||||
"deleteReasonMisidentifiedInfra": "识别错误的基础设施",
|
||||
"deleteReasonSpamVandalism": "垃圾信息/破坏",
|
||||
"deleteReasonCustom": "自定义..",
|
||||
"deleteReasonHint": "请描述原因...",
|
||||
|
||||
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
|
||||
"uploadPending": "上传待处理",
|
||||
"uploadCreatingChangeset": "正在创建变更集...",
|
||||
"uploadUploading": "正在上传...",
|
||||
"uploadClosingChangeset": "正在关闭变更集...",
|
||||
"uploadError": "上传失败",
|
||||
"uploadComplete": "上传完成",
|
||||
"viewInQueue": "在队列中查看",
|
||||
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
"selectProfile": "选择配置文件...",
|
||||
@@ -181,7 +216,10 @@
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
"downloadFailed": "启动下载失败:{}",
|
||||
"offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。",
|
||||
"currentTileProvider": "当前瓦片",
|
||||
"noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
@@ -269,8 +307,13 @@
|
||||
"closingChangeset": " (关闭变更集...)",
|
||||
"processingPaused": "队列处理已暂停",
|
||||
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
|
||||
"pausedByUser": "上传处理已手动暂停。"
|
||||
"pausedByUser": "上传处理已手动暂停。",
|
||||
"inFlightWaitTitle": "正在完成提交",
|
||||
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
|
||||
"inFlightCompleteTitle": "提交完成",
|
||||
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
"noProvidersConfigured": "未配置瓦片提供商",
|
||||
@@ -281,6 +324,9 @@
|
||||
"addProvider": "添加提供商",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"clearCaches": "清除缓存",
|
||||
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
|
||||
"cachesCleared": "已清除 \"{}\" 的缓存",
|
||||
"providerName": "提供商名称",
|
||||
"providerNameHint": "例如,自定义地图公司",
|
||||
"providerNameRequired": "提供商名称为必填项",
|
||||
@@ -329,13 +375,16 @@
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商",
|
||||
"attribution": "地图归属"
|
||||
"attribution": "地图归属",
|
||||
"mapAttribution": "地图来源:{}",
|
||||
"couldNotOpenLink": "无法打开链接",
|
||||
"openLicense": "打开许可证:{}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
@@ -350,6 +399,8 @@
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"fov360": "360°视场角",
|
||||
"fov360Subtitle": "摄像头具有360度全方向视场角",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
@@ -362,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
@@ -408,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||
"startRefresh": "开始刷新",
|
||||
"refreshStarted": "刷新已开始!",
|
||||
"refreshFailed": "刷新失败:{}"
|
||||
"refreshFailed": "刷新失败:{}",
|
||||
"disableConfirmTitle": "删除现有的离线区域数据?",
|
||||
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
|
||||
"keepData": "保留数据",
|
||||
"deleteData": "删除数据"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "细化标签",
|
||||
"operatorProfile": "运营商配置文件",
|
||||
@@ -513,14 +569,14 @@
|
||||
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
||||
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
||||
"minimumDistance": "与真实节点的最小距离",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
|
||||
"updating": "正在更新疑似位置",
|
||||
"downloadingAndProcessing": "正在下载和处理数据...",
|
||||
"updateSuccess": "疑似位置更新成功",
|
||||
"updateFailed": "疑似位置更新失败",
|
||||
"neverFetched": "从未获取",
|
||||
"daysAgo": "{}天前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"minutesAgo": "{}分钟前",
|
||||
"justNow": "刚刚"
|
||||
},
|
||||
@@ -528,7 +584,7 @@
|
||||
"title": "疑似位置 #{}",
|
||||
"ticketNo": "工单号",
|
||||
"address": "地址",
|
||||
"street": "街道",
|
||||
"street": "街道",
|
||||
"city": "城市",
|
||||
"state": "州/省",
|
||||
"intersectingStreet": "交叉街道",
|
||||
@@ -552,4 +608,4 @@
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-4
@@ -14,20 +14,56 @@ import 'screens/release_notes_screen.dart';
|
||||
import 'screens/osm_account_screen.dart';
|
||||
import 'screens/upload_queue_screen.dart';
|
||||
import 'services/localization_service.dart';
|
||||
import 'services/provider_tile_cache_manager.dart';
|
||||
import 'services/version_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
import 'services/deflock_tile_provider.dart' show TileLoadCancelledException, TileNotAvailableOfflineException;
|
||||
|
||||
|
||||
/// Suppress console noise for expected, already-handled tile load errors.
|
||||
///
|
||||
/// [TileLoadCancelledException] (tile scrolled off screen) and
|
||||
/// [TileNotAvailableOfflineException] (no cached data available) are thrown
|
||||
/// deliberately by DeflockOfflineTileImageProvider and are already handled —
|
||||
/// TileLayerManager.onTileLoadError() specifically skips retry logic for
|
||||
/// both. Flutter's image pipeline reports them via
|
||||
/// MultiFrameImageStreamCompleter with `silent: true`, but
|
||||
/// FlutterError.dumpErrorToConsole has a debug-mode assert() that ignores
|
||||
/// the silent flag and always prints to console anyway. This filters those
|
||||
/// two specific, expected exception types back out while leaving all other
|
||||
/// errors (including genuinely silent-but-unexpected ones) reported as
|
||||
/// normal.
|
||||
void _installTileErrorFilter() {
|
||||
final defaultOnError = FlutterError.onError;
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
final exception = details.exception;
|
||||
if (details.silent &&
|
||||
(exception is TileLoadCancelledException ||
|
||||
exception is TileNotAvailableOfflineException)) {
|
||||
return; // Already handled — don't spam the console.
|
||||
}
|
||||
if (defaultOnError != null) {
|
||||
defaultOnError(details);
|
||||
} else {
|
||||
FlutterError.presentError(details);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
_installTileErrorFilter();
|
||||
|
||||
|
||||
// Initialize version service
|
||||
await VersionService().init();
|
||||
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
// Resolve platform cache directory for per-provider tile caching
|
||||
await ProviderTileCacheManager.init();
|
||||
|
||||
// Initialize deep link service
|
||||
await DeepLinkService().init();
|
||||
DeepLinkService().setNavigatorKey(_navigatorKey);
|
||||
@@ -88,8 +124,22 @@ class DeFlockApp extends StatelessWidget {
|
||||
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
|
||||
onUnknownRoute: (settings) {
|
||||
// Fallback for any genuinely unknown named route (e.g. a stale/bad
|
||||
// link). Deep links (deflockapp://...) are handled exclusively by
|
||||
// DeepLinkService via the app_links package - Flutter's own
|
||||
// implicit deep-link routing is disabled at the platform level
|
||||
// (see AndroidManifest.xml's flutter_deeplinking_enabled and
|
||||
// Info.plist's FlutterDeepLinkingEnabled) to avoid this handler
|
||||
// double-navigating and burying screens pushed by DeepLinkService.
|
||||
debugPrint('[Navigation] Unknown route intercepted: ${settings.name}');
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (_) => const HomeScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app_state.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'services/suspected_location_cache.dart';
|
||||
import 'widgets/nuclear_reset_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
|
||||
|
||||
/// One-time migrations that run when users upgrade to specific versions.
|
||||
/// Each migration function is named after the version where it should run.
|
||||
@@ -114,6 +117,104 @@ class OneTimeMigrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize profile ordering for existing users (v2.7.3)
|
||||
static Future<void> migrate_2_7_3(AppState appState) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const orderKey = 'profile_order';
|
||||
|
||||
// Check if user already has custom profile ordering
|
||||
if (prefs.containsKey(orderKey)) {
|
||||
debugPrint('[Migration] 2.7.3: Profile order already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with current profile order (preserves existing UI order)
|
||||
final currentProfiles = appState.profiles;
|
||||
final initialOrder = currentProfiles.map((p) => p.id).toList();
|
||||
|
||||
if (initialOrder.isNotEmpty) {
|
||||
await prefs.setStringList(orderKey, initialOrder);
|
||||
debugPrint('[Migration] 2.7.3: Initialized profile order with ${initialOrder.length} profiles');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.7.3 completed: initialized profile ordering');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.7.3 ERROR: Failed to initialize profile ordering: $e');
|
||||
// Don't rethrow - this is non-critical, profiles will just use default order
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear non-360 FOV values from all profiles (v2.10.0)
|
||||
static Future<void> migrate_2_10_0(AppState appState) async {
|
||||
try {
|
||||
// Only perform this migration if non-360 FOVs are disabled
|
||||
if (kEnableNon360FOVs) {
|
||||
debugPrint('[Migration] 2.10.0: Non-360 FOVs enabled, skipping FOV cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all profiles from storage
|
||||
final profiles = await ProfileService().load();
|
||||
bool anyProfileChanged = false;
|
||||
int profilesCleared = 0;
|
||||
|
||||
// Clear non-360 FOV values from all profiles
|
||||
final updatedProfiles = profiles.map((profile) {
|
||||
if (profile.fov != null) {
|
||||
// Use approximation to handle floating point precision issues
|
||||
final fovValue = profile.fov!;
|
||||
final is360 = (fovValue - 360.0).abs() < 0.01; // Within 0.01 degrees of 360
|
||||
|
||||
if (!is360) {
|
||||
debugPrint('[Migration] 2.10.0: Clearing FOV $fovValue from profile: ${profile.name}');
|
||||
anyProfileChanged = true;
|
||||
profilesCleared++;
|
||||
return profile.copyWith(fov: null);
|
||||
}
|
||||
}
|
||||
return profile;
|
||||
}).toList();
|
||||
|
||||
// Save updated profiles back to storage if any changes were made
|
||||
if (anyProfileChanged) {
|
||||
await ProfileService().save(updatedProfiles);
|
||||
await appState.reloadProfiles();
|
||||
debugPrint('[Migration] 2.10.0: Cleared FOV from $profilesCleared profiles');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.10.0 completed: cleared non-360 FOV values from profiles');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[Migration] 2.10.0 ERROR: Failed to clear non-360 FOV values: $e');
|
||||
debugPrint('[Migration] 2.10.0 ERROR: Stack trace: $stackTrace');
|
||||
// Don't rethrow - this is non-critical, FOV restrictions will still apply going forward
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable offline features for existing users who already have offline
|
||||
/// area data (v2.10.5). New installs and existing users who never used
|
||||
/// offline areas will keep the new "offline features enabled" setting at
|
||||
/// its default of false.
|
||||
static Future<void> migrate_2_10_5(AppState appState) async {
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
await offlineAreaService.ensureInitialized();
|
||||
|
||||
if (offlineAreaService.offlineAreas.isNotEmpty) {
|
||||
await appState.setOfflineFeaturesEnabled(true);
|
||||
debugPrint('[Migration] 2.10.5: Existing offline area data found - enabled offline features');
|
||||
} else {
|
||||
debugPrint('[Migration] 2.10.5: No existing offline area data - leaving offline features disabled');
|
||||
}
|
||||
|
||||
debugPrint('[Migration] 2.10.5 completed: offline features migration done');
|
||||
} catch (e) {
|
||||
debugPrint('[Migration] 2.10.5 ERROR: Failed to migrate offline features setting: $e');
|
||||
// Don't rethrow - this is non-critical, worst case is the user needs
|
||||
// to manually re-enable the toggle to see their existing offline areas.
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the migration function for a specific version
|
||||
static Future<void> Function(AppState)? getMigrationForVersion(String version) {
|
||||
switch (version) {
|
||||
@@ -127,11 +228,18 @@ class OneTimeMigrations {
|
||||
return migrate_1_8_0;
|
||||
case '2.1.0':
|
||||
return migrate_2_1_0;
|
||||
case '2.7.3':
|
||||
return migrate_2_7_3;
|
||||
case '2.10.0':
|
||||
return migrate_2_10_0;
|
||||
case '2.10.5':
|
||||
return migrate_2_10_5;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Run migration for a specific version with nuclear reset on failure
|
||||
static Future<void> runMigration(String version, AppState appState, BuildContext? context) async {
|
||||
try {
|
||||
|
||||
@@ -269,16 +269,33 @@ class NodeProfile {
|
||||
/// Used as the default `<Existing tags>` option when editing nodes
|
||||
/// All existing tags will flow through as additionalExistingTags
|
||||
static NodeProfile createExistingTagsProfile(OsmNode node) {
|
||||
// Calculate FOV from existing direction ranges if applicable
|
||||
// Only assign FOV if the original direction string actually contained range notation
|
||||
// (e.g., "90-270" or "55-125"), not if it was just single directions (e.g., "90")
|
||||
double? calculatedFov;
|
||||
|
||||
// If node has direction/FOV pairs, check if they all have the same FOV
|
||||
if (node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
final raw = node.tags['direction'] ?? node.tags['camera:direction'];
|
||||
if (raw != null) {
|
||||
// Check if any part of the direction string contains range notation (dash with numbers)
|
||||
final parts = raw.split(';');
|
||||
bool hasRangeNotation = false;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
for (final part in parts) {
|
||||
final trimmed = part.trim();
|
||||
// Look for range pattern: numbers-numbers (e.g., "90-270", "55-125")
|
||||
if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) {
|
||||
hasRangeNotation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only calculate FOV if the node originally had range notation
|
||||
if (hasRangeNotation && node.directionFovPairs.isNotEmpty) {
|
||||
final firstFov = node.directionFovPairs.first.fovDegrees;
|
||||
|
||||
// If all directions have the same FOV, use it for the profile
|
||||
if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) {
|
||||
calculatedFov = firstFov;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +307,7 @@ class NodeProfile {
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: false,
|
||||
fov: calculatedFov, // Use calculated FOV from existing direction ranges
|
||||
fov: calculatedFov, // Only use FOV if original had explicit range notation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/service_policy.dart';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
@@ -10,7 +12,7 @@ class TileType {
|
||||
final Uint8List? previewTile; // Single tile image data for preview
|
||||
final int maxZoom; // Maximum zoom level for this tile type
|
||||
|
||||
const TileType({
|
||||
TileType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
@@ -76,6 +78,15 @@ class TileType {
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
/// The service policy that applies to this tile type's server.
|
||||
/// Cached because [urlTemplate] is immutable.
|
||||
late final ServicePolicy servicePolicy =
|
||||
ServicePolicyResolver.resolve(urlTemplate);
|
||||
|
||||
/// Whether this tile server's usage policy permits offline/bulk downloading.
|
||||
/// Resolved via [ServicePolicyResolver] from the URL template.
|
||||
bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/nuclear_reset_service.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
import '../widgets/submission_guide_dialog.dart';
|
||||
|
||||
@@ -76,10 +79,20 @@ class AboutScreen extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// About OpenStreetMap section
|
||||
_buildAboutOSMSection(context),
|
||||
const SizedBox(height: 24),
|
||||
// Information dialogs section
|
||||
_buildDialogButtons(context),
|
||||
const SizedBox(height: 24),
|
||||
_buildHelpLinks(context),
|
||||
|
||||
// Dev-only nuclear reset button at very bottom
|
||||
if (kDebugMode || kEnableDevelopmentModes) ...[
|
||||
const SizedBox(height: 32),
|
||||
_buildDevNuclearResetButton(context),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -96,6 +109,8 @@ class AboutScreen extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Report issue with OSM base map', 'https://www.openstreetmap.org/fixthemap'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||
const SizedBox(height: 8),
|
||||
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||
@@ -121,6 +136,39 @@ class AboutScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutOSMSection(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.aboutOSM'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.aboutOSMDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _launchUrl('https://openstreetmap.org', context),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(locService.t('auth.visitOSM')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogButtons(BuildContext context) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
@@ -165,5 +213,231 @@ class AboutScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Dev-only nuclear reset button (only visible in debug mode)
|
||||
Widget _buildDevNuclearResetButton(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.developer_mode,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Developer Tools',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'These tools are only available in debug mode for development and troubleshooting.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _showNuclearResetConfirmation(context),
|
||||
icon: const Icon(Icons.delete_forever, color: Colors.red),
|
||||
label: const Text(
|
||||
'Nuclear Reset (Clear All Data)',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show confirmation dialog for nuclear reset
|
||||
Future<void> _showNuclearResetConfirmation(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Nuclear Reset'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'This will completely clear ALL app data:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text('• All settings and preferences'),
|
||||
Text('• OAuth login credentials'),
|
||||
Text('• Custom profiles and operators'),
|
||||
Text('• Upload queue and cached data'),
|
||||
Text('• Downloaded offline areas'),
|
||||
Text('• Everything else'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'The app will behave exactly like a fresh install after this operation.',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'This action cannot be undone.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Nuclear Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
await _performNuclearReset(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the nuclear reset operation
|
||||
Future<void> _performNuclearReset(BuildContext context) async {
|
||||
// Show progress dialog
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Clearing all app data...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Perform the nuclear reset
|
||||
await NuclearResetService.clearEverything();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Close progress dialog
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Show completion dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset Complete'),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'All app data has been cleared successfully.',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Please close and restart the app to continue with a fresh state.',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Close progress dialog if it's still open
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Show error dialog
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Reset Failed'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'An error occurred during the nuclear reset:',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
e.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Some data may have been partially cleared. You may want to manually clear app data through device settings.',
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import 'settings/sections/max_nodes_section.dart';
|
||||
import 'settings/sections/proximity_alerts_section.dart';
|
||||
import 'settings/sections/suspected_locations_section.dart';
|
||||
import 'settings/sections/tile_provider_section.dart';
|
||||
import 'settings/sections/keep_screen_awake_section.dart';
|
||||
import 'settings/sections/hide_zoom_controls_section.dart';
|
||||
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class AdvancedSettingsScreen extends StatelessWidget {
|
||||
@@ -32,6 +35,11 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
||||
Divider(),
|
||||
SuspectedLocationsSection(),
|
||||
Divider(),
|
||||
KeepScreenAwakeSection(),
|
||||
Divider(),
|
||||
HideZoomControlsSection(),
|
||||
Divider(),
|
||||
|
||||
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
|
||||
// Divider(),
|
||||
TileProviderSection(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
@@ -125,4 +126,50 @@ class MapInteractionHandler {
|
||||
// Clear suspected location selection
|
||||
appState.clearSuspectedLocationSelection();
|
||||
}
|
||||
|
||||
/// Handle long press on empty map area (add node here)
|
||||
void handleMapLongPress({
|
||||
required BuildContext context,
|
||||
required LatLng tapLocation,
|
||||
required AnimatedMapController mapController,
|
||||
required VoidCallback onAddNode,
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
debugPrint('[MapInteractionHandler] Long press at: ${tapLocation.latitude}, ${tapLocation.longitude}');
|
||||
|
||||
// Check if we should handle this long press
|
||||
// Don't handle if any sheet is open or if in search mode
|
||||
if (appState.isInSearchMode || appState.showingOverview) {
|
||||
debugPrint('[MapInteractionHandler] Long press ignored - in search/navigation mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't handle if any session is active (add/edit sheets are open)
|
||||
if (appState.session != null || appState.editSession != null) {
|
||||
debugPrint('[MapInteractionHandler] Long press ignored - node sheet already open');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when user long presses
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// First, center the map on the tap location
|
||||
try {
|
||||
mapController.animateTo(
|
||||
dest: tapLocation,
|
||||
zoom: mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapInteractionHandler] Could not center map on long press location: $e');
|
||||
}
|
||||
|
||||
// After a short delay to let the map center, open the add node sheet
|
||||
// This matches the existing pattern used for node taps
|
||||
Future.delayed(const Duration(milliseconds: 350), () {
|
||||
onAddNode();
|
||||
});
|
||||
}
|
||||
}
|
||||
+113
-49
@@ -1,12 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
|
||||
|
||||
import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
@@ -19,6 +22,7 @@ import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/deep_link_service.dart';
|
||||
import 'coordinators/sheet_coordinator.dart';
|
||||
import 'coordinators/navigation_coordinator.dart';
|
||||
import 'coordinators/map_interaction_handler.dart';
|
||||
@@ -57,10 +61,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_sheetCoordinator = SheetCoordinator();
|
||||
_navigationCoordinator = NavigationCoordinator();
|
||||
_mapInteractionHandler = MapInteractionHandler();
|
||||
DeepLinkService().onNodeDeepLink = _handleNodeDeepLink;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
DeepLinkService().onNodeDeepLink = null;
|
||||
_mapController.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) {
|
||||
// Handle the map interaction (centering and follow-me disable)
|
||||
_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
|
||||
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)
|
||||
setState(() {});
|
||||
},
|
||||
onMapLongPress: _onMapLongPress,
|
||||
onUserGesture: () {
|
||||
// Only clear selected node if tag sheet is not open
|
||||
// This prevents nodes from losing their grey-out when map is moved while viewing tags
|
||||
@@ -556,63 +613,70 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7, // 70% for primary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final showDownloadButton = appState.offlineFeaturesEnabled;
|
||||
final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
// Take full width when the download button is hidden
|
||||
flex: showDownloadButton ? 7 : 10,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showDownloadButton) ...[
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: canDownload ? () {
|
||||
// Check minimum zoom level before opening download dialog
|
||||
final currentZoom = _mapController.mapController.camera.zoom;
|
||||
if (currentZoom < kMinZoomForOfflineDownload) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
LocalizationService.instance.t('download.areaTooBigMessage',
|
||||
params: [kMinZoomForOfflineDownload.toString()])
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
);
|
||||
} : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import 'settings/sections/enable_offline_features_section.dart';
|
||||
import 'settings/sections/offline_mode_section.dart';
|
||||
import 'settings/sections/offline_areas_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
@@ -16,20 +19,42 @@ class OfflineSettingsScreen extends StatelessWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.offlineSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: const [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
body: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final offlineFeaturesEnabled = appState.offlineFeaturesEnabled;
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
children: [
|
||||
const EnableOfflineFeaturesSection(),
|
||||
const Divider(),
|
||||
// Grey out and disable interaction with the rest of the
|
||||
// offline settings when the master toggle is off, while
|
||||
// keeping them visible/discoverable.
|
||||
Opacity(
|
||||
opacity: offlineFeaturesEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !offlineFeaturesEnabled,
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,8 +155,13 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty) continue; // Skip only if key is empty
|
||||
tagMap[e.key.trim()] = e.value.trim(); // Allow empty values for refinement
|
||||
final key = e.key.trim();
|
||||
final value = e.value.trim();
|
||||
|
||||
// Skip if key is empty OR value is empty (no empty values for operator profiles)
|
||||
if (key.isEmpty || value.isEmpty) continue;
|
||||
|
||||
tagMap[key] = value;
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
|
||||
@@ -195,50 +195,9 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
child: UploadModeSection(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Information Section
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('auth.aboutOSM'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('auth.aboutOSMDescription'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://openstreetmap.org');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(locService.t('auth.visitOSM')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Account deletion section - only show when logged in and not in simulate mode
|
||||
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/nsi_tag_value_field.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
@@ -22,6 +23,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
late TextEditingController _fovCtrl;
|
||||
late bool _is360Fov;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -41,6 +43,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
_fovCtrl = TextEditingController(text: widget.profile.fov?.toString() ?? '');
|
||||
_is360Fov = widget.profile.fov == 360.0;
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -94,28 +97,68 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
// Direction and FOV configuration - show different UI based on dev config
|
||||
if (kEnableNon360FOVs) ...[
|
||||
// Old UI: direction required + optional custom FOV text field (development mode)
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
),
|
||||
if (_requiresDirection) Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
child: TextField(
|
||||
controller: _fovCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.fov'),
|
||||
hintText: locService.t('profileEditor.fovHint'),
|
||||
helperText: locService.t('profileEditor.fovSubtitle'),
|
||||
errorText: _validateFov(),
|
||||
suffixText: '°',
|
||||
),
|
||||
onChanged: (value) => setState(() {}), // Trigger validation
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// New UI: mutually exclusive direction vs 360° FOV checkboxes (production mode)
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: widget.profile.editable
|
||||
? (value) {
|
||||
setState(() {
|
||||
_requiresDirection = value ?? false;
|
||||
// Make mutually exclusive with 360° FOV
|
||||
if (_requiresDirection) {
|
||||
_is360Fov = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.fov360')),
|
||||
subtitle: Text(locService.t('profileEditor.fov360Subtitle')),
|
||||
value: _is360Fov,
|
||||
onChanged: widget.profile.editable
|
||||
? (value) {
|
||||
setState(() {
|
||||
_is360Fov = value ?? false;
|
||||
// Make mutually exclusive with direction requirement
|
||||
if (_is360Fov) {
|
||||
_requiresDirection = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
@@ -199,6 +242,9 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
}
|
||||
|
||||
String? _validateFov() {
|
||||
// Only validate when using text field mode
|
||||
if (!kEnableNon360FOVs) return null;
|
||||
|
||||
final text = _fovCtrl.text.trim();
|
||||
if (text.isEmpty) return null; // Optional field
|
||||
|
||||
@@ -219,14 +265,21 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate FOV if provided
|
||||
if (_validateFov() != null) {
|
||||
// Validate FOV if using text field mode
|
||||
if (kEnableNon360FOVs && _validateFov() != null) {
|
||||
return; // Don't save if FOV validation fails
|
||||
}
|
||||
|
||||
// Parse FOV
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
final fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
// Parse FOV based on dev config mode
|
||||
double? fov;
|
||||
if (kEnableNon360FOVs) {
|
||||
// Old mode: parse from text field
|
||||
final fovText = _fovCtrl.text.trim();
|
||||
fov = fovText.isEmpty ? null : double.tryParse(fovText);
|
||||
} else {
|
||||
// New mode: use checkbox state
|
||||
fov = _is360Fov ? 360.0 : null;
|
||||
}
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
/// Master toggle for all offline-area functionality (downloading, browsing,
|
||||
/// and using cached tiles/nodes offline). When disabled, the rest of the
|
||||
/// Offline Settings screen (offline mode + offline areas list) is greyed out
|
||||
/// and the download button on the map screen is hidden. Functionally, the
|
||||
/// app will behave as if no offline area data exists at all while this is
|
||||
/// off, regardless of whether area files still exist on disk.
|
||||
class EnableOfflineFeaturesSection extends StatelessWidget {
|
||||
const EnableOfflineFeaturesSection({super.key});
|
||||
|
||||
Future<void> _handleToggle(BuildContext context, AppState appState, bool value) async {
|
||||
if (value) {
|
||||
// Enabling requires no confirmation.
|
||||
await appState.setOfflineFeaturesEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disabling: if offline area data already exists, ask whether to keep
|
||||
// or delete it. Either choice proceeds to disable the toggle - there is
|
||||
// no "cancel" path once this dialog is shown.
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
if (!context.mounted) return;
|
||||
final hasExistingAreas = offlineService.offlineAreas.isNotEmpty;
|
||||
|
||||
if (!hasExistingAreas) {
|
||||
await appState.setOfflineFeaturesEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('offlineAreas.disableConfirmTitle')),
|
||||
content: Text(locService.t('offlineAreas.disableConfirmMessage')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(locService.t('offlineAreas.keepData')),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(locService.t('offlineAreas.deleteData')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldDelete == true) {
|
||||
for (final area in offlineService.offlineAreas.toList()) {
|
||||
offlineService.deleteArea(area.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable regardless of the user's data choice.
|
||||
await appState.setOfflineFeaturesEnabled(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('settings.enableOfflineFeatures'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(locService.t('settings.enableOfflineFeaturesTitle')),
|
||||
subtitle: Text(
|
||||
locService.t('settings.enableOfflineFeaturesSubtitle'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
value: appState.offlineFeaturesEnabled,
|
||||
onChanged: (value) => _handleToggle(context, appState, value),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,76 +34,101 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
...appState.profiles.map(
|
||||
(p) => ListTile(
|
||||
leading: Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: appState.profiles.length,
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
appState.reorderProfiles(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final p = appState.profiles[index];
|
||||
return ListTile(
|
||||
key: ValueKey(p.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Checkbox
|
||||
Checkbox(
|
||||
value: appState.isEnabled(p),
|
||||
onChanged: (v) => appState.toggleProfile(p, v ?? false),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
|
||||
trailing: !p.editable
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'view',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.visibility),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.view')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'view') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/offline_area_service.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/in_flight_upload_wait_dialog.dart';
|
||||
|
||||
|
||||
class OfflineModeSection extends StatelessWidget {
|
||||
const OfflineModeSection({super.key});
|
||||
@@ -49,10 +51,17 @@ class OfflineModeSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with the change
|
||||
await appState.setOfflineMode(value);
|
||||
// Proceed with the change, respecting any in-flight submission that
|
||||
// needs to finish before offline mode actually takes effect.
|
||||
if (!context.mounted) return;
|
||||
await applyQueueSettingChangeRespectingInFlightUploads(
|
||||
context: context,
|
||||
appState: appState,
|
||||
applyChange: () => appState.setOfflineMode(value),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
@@ -54,47 +54,64 @@ class OperatorProfilesSection extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
...appState.operatorProfiles.map(
|
||||
(p) => ListTile(
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: appState.operatorProfiles.length,
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
appState.reorderOperatorProfiles(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final p = appState.operatorProfiles[index];
|
||||
return ListTile(
|
||||
key: ValueKey(p.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(
|
||||
Icons.drag_handle,
|
||||
color: Colors.grey,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
title: Text(p.name),
|
||||
subtitle: Text(locService.t('operatorProfiles.tagsCount', params: [p.tags.length.toString()])),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('actions.edit')),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('operatorProfiles.deleteOperatorProfile'), style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OperatorProfileEditor(profile: p),
|
||||
),
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteProfileDialog(context, p);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -104,6 +104,9 @@ class TileProviderSection extends StatelessWidget {
|
||||
case 'edit':
|
||||
_editProvider(context, provider);
|
||||
break;
|
||||
case 'clear_cache':
|
||||
_clearProviderCaches(context, provider);
|
||||
break;
|
||||
case 'delete':
|
||||
_deleteProvider(context, provider);
|
||||
break;
|
||||
@@ -120,6 +123,16 @@ class TileProviderSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'clear_cache',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.clear_all),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('tileProviders.clearCaches')),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
@@ -154,6 +167,37 @@ class TileProviderSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _clearProviderCaches(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('tileProviders.clearCaches')),
|
||||
content: Text(locService.t('tileProviders.clearCachesConfirm', params: [provider.name])),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(locService.t('actions.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await context.read<AppState>().clearTileProviderCaches(provider.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('tileProviders.cachesCleared', params: [provider.name])),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(locService.t('tileProviders.clearCaches')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteProvider(BuildContext context, TileProvider provider) {
|
||||
final locService = LocalizationService.instance;
|
||||
showDialog(
|
||||
|
||||
@@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget {
|
||||
subtitle: Text(locService.t('uploadMode.subtitle')),
|
||||
trailing: DropdownButton<UploadMode>(
|
||||
value: appState.uploadMode,
|
||||
// This entire section is gated behind kEnableDevelopmentModes
|
||||
// in osm_account_screen.dart, so all modes are always available here.
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: UploadMode.production,
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../widgets/in_flight_upload_wait_dialog.dart';
|
||||
|
||||
|
||||
class UploadQueueScreen extends StatelessWidget {
|
||||
const UploadQueueScreen({super.key});
|
||||
@@ -84,6 +86,22 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePauseQueueChange(BuildContext context, AppState appState, bool value) async {
|
||||
// Only need to wait for in-flight uploads when turning pause ON.
|
||||
// Resuming (turning it off) can happen immediately.
|
||||
if (!value) {
|
||||
await appState.setPauseQueueProcessing(value);
|
||||
return;
|
||||
}
|
||||
|
||||
await applyQueueSettingChangeRespectingInFlightUploads(
|
||||
context: context,
|
||||
appState: appState,
|
||||
applyChange: () => appState.setPauseQueueProcessing(value),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -166,9 +184,10 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
: (value) => _handlePauseQueueChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class AuthService {
|
||||
|
||||
void setUploadMode(UploadMode mode) {
|
||||
_mode = mode;
|
||||
if (mode == UploadMode.simulate || !kHasOsmSecrets) return;
|
||||
final isSandbox = (mode == UploadMode.sandbox);
|
||||
final authBase = isSandbox
|
||||
? 'https://master.apis.dev.openstreetmap.org'
|
||||
@@ -85,12 +86,12 @@ class AuthService {
|
||||
}
|
||||
try {
|
||||
final token = await _helper.getToken();
|
||||
if (token?.accessToken == null) {
|
||||
if (token.accessToken == null) {
|
||||
log('OAuth error: token null or missing accessToken');
|
||||
return null;
|
||||
}
|
||||
final tokenMap = {
|
||||
'accessToken': token!.accessToken,
|
||||
'accessToken': token.accessToken,
|
||||
'refreshToken': token.refreshToken,
|
||||
};
|
||||
final tokenJson = jsonEncode(tokenMap);
|
||||
@@ -150,7 +151,9 @@ class AuthService {
|
||||
|
||||
// Force a fresh login by clearing stored tokens
|
||||
Future<String?> forceLogin() async {
|
||||
await _helper.removeAllTokens();
|
||||
if (_mode != UploadMode.simulate) {
|
||||
await _helper.removeAllTokens();
|
||||
}
|
||||
_displayName = null;
|
||||
return await login();
|
||||
}
|
||||
|
||||
@@ -225,7 +225,28 @@ class ChangelogService {
|
||||
versionsNeedingMigration.add('1.6.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '1.8.0')) {
|
||||
versionsNeedingMigration.add('1.8.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.1.0')) {
|
||||
versionsNeedingMigration.add('2.1.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) {
|
||||
versionsNeedingMigration.add('2.7.3');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.10.0')) {
|
||||
versionsNeedingMigration.add('2.10.0');
|
||||
}
|
||||
|
||||
if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) {
|
||||
versionsNeedingMigration.add('2.10.5');
|
||||
}
|
||||
|
||||
// Future versions can be added here
|
||||
|
||||
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
|
||||
// versionsNeedingMigration.add('2.0.0');
|
||||
// }
|
||||
|
||||
@@ -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:convert';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/operator_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'http_client.dart';
|
||||
import 'profile_import_service.dart';
|
||||
import 'operator_profile_import_service.dart';
|
||||
import '../screens/profile_editor.dart';
|
||||
import '../screens/operator_profile_editor.dart';
|
||||
|
||||
class DeepLinkService {
|
||||
static final DeepLinkService _instance = DeepLinkService._internal();
|
||||
@@ -13,6 +20,9 @@ class DeepLinkService {
|
||||
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription<Uri>? _linkSubscription;
|
||||
|
||||
/// Callback for HomeScreen to receive node deep links
|
||||
void Function(OsmNode node)? onNodeDeepLink;
|
||||
|
||||
/// Initialize deep link handling (sets up stream listener only)
|
||||
Future<void> init() async {
|
||||
@@ -42,6 +52,9 @@ class DeepLinkService {
|
||||
case 'profiles':
|
||||
_handleProfilesLink(uri);
|
||||
break;
|
||||
case 'node':
|
||||
_handleNodeLink(uri);
|
||||
break;
|
||||
case 'auth':
|
||||
// OAuth links are handled by flutter_web_auth_2
|
||||
debugPrint('[DeepLinkService] OAuth link handled by flutter_web_auth_2');
|
||||
@@ -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
|
||||
void _handleProfilesLink(Uri uri) {
|
||||
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) {
|
||||
// Check for operator profile parameter first
|
||||
final operatorBase64Data = uri.queryParameters['op'];
|
||||
if (operatorBase64Data != null && operatorBase64Data.isNotEmpty) {
|
||||
_handleOperatorProfileImport(operatorBase64Data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise check for device profile parameter
|
||||
final base64Data = uri.queryParameters['p'];
|
||||
|
||||
if (base64Data == null || base64Data.isEmpty) {
|
||||
@@ -107,6 +181,20 @@ class DeepLinkService {
|
||||
_navigateToProfileEditor(profile);
|
||||
}
|
||||
|
||||
/// Handle operator profile import from deep link
|
||||
void _handleOperatorProfileImport(String base64Data) {
|
||||
// Parse operator profile from base64
|
||||
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (operatorProfile == null) {
|
||||
_showError('Invalid operator profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to operator profile editor with the imported profile
|
||||
_navigateToOperatorProfileEditor(operatorProfile);
|
||||
}
|
||||
|
||||
/// Navigate to profile editor with pre-filled profile data
|
||||
void _navigateToProfileEditor(NodeProfile profile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
@@ -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
|
||||
void _showError(String message) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
@@ -8,55 +9,110 @@ import 'package:http/http.dart';
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Thrown when a tile load is cancelled (tile scrolled off screen).
|
||||
/// TileLayerManager skips retry for these — the tile is already gone.
|
||||
class TileLoadCancelledException implements Exception {
|
||||
const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
/// Thrown when a tile is not available offline (no offline area or cache hit).
|
||||
/// TileLayerManager skips retry for these — retrying won't help without network.
|
||||
class TileNotAvailableOfflineException implements Exception {
|
||||
const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
/// Custom tile provider that extends NetworkTileProvider to leverage its
|
||||
/// built-in disk cache, RetryClient, ETag revalidation, and abort support,
|
||||
/// while routing URLs through our TileType logic and supporting offline tiles.
|
||||
///
|
||||
/// Each instance is configured for a specific tile provider/type combination
|
||||
/// with frozen config — no AppState lookups at request time (except for the
|
||||
/// global offlineMode toggle).
|
||||
///
|
||||
/// Two runtime paths:
|
||||
/// 1. **Common path** (no offline areas for current provider): delegates to
|
||||
/// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider
|
||||
/// pipeline (disk cache, ETag revalidation, RetryClient, abort support).
|
||||
/// 2. **Offline-first path** (has offline areas or offline mode): returns
|
||||
/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls
|
||||
/// back to HTTP via shared RetryClient on miss.
|
||||
/// DeflockOfflineTileImageProvider — checks disk cache and local tiles
|
||||
/// first, falls back to HTTP via shared RetryClient on miss.
|
||||
class DeflockTileProvider extends NetworkTileProvider {
|
||||
/// The shared HTTP client we own. We keep a reference because
|
||||
/// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient
|
||||
/// will be false (we passed it in), so super.dispose() won't close it.
|
||||
final Client _sharedHttpClient;
|
||||
|
||||
DeflockTileProvider._({required Client httpClient})
|
||||
: _sharedHttpClient = httpClient,
|
||||
/// Frozen config for this provider instance.
|
||||
final String providerId;
|
||||
final models.TileType tileType;
|
||||
final String? apiKey;
|
||||
|
||||
/// Opaque fingerprint of the config this provider was created with.
|
||||
/// Used by [TileLayerManager] to detect config drift after edits.
|
||||
final String configFingerprint;
|
||||
|
||||
/// Caching provider for the offline-first path. The same instance is passed
|
||||
/// to super for the common path — we keep a reference here so we can also
|
||||
/// use it in [DeflockOfflineTileImageProvider].
|
||||
final MapCachingProvider? _cachingProvider;
|
||||
|
||||
/// Called when a tile loads successfully via the network in the offline-first
|
||||
/// path. Used by [TileLayerManager] to reset exponential backoff.
|
||||
VoidCallback? onNetworkSuccess;
|
||||
|
||||
// ignore: use_super_parameters
|
||||
DeflockTileProvider._({
|
||||
required Client httpClient,
|
||||
required this.providerId,
|
||||
required this.tileType,
|
||||
this.apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
this.configFingerprint = '',
|
||||
}) : _sharedHttpClient = httpClient,
|
||||
_cachingProvider = cachingProvider,
|
||||
super(
|
||||
httpClient: httpClient,
|
||||
silenceExceptions: true,
|
||||
cachingProvider: cachingProvider,
|
||||
// Let errors propagate so flutter_map marks tiles as failed
|
||||
// (loadError = true) rather than caching transparent images as
|
||||
// "successfully loaded". The TileLayerManager wires a reset stream
|
||||
// that retries failed tiles after a debounced delay.
|
||||
silenceExceptions: false,
|
||||
);
|
||||
|
||||
factory DeflockTileProvider() {
|
||||
factory DeflockTileProvider({
|
||||
required String providerId,
|
||||
required models.TileType tileType,
|
||||
String? apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
VoidCallback? onNetworkSuccess,
|
||||
String configFingerprint = '',
|
||||
}) {
|
||||
final client = UserAgentClient(RetryClient(Client()));
|
||||
return DeflockTileProvider._(httpClient: client);
|
||||
return DeflockTileProvider._(
|
||||
httpClient: client,
|
||||
providerId: providerId,
|
||||
tileType: tileType,
|
||||
apiKey: apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
configFingerprint: configFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
// Fallback to base implementation if no provider configured
|
||||
return super.getTileUrl(coordinates, options);
|
||||
}
|
||||
|
||||
return selectedTileType.getTileUrl(
|
||||
return tileType.getTileUrl(
|
||||
coordinates.z,
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +122,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
TileLayer options,
|
||||
Future<void> cancelLoading,
|
||||
) {
|
||||
if (!_shouldCheckOfflineCache()) {
|
||||
if (!_shouldCheckOfflineCache(coordinates.z)) {
|
||||
// Common path: no offline areas — delegate to NetworkTileProvider's
|
||||
// full pipeline (disk cache, ETag, RetryClient, abort support).
|
||||
return super.getImageWithCancelLoadingSupport(
|
||||
@@ -77,20 +133,18 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
}
|
||||
|
||||
// Offline-first path: check local tiles first, fall back to network.
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockOfflineTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
httpClient: _sharedHttpClient,
|
||||
headers: headers,
|
||||
cancelLoading: cancelLoading,
|
||||
isOfflineOnly: appState.offlineMode,
|
||||
isOfflineOnly: AppState.instance.offlineMode,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeId: tileType.id,
|
||||
tileUrl: getTileUrl(coordinates, options),
|
||||
cachingProvider: _cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,44 +155,67 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
///
|
||||
/// This avoids the offline-first path (and its filesystem searches) when
|
||||
/// browsing online with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache() {
|
||||
final appState = AppState.instance;
|
||||
|
||||
bool _shouldCheckOfflineCache(int zoom) {
|
||||
// Always use offline path in offline mode
|
||||
if (appState.offlineMode) {
|
||||
if (AppState.instance.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only use offline path if we have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// at this zoom level — tiles outside any area's zoom range go through the
|
||||
// common NetworkTileProvider path for better performance.
|
||||
final offlineService = OfflineAreaService();
|
||||
return offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
return offlineService.hasOfflineAreasForProviderAtZoom(
|
||||
providerId,
|
||||
tileType.id,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
await super.dispose();
|
||||
} finally {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
// Only call super — do NOT close _sharedHttpClient here.
|
||||
// flutter_map calls dispose() whenever the TileLayer widget is recycled
|
||||
// (e.g. provider switch causes a new FlutterMap key), but
|
||||
// TileLayerManager caches and reuses provider instances across switches.
|
||||
// Closing the HTTP client here would leave the cached instance broken —
|
||||
// all future tile requests would fail with "Client closed".
|
||||
//
|
||||
// Since we passed our own httpClient to NetworkTileProvider,
|
||||
// _isInternallyCreatedClient is false, so super.dispose() won't close it
|
||||
// either. The client is closed in [shutdown], called by
|
||||
// TileLayerManager.dispose() when the map is truly torn down.
|
||||
await super.dispose();
|
||||
}
|
||||
|
||||
/// Permanently close the HTTP client. Called by [TileLayerManager.dispose]
|
||||
/// when the map widget is being torn down — NOT by flutter_map's widget
|
||||
/// recycling.
|
||||
void shutdown() {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image provider for the offline-first path.
|
||||
///
|
||||
/// Tries fetchLocalTile() first. On miss (and if online), falls back to an
|
||||
/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and
|
||||
/// returns transparent tiles on errors (consistent with silenceExceptions).
|
||||
/// Checks disk cache and offline areas before falling back to the network.
|
||||
/// Caches successful network fetches to disk so panning back doesn't re-fetch.
|
||||
/// On cancellation, lets in-flight downloads complete and caches the result
|
||||
/// (fire-and-forget) instead of discarding downloaded bytes.
|
||||
///
|
||||
/// **Online mode flow:**
|
||||
/// 1. Disk cache (fast hash-based file read) → hit + fresh → return
|
||||
/// 2. Offline areas (file scan) → hit → return
|
||||
/// 3. Network fetch with conditional headers from stale cache entry
|
||||
/// 4. On cancel → fire-and-forget cache write for the in-flight download
|
||||
/// 5. On 304 → return stale cached bytes, update cache metadata
|
||||
/// 6. On 200 → cache to disk, decode and return
|
||||
/// 7. On error → throw (flutter_map marks tile as failed)
|
||||
///
|
||||
/// **Offline mode flow:**
|
||||
/// 1. Offline areas (primary source — guaranteed available)
|
||||
/// 2. Disk cache (tiles cached from previous online sessions)
|
||||
/// 3. Throw if both miss (flutter_map marks tile as failed)
|
||||
class DeflockOfflineTileImageProvider
|
||||
extends ImageProvider<DeflockOfflineTileImageProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
@@ -150,6 +227,8 @@ class DeflockOfflineTileImageProvider
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
final String tileUrl;
|
||||
final MapCachingProvider? cachingProvider;
|
||||
final VoidCallback? onNetworkSuccess;
|
||||
|
||||
const DeflockOfflineTileImageProvider({
|
||||
required this.coordinates,
|
||||
@@ -161,6 +240,8 @@ class DeflockOfflineTileImageProvider
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
required this.tileUrl,
|
||||
this.cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -173,19 +254,47 @@ class DeflockOfflineTileImageProvider
|
||||
ImageStreamCompleter loadImage(
|
||||
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
final codecFuture = _loadAsync(key, decode, chunkEvents);
|
||||
|
||||
codecFuture.whenComplete(() {
|
||||
chunkEvents.close();
|
||||
});
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: codecFuture,
|
||||
// Chain whenComplete into the codec future so there's a single future
|
||||
// for MultiFrameImageStreamCompleter to handle. Without this, the
|
||||
// whenComplete creates an orphaned future whose errors go unhandled.
|
||||
codec: _loadAsync(key, decode, chunkEvents).whenComplete(() {
|
||||
chunkEvents.close();
|
||||
}),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Try to read a tile from the disk cache. Returns null on miss or error.
|
||||
Future<CachedMapTile?> _getCachedTile() async {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return null;
|
||||
try {
|
||||
return await cachingProvider!.getTile(tileUrl);
|
||||
} on CachedMapTileReadFailure {
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a tile to the disk cache (best-effort, never throws).
|
||||
void _putCachedTile({
|
||||
required Map<String, String> responseHeaders,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return;
|
||||
try {
|
||||
final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders);
|
||||
cachingProvider!
|
||||
.putTile(url: tileUrl, metadata: metadata, bytes: bytes)
|
||||
.catchError((_) {});
|
||||
} catch (_) {
|
||||
// Best-effort: never fail the tile load due to cache write errors.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec> _loadAsync(
|
||||
DeflockOfflineTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
@@ -194,78 +303,169 @@ class DeflockOfflineTileImageProvider
|
||||
Future<Codec> decodeBytes(Uint8List bytes) =>
|
||||
ImmutableBuffer.fromUint8List(bytes).then(decode);
|
||||
|
||||
Future<Codec> transparent() =>
|
||||
decodeBytes(TileProvider.transparentImage);
|
||||
// Track cancellation synchronously via Completer so the catch block
|
||||
// can reliably check it without microtask ordering races.
|
||||
final cancelled = Completer<void>();
|
||||
cancelLoading.then((_) {
|
||||
if (!cancelled.isCompleted) cancelled.complete();
|
||||
}).ignore();
|
||||
|
||||
try {
|
||||
// Track cancellation
|
||||
bool cancelled = false;
|
||||
cancelLoading.then((_) => cancelled = true);
|
||||
|
||||
// Try local tile first — pass captured IDs to avoid a race if the
|
||||
// user switches provider while this async load is in flight.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network if online
|
||||
if (isOfflineOnly) {
|
||||
return await _loadOffline(decodeBytes, cancelled);
|
||||
}
|
||||
|
||||
if (cancelled) return await transparent();
|
||||
if (isOfflineOnly) return await transparent();
|
||||
|
||||
// Fall back to network via shared RetryClient.
|
||||
// Race the download against cancelLoading so we stop waiting if the
|
||||
// tile is pruned mid-flight (the underlying TCP connection is cleaned
|
||||
// up naturally by the shared client).
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (statusCode: response.statusCode, bytes: bytes);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))),
|
||||
]);
|
||||
|
||||
if (cancelled || result.statusCode == 0) return await transparent();
|
||||
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
return await transparent();
|
||||
return await _loadOnline(decodeBytes, cancelled);
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses
|
||||
if (!e.toString().contains('offline')) {
|
||||
debugPrint(
|
||||
'[DeflockTileProvider] Offline-first tile failed '
|
||||
'${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'(${e.runtimeType})');
|
||||
// Cancelled tiles throw — flutter_map handles the error silently.
|
||||
// Preserve TileNotAvailableOfflineException even if the tile was also
|
||||
// cancelled — it has distinct semantics (genuine cache miss) that
|
||||
// matter for diagnostics and future UI indicators.
|
||||
if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) {
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
|
||||
.then(decode);
|
||||
|
||||
// Let real errors propagate so flutter_map marks loadError = true
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Online mode: disk cache → offline areas → network (with caching).
|
||||
Future<Codec> _loadOnline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check disk cache — fast hash-based file read.
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null && !cachedTile.metadata.isStale) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 2. Check offline areas — file scan per area.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network
|
||||
}
|
||||
|
||||
// 3. If cancelled before network, bail.
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
|
||||
// 4. Network fetch with conditional headers from stale cache entry.
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
if (cachedTile != null) {
|
||||
if (cachedTile.metadata.lastModified case final lastModified?) {
|
||||
request.headers[HttpHeaders.ifModifiedSinceHeader] =
|
||||
HttpDate.format(lastModified);
|
||||
}
|
||||
if (cachedTile.metadata.etag case final etag?) {
|
||||
request.headers[HttpHeaders.ifNoneMatchHeader] = etag;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Race the download against cancelLoading.
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (
|
||||
statusCode: response.statusCode,
|
||||
bytes: bytes,
|
||||
headers: response.headers,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (
|
||||
statusCode: 0,
|
||||
bytes: Uint8List(0),
|
||||
headers: <String, String>{},
|
||||
)),
|
||||
]);
|
||||
|
||||
// 6. On cancel — fire-and-forget cache write for the in-flight download
|
||||
// instead of discarding the downloaded bytes.
|
||||
if (cancelled.isCompleted || result.statusCode == 0) {
|
||||
networkFuture.then((r) {
|
||||
if (r.statusCode == 200 && r.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: r.headers, bytes: r.bytes);
|
||||
}
|
||||
}).ignore();
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
// 7. On 304 Not Modified → return stale cached bytes, update metadata.
|
||||
if (result.statusCode == HttpStatus.notModified && cachedTile != null) {
|
||||
_putCachedTile(responseHeaders: result.headers);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 8. On 200 OK → cache to disk, decode and return.
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: result.headers, bytes: result.bytes);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
// 9. Network error — throw so flutter_map marks the tile as failed.
|
||||
// Don't include tileUrl in the exception — it may contain API keys.
|
||||
throw HttpException(
|
||||
'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'returned status ${result.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Offline mode: offline areas → disk cache → throw.
|
||||
Future<Codec> _loadOffline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check offline areas (primary source — guaranteed available).
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} on TileLoadCancelledException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
// Local miss — fall through to disk cache
|
||||
}
|
||||
|
||||
// 2. Check disk cache (tiles cached from previous online sessions).
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 3. Both miss — throw so flutter_map marks the tile as failed.
|
||||
throw const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DeflockOfflineTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
other.tileTypeId == tileTypeId &&
|
||||
other.isOfflineOnly == isOfflineOnly;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
int get hashCode =>
|
||||
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ class MapDataProvider {
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int maxResults = 0, // 0 = no limit for offline downloads
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
|
||||
if (AppState.instance.offlineMode) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
@@ -7,6 +8,7 @@ import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../http_client.dart';
|
||||
import '../service_policy.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
||||
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||
@@ -58,28 +60,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await _client.get(Uri.parse(url));
|
||||
|
||||
|
||||
// Enforce max 2 concurrent download threads per OSM API usage policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client.get(Uri.parse(url));
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
return nodes;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] Exception: $e');
|
||||
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
|
||||
@@ -19,7 +23,7 @@ Future<List<int>> fetchLocalTile({
|
||||
final appState = AppState.instance;
|
||||
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
|
||||
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
|
||||
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -28,20 +32,21 @@ Future<List<int>> fetchLocalTile({
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
|
||||
if (hasTile) {
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
if (await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
}
|
||||
// O(1) bounds check instead of enumerating all tiles at this zoom level
|
||||
if (!tileInBounds(area.bounds, z, x, y)) continue;
|
||||
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
try {
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.notFound) continue;
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
} on FileSystemException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
@@ -51,6 +56,34 @@ Future<List<int>> fetchLocalTile({
|
||||
return await candidates.first.file.readAsBytes();
|
||||
}
|
||||
|
||||
/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds.
|
||||
///
|
||||
/// Uses the same Mercator projection math as [latLonToTile] in
|
||||
/// offline_tile_utils.dart, but only computes the bounding tile range
|
||||
/// instead of enumerating every tile at that zoom level.
|
||||
///
|
||||
/// Note: Y axis is inverted in tile coordinates — north = lower Y.
|
||||
@visibleForTesting
|
||||
bool tileInBounds(LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final west = bounds.west;
|
||||
final east = bounds.east;
|
||||
final north = bounds.north;
|
||||
final south = bounds.south;
|
||||
|
||||
final minX = ((west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((east + 180.0) / 360.0 * n).floor();
|
||||
// North → lower Y (Mercator projection inverts latitude)
|
||||
final minY = ((1.0 - log(tan(north * pi / 180.0) +
|
||||
1.0 / cos(north * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
final maxY = ((1.0 - log(tan(south * pi / 180.0) +
|
||||
1.0 / cos(south * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
String _tilePath(String areaDir, int z, int x, int y) =>
|
||||
'$areaDir/tiles/$z/$x/$y.png';
|
||||
|
||||
|
||||
@@ -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(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles, {
|
||||
@@ -238,7 +249,7 @@ class NodeDataManager extends ChangeNotifier {
|
||||
return nodes;
|
||||
|
||||
} 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) {
|
||||
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
||||
return [];
|
||||
@@ -254,13 +265,20 @@ class NodeDataManager extends ChangeNotifier {
|
||||
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
|
||||
|
||||
} on RateLimitError {
|
||||
// Rate limited - wait and return empty
|
||||
debugPrint('[NodeDataManager] Rate limited, backing off');
|
||||
// Rate limited - back off without splitting the area. Splitting here
|
||||
// would multiply request volume against a server that just told us to
|
||||
// slow down. We still show the same "slow" status indicator so the
|
||||
// user gets feedback, but there is no recursive quadrant fetching.
|
||||
debugPrint('[NodeDataManager] Rate limited, backing off (no area splitting)');
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.setSplitting();
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Fetch data by splitting area into quadrants
|
||||
Future<List<OsmNode>> _fetchSplitAreas(
|
||||
LatLngBounds bounds,
|
||||
@@ -372,8 +390,13 @@ class NodeDataManager extends ChangeNotifier {
|
||||
|
||||
/// Load all offline nodes into cache (call at app startup)
|
||||
Future<void> preloadOfflineNodes() async {
|
||||
// Skip entirely when the user has disabled offline features — this call
|
||||
// isn't gated by OfflineAreaService.hasOfflineAreasForProvider*() since
|
||||
// it fetches directly via fetchLocalNodes, so we guard it explicitly.
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return;
|
||||
try {
|
||||
final offlineAreaService = OfflineAreaService();
|
||||
|
||||
|
||||
for (final area in offlineAreaService.offlineAreas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
|
||||
@@ -29,18 +29,52 @@ class OfflineAreaService {
|
||||
/// Fast check: do we have any completed offline areas for a specific provider/type?
|
||||
/// This allows smart cache routing without expensive filesystem searches.
|
||||
/// Safe to call before initialization - returns false if not yet initialized.
|
||||
///
|
||||
/// Returns false immediately if the user has disabled offline features via
|
||||
/// the master "Enable Offline Features" toggle, regardless of whether area
|
||||
/// data still exists on disk — this fully gates the offline-first tile
|
||||
/// pipeline without needing to touch fetchLocalTile/fetchLocalNodes.
|
||||
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
|
||||
return _areas.any((area) =>
|
||||
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId
|
||||
);
|
||||
}
|
||||
|
||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||
/// offline-first path for tiles that will never be found locally.
|
||||
///
|
||||
/// Also returns false immediately when offline features are disabled (see
|
||||
/// [hasOfflineAreasForProvider] for rationale).
|
||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||
if (!AppState.instance.offlineFeaturesEnabled) return false;
|
||||
if (!_initialized) return false;
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId &&
|
||||
zoom >= area.minZoom &&
|
||||
zoom <= area.maxZoom
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Reset service state and inject areas for unit tests.
|
||||
@visibleForTesting
|
||||
void setAreasForTesting(List<OfflineArea> areas) {
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(areas);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
@@ -213,7 +247,7 @@ class OfflineAreaService {
|
||||
area = OfflineArea(
|
||||
id: id,
|
||||
name: name ?? area?.name ?? '',
|
||||
bounds: bounds,
|
||||
bounds: normalizeBounds(bounds),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_node.dart';
|
||||
import 'offline_tile_utils.dart' show normalizeBounds;
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -71,10 +72,10 @@ class OfflineArea {
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
final bounds = LatLngBounds(
|
||||
final bounds = normalizeBounds(LatLngBounds(
|
||||
LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']),
|
||||
LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']),
|
||||
);
|
||||
));
|
||||
return OfflineArea(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
|
||||
@@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero)
|
||||
/// spans are expanded by epsilon. Call this before storing bounds so that
|
||||
/// `tileInBounds` and [computeTileList] see consistent corner ordering.
|
||||
LatLngBounds normalizeBounds(LatLngBounds bounds) {
|
||||
const double epsilon = 1e-7;
|
||||
double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
// Expand degenerate/flat areas a hair
|
||||
var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
if ((latMax - latMin).abs() < epsilon) {
|
||||
latMin -= epsilon;
|
||||
latMax += epsilon;
|
||||
@@ -20,6 +21,16 @@ Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
lonMin -= epsilon;
|
||||
lonMax += epsilon;
|
||||
}
|
||||
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
|
||||
}
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
final normalized = normalizeBounds(bounds);
|
||||
final double latMin = normalized.south;
|
||||
final double latMax = normalized.north;
|
||||
final double lonMin = normalized.west;
|
||||
final double lonMax = normalized.east;
|
||||
for (int z = zMin; z <= zMax; z++) {
|
||||
final n = pow(2, z).toInt();
|
||||
final minTileRaw = latLonToTileRaw(latMin, lonMin, z);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+252
-110
@@ -8,149 +8,288 @@ import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Simple Overpass API client with proper HTTP retry logic.
|
||||
/// Simple Overpass API client with retry and fallback logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String _endpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter';
|
||||
static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const _policy = ResiliencePolicy(
|
||||
maxRetries: 3,
|
||||
httpTimeout: Duration(seconds: 45),
|
||||
);
|
||||
|
||||
final http.Client _client;
|
||||
/// Optional override endpoint. When null, uses [defaultEndpoint].
|
||||
final String? _endpointOverride;
|
||||
|
||||
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
OverpassService({http.Client? client, String? endpoint})
|
||||
: _client = client ?? UserAgentClient(),
|
||||
_endpointOverride = endpoint;
|
||||
|
||||
/// Resolve the primary endpoint: constructor override or default.
|
||||
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with proper retry logic.
|
||||
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
|
||||
/// Fetch surveillance nodes from Overpass API with retry and fallback.
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NodeLimitError] when the query would exceed Overpass's hard 50k node
|
||||
/// limit. This is the *only* case where the caller should split the
|
||||
/// query area into smaller regions, since it's a deterministic function
|
||||
/// of how much data lives in the requested bounds.
|
||||
/// - [RateLimitError] when rate limited (HTTP 429 or equivalent). Callers
|
||||
/// should back off, not split/retry — splitting would only amplify load
|
||||
/// on a server that just told us to slow down.
|
||||
/// - [NetworkError] for timeouts and other retryable HTTP/network failures.
|
||||
/// Timeouts are usually a sign of server load or an expensive query, not
|
||||
/// "too much area" — retrying with a hail of smaller sub-requests makes
|
||||
/// the problem worse, so timeouts are treated like any other transient
|
||||
/// network failure (handled by the existing retry/backoff/fallback logic).
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int maxRetries = 3,
|
||||
ResiliencePolicy? policy,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
|
||||
final query = _buildQuery(bounds, profiles);
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
|
||||
|
||||
final response = await _client.post(
|
||||
Uri.parse(_endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(kOverpassQueryTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - caller should split area
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded, area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Timeout error - also try splitting (complex query)
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timeout, area should be split');
|
||||
throw NodeLimitError('Query timed out - area too complex');
|
||||
}
|
||||
|
||||
// Rate limit - throw immediately, don't retry
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
// Other HTTP errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
|
||||
} catch (e) {
|
||||
// Handle specific error types without retry
|
||||
if (e is NodeLimitError || e is RateLimitError) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// Network/timeout errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
|
||||
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw NetworkError('Network error after $maxRetries retries: $e');
|
||||
}
|
||||
}
|
||||
|
||||
throw NetworkError('Max retries exceeded');
|
||||
final endpoint = _primaryEndpoint;
|
||||
final canFallback = _endpointOverride == null;
|
||||
final effectivePolicy = policy ?? _policy;
|
||||
|
||||
return executeWithFallback<List<OsmNode>>(
|
||||
primaryUrl: endpoint,
|
||||
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||
classifyError: _classifyError,
|
||||
policy: effectivePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build Overpass QL query for given bounds and profiles
|
||||
|
||||
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
|
||||
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
|
||||
debugPrint('[OverpassService] POST $endpoint');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
Uri.parse(endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(policy.httpTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
final errorBody = response.body;
|
||||
|
||||
// Node limit error - a deterministic result-set size limit. This is
|
||||
// the one case where splitting the query area into smaller regions is
|
||||
// the correct fix, since it directly reduces nodes-per-request.
|
||||
if (response.statusCode == 400 &&
|
||||
(errorBody.contains('too many nodes') && errorBody.contains('50000'))) {
|
||||
debugPrint('[OverpassService] Node limit exceeded (50k), area should be split');
|
||||
throw NodeLimitError('Query exceeded 50k node limit');
|
||||
}
|
||||
|
||||
// Rate limit - back off, don't split or hammer the server with more requests.
|
||||
if (response.statusCode == 429 ||
|
||||
errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests')) {
|
||||
debugPrint('[OverpassService] Rate limited by Overpass');
|
||||
throw RateLimitError('Rate limited by Overpass API');
|
||||
}
|
||||
|
||||
// Timeout / runtime limit exceeded - treat as a plain retryable network
|
||||
// error. This is usually server load or query cost, not "area too
|
||||
// big" — splitting into up to 64 sub-requests would only make load
|
||||
// on the server worse.
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[OverpassService] Query timed out');
|
||||
throw NetworkError('Query timed out');
|
||||
}
|
||||
|
||||
throw NetworkError('HTTP ${response.statusCode}: $errorBody');
|
||||
} catch (e) {
|
||||
if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
|
||||
rethrow;
|
||||
}
|
||||
throw NetworkError('Network error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static ErrorDisposition _classifyError(Object error) {
|
||||
if (error is NodeLimitError) return ErrorDisposition.abort;
|
||||
if (error is RateLimitError) return ErrorDisposition.fallback;
|
||||
return ErrorDisposition.retry;
|
||||
}
|
||||
|
||||
/// Build Overpass QL query for given bounds and profiles.
|
||||
///
|
||||
/// Profiles are deduplicated first: if one profile's non-empty tags are a
|
||||
/// subset of another's (with identical values), the broader profile's query
|
||||
/// clause already returns every node the narrower one would, so the
|
||||
/// narrower clause is redundant and can be dropped. This directly reduces
|
||||
/// query size/complexity without changing the result set — client-side
|
||||
/// filtering (matching returned nodes against the full profile list) still
|
||||
/// happens independently, so nothing is lost.
|
||||
String _buildQuery(LatLngBounds bounds, List<NodeProfile> profiles) {
|
||||
final nodeClauses = profiles.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
final profilesToQuery = _deduplicateProfilesForQuery(profiles);
|
||||
|
||||
if (profilesToQuery.length < profiles.length) {
|
||||
debugPrint(
|
||||
'[OverpassService] Deduplicated ${profiles.length} profiles to ${profilesToQuery.length} for query efficiency');
|
||||
}
|
||||
|
||||
// Per-profile tag filter strings (e.g. ["man_made"="surveillance"]["surveillance:type"="ALPR"]),
|
||||
// excluding empty tag values. Reused twice below: once to select our nodes
|
||||
// in the bbox, and again to re-select just our nodes out of the pool of
|
||||
// way/relation-connected nodes (which may include unrelated node types).
|
||||
final tagFilterStrings = profilesToQuery.map((profile) {
|
||||
return profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
}).toList();
|
||||
|
||||
final bboxStr = '${bounds.southWest.latitude},${bounds.southWest.longitude},'
|
||||
'${bounds.northEast.latitude},${bounds.northEast.longitude}';
|
||||
|
||||
final allNodesClauses =
|
||||
tagFilterStrings.map((filters) => 'node$filters($bboxStr);').join('\n ');
|
||||
final resNodesClauses =
|
||||
tagFilterStrings.map((filters) => 'node.resNodes$filters;').join('\n ');
|
||||
|
||||
// Query strategy:
|
||||
// 1. Find all matching nodes in bbox -> .allNodes, output them with tags ("out body").
|
||||
// 2. Find any way/relation that references one of .allNodes ("bn." = "by node"),
|
||||
// then pull the node IDs those ways/relations reference -> .wayNodes/.relNodes.
|
||||
// (`node(w)`/`node(r)` on a way/relation set returns only node IDs, not
|
||||
// full tags/coords, keeping the query/response cheap.)
|
||||
// 3. Re-filter that combined node pool down to just our own matching
|
||||
// surveillance nodes (.resNodes may contain unrelated node types too).
|
||||
// 4. `convert restriction ::id = id();` emits each of those node IDs as a
|
||||
// lightweight synthetic `{"type":"restriction","id":<nodeId>}` element —
|
||||
// a flat, unambiguous list of which node IDs are "constrained" (part of
|
||||
// some way/relation), without needing the full way/relation node lists.
|
||||
return '''
|
||||
[out:json][timeout:${kOverpassQueryTimeout.inSeconds}];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body;
|
||||
$allNodesClauses
|
||||
)->.allNodes;
|
||||
.allNodes out body;
|
||||
way(bn.allNodes); node(w)->.wayNodes;
|
||||
relation(bn.allNodes); node(r)->.relNodes;
|
||||
(.wayNodes;.relNodes;)->.resNodes;
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
$resNodesClauses
|
||||
);
|
||||
out skel;
|
||||
convert restriction ::id = id();
|
||||
out;
|
||||
''';
|
||||
}
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects
|
||||
|
||||
|
||||
/// Deduplicate profiles for Overpass queries by removing profiles that are
|
||||
/// subsumed by others. A profile A subsumes profile B if all of A's
|
||||
/// non-empty tags exist in B with identical values — meaning every node
|
||||
/// matching B's filter also matches A's filter, so B's clause is redundant.
|
||||
List<NodeProfile> _deduplicateProfilesForQuery(List<NodeProfile> profiles) {
|
||||
if (profiles.length <= 1) return profiles;
|
||||
|
||||
final result = <NodeProfile>[];
|
||||
|
||||
for (final candidate in profiles) {
|
||||
// Skip profiles that only have empty tags - they would match
|
||||
// everything and shouldn't be treated as subsuming or subsumable.
|
||||
final candidateNonEmptyTags = candidate.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (candidateNonEmptyTags.isEmpty) continue;
|
||||
|
||||
// Check if any existing profile in our result subsumes this candidate
|
||||
bool isSubsumed = false;
|
||||
for (final existing in result) {
|
||||
if (_profileSubsumes(existing, candidate)) {
|
||||
isSubsumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSubsumed) {
|
||||
// This candidate is not subsumed, so add it. But first, remove any
|
||||
// existing profiles that this candidate subsumes (candidate is
|
||||
// broader, so those clauses are now redundant).
|
||||
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
|
||||
result.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Safety check: if dedup removed everything (e.g. all-empty-tag
|
||||
// profiles), fall back to the original list rather than sending an
|
||||
// empty query.
|
||||
return result.isNotEmpty ? result : profiles;
|
||||
}
|
||||
|
||||
/// Check if [broaderProfile] subsumes [specificProfile]: true if all
|
||||
/// non-empty tags in [broaderProfile] exist in [specificProfile] with
|
||||
/// identical values.
|
||||
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
|
||||
final broaderTags = Map.fromEntries(
|
||||
broaderProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
|
||||
);
|
||||
final specificTags = Map.fromEntries(
|
||||
specificProfile.tags.entries.where((entry) => entry.value.trim().isNotEmpty),
|
||||
);
|
||||
|
||||
// If broader has no non-empty tags, it doesn't subsume anything (it
|
||||
// would match everything, which isn't a meaningful "broader filter").
|
||||
if (broaderTags.isEmpty) return false;
|
||||
|
||||
// If broader has more non-empty tags than specific, it can't subsume it.
|
||||
if (broaderTags.length > specificTags.length) return false;
|
||||
|
||||
for (final entry in broaderTags.entries) {
|
||||
if (specificTags[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/// Parse Overpass JSON response into OsmNode objects.
|
||||
///
|
||||
/// The query (see [_buildQuery]) produces two kinds of elements:
|
||||
/// - `type: "node"` — our matched surveillance nodes, with full tags/coords
|
||||
/// (from `.allNodes out body;`).
|
||||
/// - `type: "restriction"` — synthetic elements (from
|
||||
/// `convert restriction ::id = id();`) whose `id` is the OSM node ID of
|
||||
/// one of our nodes that Overpass determined is referenced by some way
|
||||
/// or relation. These carry no lat/lon/tags — just the id — and exist
|
||||
/// purely to tell us which node IDs are "constrained".
|
||||
List<OsmNode> _parseResponse(String responseBody) {
|
||||
final data = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
|
||||
// First pass: collect surveillance nodes and constrained-node markers.
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
|
||||
if (type == 'node') {
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// Mark referenced nodes as constrained
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
for (final ref in refs) {
|
||||
final nodeId = ref is int ? ref : int.tryParse(ref.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
} else if (type == 'restriction') {
|
||||
final rawId = element['id'];
|
||||
final nodeId = rawId is int ? rawId : int.tryParse(rawId.toString());
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Second pass: create OsmNode objects
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
@@ -161,13 +300,16 @@ out skel;
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final String message;
|
||||
NodeLimitError(this.message);
|
||||
@@ -183,10 +325,10 @@ class RateLimitError extends Error {
|
||||
String toString() => 'RateLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown for network/HTTP issues - retryable
|
||||
/// Error thrown for network/HTTP issues (including timeouts) - retryable
|
||||
class NetworkError extends Error {
|
||||
final String message;
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
class ProfileImportService {
|
||||
// Maximum size for base64 encoded profile data (approx 50KB decoded)
|
||||
@@ -72,13 +73,17 @@ class ProfileImportService {
|
||||
final requiresDirection = data['requiresDirection'] ?? true;
|
||||
final submittable = data['submittable'] ?? true;
|
||||
|
||||
// Parse FOV if provided
|
||||
// Parse FOV if provided, applying restrictions based on dev config
|
||||
double? fov;
|
||||
if (data['fov'] != null) {
|
||||
if (data['fov'] is num) {
|
||||
final fovValue = (data['fov'] as num).toDouble();
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'provider_tile_cache_store.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Factory and registry for per-provider [ProviderTileCacheStore] instances.
|
||||
///
|
||||
/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`.
|
||||
/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to
|
||||
/// resolve the platform cache directory. After init, [getOrCreate] is
|
||||
/// synchronous — the cache store lazily creates its directory on first write.
|
||||
class ProviderTileCacheManager {
|
||||
static final Map<String, ProviderTileCacheStore> _stores = {};
|
||||
static String? _baseCacheDir;
|
||||
|
||||
/// Resolve the platform cache directory. Call once at startup.
|
||||
static Future<void> init() async {
|
||||
if (_baseCacheDir != null) return;
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
_baseCacheDir = p.join(cacheDir.path, 'tile_cache');
|
||||
}
|
||||
|
||||
/// Whether the manager has been initialized.
|
||||
static bool get isInitialized => _baseCacheDir != null;
|
||||
|
||||
/// Get or create a cache store for a specific provider/tile type combination.
|
||||
///
|
||||
/// Synchronous after [init] has been called. The cache store lazily creates
|
||||
/// its directory on first write.
|
||||
static ProviderTileCacheStore getOrCreate({
|
||||
required String providerId,
|
||||
required String tileTypeId,
|
||||
required ServicePolicy policy,
|
||||
int? maxCacheBytes,
|
||||
}) {
|
||||
if (_baseCacheDir == null) {
|
||||
throw StateError(
|
||||
'ProviderTileCacheManager.init() must be called before getOrCreate()',
|
||||
);
|
||||
}
|
||||
|
||||
final key = '$providerId/$tileTypeId';
|
||||
if (_stores.containsKey(key)) return _stores[key]!;
|
||||
|
||||
final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId);
|
||||
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: cacheDir,
|
||||
maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024,
|
||||
overrideFreshAge: policy.minCacheTtl,
|
||||
);
|
||||
|
||||
_stores[key] = store;
|
||||
return store;
|
||||
}
|
||||
|
||||
/// Delete a specific provider's cache directory and remove the store.
|
||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||
final key = '$providerId/$tileTypeId';
|
||||
final store = _stores[key];
|
||||
if (store != null) {
|
||||
// Use the store's clear method to properly reset its internal state
|
||||
await store.clear();
|
||||
// Don't remove from registry - let it be reused with clean state
|
||||
} else if (_baseCacheDir != null) {
|
||||
// Fallback for stores not in registry
|
||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||
if (await cacheDir.exists()) {
|
||||
await cacheDir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get estimated cache sizes for all active stores.
|
||||
///
|
||||
/// Returns a map of `providerId/tileTypeId` → size in bytes.
|
||||
static Future<Map<String, int>> getCacheSizes() async {
|
||||
final sizes = <String, int>{};
|
||||
for (final entry in _stores.entries) {
|
||||
sizes[entry.key] = await entry.value.estimatedSizeBytes;
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/// Remove a store from the registry (e.g., when a provider is disposed).
|
||||
static void unregister(String providerId, String tileTypeId) {
|
||||
_stores.remove('$providerId/$tileTypeId');
|
||||
}
|
||||
|
||||
/// Clear all stores and reset the registry (for testing).
|
||||
@visibleForTesting
|
||||
static Future<void> resetAll() async {
|
||||
for (final store in _stores.values) {
|
||||
await store.clear();
|
||||
}
|
||||
_stores.clear();
|
||||
_baseCacheDir = null;
|
||||
}
|
||||
|
||||
/// Set the base cache directory directly (for testing).
|
||||
@visibleForTesting
|
||||
static void setBaseCacheDir(String dir) {
|
||||
_baseCacheDir = dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Per-provider tile cache implementing flutter_map's [MapCachingProvider].
|
||||
///
|
||||
/// Each instance manages an isolated cache directory with:
|
||||
/// - Deterministic UUID v5 key generation from tile URLs
|
||||
/// - Optional TTL override from [ServicePolicy.minCacheTtl]
|
||||
/// - Configurable max cache size with oldest-modified eviction
|
||||
///
|
||||
/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON
|
||||
/// metadata containing staleAt, lastModified, etag).
|
||||
class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final String cacheDirectory;
|
||||
final int maxCacheBytes;
|
||||
final Duration? overrideFreshAge;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Running estimate of cache size in bytes. Initialized lazily on first
|
||||
/// [putTile] call to avoid blocking construction.
|
||||
int? _estimatedSize;
|
||||
|
||||
/// Semaphore to limit concurrent file I/O operations and prevent
|
||||
/// "too many open files" errors during heavy tile loading.
|
||||
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
|
||||
|
||||
/// Throttle: don't re-scan more than once per minute.
|
||||
DateTime? _lastPruneCheck;
|
||||
|
||||
/// One-shot latch for lazy directory creation (safe under concurrent calls).
|
||||
Completer<void>? _directoryReady;
|
||||
|
||||
/// Guard against concurrent eviction runs.
|
||||
bool _isEvicting = false;
|
||||
|
||||
ProviderTileCacheStore({
|
||||
required this.cacheDirectory,
|
||||
this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default
|
||||
this.overrideFreshAge,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async {
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
// Use semaphore to limit concurrent file I/O operations
|
||||
final result = await _ioSemaphore.execute(() async {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
return (bytes: bytes, metaJson: metaJson);
|
||||
});
|
||||
|
||||
final bytes = result.bytes;
|
||||
final metaJson = result.metaJson;
|
||||
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['staleAt'] as int,
|
||||
isUtc: true,
|
||||
),
|
||||
lastModified: metaJson['lastModified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['lastModified'] as int,
|
||||
isUtc: true,
|
||||
)
|
||||
: null,
|
||||
etag: metaJson['etag'] as String?,
|
||||
);
|
||||
|
||||
return (bytes: bytes, metadata: metadata);
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw CachedMapTileReadFailure(
|
||||
url: url,
|
||||
description: 'Failed to read cached tile',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {
|
||||
await _ensureDirectory();
|
||||
|
||||
final key = keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
// Apply minimum TTL override if configured (e.g., OSM 7-day minimum).
|
||||
// Use the later of server-provided staleAt and our minimum to avoid
|
||||
// accidentally shortening a longer server-provided freshness lifetime.
|
||||
final effectiveMetadata = overrideFreshAge != null
|
||||
? (() {
|
||||
final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!);
|
||||
final staleAt = metadata.staleAt.isAfter(overrideStaleAt)
|
||||
? metadata.staleAt
|
||||
: overrideStaleAt;
|
||||
return CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: metadata.lastModified,
|
||||
etag: metadata.etag,
|
||||
);
|
||||
})()
|
||||
: metadata;
|
||||
|
||||
final metaJson = json.encode({
|
||||
'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch,
|
||||
'lastModified':
|
||||
effectiveMetadata.lastModified?.millisecondsSinceEpoch,
|
||||
'etag': effectiveMetadata.etag,
|
||||
});
|
||||
|
||||
// Write .tile before .meta: if we crash between the two writes, the
|
||||
// read path's both-must-exist check sees a miss rather than an orphan .meta.
|
||||
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
|
||||
await _ioSemaphore.execute(() async {
|
||||
if (bytes != null) {
|
||||
await tileFile.writeAsBytes(bytes);
|
||||
}
|
||||
await metaFile.writeAsString(metaJson);
|
||||
});
|
||||
|
||||
// Reset size estimate so it resyncs from disk on next check.
|
||||
// This avoids drift from overwrites where the old size isn't subtracted.
|
||||
_estimatedSize = null;
|
||||
|
||||
// Schedule lazy size check
|
||||
_scheduleEvictionCheck();
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists (lazy creation on first write).
|
||||
///
|
||||
/// Uses a Completer latch so concurrent callers share a single create().
|
||||
/// Safe under Dart's single-threaded event loop: the null check and
|
||||
/// assignment happen in the same synchronous block with no `await`
|
||||
/// between them, so no other microtask can interleave.
|
||||
Future<void> _ensureDirectory() {
|
||||
if (_directoryReady == null) {
|
||||
final completer = Completer<void>();
|
||||
_directoryReady = completer;
|
||||
Directory(cacheDirectory).create(recursive: true).then(
|
||||
(_) => completer.complete(),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
// Reset latch on error so later calls can retry directory creation.
|
||||
if (identical(_directoryReady, completer)) {
|
||||
_directoryReady = null;
|
||||
}
|
||||
completer.completeError(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _directoryReady!.future;
|
||||
}
|
||||
|
||||
/// Generate a cache key from URL using UUID v5 (same as flutter_map built-in).
|
||||
@visibleForTesting
|
||||
static String keyFor(String url) => _uuid.v5(Namespace.url.value, url);
|
||||
|
||||
/// Estimate total cache size (lazy, first call scans directory).
|
||||
Future<int> _getEstimatedSize() async {
|
||||
if (_estimatedSize != null) return _estimatedSize!;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) {
|
||||
_estimatedSize = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
total += await entity.length();
|
||||
}
|
||||
}
|
||||
_estimatedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Schedule eviction if we haven't checked recently.
|
||||
void _scheduleEvictionCheck() {
|
||||
final now = DateTime.now();
|
||||
if (_lastPruneCheck != null &&
|
||||
now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) {
|
||||
return;
|
||||
}
|
||||
_lastPruneCheck = now;
|
||||
|
||||
// Fire-and-forget: eviction is best-effort background work.
|
||||
// _estimatedSize may be momentarily stale between eviction start and
|
||||
// completion, but this is acceptable — the guard only needs to be
|
||||
// approximately correct to prevent unbounded growth, and the throttle
|
||||
// ensures we re-check within a minute.
|
||||
// ignore: discarded_futures
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Evict oldest-modified tiles if cache exceeds size limit.
|
||||
///
|
||||
/// Sorts by file mtime (oldest first), not by last access — true LRU would
|
||||
/// require touching files on every [getTile] read, adding I/O on the hot
|
||||
/// path. In practice write-recency tracks usage well because tiles are
|
||||
/// immutable and flutter_map holds visible tiles in memory.
|
||||
///
|
||||
/// Guarded by [_isEvicting] to prevent concurrent runs from corrupting
|
||||
/// [_estimatedSize].
|
||||
Future<void> _evictIfNeeded() async {
|
||||
if (_isEvicting) return;
|
||||
_isEvicting = true;
|
||||
try {
|
||||
final currentSize = await _getEstimatedSize();
|
||||
if (currentSize <= maxCacheBytes) return;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
// Collect all files, separating .tile and .meta for eviction + orphan cleanup.
|
||||
final tileFiles = <File>[];
|
||||
final metaFiles = <String>{};
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.path.endsWith('.tile')) {
|
||||
tileFiles.add(entity);
|
||||
} else if (entity.path.endsWith('.meta')) {
|
||||
metaFiles.add(p.basenameWithoutExtension(entity.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tileFiles.isEmpty) return;
|
||||
|
||||
// Sort by modification time, oldest first
|
||||
final stats = await Future.wait(
|
||||
tileFiles.map((f) async => (file: f, stat: await f.stat())),
|
||||
);
|
||||
stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified));
|
||||
|
||||
var freedBytes = 0;
|
||||
final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80%
|
||||
final evictedKeys = <String>{};
|
||||
|
||||
for (final entry in stats) {
|
||||
if (currentSize - freedBytes <= targetSize) break;
|
||||
|
||||
final key = p.basenameWithoutExtension(entry.file.path);
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
final deletedBytes = await _ioSemaphore.execute(() async {
|
||||
await entry.file.delete();
|
||||
var bytes = entry.stat.size;
|
||||
if (await metaFile.exists()) {
|
||||
final metaStat = await metaFile.stat();
|
||||
await metaFile.delete();
|
||||
bytes += metaStat.size;
|
||||
}
|
||||
return bytes;
|
||||
});
|
||||
|
||||
freedBytes += deletedBytes;
|
||||
evictedKeys.add(key);
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphan .meta files (no matching .tile file).
|
||||
// Exclude keys we just evicted — their .tile is gone so they're orphans.
|
||||
final remainingTileKeys = tileFiles
|
||||
.map((f) => p.basenameWithoutExtension(f.path))
|
||||
.toSet()
|
||||
..removeAll(evictedKeys);
|
||||
for (final metaKey in metaFiles) {
|
||||
if (!remainingTileKeys.contains(metaKey)) {
|
||||
try {
|
||||
final orphan = File(p.join(cacheDirectory, '$metaKey.meta'));
|
||||
final orphanStat = await orphan.stat();
|
||||
await orphan.delete();
|
||||
freedBytes += orphanStat.size;
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_estimatedSize = currentSize - freedBytes;
|
||||
debugPrint(
|
||||
'[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB '
|
||||
'from $cacheDirectory',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Eviction error: $e');
|
||||
} finally {
|
||||
_isEvicting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cached tiles in this store's directory.
|
||||
Future<void> clear() async {
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_estimatedSize = null;
|
||||
_directoryReady = null; // Allow lazy re-creation
|
||||
_lastPruneCheck = null; // Reset throttle so next write can trigger eviction
|
||||
}
|
||||
|
||||
/// Get the current estimated cache size in bytes.
|
||||
Future<int> get estimatedSizeBytes => _getEstimatedSize();
|
||||
|
||||
/// Force an eviction check, bypassing the throttle.
|
||||
/// Only exposed for testing — production code uses [_scheduleEvictionCheck].
|
||||
@visibleForTesting
|
||||
Future<void> forceEviction() => _evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Simple semaphore to limit concurrent operations and prevent resource exhaustion.
|
||||
class _Semaphore {
|
||||
final int maxCount;
|
||||
int _currentCount;
|
||||
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
|
||||
|
||||
_Semaphore(this.maxCount) : _currentCount = maxCount;
|
||||
|
||||
/// Acquire a permit. Returns a Future that completes when a permit is available.
|
||||
Future<void> acquire() {
|
||||
if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
return Future.value();
|
||||
} else {
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a permit, potentially unblocking a waiting operation.
|
||||
void release() {
|
||||
if (_waitQueue.isNotEmpty) {
|
||||
final completer = _waitQueue.removeFirst();
|
||||
completer.complete();
|
||||
} else {
|
||||
_currentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a function while holding a permit.
|
||||
Future<T> execute<T>(Future<T> Function() operation) async {
|
||||
await acquire();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,20 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
class RouteResult {
|
||||
final List<LatLng> waypoints;
|
||||
final double distanceMeters;
|
||||
final double durationSeconds;
|
||||
|
||||
|
||||
const RouteResult({
|
||||
required this.waypoints,
|
||||
required this.distanceMeters,
|
||||
required this.durationSeconds,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
|
||||
@@ -26,14 +26,27 @@ class RouteResult {
|
||||
}
|
||||
|
||||
class RoutingService {
|
||||
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
final http.Client _client;
|
||||
static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
|
||||
static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions';
|
||||
static const _policy = ResiliencePolicy(
|
||||
maxRetries: 1,
|
||||
httpTimeout: Duration(seconds: 30),
|
||||
);
|
||||
|
||||
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient();
|
||||
final http.Client _client;
|
||||
/// Optional override URL. When null, uses [defaultUrl].
|
||||
final String? _baseUrlOverride;
|
||||
|
||||
RoutingService({http.Client? client, String? baseUrl})
|
||||
: _client = client ?? UserAgentClient(),
|
||||
_baseUrlOverride = baseUrl;
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
// Calculate route between two points using alprwatch
|
||||
/// Resolve the primary URL to use: constructor override or default.
|
||||
String get _primaryUrl => _baseUrlOverride ?? defaultUrl;
|
||||
|
||||
// Calculate route between two points
|
||||
Future<RouteResult> calculateRoute({
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
@@ -53,8 +66,7 @@ class RoutingService {
|
||||
'tags': tags,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final uri = Uri.parse(_baseUrl);
|
||||
|
||||
final params = {
|
||||
'start': {
|
||||
'longitude': start.longitude,
|
||||
@@ -66,11 +78,25 @@ class RoutingService {
|
||||
},
|
||||
'avoidance_distance': avoidanceDistance,
|
||||
'enabled_profiles': enabledProfiles,
|
||||
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route
|
||||
'show_exclusion_zone': false,
|
||||
};
|
||||
|
||||
debugPrint('[RoutingService] alprwatch request: $uri $params');
|
||||
|
||||
|
||||
final primaryUrl = _primaryUrl;
|
||||
final canFallback = _baseUrlOverride == null;
|
||||
|
||||
return executeWithFallback<RouteResult>(
|
||||
primaryUrl: primaryUrl,
|
||||
fallbackUrl: canFallback ? fallbackUrl : null,
|
||||
execute: (url) => _postRoute(url, params),
|
||||
classifyError: _classifyError,
|
||||
policy: _policy,
|
||||
);
|
||||
}
|
||||
|
||||
Future<RouteResult> _postRoute(String url, Map<String, dynamic> params) async {
|
||||
final uri = Uri.parse(url);
|
||||
debugPrint('[RoutingService] POST $uri');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
@@ -78,7 +104,7 @@ class RoutingService {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: json.encode(params)
|
||||
).timeout(kNavigationRoutingTimeout);
|
||||
).timeout(_policy.httpTimeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (kDebugMode) {
|
||||
@@ -91,24 +117,25 @@ class RoutingService {
|
||||
: body;
|
||||
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
|
||||
}
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}',
|
||||
statusCode: response.statusCode);
|
||||
}
|
||||
|
||||
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
debugPrint('[RoutingService] alprwatch response data: $data');
|
||||
|
||||
// Check alprwatch response status
|
||||
debugPrint('[RoutingService] response data: $data');
|
||||
|
||||
// Check response status
|
||||
final ok = data['ok'] as bool? ?? false;
|
||||
if ( ! ok ) {
|
||||
final message = data['error'] as String? ?? 'Unknown routing error';
|
||||
throw RoutingException('alprwatch error: $message');
|
||||
throw RoutingException('API error: $message', isApiError: true);
|
||||
}
|
||||
|
||||
|
||||
final route = data['result']['route'] as Map<String, dynamic>?;
|
||||
if (route == null) {
|
||||
throw RoutingException('No route found between these points');
|
||||
throw RoutingException('No route found between these points', isApiError: true);
|
||||
}
|
||||
|
||||
|
||||
final waypoints = (route['coordinates'] as List<dynamic>?)
|
||||
?.map((inner) {
|
||||
final pair = inner as List<dynamic>;
|
||||
@@ -116,19 +143,19 @@ class RoutingService {
|
||||
final lng = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lng);
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
}).whereType<LatLng>().toList() ?? [];
|
||||
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
|
||||
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
|
||||
final result = RouteResult(
|
||||
waypoints: waypoints,
|
||||
distanceMeters: distance,
|
||||
durationSeconds: duration,
|
||||
);
|
||||
|
||||
|
||||
debugPrint('[RoutingService] Route calculated: $result');
|
||||
return result;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[RoutingService] Route calculation failed: $e');
|
||||
if (e is RoutingException) {
|
||||
@@ -138,13 +165,26 @@ class RoutingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ErrorDisposition _classifyError(Object error) {
|
||||
if (error is! RoutingException) return ErrorDisposition.retry;
|
||||
if (error.isApiError) return ErrorDisposition.abort;
|
||||
final status = error.statusCode;
|
||||
if (status != null && status >= 400 && status < 500) {
|
||||
if (status == 429) return ErrorDisposition.fallback;
|
||||
return ErrorDisposition.abort;
|
||||
}
|
||||
return ErrorDisposition.retry;
|
||||
}
|
||||
}
|
||||
|
||||
class RoutingException implements Exception {
|
||||
final String message;
|
||||
|
||||
const RoutingException(this.message);
|
||||
|
||||
final int? statusCode;
|
||||
final bool isApiError;
|
||||
|
||||
const RoutingException(this.message, {this.statusCode, this.isApiError = false});
|
||||
|
||||
@override
|
||||
String toString() => 'RoutingException: $message';
|
||||
}
|
||||
@@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Cached search result with expiry.
|
||||
class _CachedResult {
|
||||
final List<SearchResult> results;
|
||||
final DateTime cachedAt;
|
||||
|
||||
_CachedResult(this.results) : cachedAt = DateTime.now();
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
class SearchService {
|
||||
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
|
||||
static const int _maxResults = 5;
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
final _client = UserAgentClient();
|
||||
|
||||
|
||||
/// Client-side result cache, keyed by normalized query + viewbox.
|
||||
/// Required by Nominatim usage policy. Static so all SearchService
|
||||
/// instances share the cache and don't generate redundant requests.
|
||||
static final Map<String, _CachedResult> _resultCache = {};
|
||||
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
@@ -27,23 +45,23 @@ class SearchService {
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
SearchResult? _tryParseCoordinates(String query) {
|
||||
// Remove common separators and normalize
|
||||
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
|
||||
final parts = normalized.split(RegExp(r'\s+'));
|
||||
|
||||
|
||||
if (parts.length != 2) return null;
|
||||
|
||||
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lon = double.tryParse(parts[1]);
|
||||
|
||||
|
||||
if (lat == null || lon == null) return null;
|
||||
|
||||
|
||||
// Basic validation for Earth coordinates
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
|
||||
return SearchResult(
|
||||
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
|
||||
coordinates: LatLng(lat, lon),
|
||||
@@ -51,17 +69,17 @@ class SearchService {
|
||||
type: 'point',
|
||||
);
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
/// Search using Nominatim API with rate limiting and result caching.
|
||||
///
|
||||
/// Nominatim usage policy requires:
|
||||
/// - Max 1 request per second
|
||||
/// - Client-side result caching
|
||||
/// - No auto-complete / typeahead
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
// Normalize the viewbox first so both the cache key and the request
|
||||
// params use the same effective values (rounded + min-span expanded).
|
||||
String? viewboxParam;
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
@@ -80,31 +98,83 @@ class SearchService {
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
viewboxParam = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final cacheKey = _buildCacheKey(query, viewboxParam);
|
||||
|
||||
// Check cache first (Nominatim policy requires client-side caching)
|
||||
final cached = _resultCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
debugPrint('[SearchService] Cache hit for "$query"');
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
if (viewboxParam != null) {
|
||||
params['viewbox'] = viewboxParam;
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
// Rate limit: max 1 request/sec per Nominatim policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.nominatim);
|
||||
try {
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
|
||||
final List<dynamic> jsonResults = json.decode(response.body);
|
||||
final results = jsonResults
|
||||
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
|
||||
// Cache the results
|
||||
_resultCache[cacheKey] = _CachedResult(results);
|
||||
_pruneCache();
|
||||
|
||||
debugPrint('[SearchService] Found ${results.length} results');
|
||||
return results;
|
||||
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SearchService] Search failed: $e');
|
||||
throw Exception('Search failed: $e');
|
||||
Error.throwWithStackTrace(e, stackTrace);
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a cache key from the query and the already-normalized viewbox string.
|
||||
///
|
||||
/// The viewbox should be the same `west,north,east,south` string sent to
|
||||
/// Nominatim (after rounding and min-span expansion) so that requests with
|
||||
/// different raw bounds but the same effective viewbox share a cache entry.
|
||||
String _buildCacheKey(String query, String? viewboxParam) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (viewboxParam == null) return normalizedQuery;
|
||||
return '$normalizedQuery|$viewboxParam';
|
||||
}
|
||||
|
||||
/// Remove expired entries and limit cache size.
|
||||
void _pruneCache() {
|
||||
_resultCache.removeWhere((_, cached) => cached.isExpired);
|
||||
// Limit cache to 50 entries to prevent unbounded growth
|
||||
if (_resultCache.length > 50) {
|
||||
final sortedKeys = _resultCache.keys.toList()
|
||||
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
|
||||
for (final key in sortedKeys.take(_resultCache.length - 50)) {
|
||||
_resultCache.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Identifies the type of external service being accessed.
|
||||
/// Used by [ServicePolicyResolver] to determine the correct compliance policy.
|
||||
enum ServiceType {
|
||||
// OSMF official services
|
||||
osmEditingApi, // api.openstreetmap.org — editing & data queries
|
||||
osmTileServer, // tile.openstreetmap.org — raster tiles
|
||||
nominatim, // nominatim.openstreetmap.org — geocoding
|
||||
overpass, // overpass-api.de — read-only data queries
|
||||
tagInfo, // taginfo.openstreetmap.org — tag metadata
|
||||
|
||||
// Third-party tile services
|
||||
bingTiles, // *.tiles.virtualearth.net
|
||||
mapboxTiles, // api.mapbox.com
|
||||
|
||||
// Everything else
|
||||
custom, // user's own infrastructure / unknown
|
||||
}
|
||||
|
||||
/// Defines the compliance rules for a specific service.
|
||||
///
|
||||
/// Each policy captures the rate limits, caching requirements, offline
|
||||
/// permissions, and attribution obligations mandated by the service operator.
|
||||
/// When the app talks to official OSMF infrastructure the strict policies
|
||||
/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom]
|
||||
/// provides permissive defaults.
|
||||
class ServicePolicy {
|
||||
/// Max concurrent HTTP connections to this service.
|
||||
/// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114).
|
||||
final int maxConcurrentRequests;
|
||||
|
||||
/// Minimum interval between consecutive requests. Null means no rate limit.
|
||||
final Duration? minRequestInterval;
|
||||
|
||||
/// Whether this endpoint permits offline/bulk downloading of tiles.
|
||||
final bool allowsOfflineDownload;
|
||||
|
||||
/// Whether the client must cache responses (e.g., Nominatim policy).
|
||||
final bool requiresClientCaching;
|
||||
|
||||
/// Minimum cache TTL to enforce regardless of server headers.
|
||||
/// Null means "use server-provided max-age as-is".
|
||||
final Duration? minCacheTtl;
|
||||
|
||||
/// License/attribution URL to display in the attribution dialog.
|
||||
/// Null means no special attribution link is needed.
|
||||
final String? attributionUrl;
|
||||
|
||||
const ServicePolicy({
|
||||
this.maxConcurrentRequests = 8,
|
||||
this.minRequestInterval,
|
||||
this.allowsOfflineDownload = true,
|
||||
this.requiresClientCaching = false,
|
||||
this.minCacheTtl,
|
||||
this.attributionUrl,
|
||||
});
|
||||
|
||||
/// OSM editing API (api.openstreetmap.org)
|
||||
/// Policy: max 2 concurrent download threads.
|
||||
/// https://operations.osmfoundation.org/policies/api/
|
||||
const ServicePolicy.osmEditingApi()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a for API
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// OSM tile server (tile.openstreetmap.org)
|
||||
/// Policy: min 7-day cache, must honor cache headers.
|
||||
/// Concurrency managed by flutter_map's NetworkTileProvider.
|
||||
/// https://operations.osmfoundation.org/policies/tiles/
|
||||
const ServicePolicy.osmTileServer()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true,
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = const Duration(days: 7),
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Nominatim geocoding (nominatim.openstreetmap.org)
|
||||
/// Policy: max 1 req/sec, single machine only, results must be cached.
|
||||
/// https://operations.osmfoundation.org/policies/nominatim/
|
||||
const ServicePolicy.nominatim()
|
||||
: maxConcurrentRequests = 1,
|
||||
minRequestInterval = const Duration(seconds: 1),
|
||||
allowsOfflineDownload = true, // n/a for geocoding
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Overpass API (overpass-api.de)
|
||||
/// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore.
|
||||
const ServicePolicy.overpass()
|
||||
: maxConcurrentRequests = 0, // managed by NodeDataManager
|
||||
minRequestInterval = null, // managed by NodeDataManager
|
||||
allowsOfflineDownload = true, // n/a for data queries
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// TagInfo API (taginfo.openstreetmap.org)
|
||||
const ServicePolicy.tagInfo()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a
|
||||
requiresClientCaching = true, // already cached in NSIService
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Bing Maps tiles (*.tiles.virtualearth.net)
|
||||
const ServicePolicy.bingTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // check Bing ToS separately
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Mapbox tiles (api.mapbox.com)
|
||||
const ServicePolicy.mapboxTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // permitted with valid token
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Custom/self-hosted service — permissive defaults.
|
||||
const ServicePolicy.custom({
|
||||
int maxConcurrent = 8,
|
||||
bool allowsOffline = true,
|
||||
Duration? minInterval,
|
||||
String? attribution,
|
||||
}) : maxConcurrentRequests = maxConcurrent,
|
||||
minRequestInterval = minInterval,
|
||||
allowsOfflineDownload = allowsOffline,
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = attribution;
|
||||
|
||||
@override
|
||||
String toString() => 'ServicePolicy('
|
||||
'maxConcurrent: $maxConcurrentRequests, '
|
||||
'minInterval: $minRequestInterval, '
|
||||
'offlineDownload: $allowsOfflineDownload, '
|
||||
'clientCaching: $requiresClientCaching, '
|
||||
'minCacheTtl: $minCacheTtl, '
|
||||
'attributionUrl: $attributionUrl)';
|
||||
}
|
||||
|
||||
/// Resolves service URLs to their applicable [ServicePolicy].
|
||||
///
|
||||
/// Built-in patterns cover all OSMF official services and common third-party
|
||||
/// tile providers. Falls back to permissive defaults for unrecognized hosts.
|
||||
class ServicePolicyResolver {
|
||||
/// Host → ServiceType mapping for known services.
|
||||
static final Map<String, ServiceType> _hostPatterns = {
|
||||
'api.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'api06.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
||||
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
||||
'overpass-api.de': ServiceType.overpass,
|
||||
'overpass.deflock.org': ServiceType.overpass,
|
||||
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
||||
'tiles.virtualearth.net': ServiceType.bingTiles,
|
||||
'api.mapbox.com': ServiceType.mapboxTiles,
|
||||
};
|
||||
|
||||
/// ServiceType → policy mapping.
|
||||
static final Map<ServiceType, ServicePolicy> _policies = {
|
||||
ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(),
|
||||
ServiceType.osmTileServer: const ServicePolicy.osmTileServer(),
|
||||
ServiceType.nominatim: const ServicePolicy.nominatim(),
|
||||
ServiceType.overpass: const ServicePolicy.overpass(),
|
||||
ServiceType.tagInfo: const ServicePolicy.tagInfo(),
|
||||
ServiceType.bingTiles: const ServicePolicy.bingTiles(),
|
||||
ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(),
|
||||
ServiceType.custom: const ServicePolicy(),
|
||||
};
|
||||
|
||||
/// Resolve a URL to its applicable [ServicePolicy].
|
||||
///
|
||||
/// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
|
||||
/// for unrecognized hosts.
|
||||
static ServicePolicy resolve(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return const ServicePolicy();
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return _policies[entry.value] ?? const ServicePolicy();
|
||||
}
|
||||
}
|
||||
|
||||
return const ServicePolicy();
|
||||
}
|
||||
|
||||
/// Resolve a URL to its [ServiceType].
|
||||
///
|
||||
/// Returns [ServiceType.custom] for unrecognized hosts.
|
||||
static ServiceType resolveType(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return ServiceType.custom;
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceType.custom;
|
||||
}
|
||||
|
||||
/// Look up the [ServicePolicy] for a known [ServiceType].
|
||||
static ServicePolicy resolveByType(ServiceType type) =>
|
||||
_policies[type] ?? const ServicePolicy();
|
||||
|
||||
/// Extract the host from a URL or URL template.
|
||||
static String? _extractHost(String url) {
|
||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
// and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...'
|
||||
try {
|
||||
// Strip template variables from subdomain part for parsing
|
||||
final cleaned = url
|
||||
.replaceAll(RegExp(r'\{0_3\}'), '0')
|
||||
.replaceAll(RegExp(r'\{1_4\}'), '1')
|
||||
.replaceAll(RegExp(r'\{quadkey\}'), 'quadkey')
|
||||
.replaceAll(RegExp(r'\{z\}'), '0')
|
||||
.replaceAll(RegExp(r'\{x\}'), '0')
|
||||
.replaceAll(RegExp(r'\{y\}'), '0')
|
||||
.replaceAll(RegExp(r'\{api_key\}'), 'key');
|
||||
return Uri.parse(cleaned).host.toLowerCase();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the retry/fallback engine should handle an error.
|
||||
enum ErrorDisposition {
|
||||
/// Stop immediately. Don't retry, don't try fallback. (400, business logic)
|
||||
abort,
|
||||
/// Don't retry same server, but DO try fallback endpoint. (429 rate limit)
|
||||
fallback,
|
||||
/// Retry with backoff against same server, then fallback if exhausted. (5xx, network)
|
||||
retry,
|
||||
}
|
||||
|
||||
/// Retry and fallback configuration for resilient HTTP services.
|
||||
class ResiliencePolicy {
|
||||
final int maxRetries;
|
||||
final Duration httpTimeout;
|
||||
final Duration _retryBackoffBase;
|
||||
final int _retryBackoffMaxMs;
|
||||
|
||||
const ResiliencePolicy({
|
||||
this.maxRetries = 1,
|
||||
this.httpTimeout = const Duration(seconds: 30),
|
||||
Duration retryBackoffBase = const Duration(milliseconds: 200),
|
||||
int retryBackoffMaxMs = 5000,
|
||||
}) : _retryBackoffBase = retryBackoffBase,
|
||||
_retryBackoffMaxMs = retryBackoffMaxMs;
|
||||
|
||||
Duration retryDelay(int attempt) {
|
||||
final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt))
|
||||
.clamp(0, _retryBackoffMaxMs);
|
||||
return Duration(milliseconds: ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a request with retry and fallback logic.
|
||||
///
|
||||
/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times.
|
||||
/// 2. On each failure, calls [classifyError] to determine disposition:
|
||||
/// - [ErrorDisposition.abort]: rethrows immediately
|
||||
/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available)
|
||||
/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted
|
||||
/// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error,
|
||||
/// repeats the retry loop against the fallback.
|
||||
Future<T> executeWithFallback<T>({
|
||||
required String primaryUrl,
|
||||
required String? fallbackUrl,
|
||||
required Future<T> Function(String url) execute,
|
||||
required ErrorDisposition Function(Object error) classifyError,
|
||||
ResiliencePolicy policy = const ResiliencePolicy(),
|
||||
}) async {
|
||||
try {
|
||||
return await _executeWithRetries(primaryUrl, execute, classifyError, policy);
|
||||
} catch (e) {
|
||||
// _executeWithRetries rethrows abort/fallback/exhausted-retry errors.
|
||||
// Re-classify only to distinguish abort (which must not fall back) from
|
||||
// fallback/retry-exhausted (which should). This is the one intentional
|
||||
// re-classification — _executeWithRetries cannot short-circuit past the
|
||||
// outer try/catch.
|
||||
if (classifyError(e) == ErrorDisposition.abort) rethrow;
|
||||
if (fallbackUrl == null) rethrow;
|
||||
debugPrint('[Resilience] Primary failed ($e), trying fallback');
|
||||
return _executeWithRetries(fallbackUrl, execute, classifyError, policy);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _executeWithRetries<T>(
|
||||
String url,
|
||||
Future<T> Function(String url) execute,
|
||||
ErrorDisposition Function(Object error) classifyError,
|
||||
ResiliencePolicy policy,
|
||||
) async {
|
||||
for (int attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
||||
try {
|
||||
return await execute(url);
|
||||
} catch (e) {
|
||||
final disposition = classifyError(e);
|
||||
if (disposition == ErrorDisposition.abort) rethrow;
|
||||
if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback
|
||||
// disposition == retry
|
||||
if (attempt < policy.maxRetries) {
|
||||
final delay = policy.retryDelay(attempt);
|
||||
debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms');
|
||||
await Future.delayed(delay);
|
||||
continue;
|
||||
}
|
||||
rethrow; // retries exhausted, let caller try fallback
|
||||
}
|
||||
}
|
||||
throw StateError('Unreachable'); // loop always returns or throws
|
||||
}
|
||||
|
||||
/// Reusable per-service rate limiter and concurrency controller.
|
||||
///
|
||||
/// Enforces the rate limits and concurrency constraints defined in each
|
||||
/// service's [ServicePolicy]. Call [acquire] before making a request and
|
||||
/// [release] after the request completes.
|
||||
///
|
||||
/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0
|
||||
/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere
|
||||
/// (flutter_map, PR #114) are passed through without blocking.
|
||||
class ServiceRateLimiter {
|
||||
/// Injectable clock for testing. Defaults to [DateTime.now].
|
||||
///
|
||||
/// Override with a deterministic clock (e.g. from `FakeAsync`) so tests
|
||||
/// don't rely on wall-clock time and stay fast and stable under CI load.
|
||||
@visibleForTesting
|
||||
static DateTime Function() clock = DateTime.now;
|
||||
|
||||
/// Per-service timestamps of the last acquired request slot / request start
|
||||
/// (used for rate limiting in [acquire], not updated on completion).
|
||||
static final Map<ServiceType, DateTime> _lastRequestTime = {};
|
||||
|
||||
/// Per-service concurrency semaphores.
|
||||
static final Map<ServiceType, _Semaphore> _semaphores = {};
|
||||
|
||||
/// Acquire a slot: wait for rate limit compliance, then take a connection slot.
|
||||
///
|
||||
/// Blocks if:
|
||||
/// 1. The minimum interval between requests hasn't elapsed yet, or
|
||||
/// 2. All concurrent connection slots are in use.
|
||||
static Future<void> acquire(ServiceType service) async {
|
||||
final policy = ServicePolicyResolver.resolveByType(service);
|
||||
|
||||
// Concurrency: acquire a semaphore slot first so that at most
|
||||
// [policy.maxConcurrentRequests] callers proceed concurrently.
|
||||
// The min-interval check below is only race-free when
|
||||
// maxConcurrentRequests == 1 (currently only Nominatim). For services
|
||||
// with higher concurrency the interval is approximate, which is
|
||||
// acceptable — their policies don't specify a min interval.
|
||||
_Semaphore? semaphore;
|
||||
if (policy.maxConcurrentRequests > 0) {
|
||||
semaphore = _semaphores.putIfAbsent(
|
||||
service,
|
||||
() => _Semaphore(policy.maxConcurrentRequests),
|
||||
);
|
||||
await semaphore.acquire();
|
||||
}
|
||||
|
||||
try {
|
||||
// Rate limit: wait if we sent a request too recently
|
||||
if (policy.minRequestInterval != null) {
|
||||
final lastTime = _lastRequestTime[service];
|
||||
if (lastTime != null) {
|
||||
final elapsed = clock().difference(lastTime);
|
||||
final remaining = policy.minRequestInterval! - elapsed;
|
||||
if (remaining > Duration.zero) {
|
||||
debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms');
|
||||
await Future.delayed(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record request time
|
||||
_lastRequestTime[service] = clock();
|
||||
} catch (_) {
|
||||
// Release the semaphore slot if the rate-limit delay fails,
|
||||
// to avoid permanently leaking a slot.
|
||||
semaphore?.release();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a connection slot after request completes.
|
||||
static void release(ServiceType service) {
|
||||
_semaphores[service]?.release();
|
||||
}
|
||||
|
||||
/// Reset all rate limiter state (for testing).
|
||||
@visibleForTesting
|
||||
static void reset() {
|
||||
_lastRequestTime.clear();
|
||||
_semaphores.clear();
|
||||
clock = DateTime.now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple async counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int _maxCount;
|
||||
int _currentCount = 0;
|
||||
final List<Completer<void>> _waiters = [];
|
||||
|
||||
_Semaphore(this._maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < _maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiters.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiters.isNotEmpty) {
|
||||
final next = _waiters.removeAt(0);
|
||||
next.complete();
|
||||
} else if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
} else {
|
||||
throw StateError(
|
||||
'Semaphore.release() called more times than acquire(); '
|
||||
'currentCount is already zero.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,13 +58,18 @@ class SuspectedLocationDatabase {
|
||||
}
|
||||
|
||||
/// 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...');
|
||||
|
||||
// Main suspected locations table
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
$_columnTicketNo TEXT PRIMARY KEY,
|
||||
|
||||
$_columnCentroidLat REAL NOT NULL,
|
||||
$_columnCentroidLng REAL NOT NULL,
|
||||
$_columnBounds TEXT,
|
||||
|
||||
@@ -35,16 +35,31 @@ class SuspectedLocationService {
|
||||
// Load cache data
|
||||
await _cache.loadFromStorage();
|
||||
|
||||
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
|
||||
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) {
|
||||
// Note: Auto-refresh logic moved to initBackgroundRefresh() to avoid blocking app startup
|
||||
if (_isEnabled && (await _shouldRefresh()) && offlineMode) {
|
||||
final lastFetch = await _cache.lastFetchTime;
|
||||
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${lastFetch != null ? 'outdated' : 'missing'}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if background refresh should happen and start it (non-blocking)
|
||||
Future<void> initBackgroundRefresh({bool offlineMode = false}) async {
|
||||
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
|
||||
if (_isEnabled && (await _shouldRefresh()) && !offlineMode) {
|
||||
debugPrint('[SuspectedLocationService] Starting background auto-refresh of CSV data (older than $_maxAge or missing)');
|
||||
// Don't await - let it run in background
|
||||
_fetchData().then((success) {
|
||||
if (success) {
|
||||
debugPrint('[SuspectedLocationService] Background auto-refresh completed successfully');
|
||||
} else {
|
||||
debugPrint('[SuspectedLocationService] Background auto-refresh failed');
|
||||
}
|
||||
}).catchError((error) {
|
||||
debugPrint('[SuspectedLocationService] Background auto-refresh error: $error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable suspected locations
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
_isEnabled = enabled;
|
||||
|
||||
@@ -104,7 +104,7 @@ class Uploader {
|
||||
// Create new node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
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 = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
@@ -141,7 +141,7 @@ class Uploader {
|
||||
// Update existing node with version
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" id="${p.originalNodeId}" version="$currentVersion" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
@@ -190,7 +190,7 @@ class Uploader {
|
||||
// Extract creates a new node with tags from the original node
|
||||
final mergedTags = p.getCombinedTags();
|
||||
final tagsXml = mergedTags.entries.map((e) =>
|
||||
'<tag k="${e.key}" v="${e.value}"/>').join('\n ');
|
||||
'<tag k="${_sanitizeXmlText(e.key)}" v="${_sanitizeXmlText(e.value)}"/>').join('\n ');
|
||||
final nodeXml = '''
|
||||
<osm>
|
||||
<node changeset="$changesetId" lat="${p.coord.latitude}" lon="${p.coord.longitude}">
|
||||
|
||||
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
|
||||
_calculateRoute();
|
||||
}
|
||||
|
||||
/// Calculate route using alprwatch
|
||||
/// Calculate route via RoutingService (primary + fallback endpoints).
|
||||
void _calculateRoute() {
|
||||
if (_routeStart == null || _routeEnd == null) return;
|
||||
|
||||
debugPrint('[NavigationState] Calculating route with alprwatch...');
|
||||
debugPrint('[NavigationState] Calculating route...');
|
||||
_isCalculating = true;
|
||||
_routingError = null;
|
||||
notifyListeners();
|
||||
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
|
||||
_showingOverview = true;
|
||||
_provisionalPinLocation = null; // Hide provisional pin
|
||||
|
||||
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}');
|
||||
debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
|
||||
notifyListeners();
|
||||
|
||||
}).catchError((error) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../services/operator_profile_service.dart';
|
||||
|
||||
class OperatorProfileState extends ChangeNotifier {
|
||||
static const String _profileOrderPrefsKey = 'operator_profile_order';
|
||||
|
||||
final List<OperatorProfile> _profiles = [];
|
||||
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
|
||||
List<OperatorProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
_profiles.addAll(await OperatorProfileService().load());
|
||||
@@ -16,6 +20,10 @@ class OperatorProfileState extends ChangeNotifier {
|
||||
_profiles.addAll(OperatorProfile.getDefaults());
|
||||
await OperatorProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load custom order from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? [];
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(OperatorProfile p) {
|
||||
@@ -34,4 +42,53 @@ class OperatorProfileState extends ChangeNotifier {
|
||||
OperatorProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Reorder profiles (for drag-and-drop in settings)
|
||||
void reorderProfiles(int oldIndex, int newIndex) {
|
||||
final orderedProfiles = _getOrderedProfiles();
|
||||
|
||||
// With onReorderItem callback, newIndex is already adjusted
|
||||
final item = orderedProfiles.removeAt(oldIndex);
|
||||
orderedProfiles.insert(newIndex, item);
|
||||
|
||||
// Update custom order with new sequence
|
||||
_customOrder = orderedProfiles.map((p) => p.id).toList();
|
||||
_saveCustomOrder();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Get profiles in custom order, with unordered profiles at the end
|
||||
List<OperatorProfile> _getOrderedProfiles() {
|
||||
if (_customOrder.isEmpty) {
|
||||
return List.from(_profiles);
|
||||
}
|
||||
|
||||
final ordered = <OperatorProfile>[];
|
||||
final profilesById = {for (final p in _profiles) p.id: p};
|
||||
|
||||
// Add profiles in custom order
|
||||
for (final id in _customOrder) {
|
||||
final profile = profilesById[id];
|
||||
if (profile != null) {
|
||||
ordered.add(profile);
|
||||
profilesById.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining profiles that weren't in the custom order
|
||||
ordered.addAll(profilesById.values);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Save custom order to disk
|
||||
Future<void> _saveCustomOrder() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_profileOrderPrefsKey, _customOrder);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[OperatorProfileState] Failed to save custom order: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-10
@@ -6,9 +6,17 @@ import '../services/profile_service.dart';
|
||||
|
||||
class ProfileState extends ChangeNotifier {
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _profileOrderPrefsKey = 'profile_order';
|
||||
|
||||
final List<NodeProfile> _profiles = [];
|
||||
final Set<NodeProfile> _enabled = {};
|
||||
List<String> _customOrder = []; // List of profile IDs in user's preferred order
|
||||
|
||||
// Test-only getters for accessing private state
|
||||
@visibleForTesting
|
||||
List<NodeProfile> get internalProfiles => _profiles;
|
||||
@visibleForTesting
|
||||
Set<NodeProfile> get internalEnabled => _enabled;
|
||||
|
||||
// Callback for when a profile is deleted (used to clear stale sessions)
|
||||
void Function(NodeProfile)? _onProfileDeleted;
|
||||
@@ -18,10 +26,10 @@ class ProfileState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Getters
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_profiles);
|
||||
List<NodeProfile> get profiles => List.unmodifiable(_getOrderedProfiles());
|
||||
bool isEnabled(NodeProfile p) => _enabled.contains(p);
|
||||
List<NodeProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
_getOrderedProfiles().where(isEnabled).toList(growable: false);
|
||||
|
||||
// Initialize profiles from built-in and custom sources
|
||||
Future<void> init({bool addDefaults = false}) async {
|
||||
@@ -34,7 +42,7 @@ class ProfileState extends ChangeNotifier {
|
||||
await ProfileService().save(_profiles);
|
||||
}
|
||||
|
||||
// Load enabled profile IDs from prefs
|
||||
// Load enabled profile IDs and custom order from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
@@ -44,6 +52,31 @@ class ProfileState extends ChangeNotifier {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
|
||||
// Load custom order
|
||||
_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) {
|
||||
@@ -70,7 +103,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -84,7 +117,7 @@ class ProfileState extends ChangeNotifier {
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
_saveProfilesToStorage();
|
||||
|
||||
// Notify about profile deletion so other parts can clean up
|
||||
_onProfileDeleted?.call(p);
|
||||
@@ -92,12 +125,76 @@ class ProfileState extends ChangeNotifier {
|
||||
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<NodeProfile> _getOrderedProfiles() {
|
||||
if (_customOrder.isEmpty) {
|
||||
return List.from(_profiles);
|
||||
}
|
||||
|
||||
final ordered = <NodeProfile>[];
|
||||
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 enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if SharedPreferences isn't available
|
||||
debugPrint('[ProfileState] Failed to save enabled profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save profiles to storage
|
||||
Future<void> _saveProfilesToStorage() async {
|
||||
try {
|
||||
await ProfileService().save(_profiles);
|
||||
} catch (e) {
|
||||
// Fail gracefully in tests or if storage isn't available
|
||||
debugPrint('[ProfileState] Failed to save profiles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 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('[ProfileState] Failed to save custom order: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/provider_tile_cache_manager.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../keys.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
@@ -37,11 +39,19 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
static const String _distanceUnitPrefsKey = 'distance_unit';
|
||||
static const String _keepScreenAwakePrefsKey = 'keep_screen_awake';
|
||||
static const String _hideZoomControlsPrefsKey = 'hide_zoom_controls';
|
||||
static const String _offlineFeaturesEnabledPrefsKey = 'offline_features_enabled';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
bool _keepScreenAwake = false;
|
||||
bool _hideZoomControls = false;
|
||||
bool _offlineFeaturesEnabled = false;
|
||||
|
||||
int _maxNodes = kDefaultMaxNodes;
|
||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||
// Default must account for missing secrets (preview builds) even before init() runs
|
||||
UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production;
|
||||
FollowMeMode _followMeMode = FollowMeMode.follow;
|
||||
bool _proximityAlertsEnabled = false;
|
||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||
@@ -62,7 +72,12 @@ class SettingsState extends ChangeNotifier {
|
||||
int get proximityAlertDistance => _proximityAlertDistance;
|
||||
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||
bool get keepScreenAwake => _keepScreenAwake;
|
||||
bool get hideZoomControls => _hideZoomControls;
|
||||
bool get offlineFeaturesEnabled => _offlineFeaturesEnabled;
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
|
||||
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _distanceUnit;
|
||||
@@ -135,7 +150,20 @@ class SettingsState extends ChangeNotifier {
|
||||
|
||||
// Load suspected location minimum distance
|
||||
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
|
||||
|
||||
// Load keep screen awake setting
|
||||
_keepScreenAwake = prefs.getBool(_keepScreenAwakePrefsKey) ?? false;
|
||||
|
||||
// Load hide zoom controls setting
|
||||
_hideZoomControls = prefs.getBool(_hideZoomControlsPrefsKey) ?? false;
|
||||
|
||||
// Load offline features enabled setting (defaults to false; migration
|
||||
// for existing users with pre-existing offline area data lives in
|
||||
// migrations.dart / migrate_2_10_5)
|
||||
_offlineFeaturesEnabled = prefs.getBool(_offlineFeaturesEnabledPrefsKey) ?? false;
|
||||
|
||||
|
||||
|
||||
// Load upload mode (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
@@ -150,8 +178,16 @@ class SettingsState extends ChangeNotifier {
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
|
||||
// In production builds, force production mode if development modes are disabled
|
||||
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
// Override persisted upload mode when the current build configuration
|
||||
// doesn't support it. This handles two cases:
|
||||
// 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes
|
||||
// 2. Production builds — force production (prefs may have sandbox/simulate
|
||||
// from a previous dev build on the same device)
|
||||
if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) {
|
||||
debugPrint('SettingsState: No OSM secrets available, forcing simulate mode');
|
||||
_uploadMode = UploadMode.simulate;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
} else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
_uploadMode = UploadMode.production;
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
@@ -258,11 +294,10 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// In production builds, only allow production mode
|
||||
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
|
||||
debugPrint('SettingsState: Development modes disabled, forcing production mode');
|
||||
mode = UploadMode.production;
|
||||
}
|
||||
// The upload mode dropdown is only visible when kEnableDevelopmentModes is
|
||||
// true (gated in osm_account_screen.dart), so no secrets/dev-mode guards
|
||||
// are needed here. The init() method handles forcing the correct mode on
|
||||
// startup for production builds and builds without OAuth secrets.
|
||||
|
||||
_uploadMode = mode;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -323,6 +358,17 @@ class SettingsState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all tile caches for a specific provider
|
||||
Future<void> clearTileProviderCaches(String providerId) async {
|
||||
final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
|
||||
if (provider == null) return;
|
||||
|
||||
// Clear cache for each tile type in this provider
|
||||
for (final tileType in provider.tileTypes) {
|
||||
await ProviderTileCacheManager.deleteCache(providerId, tileType.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
if (_followMeMode != mode) {
|
||||
@@ -376,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 {
|
||||
if (_navigationAvoidanceDistance != distance) {
|
||||
_navigationAvoidanceDistance = distance;
|
||||
|
||||
@@ -61,6 +61,11 @@ class SuspectedLocationState extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start background refresh if needed (non-blocking)
|
||||
Future<void> initBackgroundRefresh({bool offlineMode = false}) async {
|
||||
await _service.initBackgroundRefresh(offlineMode: offlineMode);
|
||||
}
|
||||
|
||||
/// Enable or disable suspected locations
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
await _service.setEnabled(enabled);
|
||||
|
||||
@@ -121,7 +121,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// 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(
|
||||
coord: session.target!,
|
||||
direction: _formatDirectionsForSubmission(session.directions, session.profile),
|
||||
@@ -159,10 +159,11 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
// 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
|
||||
final UploadOperation operation;
|
||||
final LatLng coordToUse;
|
||||
@@ -250,15 +251,16 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
// Add a node deletion to the upload queue
|
||||
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
|
||||
PendingUpload addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
|
||||
final upload = PendingUpload(
|
||||
coord: node.coord,
|
||||
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
|
||||
profile: null, // No profile needed for deletions - just delete by node ID
|
||||
changesetComment: 'Delete a surveillance node', // Default comment for deletions
|
||||
changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
|
||||
uploadMode: uploadMode,
|
||||
operation: UploadOperation.delete,
|
||||
originalNodeId: node.id,
|
||||
@@ -282,6 +284,7 @@ class UploadQueueState extends ChangeNotifier {
|
||||
NodeProviderWithCache.instance.notifyListeners();
|
||||
|
||||
notifyListeners();
|
||||
return upload;
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
|
||||
@@ -262,16 +262,44 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
ElevatedButton(
|
||||
onPressed: isOfflineMode ? null : () async {
|
||||
try {
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Guard: provider and tile type must be non-null for a
|
||||
// useful offline area (fetchLocalTile requires exact match).
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
navigator.pop();
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(width: 10),
|
||||
Text(locService.t('download.title')),
|
||||
],
|
||||
),
|
||||
content: Text(locService.t('download.noTileProviderSelected')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.t('actions.ok')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
if (!context.mounted) return;
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
|
||||
// Get current tile provider info
|
||||
final appState = context.read<AppState>();
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
@@ -282,10 +310,10 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
tileProviderId: selectedProvider?.id,
|
||||
tileProviderName: selectedProvider?.name,
|
||||
tileTypeId: selectedTileType?.id,
|
||||
tileTypeName: selectedTileType?.name,
|
||||
tileProviderId: selectedProvider.id,
|
||||
tileProviderName: selectedProvider.name,
|
||||
tileTypeId: selectedTileType.id,
|
||||
tileTypeName: selectedTileType.name,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
showDialog(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
/// Applies a queue-pausing settings change (enabling offline mode or pausing
|
||||
/// the upload queue), but if there's an in-flight submission (a queue item
|
||||
/// that has already opened a changeset and hasn't closed it yet), first
|
||||
/// shows a non-dismissible-by-tap-outside dialog explaining that the
|
||||
/// submission needs to finish. Once it completes, a brief confirmation is
|
||||
/// shown before the dialog auto-closes and the setting takes effect.
|
||||
///
|
||||
/// If nothing is in-flight, [applyChange] is invoked immediately with no
|
||||
/// dialog shown at all.
|
||||
///
|
||||
/// The dialog can also be dismissed early by the user tapping "View in
|
||||
/// Queue", in case something is stuck (e.g. a failed/erroring item) — this
|
||||
/// lets the user navigate to the queue screen to investigate/clear it out,
|
||||
/// rather than getting stuck waiting indefinitely.
|
||||
Future<void> applyQueueSettingChangeRespectingInFlightUploads({
|
||||
required BuildContext context,
|
||||
required AppState appState,
|
||||
required Future<void> Function() applyChange,
|
||||
}) async {
|
||||
if (!appState.hasInFlightUploads) {
|
||||
await applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => InFlightUploadWaitDialog(
|
||||
onAllComplete: () {
|
||||
// Fire and forget - the dialog doesn't need to await this.
|
||||
applyChange();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class InFlightUploadWaitDialog extends StatefulWidget {
|
||||
/// Called exactly once, as soon as no queue items are actively processing.
|
||||
final VoidCallback onAllComplete;
|
||||
|
||||
const InFlightUploadWaitDialog({super.key, required this.onAllComplete});
|
||||
|
||||
@override
|
||||
State<InFlightUploadWaitDialog> createState() => _InFlightUploadWaitDialogState();
|
||||
}
|
||||
|
||||
class _InFlightUploadWaitDialogState extends State<InFlightUploadWaitDialog> {
|
||||
bool _completed = false;
|
||||
bool _appliedChange = false;
|
||||
Timer? _autoCloseTimer;
|
||||
late final AppState _appState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appState = AppState.instance;
|
||||
_appState.addListener(_onAppStateChanged);
|
||||
// Handle the (unlikely) race where the in-flight upload finishes between
|
||||
// the initial check and this dialog actually mounting.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _onAppStateChanged());
|
||||
}
|
||||
|
||||
void _onAppStateChanged() {
|
||||
if (!mounted || _completed) return;
|
||||
if (_appState.hasInFlightUploads) return;
|
||||
|
||||
setState(() {
|
||||
_completed = true;
|
||||
});
|
||||
|
||||
if (!_appliedChange) {
|
||||
_appliedChange = true;
|
||||
widget.onAllComplete();
|
||||
}
|
||||
|
||||
_autoCloseTimer = Timer(kInFlightUploadCompleteDisplayDuration, () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appState.removeListener(_onAppStateChanged);
|
||||
_autoCloseTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _viewInQueue() {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
Navigator.of(context).pushNamed('/settings/queue');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_completed ? Icons.check_circle : Icons.cloud_upload,
|
||||
color: _completed ? Colors.green : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_completed
|
||||
? locService.t('queue.inFlightCompleteTitle')
|
||||
: locService.t('queue.inFlightWaitTitle'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_completed
|
||||
? locService.t('queue.inFlightCompleteMessage')
|
||||
: locService.t('queue.inFlightWaitMessage'),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: _completed
|
||||
? const Icon(Icons.check_circle, size: 40, color: Colors.green)
|
||||
: const CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!_completed)
|
||||
TextButton(
|
||||
onPressed: _viewInQueue,
|
||||
child: Text(locService.t('node.viewInQueue')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,16 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
|
||||
|
||||
import '../../dev_config.dart';
|
||||
import '../../app_state.dart' show FollowMeMode;
|
||||
import '../../services/proximity_alert_service.dart';
|
||||
import '../../services/coordinate_validation.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
|
||||
|
||||
/// Simple GPS controller that handles precise location permissions only.
|
||||
/// Key principles:
|
||||
/// - Respect "denied forever" - stop trying
|
||||
@@ -145,15 +148,21 @@ class GpsController {
|
||||
void _startPositionStream() {
|
||||
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
|
||||
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
|
||||
|
||||
|
||||
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
|
||||
|
||||
|
||||
try {
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.high, // Request best, accept what we get
|
||||
distanceFilter: distanceFilter,
|
||||
),
|
||||
locationSettings: defaultTargetPlatform == TargetPlatform.android
|
||||
? AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
forceLocationManager: true,
|
||||
)
|
||||
: LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
),
|
||||
).listen(
|
||||
_onPositionReceived,
|
||||
onError: _onPositionError,
|
||||
@@ -180,6 +189,18 @@ class GpsController {
|
||||
|
||||
/// Handle incoming GPS position
|
||||
void _onPositionReceived(Position position) {
|
||||
// Reject malformed fixes (occasionally NaN/Infinite from the platform
|
||||
// location stack) before they reach map state.
|
||||
if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) {
|
||||
|
||||
debugPrint(
|
||||
'[GpsController] Ignoring invalid GPS position: '
|
||||
'lat=${position.latitude}, lng=${position.longitude}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
final newLocation = LatLng(position.latitude, position.longitude);
|
||||
_currentLocation = newLocation;
|
||||
|
||||
@@ -202,6 +223,7 @@ class GpsController {
|
||||
}
|
||||
|
||||
/// Handle GPS stream errors
|
||||
|
||||
void _onPositionError(dynamic error) {
|
||||
debugPrint('[GpsController] Position stream error: $error');
|
||||
if (_hasLocation) {
|
||||
@@ -358,4 +380,4 @@ class GpsController {
|
||||
_onMapMovedProgrammatically = null;
|
||||
_isUserInteracting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import '../../dev_config.dart';
|
||||
/// Manages data fetching, filtering, and node limit logic for the map.
|
||||
/// Handles profile changes, zoom level restrictions, and node rendering limits.
|
||||
class MapDataManager {
|
||||
final List<OsmNode> Function(LatLngBounds bounds) _getNodesForBounds;
|
||||
|
||||
MapDataManager({
|
||||
List<OsmNode> Function(LatLngBounds bounds)? getNodesForBounds,
|
||||
}) : _getNodesForBounds = getNodesForBounds ??
|
||||
((bounds) => NodeProviderWithCache.instance.getCachedNodesForBounds(bounds));
|
||||
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
@@ -51,28 +58,42 @@ class MapDataManager {
|
||||
List<OsmNode> allNodes;
|
||||
List<OsmNode> nodesToRender;
|
||||
bool isLimitActive = false;
|
||||
|
||||
int validNodesCount = 0;
|
||||
|
||||
if (currentZoom >= minZoom) {
|
||||
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
|
||||
if (mapBounds != null) {
|
||||
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
|
||||
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
|
||||
allNodes = _getNodesForBounds(expandedBounds);
|
||||
} else {
|
||||
allNodes = <OsmNode>[];
|
||||
}
|
||||
|
||||
|
||||
// Filter out invalid coordinates before applying limit
|
||||
final validNodes = allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).toList();
|
||||
|
||||
// Apply rendering limit to prevent UI lag
|
||||
if (validNodes.length > maxNodes) {
|
||||
validNodesCount = validNodes.length;
|
||||
|
||||
// Apply rendering limit to prevent UI lag.
|
||||
// Sort by distance from viewport center so the most visible nodes
|
||||
// always make the cut, preventing gaps that shift as you pan.
|
||||
if (validNodesCount > maxNodes) {
|
||||
final bounds = mapBounds!;
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
validNodes.sort((a, b) {
|
||||
final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) +
|
||||
(a.coord.longitude - centerLng) * (a.coord.longitude - centerLng);
|
||||
final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) +
|
||||
(b.coord.longitude - centerLng) * (b.coord.longitude - centerLng);
|
||||
final cmp = distA.compareTo(distB);
|
||||
return cmp != 0 ? cmp : a.id.compareTo(b.id);
|
||||
});
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
|
||||
} else {
|
||||
nodesToRender = validNodes;
|
||||
isLimitActive = false;
|
||||
@@ -87,6 +108,9 @@ class MapDataManager {
|
||||
// Notify parent if limit state changed (for button disabling)
|
||||
if (isLimitActive != _lastNodeLimitState) {
|
||||
_lastNodeLimitState = isLimitActive;
|
||||
if (isLimitActive) {
|
||||
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of $validNodesCount valid devices');
|
||||
}
|
||||
// Schedule callback after build completes to avoid setState during build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onNodeLimitChanged?.call(isLimitActive);
|
||||
@@ -97,11 +121,7 @@ class MapDataManager {
|
||||
allNodes: allNodes,
|
||||
nodesToRender: nodesToRender,
|
||||
isLimitActive: isLimitActive,
|
||||
validNodesCount: isLimitActive ? allNodes.where((node) {
|
||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||
node.coord.latitude.abs() <= 90 &&
|
||||
node.coord.longitude.abs() <= 180;
|
||||
}).length : 0,
|
||||
validNodesCount: isLimitActive ? validNodesCount : 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
@@ -16,6 +17,7 @@ class MapOverlays extends StatelessWidget {
|
||||
final EditNodeSession? editSession;
|
||||
final String? attribution; // Attribution for current tile provider
|
||||
final VoidCallback? onSearchPressed; // Callback for search button
|
||||
final bool hideZoomControls; // Hide zoom indicator + zoom in/out buttons
|
||||
const MapOverlays({
|
||||
super.key,
|
||||
required this.mapController,
|
||||
@@ -24,18 +26,67 @@ class MapOverlays extends StatelessWidget {
|
||||
this.editSession,
|
||||
this.attribution,
|
||||
this.onSearchPressed,
|
||||
this.hideZoomControls = false,
|
||||
});
|
||||
|
||||
/// Show full attribution text in a dialog
|
||||
|
||||
/// Show full attribution text in a dialog with license link.
|
||||
void _showAttributionDialog(BuildContext context, String attribution) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Get the license URL from the current tile provider's service policy
|
||||
final appState = AppState.instance;
|
||||
final tileType = appState.selectedTileType;
|
||||
final attributionUrl = tileType?.servicePolicy.attributionUrl;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(locService.t('mapTiles.attribution')),
|
||||
content: SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
attribution,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (attributionUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
link: true,
|
||||
label: locService.t('mapTiles.openLicense', params: [attributionUrl]),
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
final uri = Uri.parse(attributionUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
attributionUrl,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -90,58 +141,67 @@ class MapOverlays extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Zoom indicator, positioned relative to button bar with left safe area
|
||||
Positioned(
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
double zoom = 15.0; // fallback
|
||||
try {
|
||||
zoom = mapController.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
if (!hideZoomControls)
|
||||
Positioned(
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.52),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
double zoom = 15.0; // fallback
|
||||
try {
|
||||
zoom = mapController.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
return Text(
|
||||
'Zoom: ${zoom.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Attribution overlay, positioned relative to button bar with left safe area
|
||||
if (attribution != null)
|
||||
Positioned(
|
||||
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
|
||||
left: leftPositionWithSafeArea(10, safeArea),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
onTap: () => _showAttributionDialog(context, attribution!),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: Text(
|
||||
attribution!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -173,37 +233,40 @@ class MapOverlays extends StatelessWidget {
|
||||
|
||||
// Layer selector button
|
||||
const LayerSelectorButton(),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
if (!hideZoomControls) ...[
|
||||
const SizedBox(height: 8),
|
||||
// Zoom in button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_in",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Zoom out button
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
heroTag: "zoom_out",
|
||||
onPressed: () {
|
||||
try {
|
||||
final zoom = mapController.mapController.camera.zoom;
|
||||
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
|
||||
} catch (_) {
|
||||
// Map controller not ready yet
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.remove),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../services/coordinate_validation.dart';
|
||||
|
||||
/// Manages map position persistence and initial positioning.
|
||||
/// Handles saving/loading last map position and moving to initial locations.
|
||||
@@ -29,9 +30,9 @@ class MapPositionManager {
|
||||
final lng = prefs.getDouble('last_map_longitude');
|
||||
final zoom = prefs.getDouble('last_map_zoom');
|
||||
|
||||
if (lat != null && lng != null &&
|
||||
_isValidCoordinate(lat) && _isValidCoordinate(lng)) {
|
||||
final validZoom = zoom != null && _isValidZoom(zoom) ? zoom : 15.0;
|
||||
if (lat != null && lng != null &&
|
||||
isValidLatitude(lat) && isValidLongitude(lng)) {
|
||||
final validZoom = zoom != null && isValidZoom(zoom) ? zoom : 15.0;
|
||||
_initialLocation = LatLng(lat, lng);
|
||||
_initialZoom = validZoom;
|
||||
debugPrint('[MapPositionManager] Loaded last map position: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}, zoom: $_initialZoom');
|
||||
@@ -50,9 +51,9 @@ class MapPositionManager {
|
||||
try {
|
||||
final zoom = _initialZoom ?? 15.0;
|
||||
// Double-check coordinates are valid before moving
|
||||
if (_isValidCoordinate(_initialLocation!.latitude) &&
|
||||
_isValidCoordinate(_initialLocation!.longitude) &&
|
||||
_isValidZoom(zoom)) {
|
||||
if (isValidLatitude(_initialLocation!.latitude) &&
|
||||
isValidLongitude(_initialLocation!.longitude) &&
|
||||
isValidZoom(zoom)) {
|
||||
controller.mapController.move(_initialLocation!, zoom);
|
||||
_hasMovedToInitialLocation = true;
|
||||
debugPrint('[MapPositionManager] Moved to initial location: ${_initialLocation!.latitude}, ${_initialLocation!.longitude}');
|
||||
@@ -70,9 +71,9 @@ class MapPositionManager {
|
||||
Future<void> saveMapPosition(LatLng location, double zoom) async {
|
||||
try {
|
||||
// Validate coordinates and zoom before saving
|
||||
if (!_isValidCoordinate(location.latitude) ||
|
||||
!_isValidCoordinate(location.longitude) ||
|
||||
!_isValidZoom(zoom)) {
|
||||
if (!isValidLatitude(location.latitude) ||
|
||||
!isValidLongitude(location.longitude) ||
|
||||
!isValidZoom(zoom)) {
|
||||
debugPrint('[MapPositionManager] Invalid map position, not saving: lat=${location.latitude}, lng=${location.longitude}, zoom=$zoom');
|
||||
return;
|
||||
}
|
||||
@@ -87,8 +88,6 @@ class MapPositionManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Clear any stored map position (useful for recovery from invalid data)
|
||||
static Future<void> clearStoredMapPosition() async {
|
||||
try {
|
||||
@@ -101,20 +100,4 @@ class MapPositionManager {
|
||||
debugPrint('[MapPositionManager] Failed to clear stored map position: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a coordinate value is valid (not NaN, not infinite, within bounds)
|
||||
bool _isValidCoordinate(double value) {
|
||||
return !value.isNaN &&
|
||||
!value.isInfinite &&
|
||||
value >= -180.0 &&
|
||||
value <= 180.0;
|
||||
}
|
||||
|
||||
/// Validate that a zoom level is valid
|
||||
bool _isValidZoom(double zoom) {
|
||||
return !zoom.isNaN &&
|
||||
!zoom.isInfinite &&
|
||||
zoom >= 1.0 &&
|
||||
zoom <= 25.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +1,124 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/tile_provider.dart' as models;
|
||||
import '../../services/deflock_tile_provider.dart';
|
||||
import '../../services/provider_tile_cache_manager.dart';
|
||||
|
||||
/// Manages tile layer creation, caching, and provider switching.
|
||||
/// Uses DeFlock's custom tile provider for clean integration.
|
||||
/// Manages tile layer creation with per-provider caching and provider switching.
|
||||
///
|
||||
/// Each tile provider/type combination gets its own [DeflockTileProvider]
|
||||
/// instance with isolated caching (separate cache directory, configurable size
|
||||
/// limit, and policy-driven TTL enforcement). Providers are created lazily on
|
||||
/// first use and cached for instant switching.
|
||||
class TileLayerManager {
|
||||
DeflockTileProvider? _tileProvider;
|
||||
final Map<String, DeflockTileProvider> _providers = {};
|
||||
int _mapRebuildKey = 0;
|
||||
String? _lastProviderId;
|
||||
String? _lastTileTypeId;
|
||||
bool? _lastOfflineMode;
|
||||
|
||||
/// Get the current map rebuild key for cache busting
|
||||
/// Stream that triggers flutter_map to drop all tiles and reload.
|
||||
/// Fired after a debounced delay when tile errors are detected.
|
||||
final StreamController<void> _resetController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
/// Debounce timer for scheduling a tile reset after errors.
|
||||
Timer? _retryTimer;
|
||||
|
||||
/// Current retry delay — starts at [_minRetryDelay] and doubles on each
|
||||
/// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay]
|
||||
/// when a tile loads successfully.
|
||||
Duration _retryDelay = const Duration(seconds: 2);
|
||||
|
||||
static const _minRetryDelay = Duration(seconds: 2);
|
||||
static const _maxRetryDelay = Duration(seconds: 60);
|
||||
|
||||
/// Get the current map rebuild key for cache busting.
|
||||
int get mapRebuildKey => _mapRebuildKey;
|
||||
|
||||
/// Initialize the tile layer manager
|
||||
/// Current retry delay (exposed for testing).
|
||||
@visibleForTesting
|
||||
Duration get retryDelay => _retryDelay;
|
||||
|
||||
/// Stream of reset events (exposed for testing).
|
||||
@visibleForTesting
|
||||
Stream<void> get resetStream => _resetController.stream;
|
||||
|
||||
/// Initialize the tile layer manager.
|
||||
///
|
||||
/// [ProviderTileCacheManager.init] is called in main() before any widgets
|
||||
/// build, so this is a no-op retained for API compatibility.
|
||||
void initialize() {
|
||||
// Don't create tile provider here - create it fresh for each build
|
||||
// Cache directory is already resolved in main().
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
/// Dispose of all provider resources.
|
||||
///
|
||||
/// Synchronous to match Flutter's [State.dispose] contract. Calls
|
||||
/// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP
|
||||
/// client. (We don't call provider.dispose() here — flutter_map already
|
||||
/// called it when the TileLayer widget was removed, and it's safe to call
|
||||
/// again but unnecessary.)
|
||||
void dispose() {
|
||||
_tileProvider?.dispose();
|
||||
_retryTimer?.cancel();
|
||||
_resetController.close();
|
||||
for (final provider in _providers.values) {
|
||||
provider.shutdown();
|
||||
}
|
||||
_providers.clear();
|
||||
}
|
||||
|
||||
/// Check if cache should be cleared and increment rebuild key if needed.
|
||||
/// Returns true if cache was cleared (map should be rebuilt).
|
||||
bool checkAndClearCacheIfNeeded({
|
||||
required String? currentProviderId,
|
||||
required String? currentTileTypeId,
|
||||
required bool currentOfflineMode,
|
||||
}) {
|
||||
bool shouldClear = false;
|
||||
String? reason;
|
||||
|
||||
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
|
||||
if (_lastProviderId != currentProviderId) {
|
||||
reason = 'provider ($currentProviderId)';
|
||||
shouldClear = true;
|
||||
} else if (_lastTileTypeId != currentTileTypeId) {
|
||||
reason = 'tile type ($currentTileTypeId)';
|
||||
shouldClear = true;
|
||||
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
|
||||
} else if (_lastOfflineMode != currentOfflineMode) {
|
||||
reason = 'offline mode ($currentOfflineMode)';
|
||||
shouldClear = true;
|
||||
}
|
||||
|
||||
if (shouldClear) {
|
||||
// Force map rebuild with new key to bust flutter_map cache
|
||||
// Force map rebuild with new key to bust flutter_map cache.
|
||||
// We don't dispose providers here — they're reusable across switches.
|
||||
_mapRebuildKey++;
|
||||
// Dispose old provider before creating a fresh one (closes HTTP client)
|
||||
_tileProvider?.dispose();
|
||||
_tileProvider = null;
|
||||
// Reset backoff so the new provider starts with a clean slate.
|
||||
// Cancel any pending retry timer — it belongs to the old provider's errors.
|
||||
_retryDelay = _minRetryDelay;
|
||||
_retryTimer?.cancel();
|
||||
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
|
||||
}
|
||||
|
||||
_lastProviderId = currentProviderId;
|
||||
_lastTileTypeId = currentTileTypeId;
|
||||
_lastOfflineMode = currentOfflineMode;
|
||||
|
||||
return shouldClear;
|
||||
}
|
||||
|
||||
/// Clear the tile request queue (call after cache clear)
|
||||
/// Clear the tile request queue (call after cache clear).
|
||||
///
|
||||
/// In the old architecture this incremented [_mapRebuildKey] a second time
|
||||
/// to force a rebuild after the provider was disposed and recreated. With
|
||||
/// per-provider caching, [checkAndClearCacheIfNeeded] already increments the
|
||||
/// key, so this is now a no-op. Kept for API compatibility with map_view.
|
||||
void clearTileQueue() {
|
||||
// With NetworkTileProvider, clearing is handled by FlutterMap's internal cache
|
||||
// We just need to increment the rebuild key to bust the cache
|
||||
_mapRebuildKey++;
|
||||
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
|
||||
// No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey.
|
||||
}
|
||||
|
||||
/// Clear tile queue immediately (for zoom changes, etc.)
|
||||
@@ -70,19 +126,85 @@ class TileLayerManager {
|
||||
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Clear only tiles that are no longer visible in the current bounds
|
||||
/// Clear only tiles that are no longer visible in the current bounds.
|
||||
void clearStaleRequests({required LatLngBounds currentBounds}) {
|
||||
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
|
||||
}
|
||||
|
||||
/// Called by flutter_map when a tile fails to load. Schedules a debounced
|
||||
/// reset so that all failed tiles get retried after the burst of errors
|
||||
/// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap.
|
||||
///
|
||||
/// Skips retry for [TileLoadCancelledException] (tile scrolled off screen)
|
||||
/// and [TileNotAvailableOfflineException] (no cached data, retrying won't
|
||||
/// help without network).
|
||||
@visibleForTesting
|
||||
void onTileLoadError(
|
||||
TileImage tile,
|
||||
Object error,
|
||||
StackTrace? stackTrace,
|
||||
) {
|
||||
// Cancelled tiles are already gone — no retry needed.
|
||||
if (error is TileLoadCancelledException) return;
|
||||
|
||||
// Offline misses won't resolve by retrying — tile isn't cached.
|
||||
if (error is TileNotAvailableOfflineException) return;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Tile error at '
|
||||
'${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, '
|
||||
'scheduling retry in ${_retryDelay.inSeconds}s',
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/// Schedule a debounced tile reset with exponential backoff.
|
||||
///
|
||||
/// Cancels any pending retry timer and starts a new one at the current
|
||||
/// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped
|
||||
/// at [_maxRetryDelay]).
|
||||
@visibleForTesting
|
||||
void scheduleRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(_retryDelay, () {
|
||||
if (!_resetController.isClosed) {
|
||||
debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles');
|
||||
_resetController.add(null);
|
||||
}
|
||||
// Back off for next failure cycle
|
||||
_retryDelay = Duration(
|
||||
milliseconds: min(
|
||||
_retryDelay.inMilliseconds * 2,
|
||||
_maxRetryDelay.inMilliseconds,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset backoff to minimum delay. Called when a tile loads successfully
|
||||
/// via the offline-first path, indicating connectivity has been restored.
|
||||
///
|
||||
/// Note: the common path (`NetworkTileImageProvider`) does not call this,
|
||||
/// so backoff resets only when the offline-first path succeeds over the
|
||||
/// network. In practice this is fine — the common path's `RetryClient`
|
||||
/// handles its own retries, and the reset stream only retries tiles that
|
||||
/// flutter_map has already marked as `loadError`.
|
||||
void onTileLoadSuccess() {
|
||||
_retryDelay = _minRetryDelay;
|
||||
}
|
||||
|
||||
/// Build tile layer widget with current provider and type.
|
||||
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
|
||||
///
|
||||
/// Gets or creates a [DeflockTileProvider] for the given provider/type
|
||||
/// combination, each with its own isolated cache.
|
||||
Widget buildTileLayer({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
// Create a fresh tile provider instance if we don't have one or cache was cleared
|
||||
_tileProvider ??= DeflockTileProvider();
|
||||
final tileProvider = _getOrCreateProvider(
|
||||
selectedProvider: selectedProvider,
|
||||
selectedTileType: selectedTileType,
|
||||
);
|
||||
|
||||
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
|
||||
// override handles the real URL generation; flutter_map uses urlTemplate
|
||||
@@ -94,7 +216,89 @@ class TileLayerManager {
|
||||
urlTemplate: urlTemplate,
|
||||
userAgentPackageName: 'me.deflock.deflockapp',
|
||||
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
|
||||
tileProvider: _tileProvider!,
|
||||
tileProvider: tileProvider,
|
||||
// Wire the reset stream so failed tiles get retried after a delay.
|
||||
reset: _resetController.stream,
|
||||
errorTileCallback: onTileLoadError,
|
||||
// Clean up error tiles when they scroll off screen.
|
||||
evictErrorTileStrategy: EvictErrorTileStrategy.notVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a config fingerprint for drift detection.
|
||||
///
|
||||
/// If any of these fields change (e.g. user edits the URL template or
|
||||
/// rotates an API key) the cached [DeflockTileProvider] must be replaced.
|
||||
static String _configFingerprint(
|
||||
models.TileProvider provider,
|
||||
models.TileType tileType,
|
||||
) =>
|
||||
'${provider.id}/${tileType.id}'
|
||||
'|${tileType.urlTemplate}'
|
||||
'|${tileType.maxZoom}'
|
||||
'|${provider.apiKey ?? ''}';
|
||||
|
||||
/// Get or create a [DeflockTileProvider] for the given provider/type.
|
||||
///
|
||||
/// Providers are cached by `providerId/tileTypeId`. If the effective config
|
||||
/// (URL template, max zoom, API key) has changed since the provider was
|
||||
/// created, the stale instance is shut down and replaced.
|
||||
DeflockTileProvider _getOrCreateProvider({
|
||||
required models.TileProvider? selectedProvider,
|
||||
required models.TileType? selectedTileType,
|
||||
}) {
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
// No provider configured — return a fallback with default config.
|
||||
return _providers.putIfAbsent(
|
||||
'_fallback',
|
||||
() => DeflockTileProvider(
|
||||
providerId: 'unknown',
|
||||
tileType: models.TileType(
|
||||
id: 'unknown',
|
||||
name: 'Unknown',
|
||||
urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}',
|
||||
attribution: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final key = '${selectedProvider.id}/${selectedTileType.id}';
|
||||
final fingerprint = _configFingerprint(selectedProvider, selectedTileType);
|
||||
|
||||
// Check for config drift: if the provider exists but its config has
|
||||
// changed, shut down the stale instance so a fresh one is created below.
|
||||
final existing = _providers[key];
|
||||
if (existing != null && existing.configFingerprint != fingerprint) {
|
||||
debugPrint(
|
||||
'[TileLayerManager] Config changed for $key — replacing provider',
|
||||
);
|
||||
existing.shutdown();
|
||||
_providers.remove(key);
|
||||
}
|
||||
|
||||
return _providers.putIfAbsent(key, () {
|
||||
final cachingProvider = ProviderTileCacheManager.isInitialized
|
||||
? ProviderTileCacheManager.getOrCreate(
|
||||
providerId: selectedProvider.id,
|
||||
tileTypeId: selectedTileType.id,
|
||||
policy: selectedTileType.servicePolicy,
|
||||
)
|
||||
: null;
|
||||
|
||||
debugPrint(
|
||||
'[TileLayerManager] Creating provider for $key '
|
||||
'(cache: ${cachingProvider != null ? "enabled" : "disabled"})',
|
||||
);
|
||||
|
||||
return DeflockTileProvider(
|
||||
providerId: selectedProvider.id,
|
||||
tileType: selectedTileType,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onTileLoadSuccess,
|
||||
configFingerprint: fingerprint,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+104
-11
@@ -3,6 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../app_state.dart' show AppState, FollowMeMode;
|
||||
import '../services/offline_area_service.dart';
|
||||
@@ -25,9 +26,12 @@ import 'node_limit_indicator.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/proximity_alert_service.dart';
|
||||
import '../services/coordinate_validation.dart';
|
||||
import 'sheet_aware_map.dart';
|
||||
|
||||
import 'custom_scale_bar.dart';
|
||||
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final AnimatedMapController controller;
|
||||
const MapView({
|
||||
@@ -42,6 +46,7 @@ class MapView extends StatefulWidget {
|
||||
this.onSearchPressed,
|
||||
this.onNodeLimitChanged,
|
||||
this.onLocationStatusChanged,
|
||||
this.onMapLongPress,
|
||||
});
|
||||
|
||||
final FollowMeMode followMeMode;
|
||||
@@ -53,6 +58,7 @@ class MapView extends StatefulWidget {
|
||||
final VoidCallback? onSearchPressed;
|
||||
final void Function(bool isLimited)? onNodeLimitChanged;
|
||||
final VoidCallback? onLocationStatusChanged;
|
||||
final void Function(LatLng)? onMapLongPress;
|
||||
|
||||
@override
|
||||
State<MapView> createState() => MapViewState();
|
||||
@@ -77,6 +83,13 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
// Last known-good camera center/zoom, used to self-heal if the camera
|
||||
// ever gets pushed into an invalid state.
|
||||
LatLng? _lastValidCenter;
|
||||
double? _lastValidZoom;
|
||||
|
||||
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
@@ -84,7 +97,20 @@ class MapViewState extends State<MapView> {
|
||||
// Track active pointers to suppress follow-me animations during touch
|
||||
int _activePointers = 0;
|
||||
|
||||
bool _isWakelockEnabled = false;
|
||||
|
||||
void _updateWakelock(bool shouldBeAwake) {
|
||||
if (_isWakelockEnabled != shouldBeAwake) {
|
||||
_isWakelockEnabled = shouldBeAwake;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (shouldBeAwake) {
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -213,6 +239,7 @@ class MapViewState extends State<MapView> {
|
||||
_nodeController.dispose();
|
||||
_tileManager.dispose();
|
||||
_gpsController.dispose();
|
||||
WakelockPlus.disable();
|
||||
// PrefetchAreaService no longer used - replaced with NodeDataManager
|
||||
super.dispose();
|
||||
}
|
||||
@@ -258,6 +285,27 @@ class MapViewState extends State<MapView> {
|
||||
return (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
}
|
||||
|
||||
/// Known-good fallback location for [initialCenter]: validated GPS
|
||||
/// location, then validated persisted position, then a hardcoded default.
|
||||
LatLng get _safeInitialCenter {
|
||||
final gpsLocation = _gpsController.currentLocation;
|
||||
if (gpsLocation != null &&
|
||||
isValidLatitude(gpsLocation.latitude) &&
|
||||
isValidLongitude(gpsLocation.longitude)) {
|
||||
return gpsLocation;
|
||||
}
|
||||
final persisted = _positionManager.initialLocation;
|
||||
if (persisted != null &&
|
||||
isValidLatitude(persisted.latitude) &&
|
||||
isValidLongitude(persisted.longitude)) {
|
||||
return persisted;
|
||||
}
|
||||
return LatLng(37.7749, -122.4194);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MapView oldWidget) {
|
||||
@@ -278,23 +326,21 @@ class MapViewState extends State<MapView> {
|
||||
final session = appState.session;
|
||||
final editSession = appState.editSession;
|
||||
|
||||
// Keep screen awake based on user setting
|
||||
_updateWakelock(appState.keepScreenAwake);
|
||||
|
||||
// Check if enabled profiles changed and refresh nodes if needed
|
||||
_nodeController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
onProfilesChanged: _refreshNodesFromProvider,
|
||||
);
|
||||
|
||||
// Check if tile type OR offline mode changed and clear cache if needed
|
||||
final cacheCleared = _tileManager.checkAndClearCacheIfNeeded(
|
||||
// Check if provider, tile type, or offline mode changed and clear cache if needed
|
||||
_tileManager.checkAndClearCacheIfNeeded(
|
||||
currentProviderId: appState.selectedTileProvider?.id,
|
||||
currentTileTypeId: appState.selectedTileType?.id,
|
||||
currentOfflineMode: appState.offlineMode,
|
||||
);
|
||||
|
||||
if (cacheCleared) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tileManager.clearTileQueue();
|
||||
});
|
||||
}
|
||||
|
||||
// Seed add‑mode target once, after first controller center is available.
|
||||
if (session != null && session.target == null) {
|
||||
@@ -396,21 +442,50 @@ class MapViewState extends State<MapView> {
|
||||
if (_activePointers > 0) _activePointers--;
|
||||
},
|
||||
child: FlutterMap(
|
||||
key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'),
|
||||
key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'),
|
||||
mapController: _controller.mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
|
||||
initialCenter: _safeInitialCenter,
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
|
||||
minZoom: 1.0,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
// Self-heal if the camera center/zoom ever becomes invalid
|
||||
// (observed to happen silently during ordinary panning,
|
||||
// leaving a blank grey map with no tiles/markers). Snap back
|
||||
// to the last known-good position immediately.
|
||||
if (!isValidLatitude(pos.center.latitude) ||
|
||||
!isValidLongitude(pos.center.longitude) ||
|
||||
!isValidZoom(pos.zoom)) {
|
||||
debugPrint(
|
||||
|
||||
'[MapView] Detected invalid camera position '
|
||||
'(lat=${pos.center.latitude}, lng=${pos.center.longitude}, zoom=${pos.zoom}) '
|
||||
'- snapping back to last known-good position',
|
||||
);
|
||||
final recoveryCenter = _lastValidCenter ?? _safeInitialCenter;
|
||||
final recoveryZoom = _lastValidZoom ?? (_positionManager.initialZoom ?? 15.0);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
_controller.mapController.move(recoveryCenter, recoveryZoom);
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Failed to recover camera position: $e');
|
||||
}
|
||||
});
|
||||
return; // Don't process this invalid position further
|
||||
}
|
||||
_lastValidCenter = pos.center;
|
||||
_lastValidZoom = pos.zoom;
|
||||
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
widget.onUserGesture();
|
||||
}
|
||||
|
||||
// Enforce minimum zoom level for add/edit node sheets (but not tag sheet)
|
||||
|
||||
if ((session != null || editSession != null) && pos.zoom < kMinZoomForNodeEditingSheets) {
|
||||
// User tried to zoom out below minimum - snap back to minimum zoom
|
||||
_controller.animateTo(
|
||||
@@ -501,6 +576,15 @@ class MapViewState extends State<MapView> {
|
||||
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
|
||||
}
|
||||
},
|
||||
onTap: (tapPosition, point) {
|
||||
// Handle tap on empty map area - currently no action needed
|
||||
debugPrint('[MapView] Tap at: $point');
|
||||
},
|
||||
onLongPress: (tapPosition, point) {
|
||||
// Handle long press on empty map area - add node here
|
||||
debugPrint('[MapView] Long press at: $point');
|
||||
widget.onMapLongPress?.call(point);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
_tileManager.buildTileLayer(
|
||||
@@ -512,17 +596,24 @@ class MapViewState extends State<MapView> {
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
// When zoom controls (and the zoom level indicator) are hidden,
|
||||
// shift the scale bar down into the indicator's old slot so we
|
||||
// don't leave a weird empty gap above the attribution text.
|
||||
final scaleBarSpacing = appState.hideZoomControls
|
||||
? kZoomIndicatorSpacingAboveButtonBar
|
||||
: kScaleBarSpacingAboveButtonBar;
|
||||
return CustomScaleBar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom),
|
||||
bottom: bottomPositionFromButtonBar(scaleBarSpacing, safeArea.bottom),
|
||||
),
|
||||
maxWidthPx: 120,
|
||||
barHeight: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -536,8 +627,10 @@ class MapViewState extends State<MapView> {
|
||||
editSession: editSession,
|
||||
attribution: appState.selectedTileType?.attribution,
|
||||
onSearchPressed: widget.onSearchPressed,
|
||||
hideZoomControls: appState.hideZoomControls,
|
||||
),
|
||||
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
builder: (context) {
|
||||
|
||||
+542
-75
@@ -2,13 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/map_data_provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'advanced_edit_options_sheet.dart';
|
||||
|
||||
class NodeTagSheet extends StatelessWidget {
|
||||
|
||||
class NodeTagSheet extends StatefulWidget {
|
||||
final OsmNode node;
|
||||
final VoidCallback? onEditPressed;
|
||||
final bool isNodeLimitActive;
|
||||
@@ -20,6 +24,88 @@ class NodeTagSheet extends StatelessWidget {
|
||||
this.isNodeLimitActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NodeTagSheet> createState() => _NodeTagSheetState();
|
||||
}
|
||||
|
||||
class _NodeTagSheetState extends State<NodeTagSheet> {
|
||||
// The PendingUpload backing this node (if it was in a pending state when the
|
||||
// sheet was opened). We resolve this once and hold onto the object reference
|
||||
// itself (not just its id) so we can keep observing its live status via its
|
||||
// mutable fields even after it's eventually removed from the queue list
|
||||
// (which happens ~1s after completion). Without this, once the item left
|
||||
// the queue we'd have no way to tell "finished successfully" apart from
|
||||
// "still pending" and would show stale/misleading status forever.
|
||||
PendingUpload? _upload;
|
||||
bool _uploadResolved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolveTrackedUpload();
|
||||
}
|
||||
|
||||
void _resolveTrackedUpload() {
|
||||
if (_uploadResolved) return;
|
||||
_uploadResolved = true;
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
if (widget.node.tags['_pending_upload'] == 'true') {
|
||||
_upload = appState.pendingUploads
|
||||
.firstWhereOrNull((u) => u.tempNodeId == widget.node.id);
|
||||
} else if (widget.node.tags['_pending_edit'] == 'true' ||
|
||||
widget.node.tags['_pending_deletion'] == 'true') {
|
||||
_upload = appState.pendingUploads
|
||||
.firstWhereOrNull((u) => u.originalNodeId == widget.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the node data that should currently be displayed.
|
||||
///
|
||||
/// While an upload/edit/deletion is still in progress (or has failed), this
|
||||
/// is just the snapshot that was passed in when the sheet was opened -
|
||||
/// nothing has changed in the cache yet. Once the tracked upload completes,
|
||||
/// we switch to the live node from the cache using the *real* submitted ID
|
||||
/// (which can differ from the temporary negative "-epoch" ID for creates
|
||||
/// and extracts, but matches the original ID for edits) so that tags, id,
|
||||
/// and editability reflect reality instead of the stale pre-submission
|
||||
/// snapshot. Returns null if the node has been deleted.
|
||||
OsmNode? _resolveDisplayNode() {
|
||||
final upload = _upload;
|
||||
if (upload == null) {
|
||||
// No tracked upload - either this node was never pending, or we
|
||||
// couldn't find a matching queue entry (shouldn't normally happen).
|
||||
// Try a live cache lookup so we still pick up any external updates,
|
||||
// but fall back to the passed-in node if it's not (or no longer) cached.
|
||||
return MapDataProvider().getNodeById(widget.node.id) ?? widget.node;
|
||||
}
|
||||
|
||||
if (!upload.isComplete) {
|
||||
// Still pending/in-progress/failed - nothing has changed yet.
|
||||
return widget.node;
|
||||
}
|
||||
|
||||
if (upload.isDeletion) {
|
||||
return null; // Node has been deleted
|
||||
}
|
||||
|
||||
// Upload completed successfully (create/modify/extract). Look up the
|
||||
// live node using the real submitted ID (same as originalNodeId for
|
||||
// modify operations, a brand-new positive ID for create/extract).
|
||||
final realId = upload.submittedNodeId ?? widget.node.id;
|
||||
final cached = MapDataProvider().getNodeById(realId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
// Fallback: cache might have already been cleared/replaced; reconstruct
|
||||
// from the upload data directly so we don't fall back to stale pending
|
||||
// tags/id.
|
||||
return OsmNode(
|
||||
id: realId,
|
||||
coord: upload.coord,
|
||||
tags: upload.getCombinedTags(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -27,22 +113,30 @@ class NodeTagSheet extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
|
||||
final upload = _upload;
|
||||
final isDeleted = upload != null && upload.isComplete && upload.isDeletion;
|
||||
final displayNode = isDeleted ? null : _resolveDisplayNode();
|
||||
|
||||
if (displayNode == null) {
|
||||
return _buildDeletedSheet(context, locService);
|
||||
}
|
||||
|
||||
// Check if this device is editable (not a pending upload, pending edit, or pending deletion)
|
||||
final isEditable = (!node.tags.containsKey('_pending_upload') ||
|
||||
node.tags['_pending_upload'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_edit') ||
|
||||
node.tags['_pending_edit'] != 'true') &&
|
||||
(!node.tags.containsKey('_pending_deletion') ||
|
||||
node.tags['_pending_deletion'] != 'true');
|
||||
final isEditable = (!displayNode.tags.containsKey('_pending_upload') ||
|
||||
displayNode.tags['_pending_upload'] != 'true') &&
|
||||
(!displayNode.tags.containsKey('_pending_edit') ||
|
||||
displayNode.tags['_pending_edit'] != 'true') &&
|
||||
(!displayNode.tags.containsKey('_pending_deletion') ||
|
||||
displayNode.tags['_pending_deletion'] != 'true');
|
||||
|
||||
// Check if this is a real OSM node (not pending) - for "View on OSM" button
|
||||
final isRealOSMNode = !node.tags.containsKey('_pending_upload') &&
|
||||
node.id > 0; // Real OSM nodes have positive IDs
|
||||
final isRealOSMNode = !displayNode.tags.containsKey('_pending_upload') &&
|
||||
displayNode.id > 0; // Real OSM nodes have positive IDs
|
||||
|
||||
void openEditSheet() {
|
||||
// Check if node limit is active and warn user
|
||||
if (isNodeLimitActive) {
|
||||
if (widget.isNodeLimitActive) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
@@ -55,22 +149,22 @@ class NodeTagSheet extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onEditPressed != null) {
|
||||
onEditPressed!(); // Use callback if provided
|
||||
if (widget.onEditPressed != null) {
|
||||
widget.onEditPressed!(); // Use callback if provided
|
||||
} else {
|
||||
// Fallback behavior
|
||||
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 {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
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.confirmDeleteMessage', params: [node.id.toString()])),
|
||||
content: Text(locService.t('node.mustBeLoggedInToDelete')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
@@ -78,17 +172,29 @@ class NodeTagSheet extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(locService.t('actions.delete')),
|
||||
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})>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => _DeleteNodeDialog(
|
||||
nodeId: displayNode.id.toString(),
|
||||
locService: locService,
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldDelete == true && context.mounted) {
|
||||
if ((result?.confirmed ?? false) && context.mounted) {
|
||||
Navigator.pop(context); // Close this sheet first
|
||||
appState.deleteNode(node);
|
||||
appState.deleteNode(displayNode, changesetComment: result!.comment);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
|
||||
);
|
||||
@@ -96,7 +202,7 @@ class NodeTagSheet extends StatelessWidget {
|
||||
}
|
||||
|
||||
void viewOnOSM() async {
|
||||
final url = 'https://www.openstreetmap.org/node/${node.id}';
|
||||
final url = 'https://www.openstreetmap.org/node/${displayNode.id}';
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
@@ -121,10 +227,148 @@ class NodeTagSheet extends StatelessWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
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(
|
||||
builder: (context, constraints) {
|
||||
return SafeArea(
|
||||
@@ -135,78 +379,92 @@ class NodeTagSheet extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
locService.t('node.title').replaceAll('{}', node.id.toString()),
|
||||
locService.t('node.title').replaceAll('{}', displayNode.id.toString()),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
?uploadStatusBanner,
|
||||
|
||||
// Tag list with flexible height constraint
|
||||
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...node.tags.entries.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Linkify(
|
||||
onOpen: (link) async {
|
||||
final uri = Uri.parse(link.url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
|
||||
);
|
||||
}
|
||||
},
|
||||
text: e.value,
|
||||
child: SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...displayNode.tags.entries
|
||||
.where((e) => !e.key.startsWith('_')) // Hide internal bookkeeping tags
|
||||
.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
e.key,
|
||||
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),
|
||||
// First row: View and Advanced buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isRealOSMNode) ...[
|
||||
TextButton.icon(
|
||||
OutlinedButton.icon(
|
||||
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')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
if (isEditable) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: openAdvancedEdit,
|
||||
@@ -235,12 +493,12 @@ class NodeTagSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: node.isConstrained ? null : deleteNode,
|
||||
onPressed: displayNode.isConstrained ? null : deleteNode,
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: Text(locService.t('actions.delete')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 36),
|
||||
foregroundColor: node.isConstrained ? null : Colors.red,
|
||||
foregroundColor: displayNode.isConstrained ? null : Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -260,4 +518,213 @@ 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 {
|
||||
final String nodeId;
|
||||
final LocalizationService locService;
|
||||
|
||||
const _DeleteNodeDialog({
|
||||
required this.nodeId,
|
||||
required this.locService,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState();
|
||||
}
|
||||
|
||||
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
|
||||
late final TextEditingController _commentController;
|
||||
_DeleteReason? _selectedReason;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_commentController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canConfirm {
|
||||
if (_selectedReason == null) return false;
|
||||
if (_selectedReason == _DeleteReason.custom) {
|
||||
return _commentController.text.trim().isNotEmpty;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.locService.t('node.confirmDeleteTitle')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.locService.t('node.deleteReasonLabel'),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<_DeleteReason>(
|
||||
initialValue: _selectedReason,
|
||||
hint: Text(widget.locService.t('node.deleteReasonSelectHint')),
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: _DeleteReason.values
|
||||
.map((reason) => DropdownMenuItem(
|
||||
value: reason,
|
||||
child: Text(reason.label(widget.locService)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (reason) {
|
||||
setState(() {
|
||||
_selectedReason = reason;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_selectedReason == _DeleteReason.custom) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _commentController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.locService.t('node.deleteReasonHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: 2,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
|
||||
child: Text(widget.locService.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _canConfirm
|
||||
? () {
|
||||
final reasonText = _selectedReason == _DeleteReason.custom
|
||||
? _commentController.text.trim()
|
||||
: _selectedReason!.commentText;
|
||||
final finalComment = reasonText.isEmpty
|
||||
? 'Delete a surveillance node'
|
||||
: 'Delete a surveillance node: $reasonText';
|
||||
Navigator.of(context).pop((confirmed: true, comment: finalComment));
|
||||
}
|
||||
: null,
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: Text(widget.locService.t('actions.delete')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+173
-141
@@ -13,10 +13,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
|
||||
sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
version: "7.2.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -29,10 +29,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: app_links_platform_interface
|
||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||
sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.0.3"
|
||||
app_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -61,10 +61,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -77,10 +77,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -109,10 +109,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -165,20 +165,20 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.14"
|
||||
desktop_webview_window:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: desktop_webview_window
|
||||
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
|
||||
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.3"
|
||||
version: "0.3.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
@@ -242,34 +242,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
version: "19.5.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
|
||||
sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.2"
|
||||
version: "8.3.0"
|
||||
flutter_map_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -282,34 +290,34 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
|
||||
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.7"
|
||||
version: "2.4.8"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
version: "10.3.1"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.3.2"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -322,10 +330,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -338,10 +346,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
version: "2.3.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -351,10 +359,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth_2
|
||||
sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3"
|
||||
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.1"
|
||||
version: "5.0.3"
|
||||
flutter_web_auth_2_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -388,18 +396,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_apple
|
||||
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
|
||||
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.13"
|
||||
version: "2.3.14"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_platform_interface
|
||||
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
|
||||
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.6"
|
||||
version: "4.2.8"
|
||||
geolocator_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -416,30 +424,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: gtk
|
||||
sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c
|
||||
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "2.2.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "2.0.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -480,14 +480,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
version: "4.12.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -536,22 +552,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -564,50 +564,42 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
||||
sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "3.0.0"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
version: "1.0.5"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -620,26 +612,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: oauth2_client
|
||||
sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f"
|
||||
sha256: "74d1024ce3bd8c6012d079e075068c94d64d804a40bcb3d4adb7348bf0c278f7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
version: "4.3.2"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,18 +668,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -700,10 +700,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -748,10 +748,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "3.0.0"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -776,22 +776,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.20"
|
||||
version: "2.4.26"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -812,10 +820,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -832,6 +840,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
simple_sparse_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_sparse_list
|
||||
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -849,42 +865,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+2"
|
||||
version: "2.4.3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
version: "2.5.11"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
sha256: "164a5d73ab87a134566057219988bafde837029a64264e61f1f04376ef3cfcd2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
version: "2.4.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -913,10 +929,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
version: "3.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -929,18 +945,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -953,10 +969,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
version: "1.1.9"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -977,10 +993,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
version: "6.3.32"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1017,10 +1033,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1041,10 +1057,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||
sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.19"
|
||||
version: "1.2.2"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1057,10 +1073,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
|
||||
sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.6"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1073,10 +1089,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1097,10 +1129,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: window_to_front
|
||||
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee"
|
||||
sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
version: "0.0.4"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1134,5 +1166,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
+5
-2
@@ -1,10 +1,11 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.7.2+48 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.11.0+61 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
|
||||
flutter: ">=3.44.0" # Mandate Flutter 3.44.0 for SPM default
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -19,7 +20,7 @@ dependencies:
|
||||
http: ^1.2.1
|
||||
flutter_svg: ^2.0.10
|
||||
xml: ^6.4.2
|
||||
flutter_local_notifications: ^17.2.2
|
||||
flutter_local_notifications: ^19.0.0
|
||||
url_launcher: ^6.3.0
|
||||
flutter_linkify: ^6.0.0
|
||||
app_links: ^7.0.0
|
||||
@@ -38,11 +39,13 @@ dependencies:
|
||||
package_info_plus: ^9.0.0
|
||||
csv: ^6.0.0
|
||||
collection: ^1.18.0
|
||||
wakelock_plus: ^1.5.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^1.0.4
|
||||
fake_async: ^1.3.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_native_splash: ^2.4.6
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
localize.py — small helper for managing lib/localizations/*.json translation files.
|
||||
|
||||
Usage (normally invoked via scripts/localize.sh):
|
||||
|
||||
localize.sh --check
|
||||
Validate that every locale file is valid JSON, has a language.name key,
|
||||
and has exactly the same set of leaf keys as en.json (the reference).
|
||||
Exits non-zero (and prints details) if anything doesn't match.
|
||||
|
||||
localize.sh --list [--lang xx]
|
||||
Print all dot-notation leaf keys. Defaults to en.json if --lang is
|
||||
omitted. Useful for seeing the full key structure at a glance.
|
||||
|
||||
localize.sh --get --lang xx --key some.nested.key
|
||||
Print the value of a single key from lib/localizations/xx.json.
|
||||
|
||||
localize.sh --write --lang xx --key some.nested.key "New value"
|
||||
Set (or add) the value of a key in lib/localizations/xx.json. Creates
|
||||
intermediate objects as needed. Preserves key order/formatting via
|
||||
Python's json module (2-space indent, matching existing files).
|
||||
|
||||
Examples:
|
||||
./scripts/localize.sh --check
|
||||
./scripts/localize.sh --list --lang de
|
||||
./scripts/localize.sh --get --lang en --key node.title
|
||||
./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LOCALIZATIONS_DIR = Path(__file__).resolve().parent.parent / "lib" / "localizations"
|
||||
REFERENCE_LOCALE = "en"
|
||||
|
||||
|
||||
def locale_path(lang: str) -> Path:
|
||||
return LOCALIZATIONS_DIR / f"{lang}.json"
|
||||
|
||||
|
||||
def load_locale(lang: str) -> dict:
|
||||
path = locale_path(lang)
|
||||
if not path.exists():
|
||||
print(f"error: no such locale file: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"error: {path.name} is not valid JSON: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def save_locale(lang: str, data: dict) -> None:
|
||||
path = locale_path(lang)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def all_locale_files():
|
||||
return sorted(LOCALIZATIONS_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def extract_leaf_keys(data, prefix=""):
|
||||
keys = set()
|
||||
for k, v in data.items():
|
||||
full_key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
keys |= extract_leaf_keys(v, full_key)
|
||||
else:
|
||||
keys.add(full_key)
|
||||
return keys
|
||||
|
||||
|
||||
def get_nested(data: dict, dotted_key: str):
|
||||
parts = dotted_key.split(".")
|
||||
node = data
|
||||
for part in parts:
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return None
|
||||
node = node[part]
|
||||
return node
|
||||
|
||||
|
||||
def set_nested(data: dict, dotted_key: str, value: str) -> None:
|
||||
parts = dotted_key.split(".")
|
||||
node = data
|
||||
for part in parts[:-1]:
|
||||
if part not in node or not isinstance(node[part], dict):
|
||||
node[part] = {}
|
||||
node = node[part]
|
||||
node[parts[-1]] = value
|
||||
|
||||
|
||||
def cmd_check(_args) -> int:
|
||||
files = all_locale_files()
|
||||
if not files:
|
||||
print(f"error: no locale files found in {LOCALIZATIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ref_path = locale_path(REFERENCE_LOCALE)
|
||||
if not ref_path.exists():
|
||||
print(f"error: reference locale '{REFERENCE_LOCALE}.json' not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ok = True
|
||||
|
||||
parsed = {}
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
parsed[path.stem] = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"FAIL {path.name}: invalid JSON: {e}")
|
||||
ok = False
|
||||
|
||||
for lang, data in parsed.items():
|
||||
lang_name = data.get("language", {}).get("name")
|
||||
if not isinstance(lang_name, str) or not lang_name:
|
||||
print(f"FAIL {lang}.json: missing 'language.name' string")
|
||||
ok = False
|
||||
|
||||
if REFERENCE_LOCALE not in parsed:
|
||||
print(f"error: {REFERENCE_LOCALE}.json failed to parse, cannot compare keys")
|
||||
return 1
|
||||
|
||||
reference_keys = extract_leaf_keys(parsed[REFERENCE_LOCALE])
|
||||
|
||||
for lang, data in parsed.items():
|
||||
if lang == REFERENCE_LOCALE:
|
||||
continue
|
||||
keys = extract_leaf_keys(data)
|
||||
missing = reference_keys - keys
|
||||
extra = keys - reference_keys
|
||||
if missing:
|
||||
print(f"FAIL {lang}.json: missing {len(missing)} key(s):")
|
||||
for k in sorted(missing):
|
||||
print(f" - {k}")
|
||||
ok = False
|
||||
if extra:
|
||||
print(f"FAIL {lang}.json: has {len(extra)} extra key(s) not in {REFERENCE_LOCALE}.json:")
|
||||
for k in sorted(extra):
|
||||
print(f" + {k}")
|
||||
ok = False
|
||||
|
||||
if ok:
|
||||
print(f"OK {len(parsed)} locale file(s) valid, all keys match {REFERENCE_LOCALE}.json")
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_list(args) -> int:
|
||||
lang = args.lang or REFERENCE_LOCALE
|
||||
data = load_locale(lang)
|
||||
keys = sorted(extract_leaf_keys(data))
|
||||
for k in keys:
|
||||
print(k)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_get(args) -> int:
|
||||
if not args.lang or not args.key:
|
||||
print("error: --get requires --lang and --key", file=sys.stderr)
|
||||
return 1
|
||||
data = load_locale(args.lang)
|
||||
value = get_nested(data, args.key)
|
||||
if value is None:
|
||||
print(f"error: key '{args.key}' not found in {args.lang}.json", file=sys.stderr)
|
||||
return 1
|
||||
if isinstance(value, dict):
|
||||
print(f"error: '{args.key}' is not a leaf key (it's an object) in {args.lang}.json", file=sys.stderr)
|
||||
return 1
|
||||
print(value)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_write(args) -> int:
|
||||
if not args.lang or not args.key or args.value is None:
|
||||
print("error: --write requires --lang, --key, and a value argument", file=sys.stderr)
|
||||
return 1
|
||||
data = load_locale(args.lang)
|
||||
set_nested(data, args.key, args.value)
|
||||
save_locale(args.lang, data)
|
||||
print(f"Wrote {args.lang}.json: {args.key} = {args.value!r}")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage DeFlock localization JSON files.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true", help="Validate all locale files match en.json's key structure")
|
||||
group.add_argument("--list", action="store_true", help="List all dot-notation leaf keys for a locale (default: en)")
|
||||
group.add_argument("--get", action="store_true", help="Print the value of a single key")
|
||||
group.add_argument("--write", action="store_true", help="Set the value of a single key")
|
||||
|
||||
parser.add_argument("--lang", help="Language code, e.g. 'en', 'de', 'es' (filename without .json)")
|
||||
parser.add_argument("--key", help="Dot-notation key, e.g. 'node.title'")
|
||||
parser.add_argument("value", nargs="?", help="New value (only used with --write)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
sys.exit(cmd_check(args))
|
||||
elif args.list:
|
||||
sys.exit(cmd_list(args))
|
||||
elif args.get:
|
||||
sys.exit(cmd_get(args))
|
||||
elif args.write:
|
||||
sys.exit(cmd_write(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper around localize.py — see that file's docstring for full usage.
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/localize.sh --check
|
||||
# ./scripts/localize.sh --list --lang de
|
||||
# ./scripts/localize.sh --get --lang en --key node.title
|
||||
# ./scripts/localize.sh --write --lang es --key node.checkingNodeStatusTitle "Comprobando estado"
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
|
||||
exec "$PYTHON_BIN" "$SCRIPT_DIR/localize.py" "$@"
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:deflockapp/models/node_profile.dart';
|
||||
import 'package:deflockapp/models/osm_node.dart';
|
||||
import 'package:deflockapp/state/profile_state.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
group('NodeProfile', () {
|
||||
test('toJson/fromJson round-trip preserves all fields', () {
|
||||
final profile = NodeProfile(
|
||||
@@ -72,5 +79,180 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
expect(a, isNot(equals(c)));
|
||||
});
|
||||
|
||||
group('createExistingTagsProfile', () {
|
||||
test('should NOT assign FOV for nodes with single direction', () {
|
||||
// This is the core bug fix: nodes with just "direction=90" should not get a default FOV
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90',
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single direction nodes should not get default FOV');
|
||||
expect(profile.name, equals('<Existing tags>'));
|
||||
expect(profile.tags, isEmpty, reason: 'Existing tags profile should have empty tags');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with range notation', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125', // Range notation = explicit FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNotNull, reason: 'Range notation should preserve FOV');
|
||||
expect(profile.fov, equals(70.0), reason: 'Range 55-125 should calculate to 70 degree FOV');
|
||||
});
|
||||
|
||||
test('should assign FOV for nodes with multiple consistent ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '55-125;235-305', // Two ranges with same FOV
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, equals(70.0), reason: 'Multiple consistent ranges should preserve FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for mixed single directions and ranges', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180-360', // Mix of single direction and range
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Mixed notation should not assign FOV');
|
||||
});
|
||||
|
||||
test('should NOT assign FOV for multiple single directions', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90;180;270', // Multiple single directions
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Multiple single directions should not get default FOV');
|
||||
});
|
||||
|
||||
test('should handle camera:direction tag', () {
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'camera:direction': '180', // Using camera:direction instead of direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'camera',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
expect(profile.fov, isNull, reason: 'Single camera:direction should not get default FOV');
|
||||
});
|
||||
|
||||
test('should fix the specific bug: direction=90 should not become direction=55-125', () {
|
||||
// This tests the exact bug scenario mentioned in the issue
|
||||
final node = OsmNode(
|
||||
id: 123,
|
||||
coord: const LatLng(37.7749, -122.4194),
|
||||
tags: {
|
||||
'direction': '90', // Single direction, should stay as single direction
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
);
|
||||
|
||||
final profile = NodeProfile.createExistingTagsProfile(node);
|
||||
|
||||
// Key fix: profile should NOT have an FOV, so upload won't convert to range notation
|
||||
expect(profile.fov, isNull, reason: 'direction=90 should not get converted to direction=55-125');
|
||||
|
||||
// Verify the node does have directionFovPairs (for rendering), but profile ignores them
|
||||
expect(node.directionFovPairs, hasLength(1));
|
||||
expect(node.directionFovPairs.first.centerDegrees, equals(90.0));
|
||||
expect(node.directionFovPairs.first.fovDegrees, equals(70.0)); // Default FOV for rendering
|
||||
});
|
||||
});
|
||||
|
||||
group('ProfileState reordering', () {
|
||||
test('should reorder profiles correctly', () async {
|
||||
final profileState = ProfileState();
|
||||
|
||||
// Add some test profiles directly to avoid storage operations
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to the internal list to avoid storage
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Initial order should be A, B, C
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c']));
|
||||
|
||||
// Move profile at index 0 (A) to index 2 (with onReorderItem, newIndex is pre-adjusted)
|
||||
profileState.reorderProfiles(0, 2);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['b', 'c', 'a']));
|
||||
|
||||
// Move profile at index 1 (C) to index 0 (should become C, B, A)
|
||||
profileState.reorderProfiles(1, 0);
|
||||
expect(profileState.profiles.map((p) => p.id), equals(['c', 'b', 'a']));
|
||||
});
|
||||
|
||||
test('should maintain enabled status after reordering', () {
|
||||
final profileState = ProfileState();
|
||||
|
||||
final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {});
|
||||
final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {});
|
||||
final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {});
|
||||
|
||||
// Add profiles directly to avoid storage operations
|
||||
profileState.internalProfiles.addAll([profileA, profileB, profileC]);
|
||||
profileState.internalEnabled.addAll([profileA, profileB, profileC]);
|
||||
|
||||
// Disable profile B
|
||||
profileState.internalEnabled.remove(profileB);
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
|
||||
// Reorder profiles
|
||||
profileState.reorderProfiles(0, 2);
|
||||
|
||||
// Profile B should still be disabled after reordering
|
||||
expect(profileState.isEnabled(profileB), isFalse);
|
||||
expect(profileState.isEnabled(profileA), isTrue);
|
||||
expect(profileState.isEnabled(profileC), isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,46 +1,60 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import 'package:deflockapp/app_state.dart';
|
||||
import 'package:deflockapp/models/tile_provider.dart' as models;
|
||||
import 'package:deflockapp/services/deflock_tile_provider.dart';
|
||||
import 'package:deflockapp/services/provider_tile_cache_store.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
class MockMapCachingProvider extends Mock implements MapCachingProvider {}
|
||||
|
||||
void main() {
|
||||
late DeflockTileProvider provider;
|
||||
late MockAppState mockAppState;
|
||||
|
||||
final osmTileType = models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final mapboxTileType = models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockAppState = MockAppState();
|
||||
AppState.instance = mockAppState;
|
||||
|
||||
// Default stubs: online, OSM provider selected, 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.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19,
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.offlineFeaturesEnabled).thenReturn(true);
|
||||
|
||||
provider = DeflockTileProvider();
|
||||
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await provider.dispose();
|
||||
provider.shutdown();
|
||||
AppState.instance = MockAppState();
|
||||
});
|
||||
|
||||
@@ -49,7 +63,7 @@ void main() {
|
||||
expect(provider.supportsCancelLoading, isTrue);
|
||||
});
|
||||
|
||||
test('getTileUrl() delegates to TileType.getTileUrl()', () {
|
||||
test('getTileUrl() uses frozen tileType config', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
@@ -58,23 +72,12 @@ void main() {
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
|
||||
test('getTileUrl() includes API key when present', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(
|
||||
const models.TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
apiKey: 'test_key_123',
|
||||
tileTypes: [],
|
||||
),
|
||||
);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(
|
||||
const models.TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate:
|
||||
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox',
|
||||
),
|
||||
test('getTileUrl() includes API key when present', () async {
|
||||
provider.shutdown();
|
||||
provider = DeflockTileProvider(
|
||||
providerId: 'mapbox',
|
||||
tileType: mapboxTileType,
|
||||
apiKey: 'test_key_123',
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 10);
|
||||
@@ -86,19 +89,6 @@ void main() {
|
||||
expect(url, contains('/10/1/2@2x'));
|
||||
});
|
||||
|
||||
test('getTileUrl() falls back to super when no provider selected', () {
|
||||
when(() => mockAppState.selectedTileProvider).thenReturn(null);
|
||||
when(() => mockAppState.selectedTileType).thenReturn(null);
|
||||
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
|
||||
// Super implementation uses the urlTemplate from TileLayer options
|
||||
expect(url, equals('https://example.com/3/1/2'));
|
||||
});
|
||||
|
||||
test('routes to network path when no offline areas exist', () {
|
||||
// offlineMode = false, OfflineAreaService not initialized → no offline areas
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
@@ -136,10 +126,19 @@ void main() {
|
||||
expect(offlineProvider.providerId, equals('openstreetmap'));
|
||||
expect(offlineProvider.tileTypeId, equals('osm_street'));
|
||||
});
|
||||
|
||||
test('frozen config is independent of AppState', () {
|
||||
// Provider was created with OSM config — changing AppState should not affect it
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
|
||||
|
||||
final url = provider.getTileUrl(coords, options);
|
||||
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider', () {
|
||||
test('equal for same coordinates and provider/type', () {
|
||||
test('equal for same coordinates, provider/type, and offlineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
@@ -161,7 +160,7 @@ void main() {
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true, // different — but not in ==
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
|
||||
@@ -171,6 +170,37 @@ void main() {
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('not equal for different isOfflineOnly', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final online = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
final offline = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: true,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(online, isNot(equals(offline)));
|
||||
});
|
||||
|
||||
test('not equal for different coordinates', () {
|
||||
const coords1 = TileCoordinates(1, 2, 3);
|
||||
const coords2 = TileCoordinates(1, 2, 4);
|
||||
@@ -247,5 +277,298 @@ void main() {
|
||||
expect(base, isNot(equals(diffType)));
|
||||
expect(base.hashCode, isNot(equals(diffType.hashCode)));
|
||||
});
|
||||
|
||||
test('equality ignores cachingProvider and onNetworkSuccess', () {
|
||||
const coords = TileCoordinates(1, 2, 3);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancel = Future<void>.value();
|
||||
|
||||
final withCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
cachingProvider: MockMapCachingProvider(),
|
||||
onNetworkSuccess: () {},
|
||||
);
|
||||
final withoutCaching = DeflockOfflineTileImageProvider(
|
||||
coordinates: coords,
|
||||
options: options,
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: cancel,
|
||||
isOfflineOnly: false,
|
||||
providerId: 'prov_a',
|
||||
tileTypeId: 'type_1',
|
||||
tileUrl: 'url',
|
||||
);
|
||||
|
||||
expect(withCaching, equals(withoutCaching));
|
||||
expect(withCaching.hashCode, equals(withoutCaching.hashCode));
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockTileProvider caching integration', () {
|
||||
test('passes cachingProvider through to offline path', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
final mockCaching = MockMapCachingProvider();
|
||||
var successCalled = false;
|
||||
|
||||
final cachingProvider = DeflockTileProvider(
|
||||
providerId: 'openstreetmap',
|
||||
tileType: osmTileType,
|
||||
cachingProvider: mockCaching,
|
||||
onNetworkSuccess: () => successCalled = true,
|
||||
);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = cachingProvider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, same(mockCaching));
|
||||
expect(offlineProvider.onNetworkSuccess, isNotNull);
|
||||
|
||||
// Invoke the callback to verify it's wired correctly
|
||||
offlineProvider.onNetworkSuccess!();
|
||||
expect(successCalled, isTrue);
|
||||
|
||||
cachingProvider.shutdown();
|
||||
});
|
||||
|
||||
test('offline provider has null caching when not provided', () {
|
||||
when(() => mockAppState.offlineMode).thenReturn(true);
|
||||
|
||||
const coords = TileCoordinates(5, 10, 12);
|
||||
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
|
||||
final cancelLoading = Future<void>.value();
|
||||
|
||||
final imageProvider = provider.getImageWithCancelLoadingSupport(
|
||||
coords,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
|
||||
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
|
||||
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
|
||||
expect(offlineProvider.cachingProvider, isNull);
|
||||
expect(offlineProvider.onNetworkSuccess, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider caching helpers', () {
|
||||
late Directory tempDir;
|
||||
late ProviderTileCacheStore cacheStore;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('tile_cache_test_');
|
||||
cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('disk cache integration: putTile then getTile round-trip', () async {
|
||||
const url = 'https://tile.example.com/3/1/2.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3, 4, 5]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: DateTime.utc(2026, 2, 20),
|
||||
etag: '"tile-etag"',
|
||||
);
|
||||
|
||||
// Write to cache
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
// Read back
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes));
|
||||
expect(cached.metadata.etag, equals('"tile-etag"'));
|
||||
expect(cached.metadata.isStale, isFalse);
|
||||
});
|
||||
|
||||
test('disk cache: stale tiles are detectable', () async {
|
||||
const url = 'https://tile.example.com/stale.png';
|
||||
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: null,
|
||||
);
|
||||
|
||||
await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.metadata.isStale, isTrue);
|
||||
// Bytes are still available even when stale (for conditional revalidation)
|
||||
expect(cached.bytes, equals(bytes));
|
||||
});
|
||||
|
||||
test('disk cache: metadata-only update preserves bytes', () async {
|
||||
const url = 'https://tile.example.com/revalidated.png';
|
||||
final bytes = Uint8List.fromList([10, 20, 30]);
|
||||
|
||||
// Initial write with bytes
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v1"',
|
||||
),
|
||||
bytes: bytes,
|
||||
);
|
||||
|
||||
// Metadata-only update (simulating 304 Not Modified revalidation)
|
||||
await cacheStore.putTile(
|
||||
url: url,
|
||||
metadata: CachedMapTileMetadata(
|
||||
staleAt: DateTime.timestamp().add(const Duration(hours: 1)),
|
||||
lastModified: null,
|
||||
etag: '"v2"',
|
||||
),
|
||||
// No bytes — metadata only
|
||||
);
|
||||
|
||||
final cached = await cacheStore.getTile(url);
|
||||
expect(cached, isNotNull);
|
||||
expect(cached!.bytes, equals(bytes)); // original bytes preserved
|
||||
expect(cached.metadata.etag, equals('"v2"')); // metadata updated
|
||||
expect(cached.metadata.isStale, isFalse); // now fresh
|
||||
});
|
||||
});
|
||||
|
||||
group('DeflockOfflineTileImageProvider load error paths', () {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
});
|
||||
|
||||
/// Load the tile via [loadImage] and return the first error from the
|
||||
/// image stream. The decode callback should never be reached on error
|
||||
/// paths, so we throw if it is.
|
||||
Future<Object> loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider provider) {
|
||||
final completer = Completer<Object>();
|
||||
final stream = provider.loadImage(
|
||||
provider,
|
||||
(buffer, {getTargetSize}) async =>
|
||||
throw StateError('decode should not be called'),
|
||||
);
|
||||
stream.addListener(ImageStreamListener(
|
||||
(_, _) {
|
||||
if (!completer.isCompleted) {
|
||||
completer
|
||||
.completeError(StateError('expected error but got image'));
|
||||
}
|
||||
},
|
||||
onError: (error, _) {
|
||||
if (!completer.isCompleted) completer.complete(error);
|
||||
},
|
||||
));
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
test('offline both-miss throws TileNotAvailableOfflineException',
|
||||
() async {
|
||||
// No offline areas, no cache → both miss.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileNotAvailableOfflineException>());
|
||||
});
|
||||
|
||||
test('cancelled offline tile throws TileLoadCancelledException',
|
||||
() async {
|
||||
// cancelLoading already resolved → _loadAsync catch block detects
|
||||
// cancellation and throws TileLoadCancelledException instead of
|
||||
// the underlying TileNotAvailableOfflineException.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: true,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('online cancel before network throws TileLoadCancelledException',
|
||||
() async {
|
||||
// Online mode: cache miss, local miss, then cancelled check fires
|
||||
// before reaching the network fetch.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(1, 2, 3),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: http.Client(),
|
||||
headers: const {},
|
||||
cancelLoading: Future<void>.value(), // already cancelled
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/3/1/2.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<TileLoadCancelledException>());
|
||||
});
|
||||
|
||||
test('network error throws HttpException', () async {
|
||||
// Online mode: cache miss, local miss, not cancelled, network
|
||||
// returns 500 → HttpException with tile coordinates and status.
|
||||
final error = await loadAndExpectError(
|
||||
DeflockOfflineTileImageProvider(
|
||||
coordinates: const TileCoordinates(4, 5, 6),
|
||||
options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'),
|
||||
httpClient: MockClient((_) async => http.Response('', 500)),
|
||||
headers: const {},
|
||||
cancelLoading: Completer<void>().future, // never cancels
|
||||
isOfflineOnly: false,
|
||||
providerId: 'nonexistent',
|
||||
tileTypeId: 'nonexistent',
|
||||
tileUrl: 'https://example.com/6/4/5.png',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error, isA<HttpException>());
|
||||
expect((error as HttpException).message, contains('6/4/5'));
|
||||
expect(error.message, contains('500'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
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_areas/offline_area_models.dart';
|
||||
|
||||
class MockAppState extends Mock implements AppState {}
|
||||
|
||||
|
||||
OfflineArea _makeArea({
|
||||
String providerId = 'osm',
|
||||
String tileTypeId = 'standard',
|
||||
int minZoom = 5,
|
||||
int maxZoom = 12,
|
||||
OfflineAreaStatus status = OfflineAreaStatus.complete,
|
||||
}) {
|
||||
return OfflineArea(
|
||||
id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom',
|
||||
bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: '/tmp/test-area',
|
||||
status: status,
|
||||
tileProviderId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = OfflineAreaService();
|
||||
late MockAppState mockAppState;
|
||||
|
||||
setUp(() {
|
||||
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', () {
|
||||
test('returns true for zoom within range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue);
|
||||
});
|
||||
|
||||
test('returns false for zoom outside range', () {
|
||||
service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong provider', () {
|
||||
service.setAreasForTesting([_makeArea(providerId: 'osm')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for wrong tile type', () {
|
||||
service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false for non-complete areas', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(status: OfflineAreaStatus.downloading),
|
||||
_makeArea(status: OfflineAreaStatus.error),
|
||||
]);
|
||||
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('returns false when initialized with no areas', () {
|
||||
service.setAreasForTesting([]);
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse);
|
||||
});
|
||||
|
||||
test('matches when any area covers the zoom level', () {
|
||||
service.setAreasForTesting([
|
||||
_makeArea(minZoom: 5, maxZoom: 8),
|
||||
_makeArea(minZoom: 10, maxZoom: 14),
|
||||
]);
|
||||
|
||||
// In first area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue);
|
||||
// In gap between areas
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse);
|
||||
// In second area's range
|
||||
expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue);
|
||||
// Beyond both areas
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user