mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 15:28:23 +02:00
Compare commits
210
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdc5399c9f | ||
|
|
eb6a409b92 | ||
|
|
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 | ||
|
|
dc817e5eb7 | ||
|
|
e1cca2f503 | ||
|
|
abd8682b49 | ||
|
|
90a806a10d | ||
|
|
b6bcd23667 | ||
|
|
dba375c63d | ||
|
|
6c52541361 | ||
|
|
fcf7ff7a98 | ||
|
|
206b3afe9d | ||
|
|
e72d557d2a | ||
|
|
25a34aab0b | ||
|
|
610c5c71b1 | ||
|
|
8983939b05 | ||
|
|
9448305738 | ||
|
|
775148cfb7 | ||
|
|
0137fd66aa | ||
|
|
fe20356734 | ||
|
|
14d7c10ca6 | ||
|
|
348256270d | ||
|
|
8a759e88e9 | ||
|
|
e168b6e19c | ||
|
|
f78ea1a300 | ||
|
|
2dca311d2a | ||
|
|
7470b14e38 | ||
|
|
5df0170344 | ||
|
|
1429f1d02b | ||
|
|
f0f23489b5 | ||
|
|
9ae7d9c894 | ||
|
|
c8e396a6eb | ||
|
|
97675f9f48 | ||
|
|
75014be485 | ||
|
|
af42e18f6e | ||
|
|
c7cfdc471c | ||
|
|
19b3ca236e | ||
|
|
2e0dcb1b2b | ||
|
|
59afd75887 | ||
|
|
83370fba7e | ||
|
|
ba6c7cdbda | ||
|
|
311125e1f5 | ||
|
|
5abcc58a78 | ||
|
|
71776ee8f0 | ||
|
|
6607e30038 | ||
|
|
ef4205f4bd | ||
|
|
ef6fc1c9c8 | ||
|
|
26c85df7e8 | ||
|
|
20c1b9b108 | ||
|
|
0207f999ee | ||
|
|
4a342aee9d | ||
|
|
3827a6fa1d | ||
|
|
ed38e9467c | ||
|
|
d124cee9b3 | ||
|
|
c13dd8e58a | ||
|
|
037165653c | ||
|
|
98b73fe019 | ||
|
|
86e0d656d3 | ||
|
|
a149562001 | ||
|
|
e4b36719d7 | ||
|
|
3570104800 | ||
|
|
4fddd8e807 | ||
|
|
3dada20ec2 | ||
|
|
c712aba724 | ||
|
|
498e36f69d | ||
|
|
e2d0d1b790 | ||
|
|
61a2a99bbc | ||
|
|
7e67859b2f | ||
|
|
e559b86400 | ||
|
|
73160c32de | ||
|
|
d6f7e99941 | ||
|
|
0ffb7956c5 | ||
|
|
2620c8758e | ||
|
|
8804fdadf4 | ||
|
|
c50d43e00c | ||
|
|
5df16f376d | ||
|
|
38245bfb5b | ||
|
|
aba919f8d4 | ||
|
|
659cf5c0f0 | ||
|
|
ff5821b184 | ||
|
|
dd1838319d | ||
|
|
f76268f241 | ||
|
|
ba3b844c1e | ||
|
|
1dd0258c0b | ||
|
|
83d7814fb6 | ||
|
|
d095736078 | ||
|
|
77648db32f | ||
|
|
9a17d7e666 | ||
|
|
a34b786918 | ||
|
|
6707efebbe | ||
|
|
69ebd43e0d | ||
|
|
79d2fe711d | ||
|
|
4a36c52982 | ||
|
|
f478a3eb2d | ||
|
|
9621e5f35a | ||
|
|
f048ebc7db | ||
|
|
33ae6473bb | ||
|
|
0957670a15 | ||
|
|
3fc3a72cde | ||
|
|
1d65d5ecca | ||
|
|
1873d6e768 | ||
|
|
4638a18887 | ||
|
|
6bfdfadd97 | ||
|
|
72f3c9ee79 | ||
|
|
05e2e4e7c6 | ||
|
|
2e679c9a7e | ||
|
|
3ef053126b | ||
|
|
ae354c43a4 | ||
|
|
34eac41a96 | ||
|
|
816dadfbd1 | ||
|
|
607ecbafaf | ||
|
|
8b44b3abf5 | ||
|
|
a675cf185a | ||
|
|
26b479bf20 | ||
|
|
ae795a7607 | ||
|
|
a05e03567e | ||
|
|
da6887f7d3 |
No files matched your search
@@ -0,0 +1,167 @@
|
||||
name: Validate PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Analyze & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
build-debug-apk:
|
||||
name: Build Debug APK
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: build/app/outputs/flutter-apk/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
build-ios-simulator:
|
||||
name: Build iOS Simulator
|
||||
runs-on: macos-26
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
|
||||
- name: Build iOS simulator app
|
||||
run: flutter build ios --debug --simulator
|
||||
|
||||
- name: Zip Runner.app
|
||||
run: cd build/ios/iphonesimulator && zip -r "$GITHUB_WORKSPACE/Runner.app.zip" Runner.app
|
||||
|
||||
- name: Upload simulator build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-simulator
|
||||
path: Runner.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
comment-artifacts:
|
||||
name: Post Artifact Links
|
||||
needs: [build-debug-apk, build-ios-simulator]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !cancelled()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
env:
|
||||
APK_RESULT: ${{ needs.build-debug-apk.result }}
|
||||
IOS_RESULT: ${{ needs.build-ios-simulator.result }}
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}#artifacts`;
|
||||
const apkOk = process.env.APK_RESULT === 'success';
|
||||
const iosOk = process.env.IOS_RESULT === 'success';
|
||||
|
||||
const lines = ['## Debug builds', ''];
|
||||
|
||||
if (apkOk || iosOk) {
|
||||
lines.push(`Download from the [artifacts page](${runUrl}):`);
|
||||
if (apkOk) lines.push('- **debug-apk** — install on Android device/emulator');
|
||||
if (iosOk) lines.push('- **ios-simulator** — unzip and install with `xcrun simctl install booted Runner.app`');
|
||||
}
|
||||
|
||||
if (!apkOk || !iosOk) {
|
||||
const failed = [];
|
||||
if (!apkOk) failed.push('Android APK');
|
||||
if (!iosOk) failed.push('iOS simulator');
|
||||
lines.push('', `**Failed:** ${failed.join(', ')} — check the [workflow run](${runUrl}) for details.`);
|
||||
}
|
||||
|
||||
const body = lines.join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.startsWith('## Debug builds'));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
@@ -58,8 +58,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -110,8 +110,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
build-ios:
|
||||
name: Build iOS
|
||||
needs: get-version
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -155,8 +155,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -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
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
upload-to-stores:
|
||||
name: Upload to App Stores
|
||||
needs: [get-version, build-android-aab, build-ios]
|
||||
runs-on: macos-latest # Need macOS for iOS uploads
|
||||
runs-on: macos-26 # Need macOS for iOS uploads
|
||||
if: needs.get-version.outputs.should_upload_to_stores == 'true'
|
||||
steps:
|
||||
- name: Download AAB artifact for Google Play
|
||||
@@ -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/
|
||||
+148
-47
@@ -97,10 +97,15 @@ Changelog content is stored in `assets/changelog.json`:
|
||||
|
||||
### User Experience Flow
|
||||
- **First Launch**: Welcome popup with "don't show again" option
|
||||
- **Location Permission**: Requested immediately after welcome dialog on first launch (v2.6.3+)
|
||||
- **First Submission**: Submission guide popup with best practices and resource links
|
||||
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
|
||||
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
|
||||
|
||||
### Permission Handling (Updated v2.6.3)
|
||||
- **Location Permission**: Requested on first launch for core GPS functionality
|
||||
- **Notification Permission**: Requested on-demand when user enables proximity alerts
|
||||
|
||||
### Privacy Integration
|
||||
The welcome popup explains that the app:
|
||||
- Runs entirely locally on device
|
||||
@@ -202,15 +207,24 @@ Deletions don't need position dragging or tag editing - they just need confirmat
|
||||
- Retries: Exponential backoff up to 59 minutes
|
||||
- Failures: OSM auto-closes after 60 minutes, so we eventually give up
|
||||
|
||||
**Queue processing workflow:**
|
||||
**Queue processing workflow (v2.3.0+ concurrent processing):**
|
||||
1. User action (add/edit/delete) → `PendingUpload` created with `UploadState.pending`
|
||||
2. Immediate visual feedback (cache updated with temp markers)
|
||||
3. Background uploader processes queue when online:
|
||||
3. Background uploader starts new uploads every 5 seconds (configurable via `kUploadQueueProcessingInterval`):
|
||||
- **Concurrency limit**: Maximum 5 uploads processing simultaneously (`kMaxConcurrentUploads`)
|
||||
- **Individual lifecycles**: Each upload processes through all three stages independently
|
||||
- **Timer role**: Only used to start new pending uploads, not control stage progression
|
||||
4. Each upload processes through stages without waiting for other uploads:
|
||||
- **Pending** → Create changeset → **CreatingChangeset** → **Uploading**
|
||||
- **Uploading** → Upload node → **ClosingChangeset**
|
||||
- **ClosingChangeset** → Close changeset → **Complete**
|
||||
4. Success → cache updated with real data, temp markers removed
|
||||
5. Failures → appropriate retry logic based on which stage failed
|
||||
5. Success → cache updated with real data, temp markers removed
|
||||
6. Failures → appropriate retry logic based on which stage failed
|
||||
|
||||
**Performance improvement (v2.3.0):**
|
||||
- **Before**: Sequential processing with 10-second delays between each stage of each upload
|
||||
- **After**: Concurrent processing with uploads completing in 10-30 seconds regardless of queue size
|
||||
- **User benefit**: 3-5x faster upload processing for users with good internet connections
|
||||
|
||||
**Why three explicit stages:**
|
||||
The previous implementation conflated changeset creation + node operation as one step, making error handling unclear. The new approach:
|
||||
@@ -283,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)
|
||||
|
||||
@@ -356,11 +367,13 @@ Local cache contains production data. Showing production nodes in sandbox mode w
|
||||
- **Dual alert types**: Push notifications (background) and visual banners (foreground)
|
||||
- **Configurable distance**: 25-200 meter alert radius
|
||||
- **Battery awareness**: Users explicitly opt into background location monitoring
|
||||
- **On-demand permissions**: Notification permission requested only when user enables proximity alerts (v2.6.3+)
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses Flutter Local Notifications for cross-platform background alerts
|
||||
- Simple RecentAlert tracking prevents duplicate notifications
|
||||
- Visual callback system for in-app alerts when app is active
|
||||
- Permission requests deferred until feature activation for better UX
|
||||
|
||||
### 9. Compass Indicator & North Lock
|
||||
|
||||
@@ -386,30 +399,39 @@ Local cache contains production data. Showing production nodes in sandbox mode w
|
||||
**Why separate from follow mode:**
|
||||
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
|
||||
|
||||
### 10. Network Status Indicator (Simplified in v1.5.2+)
|
||||
### 10. Network Status Indicator (Refactored in v2.6.1)
|
||||
|
||||
**Purpose**: Show loading and error states for surveillance data fetching only
|
||||
|
||||
**Simplified approach (v1.5.2+):**
|
||||
- **Surveillance data focus**: Only tracks node/camera data loading, not tile loading
|
||||
- **Visual feedback**: Tiles show their own loading progress naturally
|
||||
- **Reduced complexity**: Eliminated tile completion tracking and multiple issue types
|
||||
**Brutalist approach (v2.6.1+):**
|
||||
- **Single enum state**: Replaced multiple boolean flags with simple `NetworkRequestStatus` enum
|
||||
- **User-initiated only**: Only tracks latest user-initiated requests (pan/zoom), background requests ignored
|
||||
- **Auto-reset timers**: Different timeouts for different states (success: 2s, errors: 5s, rate limit: 2min)
|
||||
|
||||
**Status types:**
|
||||
- **Loading**: Shows when fetching surveillance data from APIs
|
||||
- **Success**: Brief confirmation when data loads successfully
|
||||
- **Timeout**: Network request timeouts
|
||||
- **Limit reached**: When node display limit is hit
|
||||
- **API issues**: Overpass/OSM API problems only
|
||||
**Status states:**
|
||||
```dart
|
||||
enum NetworkRequestStatus {
|
||||
idle, // No indicator shown
|
||||
loading, // Initial request in progress
|
||||
splitting, // Request being split due to limits/timeouts
|
||||
success, // Data loaded successfully (brief confirmation)
|
||||
timeout, // Request timed out
|
||||
rateLimited, // API rate limited
|
||||
noData, // No offline data available
|
||||
error, // Other network errors
|
||||
}
|
||||
```
|
||||
|
||||
**What was removed:**
|
||||
- Tile server issue tracking (tiles handle their own progress)
|
||||
- "Both" network issue type (only surveillance data matters)
|
||||
- Complex semaphore-based completion detection
|
||||
- Tile-related status messages and localizations
|
||||
**State transitions:**
|
||||
- **Normal flow**: `idle` → `loading` → `success` → `idle`
|
||||
- **Split requests**: `loading` → `splitting` → `success` → `idle`
|
||||
- **Errors**: `loading` → `timeout/error/rateLimited` → `idle`
|
||||
|
||||
**Why the change:**
|
||||
The previous approach tracked both tile loading and surveillance data, creating redundancy since tiles already show loading progress visually on the map. Users don't need to be notified about tile loading issues when they can see tiles loading/failing directly. Focusing only on surveillance data makes the indicator more purposeful and less noisy.
|
||||
**Why the refactor:**
|
||||
The previous system used multiple boolean flags with complex reconciliation logic, creating potential for conflicting states and race conditions. The new enum-based approach provides clear, explicit state management with predictable transitions and eliminates complexity while maintaining all functionality.
|
||||
|
||||
**Request ownership:**
|
||||
Only user-initiated requests (pan, zoom, manual refresh) report status. Background requests (pre-fetch, cache warming) complete silently to avoid status indicator noise.
|
||||
|
||||
### 11. Suspected Locations (v1.8.0+: SQLite Database Storage)
|
||||
|
||||
@@ -775,32 +797,111 @@ The app uses a **clean, release-triggered workflow** that rebuilds from scratch
|
||||
## Build & Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Flutter SDK**: Latest stable version
|
||||
- **Xcode**: For iOS builds (macOS only)
|
||||
- **Android Studio**: For Android builds
|
||||
- **Git**: For version control
|
||||
|
||||
**macOS** (required for iOS builds; Android-only contributors can use macOS or Linux):
|
||||
|
||||
| Tool | Install | Notes |
|
||||
|------|---------|-------|
|
||||
| **Homebrew** | [brew.sh](https://brew.sh) | Package manager for macOS |
|
||||
| **Flutter SDK 3.35+** | `brew install --cask flutter` | Installs Flutter + Dart (3.35+ required for RadioGroup widget) |
|
||||
| **Xcode** | Mac App Store | Required for iOS builds |
|
||||
| **CocoaPods** | `brew install cocoapods` | Required for iOS plugin resolution |
|
||||
| **Android SDK** | See below | Required for Android builds |
|
||||
| **Java 17+** | `brew install --cask temurin` | Required by Android toolchain (skip if already installed) |
|
||||
|
||||
After installing, verify with:
|
||||
```bash
|
||||
flutter doctor # All checks should be green
|
||||
```
|
||||
|
||||
### Android SDK Setup (without Android Studio)
|
||||
|
||||
You don't need the full Android Studio IDE. Install the command-line tools and let Flutter's build system pull what it needs:
|
||||
|
||||
```bash
|
||||
# 1. Install command-line tools
|
||||
brew install --cask android-commandlinetools
|
||||
|
||||
# 2. Create the SDK directory and install required components
|
||||
mkdir -p ~/Library/Android/sdk/licenses
|
||||
# Write license acceptance hashes
|
||||
printf "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > ~/Library/Android/sdk/licenses/android-sdk-license
|
||||
printf "\n84831b9409646a918e30573bab4c9c91346d8abd" > ~/Library/Android/sdk/licenses/android-sdk-preview-license
|
||||
|
||||
# 3. Install platform tools and the SDK platform Flutter needs
|
||||
"$(brew --prefix android-commandlinetools)/cmdline-tools/latest/bin/sdkmanager" \
|
||||
--sdk_root="$HOME/Library/Android/sdk" \
|
||||
"platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
||||
|
||||
# 4. Copy cmdline-tools into the SDK root (Flutter expects them there)
|
||||
mkdir -p ~/Library/Android/sdk/cmdline-tools
|
||||
cp -R "$(brew --prefix android-commandlinetools)/cmdline-tools/latest" \
|
||||
~/Library/Android/sdk/cmdline-tools/latest
|
||||
|
||||
# 5. Point Flutter at the SDK and accept licenses
|
||||
flutter config --android-sdk ~/Library/Android/sdk
|
||||
yes | flutter doctor --android-licenses
|
||||
```
|
||||
|
||||
> **Note:** The first `flutter build apk` will auto-download additional components it needs (NDK, CMake, etc). This is normal and only happens once.
|
||||
|
||||
### OAuth2 Setup
|
||||
|
||||
**Required registrations:**
|
||||
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
|
||||
|
||||
**Configuration:**
|
||||
For local builds, create `build_keys.conf` (gitignored):
|
||||
```bash
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Edit keys.dart with your OAuth2 client IDs
|
||||
cp build_keys.conf.example build_keys.conf
|
||||
# Edit build_keys.conf with your OAuth2 client IDs
|
||||
```
|
||||
|
||||
### iOS Setup
|
||||
You can also pass keys directly via `--dart-define`:
|
||||
```bash
|
||||
cd ios && pod install
|
||||
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
```
|
||||
|
||||
### First Build
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
flutter pub get
|
||||
|
||||
# 2. Generate icons and splash screens (gitignored, must be regenerated)
|
||||
./gen_icons_splashes.sh
|
||||
|
||||
# 3. Build Android
|
||||
flutter build apk --debug \
|
||||
--dart-define=OSM_PROD_CLIENTID=your_id \
|
||||
--dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
|
||||
# 4. Build iOS (macOS only, no signing needed for testing)
|
||||
flutter build ios --no-codesign \
|
||||
--dart-define=OSM_PROD_CLIENTID=your_id \
|
||||
--dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
```
|
||||
|
||||
> **Important:** You must run `./gen_icons_splashes.sh` before the first build. The generated icons and splash screen assets are gitignored, so the build will fail without this step.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
# Run on connected device or simulator
|
||||
flutter run --dart-define=OSM_PROD_CLIENTID=your_id --dart-define=OSM_SANDBOX_CLIENTID=your_id
|
||||
|
||||
# Or use the build script (reads keys from build_keys.conf)
|
||||
./do_builds.sh # Both platforms
|
||||
./do_builds.sh --android # Android only
|
||||
./do_builds.sh --ios # iOS only
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -53,6 +53,12 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### Profile Import & Sharing
|
||||
- **Deep link support**: Import custom profiles via `deflockapp://profiles/add?p=<base64>` URLs
|
||||
- **Website integration**: Generate profile import links from [deflock.me](https://deflock.me)
|
||||
- **Pre-filled editor**: Imported profiles open in the profile editor for review and modification
|
||||
- **Seamless workflow**: Edit imported profiles like any custom profile before saving
|
||||
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
@@ -84,12 +90,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
|
||||
- Code organization and contribution guidelines
|
||||
- Debugging tips and troubleshooting
|
||||
|
||||
**Quick setup:**
|
||||
**Quick setup (macOS with Homebrew):**
|
||||
```shell
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add OAuth2 client IDs, then: flutter run
|
||||
brew install --cask flutter # Install Flutter SDK
|
||||
brew install cocoapods # Required for iOS
|
||||
flutter pub get # Install dependencies
|
||||
./gen_icons_splashes.sh # Generate icons & splash screens (required before first build)
|
||||
cp build_keys.conf.example build_keys.conf # Add your OSM OAuth2 client IDs
|
||||
./do_builds.sh # Build both platforms
|
||||
```
|
||||
See [DEVELOPER.md](DEVELOPER.md) for cross-platform instructions and Android SDK setup.
|
||||
|
||||
**Releases**: The app uses GitHub's release system for automated building and store uploads. Simply create a GitHub release and use the "pre-release" checkbox to control whether builds go to app stores - checked for beta releases, unchecked for production releases.
|
||||
|
||||
@@ -97,42 +107,7 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Add cancel button to submission guide
|
||||
- When not logged in, submit button should take users to settings>account to log in.
|
||||
- Ensure GPS/follow-me works after recent revamp (loses lock? have to move map for button state to update?)
|
||||
- Add new tags to top of a profile so they're visible immediately
|
||||
- Allow arbitrary entry on refine tags page
|
||||
- Don't show NSI suggestions that aren't sufficiently popular (image=)
|
||||
- Clean cache when nodes have been deleted by others
|
||||
- Are offline areas preferred for fast loading even when online? Check working.
|
||||
|
||||
### Current Development
|
||||
- Add ability to downvote suspected locations which are old enough
|
||||
- Turn by turn navigation or at least swipe nav sheet up to see a list
|
||||
- Import/Export map providers, profiles (profiles from deflock identify page?)
|
||||
|
||||
### On Pause
|
||||
- Offline navigation (pending vector map tiles)
|
||||
|
||||
### Future Features & Wishlist
|
||||
- Optional reason message when deleting
|
||||
- Update offline area data while browsing?
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details
|
||||
- Android Auto / CarPlay
|
||||
- "Cache accumulating" offline area?
|
||||
- "Offline areas" as tile provider?
|
||||
- Grab the full latest database for each profile just like for suspected locations (instead of overpass)?
|
||||
- Optional custom icons for profiles to aid identification
|
||||
- Custom device providers and OSM/Overpass alternatives
|
||||
- Offer options for extracting nodes which are attached to a way/relation:
|
||||
- Auto extract (how?)
|
||||
- Leave it alone (wrong answer unless user chooses intentionally)
|
||||
- Manual cleanup (cognitive load for users)
|
||||
- Delete the old one (also wrong answer unless user chooses intentionally)
|
||||
- Give multiple of these options??
|
||||
See [GitHub Issues](https://github.com/FoggedLens/deflock-app/issues) for the full list of planned features, known bugs, and ideas.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,29 +17,28 @@ if (keystorePropertiesFile.exists()) {
|
||||
android {
|
||||
namespace = "me.deflock.deflockapp"
|
||||
|
||||
// Matches current stable Flutter (compileSdk 34 as of July 2025)
|
||||
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_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
// ────────────────────────────────────────────────────────────
|
||||
minSdk = 23
|
||||
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
|
||||
minSdk = maxOf(flutter.minSdkVersion, 23)
|
||||
targetSdk = 36
|
||||
|
||||
// Flutter tool injects these during `flutter build`
|
||||
@@ -76,6 +75,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
@@ -30,11 +30,33 @@
|
||||
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"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Profile import deep links -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<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>
|
||||
|
||||
<!-- flutter_web_auth_2 callback activity (V2 embedding) -->
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
+176
-1
@@ -1,4 +1,179 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"2.7.1": {
|
||||
"content": [
|
||||
"• Fixed operator profile selection being lost when moving node position, adjusting direction, or changing profiles",
|
||||
"• Further improved node loading by only fetching what is needed to determine whether a node is attached to a way/relation"
|
||||
]
|
||||
},
|
||||
"2.6.4": {
|
||||
"content": [
|
||||
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
|
||||
"• Moved units setting from Navigation to Language & Region settings page"
|
||||
]
|
||||
},
|
||||
"2.6.3": {
|
||||
"content": [
|
||||
"• Improved first launch experience - location permission is now requested immediately after welcome dialog",
|
||||
"• Prevent edit submissions where nothing (location, tags, direction) has been changed",
|
||||
"• Allow customizing changeset comment on refine tags page",
|
||||
"• Moved upload queue pause toggle to upload queue screen for better discoverability"
|
||||
]
|
||||
},
|
||||
"2.6.2": {
|
||||
"content": [
|
||||
"• Enhanced edit workflow; new '<Existing tags>' profile preserves current tags while allowing direction and location edits",
|
||||
"• New '<Existing operator>' profile when editing nodes with operator tags; preserves operator details automatically",
|
||||
"• Tag pre-population; existing node values automatically fill empty profile tags to prevent data loss"
|
||||
]
|
||||
},
|
||||
"2.6.1": {
|
||||
"content": [
|
||||
"• Simplified network status indicator - cleaner state management",
|
||||
"• Improved error handling for surveillance data requests",
|
||||
"• Better status reporting for background vs. user-initiated requests"
|
||||
]
|
||||
},
|
||||
"2.6.0": {
|
||||
"content": [
|
||||
"• Fix slow node loading, offline node loading",
|
||||
"• Prevent submissions when we have no data in that area"
|
||||
]
|
||||
},
|
||||
"2.5.0": {
|
||||
"content": [
|
||||
"• NEW: 'Get more...' button in profile dropdowns - easily browse and import profiles from deflock.me/identify",
|
||||
"• NEW: Profile creation choice dialog - when adding profiles in settings, choose between creating custom profiles or importing from website",
|
||||
"• Enhanced profile discovery workflow - clearer path for users to find and import community-created profiles"
|
||||
]
|
||||
},
|
||||
"2.4.4": {
|
||||
"content": [
|
||||
"• Search results now prioritize locations near your current map view"
|
||||
]
|
||||
},
|
||||
"2.4.3": {
|
||||
"content": [
|
||||
"• Fixed 360° FOV rendering - devices with full circle coverage now render as complete rings instead of having a wedge cut out or being a line",
|
||||
"• Fixed 360° FOV submission - now correctly submits '0-360' to OpenStreetMap instead of incorrect '180-180' values, disables direction slider"
|
||||
]
|
||||
},
|
||||
"2.4.1": {
|
||||
"content": [
|
||||
"• Save button moved to top-right corner of profile editor screens",
|
||||
"• Fixed issue where FOV values could not be removed from profiles",
|
||||
"• Direction slider is now disabled for profiles with 360° FOV"
|
||||
]
|
||||
},
|
||||
"2.4.0": {
|
||||
"content": [
|
||||
"• Profile import from website links",
|
||||
"• Visit deflock.me for profile links to auto-populate custom profiles"
|
||||
]
|
||||
},
|
||||
"2.3.1": {
|
||||
"content": [
|
||||
"• Follow-me mode now automatically restores when add/edit/tag sheets are closed",
|
||||
"• Follow-me button is greyed out while node sheets are open (add/edit/tag) since following doesn't make sense during node operations",
|
||||
"• Drop support for approximate location since I can't get it to work reliably; apologies"
|
||||
]
|
||||
},
|
||||
"2.3.0": {
|
||||
"content": [
|
||||
"• Concurrent upload queue processing",
|
||||
"• Each submission is now much faster"
|
||||
]
|
||||
},
|
||||
"2.2.1": {
|
||||
"content": [
|
||||
"• Fixed network status indicator timing out prematurely",
|
||||
"• Improved GPS follow-me reliability - fixed sync issues that could cause tracking to stop working",
|
||||
"• Network status now accurately shows 'taking a while' when requests split or backoff, and only shows 'timed out' for actual network failures"
|
||||
]
|
||||
},
|
||||
"2.2.0": {
|
||||
"content": [
|
||||
"• Fixed follow-me sync issues where tracking would sometimes stop working after mode changes",
|
||||
@@ -259,4 +434,4 @@
|
||||
"• New suspected locations feature"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -28,7 +28,7 @@ read_from_file() {
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(cat "$file"; echo)
|
||||
|
||||
return 1
|
||||
}
|
||||
@@ -80,9 +80,9 @@ fi
|
||||
# Build the dart-define arguments
|
||||
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
|
||||
|
||||
# Validate localizations before building
|
||||
echo "Validating localizations..."
|
||||
dart run scripts/validate_localizations.dart || exit 1
|
||||
# Run tests before building
|
||||
echo "Running tests..."
|
||||
flutter test || exit 1
|
||||
echo
|
||||
|
||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.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, '12.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 */
|
||||
@@ -455,7 +364,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -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;
|
||||
@@ -588,7 +494,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -639,7 +545,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -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>
|
||||
+159
-18
@@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'services/http_client.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -14,13 +14,14 @@ import 'models/suspected_location.dart';
|
||||
import 'models/tile_provider.dart';
|
||||
import 'models/search_result.dart';
|
||||
import 'services/offline_area_service.dart';
|
||||
import 'services/node_cache.dart';
|
||||
import 'services/map_data_provider.dart';
|
||||
import 'services/node_data_manager.dart';
|
||||
import 'services/tile_preview_service.dart';
|
||||
import 'services/changelog_service.dart';
|
||||
import 'services/operator_profile_service.dart';
|
||||
import 'services/deep_link_service.dart';
|
||||
import 'widgets/node_provider_with_cache.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/proximity_warning_dialog.dart';
|
||||
import 'widgets/reauth_messages_dialog.dart';
|
||||
import 'dev_config.dart';
|
||||
import 'state/auth_state.dart';
|
||||
@@ -38,6 +39,7 @@ import 'state/upload_queue_state.dart';
|
||||
export 'state/navigation_state.dart' show AppNavigationMode;
|
||||
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
|
||||
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
|
||||
export 'models/pending_upload.dart' show UploadOperation;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
@@ -62,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();
|
||||
@@ -129,6 +137,7 @@ class AppState extends ChangeNotifier {
|
||||
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
|
||||
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
|
||||
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _settingsState.distanceUnit;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
@@ -153,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;
|
||||
@@ -169,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;
|
||||
@@ -239,11 +259,22 @@ class AppState extends ChangeNotifier {
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Preload offline nodes into cache for immediate display
|
||||
await NodeDataManager().preloadOfflineNodes();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_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();
|
||||
});
|
||||
|
||||
// Start periodic message checking
|
||||
_startMessageCheckTimer();
|
||||
|
||||
@@ -324,35 +355,39 @@ class AppState extends ChangeNotifier {
|
||||
final accessToken = await _authState.getAccessToken();
|
||||
if (accessToken == null) return false;
|
||||
|
||||
final client = UserAgentClient();
|
||||
try {
|
||||
// Try to fetch user details - this should include message data if scope is correct
|
||||
final response = await http.get(
|
||||
final response = await client.get(
|
||||
Uri.parse('${_getApiHost()}/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
|
||||
if (response.statusCode == 403) {
|
||||
// Forbidden - likely missing scope
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final messages = data['user']?['messages'];
|
||||
// If messages field is missing, we might not have the right scope
|
||||
return messages == null;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// On error, assume no re-auth needed to avoid annoying users
|
||||
return false;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show re-authentication dialog if needed
|
||||
Future<void> checkAndPromptReauthForMessages(BuildContext context) async {
|
||||
if (await needsReauthForMessages()) {
|
||||
if (!context.mounted) return;
|
||||
_showReauthDialog(context);
|
||||
}
|
||||
}
|
||||
@@ -392,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) {
|
||||
@@ -418,13 +462,17 @@ class AppState extends ChangeNotifier {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
void reorderOperatorProfiles(int oldIndex, int newIndex) {
|
||||
_operatorProfileState.reorderProfiles(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void startEditSession(OsmNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
_sessionState.startEditSession(node, enabledProfiles, operatorProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
@@ -433,6 +481,9 @@ class AppState extends ChangeNotifier {
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
@@ -440,6 +491,9 @@ class AppState extends ChangeNotifier {
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
refinedTags: refinedTags,
|
||||
additionalExistingTags: additionalExistingTags,
|
||||
changesetComment: changesetComment,
|
||||
updateOperatorProfile: updateOperatorProfile,
|
||||
);
|
||||
|
||||
// Check tutorial completion if position changed
|
||||
@@ -455,6 +509,9 @@ class AppState extends ChangeNotifier {
|
||||
LatLng? target,
|
||||
bool? extractFromWay,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
String? changesetComment,
|
||||
bool updateOperatorProfile = false,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
@@ -463,6 +520,9 @@ class AppState extends ChangeNotifier {
|
||||
target: target,
|
||||
extractFromWay: extractFromWay,
|
||||
refinedTags: refinedTags,
|
||||
additionalExistingTags: additionalExistingTags,
|
||||
changesetComment: changesetComment,
|
||||
updateOperatorProfile: updateOperatorProfile,
|
||||
);
|
||||
|
||||
// Check tutorial completion if position changed
|
||||
@@ -518,6 +578,8 @@ class AppState extends ChangeNotifier {
|
||||
_sessionState.removeDirection();
|
||||
}
|
||||
|
||||
bool get canRemoveDirection => _sessionState.canRemoveDirection;
|
||||
|
||||
void cycleDirection() {
|
||||
_sessionState.cycleDirection();
|
||||
}
|
||||
@@ -535,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);
|
||||
@@ -563,8 +645,8 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// ---------- Navigation Methods - Simplified ----------
|
||||
void enterSearchMode(LatLng mapCenter) {
|
||||
_navigationState.enterSearchMode(mapCenter);
|
||||
void enterSearchMode(LatLng mapCenter, {LatLngBounds? viewbox}) {
|
||||
_navigationState.enterSearchMode(mapCenter, viewbox: viewbox);
|
||||
}
|
||||
|
||||
void cancelNavigation() {
|
||||
@@ -636,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) {
|
||||
@@ -651,7 +758,7 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
MapDataProvider().clearCache();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
@@ -684,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);
|
||||
@@ -714,7 +826,11 @@ class AppState extends ChangeNotifier {
|
||||
/// Set navigation avoidance distance
|
||||
Future<void> setNavigationAvoidanceDistance(int distance) async {
|
||||
await _settingsState.setNavigationAvoidanceDistance(distance);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setDistanceUnit(DistanceUnit unit) async {
|
||||
await _settingsState.setDistanceUnit(unit);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
@@ -784,6 +900,31 @@ class AppState extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Utility Methods ----------
|
||||
|
||||
/// Generate a default changeset comment for a submission
|
||||
/// Handles special case of `<Existing tags>` profile by using "a" instead
|
||||
static String generateDefaultChangesetComment({
|
||||
required NodeProfile? profile,
|
||||
required UploadOperation operation,
|
||||
}) {
|
||||
// Handle temp profiles with brackets by using "a"
|
||||
final profileName = profile?.name.startsWith('<') == true && profile?.name.endsWith('>') == true
|
||||
? 'a'
|
||||
: profile?.name ?? 'surveillance';
|
||||
|
||||
switch (operation) {
|
||||
case UploadOperation.create:
|
||||
return 'Add $profileName surveillance node';
|
||||
case UploadOperation.modify:
|
||||
return 'Update $profileName surveillance node';
|
||||
case UploadOperation.delete:
|
||||
return 'Delete $profileName surveillance node';
|
||||
case UploadOperation.extract:
|
||||
return 'Extract $profileName surveillance node';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Private Methods ----------
|
||||
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
||||
void _fetchMissingTilePreviews() {
|
||||
|
||||
+29
-22
@@ -52,16 +52,27 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
const String kContactEmail = 'admin@stopflock.com';
|
||||
const String kHomepageUrl = 'https://deflock.org';
|
||||
|
||||
// Upload and changeset configuration
|
||||
const Duration kUploadHttpTimeout = Duration(seconds: 30); // HTTP request timeout for uploads
|
||||
const Duration kUploadQueueProcessingInterval = Duration(seconds: 5); // How often to check for new uploads to start
|
||||
const int kMaxConcurrentUploads = 5; // Maximum number of uploads processing simultaneously
|
||||
const Duration kChangesetCloseInitialRetryDelay = Duration(seconds: 10);
|
||||
const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5 minutes
|
||||
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: 120); // 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)
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
@@ -78,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 = false; // Set to true to enable auto-focus behavior
|
||||
|
||||
// Profile FOV features - set to false to restrict profiles to 360° FOV only
|
||||
const bool kEnableNon360FOVs = false; // Set to true to allow custom FOV values in profiles
|
||||
|
||||
/// Navigation availability: only dev builds, and only when online
|
||||
bool enableNavigationFeatures({required bool offlineMode}) {
|
||||
return kEnableNavigationFeatures && !offlineMode;
|
||||
@@ -86,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
|
||||
@@ -124,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
|
||||
@@ -132,13 +147,14 @@ 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
|
||||
|
||||
// NSI (Name Suggestion Index) configuration
|
||||
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
|
||||
const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display
|
||||
|
||||
// Map interaction configuration
|
||||
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
|
||||
@@ -147,15 +163,6 @@ const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom
|
||||
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
|
||||
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
|
||||
|
||||
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
|
||||
const int kTileFetchConcurrentThreads = 8; // Reduced from 10 to 8 for better cross-platform performance
|
||||
const int kTileFetchInitialDelayMs = 150; // Reduced from 200ms for faster retries
|
||||
const double kTileFetchBackoffMultiplier = 1.4; // Slightly reduced for faster recovery
|
||||
const int kTileFetchMaxDelayMs = 4000; // Reduced from 5000ms for faster max retry
|
||||
const int kTileFetchRandomJitterMs = 50; // Reduced jitter for more predictable timing
|
||||
const int kTileFetchMaxQueueSize = 100; // Reasonable queue size to prevent memory bloat
|
||||
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
@@ -165,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;
|
||||
}
|
||||
+112
-28
@@ -47,12 +47,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"language": "Sprache & Region",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache und Einheiten",
|
||||
"distanceUnit": "Entfernungseinheiten",
|
||||
"distanceUnitSubtitle": "Wählen Sie zwischen metrischen (km/m) oder imperialen (mi/ft) Einheiten",
|
||||
"metricUnits": "Metrisch (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen.",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
@@ -70,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",
|
||||
@@ -82,8 +101,7 @@
|
||||
"enableNotifications": "Benachrichtigungen Aktivieren",
|
||||
"checkingPermissions": "Berechtigungen prüfen...",
|
||||
"alertDistance": "Warnentfernung: ",
|
||||
"meters": "Meter",
|
||||
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
|
||||
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
@@ -92,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...",
|
||||
@@ -103,8 +141,8 @@
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"refineTags": "Tags Verfeinern"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
@@ -118,12 +156,16 @@
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
|
||||
"cannotMoveConstrainedNode": "Kann diese Kamera nicht verschieben - sie ist mit einem anderen Kartenelement verbunden (OSM-Weg/Relation). Sie können trotzdem ihre Tags und Richtung bearbeiten.",
|
||||
"zoomInRequiredMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Überwachungsknoten hinzuzufügen oder zu bearbeiten. Dies gewährleistet eine präzise Positionierung für genaues Kartieren.",
|
||||
"extractFromWay": "Knoten aus Weg/Relation extrahieren",
|
||||
"extractFromWaySubtitle": "Neuen Knoten mit gleichen Tags erstellen, Verschieben an neuen Ort ermöglichen",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
"existingTags": "<Vorhandene Tags>",
|
||||
"noChangesDetected": "Keine Änderungen erkannt - nichts zu übertragen",
|
||||
"noChangesTitle": "Keine Änderungen zu Übertragen",
|
||||
"noChangesMessage": "Sie haben keine Änderungen an diesem Knoten vorgenommen. Um eine Bearbeitung zu übertragen, müssen Sie den Standort, das Profil, die Richtungen oder die Tags ändern."
|
||||
},
|
||||
"download": {
|
||||
"title": "Kartenbereich Herunterladen",
|
||||
@@ -137,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",
|
||||
@@ -225,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",
|
||||
@@ -237,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",
|
||||
@@ -279,12 +332,22 @@
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
"profileDeleted": "Profil gelöscht",
|
||||
"getMore": "Weitere anzeigen...",
|
||||
"addProfileChoice": "Profil Hinzufügen",
|
||||
"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",
|
||||
"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",
|
||||
@@ -299,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",
|
||||
@@ -311,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",
|
||||
@@ -357,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",
|
||||
@@ -374,7 +444,11 @@
|
||||
"profileTagsDescription": "Geben Sie Werte für Tags an, die verfeinert werden müssen:",
|
||||
"selectValue": "Wert auswählen...",
|
||||
"noValue": "(Kein Wert)",
|
||||
"noSuggestions": "Keine Vorschläge verfügbar"
|
||||
"noSuggestions": "Keine Vorschläge verfügbar",
|
||||
"existingTagsTitle": "Vorhandene Tags",
|
||||
"existingTagsDescription": "Bearbeiten Sie die vorhandenen Tags auf diesem Gerät. Hinzufügen, entfernen oder ändern Sie beliebige Tags:",
|
||||
"existingOperator": "<Vorhandener Betreiber>",
|
||||
"existingOperatorTags": "vorhandene Betreiber-Tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
|
||||
@@ -409,7 +483,9 @@
|
||||
"timedOut": "Anfrage Zeitüberschreitung",
|
||||
"noData": "Keine Offline-Daten",
|
||||
"success": "Überwachungsdaten geladen",
|
||||
"nodeDataSlow": "Überwachungsdaten langsam"
|
||||
"nodeDataSlow": "Überwachungsdaten langsam",
|
||||
"rateLimited": "Server-Limitierung",
|
||||
"networkError": "Netzwerkfehler"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Zeige {rendered} von {total} Geräten",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "Vermeidungsabstand",
|
||||
"avoidanceDistanceSubtitle": "Mindestabstand zu Überwachungsgeräten",
|
||||
"searchHistory": "Max. Suchverlauf",
|
||||
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken",
|
||||
"units": "Einheiten",
|
||||
"unitsSubtitle": "Anzeigeeinheiten für Entfernungen und Messungen",
|
||||
"metric": "Metrisch (km, m)",
|
||||
"imperial": "Britisch (mi, ft)",
|
||||
"meters": "Meter",
|
||||
"feet": "Fuß"
|
||||
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdächtige Standorte",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,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",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinaten",
|
||||
"noAddressAvailable": "Keine Adresse verfügbar"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "Meter",
|
||||
"feetLong": "Fuß",
|
||||
"kilometersLong": "Kilometer",
|
||||
"milesLong": "Meilen",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-31
@@ -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",
|
||||
@@ -84,12 +84,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"language": "Language & Region",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"languageSubtitle": "Choose your preferred language and units",
|
||||
"distanceUnit": "Distance Units",
|
||||
"distanceUnitSubtitle": "Choose between metric (km/m) or imperial (mi/ft) units",
|
||||
"metricUnits": "Metric (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map.",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
@@ -107,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",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "Enable Notifications",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"alertDistance": "Alert distance: ",
|
||||
"meters": "meters",
|
||||
"rangeInfo": "Range: {}-{} meters (default: {})"
|
||||
"rangeInfo": "Range: {}-{} {} (default: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
@@ -129,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...",
|
||||
@@ -140,8 +177,8 @@
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"refineTags": "Refine Tags"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
@@ -155,12 +192,16 @@
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"loadingAreaData": "Loading area data... Please wait before submitting.",
|
||||
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
|
||||
"zoomInRequiredMessage": "Zoom in to at least level {} to add or edit surveillance nodes. This ensures precise positioning for accurate mapping.",
|
||||
"extractFromWay": "Extract node from way/relation",
|
||||
"extractFromWaySubtitle": "Create new node with same tags, allow moving to new location",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
"existingTags": "<Existing tags>",
|
||||
"noChangesDetected": "No changes detected - nothing to submit",
|
||||
"noChangesTitle": "No Changes to Submit",
|
||||
"noChangesMessage": "You haven't made any changes to this node. To submit an edit, you need to change the location, profile, directions, or tags."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Map Area",
|
||||
@@ -174,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",
|
||||
@@ -262,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",
|
||||
@@ -272,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",
|
||||
@@ -316,12 +368,22 @@
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
"profileDeleted": "Profile deleted",
|
||||
"getMore": "Get more...",
|
||||
"addProfileChoice": "Add Profile",
|
||||
"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",
|
||||
"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",
|
||||
@@ -336,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",
|
||||
@@ -348,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",
|
||||
@@ -394,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",
|
||||
@@ -411,7 +480,11 @@
|
||||
"profileTagsDescription": "Complete these optional tag values for more detailed submissions:",
|
||||
"selectValue": "Select value...",
|
||||
"noValue": "(leave empty)",
|
||||
"noSuggestions": "No suggestions available"
|
||||
"noSuggestions": "No suggestions available",
|
||||
"existingTagsTitle": "Existing Tags",
|
||||
"existingTagsDescription": "Edit the existing tags on this device. Add, remove, or modify any tag:",
|
||||
"existingOperator": "<Existing operator>",
|
||||
"existingOperatorTags": "existing operator tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
|
||||
@@ -425,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",
|
||||
@@ -446,7 +519,9 @@
|
||||
"timedOut": "Request timed out",
|
||||
"noData": "No offline data",
|
||||
"success": "Surveillance data loaded",
|
||||
"nodeDataSlow": "Surveillance data slow"
|
||||
"nodeDataSlow": "Surveillance data slow",
|
||||
"rateLimited": "Rate limited by server",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Showing {rendered} of {total} devices",
|
||||
@@ -481,13 +556,7 @@
|
||||
"avoidanceDistance": "Avoidance Distance",
|
||||
"avoidanceDistanceSubtitle": "Minimum distance to stay away from surveillance devices",
|
||||
"searchHistory": "Max Search History",
|
||||
"searchHistorySubtitle": "Maximum number of recent searches to remember",
|
||||
"units": "Units",
|
||||
"unitsSubtitle": "Display units for distances and measurements",
|
||||
"metric": "Metric (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "meters",
|
||||
"feet": "feet"
|
||||
"searchHistorySubtitle": "Maximum number of recent searches to remember"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Suspected Locations",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,7 +583,7 @@
|
||||
"title": "Suspected Location #{}",
|
||||
"ticketNo": "Ticket No",
|
||||
"address": "Address",
|
||||
"street": "Street",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"intersectingStreet": "Intersecting Street",
|
||||
@@ -523,5 +592,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinates",
|
||||
"noAddressAvailable": "No address available"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "meters",
|
||||
"feetLong": "feet",
|
||||
"kilometersLong": "kilometers",
|
||||
"milesLong": "miles",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-28
@@ -84,12 +84,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"language": "Idioma y Región",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"languageSubtitle": "Elige tu idioma preferido y unidades",
|
||||
"distanceUnit": "Unidades de Distancia",
|
||||
"distanceUnitSubtitle": "Elige entre unidades métricas (km/m) o imperiales (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa.",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
@@ -107,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",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "Habilitar Notificaciones",
|
||||
"checkingPermissions": "Verificando permisos...",
|
||||
"alertDistance": "Distancia de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
|
||||
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -129,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...",
|
||||
@@ -140,8 +178,8 @@
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"refineTags": "Refinar Etiquetas"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
@@ -155,12 +193,16 @@
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "No se puede mover esta cámara - está conectada a otro elemento del mapa (OSM way/relation). Aún puede editar sus etiquetas y dirección.",
|
||||
"zoomInRequiredMessage": "Amplíe al menos al nivel {} para agregar o editar nodos de vigilancia. Esto garantiza un posicionamiento preciso para un mapeo exacto.",
|
||||
"extractFromWay": "Extraer nodo de way/relation",
|
||||
"extractFromWaySubtitle": "Crear nuevo nodo con las mismas etiquetas, permitir mover a nueva ubicación",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
"existingTags": "<Etiquetas existentes>",
|
||||
"noChangesDetected": "No se detectaron cambios - nada que enviar",
|
||||
"noChangesTitle": "No Hay Cambios que Enviar",
|
||||
"noChangesMessage": "No ha realizado ningún cambio en este nodo. Para enviar una edición, necesita cambiar la ubicación, el perfil, las direcciones o las etiquetas."
|
||||
},
|
||||
"download": {
|
||||
"title": "Descargar Área del Mapa",
|
||||
@@ -174,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",
|
||||
@@ -262,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",
|
||||
@@ -274,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",
|
||||
@@ -316,12 +369,22 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
"profileDeleted": "Perfil eliminado",
|
||||
"getMore": "Obtener más...",
|
||||
"addProfileChoice": "Añadir Perfil",
|
||||
"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",
|
||||
"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",
|
||||
@@ -336,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",
|
||||
@@ -348,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",
|
||||
@@ -394,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",
|
||||
@@ -411,7 +481,11 @@
|
||||
"profileTagsDescription": "Especifique valores para etiquetas que necesitan refinamiento:",
|
||||
"selectValue": "Seleccionar un valor...",
|
||||
"noValue": "(Sin valor)",
|
||||
"noSuggestions": "No hay sugerencias disponibles"
|
||||
"noSuggestions": "No hay sugerencias disponibles",
|
||||
"existingTagsTitle": "Etiquetas Existentes",
|
||||
"existingTagsDescription": "Edite las etiquetas existentes en este dispositivo. Agregue, elimine o modifique cualquier etiqueta:",
|
||||
"existingOperator": "<Operador existente>",
|
||||
"existingOperatorTags": "etiquetas de operador existentes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
|
||||
@@ -446,7 +520,9 @@
|
||||
"timedOut": "Solicitud agotada",
|
||||
"noData": "Sin datos sin conexión",
|
||||
"success": "Datos de vigilancia cargados",
|
||||
"nodeDataSlow": "Datos de vigilancia lentos"
|
||||
"nodeDataSlow": "Datos de vigilancia lentos",
|
||||
"rateLimited": "Limitado por el servidor",
|
||||
"networkError": "Error de red"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostrando {rendered} de {total} dispositivos",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "Distancia de evitación",
|
||||
"avoidanceDistanceSubtitle": "Distancia mínima para mantenerse alejado de dispositivos de vigilancia",
|
||||
"searchHistory": "Historial máximo de búsqueda",
|
||||
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar",
|
||||
"units": "Unidades",
|
||||
"unitsSubtitle": "Unidades de visualización para distancias y medidas",
|
||||
"metric": "Métrico (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pies"
|
||||
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Ubicaciones Sospechosas",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,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",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "No hay dirección disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pies",
|
||||
"kilometersLong": "kilómetros",
|
||||
"milesLong": "millas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-27
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"languageSubtitle": "Choisissez votre langue préférée et unités",
|
||||
"distanceUnit": "Unités de Distance",
|
||||
"distanceUnitSubtitle": "Choisir entre unités métriques (km/m) ou impériales (mi/ft)",
|
||||
"metricUnits": "Métrique (km, m)",
|
||||
"imperialUnits": "Impérial (mi, ft)",
|
||||
"maxNodes": "Max. nœuds dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte.",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
@@ -107,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",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "Activer les Notifications",
|
||||
"checkingPermissions": "Vérification des autorisations...",
|
||||
"alertDistance": "Distance d'alerte : ",
|
||||
"meters": "mètres",
|
||||
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
|
||||
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
@@ -129,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...",
|
||||
@@ -140,8 +178,8 @@
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"refineTags": "Affiner Balises"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
@@ -155,12 +193,16 @@
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
|
||||
"cannotMoveConstrainedNode": "Impossible de déplacer cette caméra - elle est connectée à un autre élément de carte (OSM way/relation). Vous pouvez toujours modifier ses balises et sa direction.",
|
||||
"zoomInRequiredMessage": "Zoomez au moins au niveau {} pour ajouter ou modifier des nœuds de surveillance. Cela garantit un positionnement précis pour une cartographie exacte.",
|
||||
"extractFromWay": "Extraire le nœud du way/relation",
|
||||
"extractFromWaySubtitle": "Créer un nouveau nœud avec les mêmes balises, permettre le déplacement vers un nouvel emplacement",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
"existingTags": "<Balises existantes>",
|
||||
"noChangesDetected": "Aucun changement détecté - rien à soumettre",
|
||||
"noChangesTitle": "Aucun Changement à Soumettre",
|
||||
"noChangesMessage": "Vous n'avez apporté aucun changement à ce nœud. Pour soumettre une modification, vous devez changer l'emplacement, le profil, les directions ou les balises."
|
||||
},
|
||||
"download": {
|
||||
"title": "Télécharger Zone de Carte",
|
||||
@@ -174,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é",
|
||||
@@ -262,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é",
|
||||
@@ -274,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",
|
||||
@@ -316,12 +369,22 @@
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
"profileDeleted": "Profil supprimé",
|
||||
"getMore": "En obtenir plus...",
|
||||
"addProfileChoice": "Ajouter Profil",
|
||||
"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",
|
||||
"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",
|
||||
@@ -336,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",
|
||||
@@ -348,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",
|
||||
@@ -394,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",
|
||||
@@ -411,7 +481,11 @@
|
||||
"profileTagsDescription": "Spécifiez des valeurs pour les étiquettes qui nécessitent un raffinement :",
|
||||
"selectValue": "Sélectionner une valeur...",
|
||||
"noValue": "(Aucune valeur)",
|
||||
"noSuggestions": "Aucune suggestion disponible"
|
||||
"noSuggestions": "Aucune suggestion disponible",
|
||||
"existingTagsTitle": "Balises Existantes",
|
||||
"existingTagsDescription": "Modifiez les balises existantes sur cet appareil. Ajoutez, supprimez ou modifiez n'importe quelle balise :",
|
||||
"existingOperator": "<Opérateur existant>",
|
||||
"existingOperatorTags": "balises d'opérateur existantes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
|
||||
@@ -446,7 +520,9 @@
|
||||
"timedOut": "Demande expirée",
|
||||
"noData": "Aucune donnée hors ligne",
|
||||
"success": "Données de surveillance chargées",
|
||||
"nodeDataSlow": "Données de surveillance lentes"
|
||||
"nodeDataSlow": "Données de surveillance lentes",
|
||||
"rateLimited": "Limité par le serveur",
|
||||
"networkError": "Erreur réseau"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Affichage de {rendered} sur {total} appareils",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "Distance d'évitement",
|
||||
"avoidanceDistanceSubtitle": "Distance minimale pour éviter les dispositifs de surveillance",
|
||||
"searchHistory": "Historique de recherche max",
|
||||
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir",
|
||||
"units": "Unités",
|
||||
"unitsSubtitle": "Unités d'affichage pour distances et mesures",
|
||||
"metric": "Métrique (km, m)",
|
||||
"imperial": "Impérial (mi, ft)",
|
||||
"meters": "mètres",
|
||||
"feet": "pieds"
|
||||
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Emplacements Suspects",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,7 +584,7 @@
|
||||
"title": "Emplacement Suspect #{}",
|
||||
"ticketNo": "N° de Ticket",
|
||||
"address": "Adresse",
|
||||
"street": "Rue",
|
||||
"street": "Rue",
|
||||
"city": "Ville",
|
||||
"state": "État",
|
||||
"intersectingStreet": "Rue Transversale",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordonnées",
|
||||
"noAddressAvailable": "Aucune adresse disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "mètres",
|
||||
"feetLong": "pieds",
|
||||
"kilometersLong": "kilomètres",
|
||||
"milesLong": "milles",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
-27
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "Informazioni",
|
||||
"aboutThisApp": "Informazioni su questa App",
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita e unità",
|
||||
"distanceUnit": "Unità di Distanza",
|
||||
"distanceUnitSubtitle": "Scegli tra unità metriche (km/m) o imperiali (mi/ft)",
|
||||
"metricUnits": "Metrico (km, m)",
|
||||
"imperialUnits": "Imperiale (mi, ft)",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa.",
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
@@ -107,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",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "Abilita Notifiche",
|
||||
"checkingPermissions": "Controllo autorizzazioni...",
|
||||
"alertDistance": "Distanza di avviso: ",
|
||||
"meters": "metri",
|
||||
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
|
||||
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -129,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...",
|
||||
@@ -140,8 +178,8 @@
|
||||
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"refineTags": "Affina Tag"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifica Nodo #{}",
|
||||
@@ -155,12 +193,16 @@
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
|
||||
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
|
||||
"zoomInRequiredMessage": "Ingrandisci almeno al livello {} per aggiungere o modificare nodi di sorveglianza. Questo garantisce un posizionamento preciso per una mappatura accurata.",
|
||||
"extractFromWay": "Estrai nodo da way/relation",
|
||||
"extractFromWaySubtitle": "Crea nuovo nodo con gli stessi tag, consenti spostamento in nuova posizione",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
"existingTags": "<Tag esistenti>",
|
||||
"noChangesDetected": "Nessuna modifica rilevata - niente da inviare",
|
||||
"noChangesTitle": "Nessuna Modifica da Inviare",
|
||||
"noChangesMessage": "Non hai apportato modifiche a questo nodo. Per inviare una modifica, devi cambiare la posizione, il profilo, le direzioni o i tag."
|
||||
},
|
||||
"download": {
|
||||
"title": "Scarica Area Mappa",
|
||||
@@ -174,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",
|
||||
@@ -262,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",
|
||||
@@ -274,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",
|
||||
@@ -316,12 +369,22 @@
|
||||
"view": "Visualizza",
|
||||
"deleteProfile": "Elimina Profilo",
|
||||
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"profileDeleted": "Profilo eliminato"
|
||||
"profileDeleted": "Profilo eliminato",
|
||||
"getMore": "Ottieni altri...",
|
||||
"addProfileChoice": "Aggiungi Profilo",
|
||||
"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",
|
||||
"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",
|
||||
@@ -336,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",
|
||||
@@ -348,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",
|
||||
@@ -394,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",
|
||||
@@ -411,7 +481,11 @@
|
||||
"profileTagsDescription": "Specificare valori per i tag che necessitano di raffinamento:",
|
||||
"selectValue": "Seleziona un valore...",
|
||||
"noValue": "(Nessun valore)",
|
||||
"noSuggestions": "Nessun suggerimento disponibile"
|
||||
"noSuggestions": "Nessun suggerimento disponibile",
|
||||
"existingTagsTitle": "Tag Esistenti",
|
||||
"existingTagsDescription": "Modifica i tag esistenti su questo dispositivo. Aggiungi, rimuovi o modifica qualsiasi tag:",
|
||||
"existingOperator": "<Operatore esistente>",
|
||||
"existingOperatorTags": "tag operatore esistenti"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
|
||||
@@ -446,7 +520,9 @@
|
||||
"timedOut": "Richiesta scaduta",
|
||||
"noData": "Nessun dato offline",
|
||||
"success": "Dati di sorveglianza caricati",
|
||||
"nodeDataSlow": "Dati di sorveglianza lenti"
|
||||
"nodeDataSlow": "Dati di sorveglianza lenti",
|
||||
"rateLimited": "Limitato dal server",
|
||||
"networkError": "Errore di rete"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostra {rendered} di {total} dispositivi",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "Distanza di evitamento",
|
||||
"avoidanceDistanceSubtitle": "Distanza minima da mantenere dai dispositivi di sorveglianza",
|
||||
"searchHistory": "Cronologia ricerca max",
|
||||
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare",
|
||||
"units": "Unità",
|
||||
"unitsSubtitle": "Unità di visualizzazione per distanze e misure",
|
||||
"metric": "Metrico (km, m)",
|
||||
"imperial": "Imperiale (mi, ft)",
|
||||
"meters": "metri",
|
||||
"feet": "piedi"
|
||||
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Posizioni Sospette",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,7 +584,7 @@
|
||||
"title": "Posizione Sospetta #{}",
|
||||
"ticketNo": "N. Ticket",
|
||||
"address": "Indirizzo",
|
||||
"street": "Via",
|
||||
"street": "Via",
|
||||
"city": "Città",
|
||||
"state": "Stato",
|
||||
"intersectingStreet": "Via che Interseca",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinate",
|
||||
"noAddressAvailable": "Nessun indirizzo disponibile"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metri",
|
||||
"feetLong": "piedi",
|
||||
"kilometersLong": "chilometri",
|
||||
"milesLong": "miglia",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Nederlands"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Surveillance Transparantie",
|
||||
"description": "DeFlock is een privacy-gerichte mobiele app voor het in kaart brengen van openbare surveillance infrastructuur met behulp van OpenStreetMap. Documenteer camera's, ALPR's, schot detectoren en andere surveillance apparaten in uw gemeenschap om deze infrastructuur zichtbaar en doorzoekbaar te maken.",
|
||||
"features": "• Offline-capabel karteren met downloadbare gebieden\n• Direct uploaden naar OpenStreetMap met OAuth2\n• Ingebouwde profielen voor grote fabrikanten\n• Privacy-respecterend - geen gebruikersgegevens verzameld\n• Meerdere kaart tile providers (OSM, satellietbeelden)",
|
||||
"initiative": "Onderdeel van het bredere DeFlock initiatief om surveillance transparantie te bevorderen.",
|
||||
"footer": "Bezoek: deflock.me\nGebouwd met Flutter • Open Source",
|
||||
"showWelcome": "Toon Welkomstbericht",
|
||||
"showSubmissionGuide": "Toon Inzendingsgids",
|
||||
"viewReleaseNotes": "Bekijk Release Notes"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welkom bij DeFlock",
|
||||
"description": "DeFlock werd opgericht op het idee dat openbare surveillance tools transparant zouden moeten zijn. Binnen deze mobiele app, net zoals op de website, zult u de locatie van ALPR's en andere surveillance infrastructuur in uw lokale gebied en in het buitenland kunnen bekijken.",
|
||||
"mission": "Echter, dit project is niet geautomatiseerd; het vraagt ons allemaal om dit project beter te maken. Bij het bekijken van de kaart kunt u op \"Nieuwe Node\" tikken om een voorheen onbekende installatie toe te voegen. Met uw hulp kunnen we ons doel van verhoogde transparantie en publiek bewustzijn van surveillance infrastructuur bereiken.",
|
||||
"firsthandKnowledge": "BELANGRIJK: Draag alleen surveillance apparaten bij die u persoonlijk uit de eerste hand heeft waargenomen. OpenStreetMap en Google beleid verbiedt het gebruik van bronnen zoals Street View beelden voor inzendingen. Uw bijdragen moeten gebaseerd zijn op uw eigen directe waarnemingen.",
|
||||
"privacy": "Privacy Opmerking: Deze app draait volledig lokaal op uw apparaat en gebruikt de derde partij OpenStreetMap API voor gegevensopslag en inzendingen. DeFlock verzamelt of slaat geen gebruikersgegevens op van welke aard dan ook, en is niet verantwoordelijk voor accountbeheer.",
|
||||
"tileNote": "OPMERKING: De gratis kaart tiles van OpenStreetMap kunnen erg traag laden. Alternatieve tile providers kunnen geconfigureerd worden in Instellingen > Geavanceerd.",
|
||||
"moreInfo": "U kunt meer links vinden onder Instellingen > Over.",
|
||||
"dontShowAgain": "Toon dit welkomstbericht niet opnieuw",
|
||||
"getStarted": "Laten we beginnen met DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Inzending Beste Praktijken",
|
||||
"description": "Voordat u uw eerste surveillance apparaat inzendt, neem even de tijd om deze belangrijke richtlijnen door te nemen om hoogwaardige bijdragen aan OpenStreetMap te garanderen.",
|
||||
"bestPractices": "• Breng alleen apparaten in kaart die u persoonlijk uit de eerste hand heeft waargenomen\n• Neem de tijd om het apparaattype en fabrikant nauwkeurig te identificeren\n• Gebruik precieze positionering - zoom dichtbij voordat u de marker plaatst\n• Neem richtingsinformatie mee wanneer van toepassing\n• Controleer uw tag selecties dubbel voordat u inzendt",
|
||||
"placementNote": "Onthoud: Nauwkeurige, eerste hands gegevens zijn essentieel voor de DeFlock gemeenschap en OpenStreetMap project.",
|
||||
"moreInfo": "Voor gedetailleerde begeleiding bij apparaat identificatie en kartering beste praktijken:",
|
||||
"identificationGuide": "Identificatie Gids",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Toon deze gids niet opnieuw",
|
||||
"gotIt": "Begrepen!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Verfijn Uw Locatie",
|
||||
"instructions": "Sleep de kaart om de apparaat marker precies over de locatie van het surveillance apparaat te positioneren.",
|
||||
"hint": "U kunt inzoomen voor betere nauwkeurigheid voordat u positioneert."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nieuwe Node",
|
||||
"download": "Download",
|
||||
"settings": "Instellingen",
|
||||
"edit": "Bewerken",
|
||||
"delete": "Verwijderen",
|
||||
"cancel": "Annuleren",
|
||||
"ok": "OK",
|
||||
"close": "Sluiten",
|
||||
"submit": "Indienen",
|
||||
"logIn": "Inloggen",
|
||||
"saveEdit": "Bewerking Opslaan",
|
||||
"clear": "Wissen",
|
||||
"viewOnOSM": "Bekijk op OSM",
|
||||
"advanced": "Geavanceerd",
|
||||
"useAdvancedEditor": "Gebruik Geavanceerde Editor"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Node Zeer Dichtbij Bestaand Apparaat",
|
||||
"message": "Deze node is slechts {} meter van een bestaand surveillance apparaat.",
|
||||
"suggestion": "Als meerdere apparaten op dezelfde paal staan, gebruik dan meerdere richtingen op een enkele node in plaats van aparte nodes te creëren.",
|
||||
"nearbyNodes": "Nabijgelegen apparaat/apparaten gevonden ({}):",
|
||||
"nodeInfo": "Node #{} - {}",
|
||||
"andMore": "...en {} meer",
|
||||
"goBack": "Ga Terug",
|
||||
"submitAnyway": "Toch Indienen",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Camera",
|
||||
"publicCamera": "Openbare Surveillance Camera",
|
||||
"camera": "Surveillance Camera",
|
||||
"amenity": "{}",
|
||||
"device": "{} Apparaat",
|
||||
"unknown": "Onbekend Apparaat"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Schakel volg-me in",
|
||||
"follow": "Schakel volg-me in (roterend)",
|
||||
"rotating": "Schakel volg-me uit"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"language": "Taal & Regio",
|
||||
"systemDefault": "Systeem Standaard",
|
||||
"aboutInfo": "Over / Info",
|
||||
"aboutThisApp": "Over Deze App",
|
||||
"aboutSubtitle": "App informatie en credits",
|
||||
"languageSubtitle": "Kies uw voorkeurtaal en eenheden",
|
||||
"distanceUnit": "Afstand Eenheden",
|
||||
"distanceUnitSubtitle": "Kies tussen metrische (km/m) of imperiale (mijl/voet) eenheden",
|
||||
"metricUnits": "Metrisch (km, m)",
|
||||
"imperialUnits": "Imperiaal (mijl, voet)",
|
||||
"maxNodes": "Max getekende nodes",
|
||||
"maxNodesSubtitle": "Stel een bovengrens in voor het aantal nodes op de kaart.",
|
||||
"maxNodesWarning": "U wilt dit waarschijnlijk niet doen tenzij u absoluut zeker weet dat u daar een goede reden voor heeft.",
|
||||
"offlineMode": "Offline Modus",
|
||||
"offlineModeSubtitle": "Schakel alle netwerk verzoeken uit behalve voor lokale/offline gebieden.",
|
||||
"pauseQueueProcessing": "Pauzeer Upload Wachtrij",
|
||||
"pauseQueueProcessingSubtitle": "Stop het uploaden van wachtrij veranderingen terwijl live data toegang behouden blijft.",
|
||||
"offlineModeWarningTitle": "Actieve Downloads",
|
||||
"offlineModeWarningMessage": "Het inschakelen van offline modus zal alle actieve gebied downloads annuleren. Wilt u doorgaan?",
|
||||
"enableOfflineMode": "Schakel Offline Modus In",
|
||||
"profiles": "Profielen",
|
||||
"profilesSubtitle": "Beheer node en operator profielen",
|
||||
"offlineSettings": "Offline Instellingen",
|
||||
"offlineSettingsSubtitle": "Beheer offline modus en gedownloade gebieden",
|
||||
"advancedSettings": "Geavanceerde Instellingen",
|
||||
"advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen",
|
||||
"proximityAlerts": "Nabijheids Waarschuwingen",
|
||||
"keepScreenAwake": "Scherm Actief Houden",
|
||||
"networkStatusIndicator": "Netwerk Status Indicator",
|
||||
"hideZoomControls": "Zoomknoppen Verbergen",
|
||||
"enableOfflineFeatures": "Offline Functies Inschakelen",
|
||||
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
|
||||
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Houd het scherm actief tijdens het gebruik van de app",
|
||||
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Zoomknoppen verbergen",
|
||||
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
|
||||
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
|
||||
"notificationsEnabled": "✓ Meldingen ingeschakeld",
|
||||
"notificationsDisabled": "⚠ Meldingen uitgeschakeld",
|
||||
"permissionRequired": "Melding toestemming vereist",
|
||||
"permissionExplanation": "Push meldingen zijn uitgeschakeld. U ziet alleen in-app waarschuwingen en wordt niet gewaarschuwd wanneer de app op de achtergrond draait.",
|
||||
"enableNotifications": "Schakel Meldingen In",
|
||||
"checkingPermissions": "Toestemmingen controleren...",
|
||||
"alertDistance": "Waarschuwingsafstand: ",
|
||||
"rangeInfo": "Bereik: {}-{} {} (standaard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Apparaat Tags",
|
||||
"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.",
|
||||
"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...",
|
||||
"profileRequired": "Selecteer een profiel om door te gaan.",
|
||||
"direction": "Richting {}°",
|
||||
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
|
||||
"mustBeLoggedIn": "U moet ingelogd zijn om nieuwe nodes in te dienen. Log in via Instellingen.",
|
||||
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nieuwe nodes in te dienen.",
|
||||
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nieuwe nodes in te dienen.",
|
||||
"loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.",
|
||||
"refineTags": "Tags Verfijnen"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Bewerk Node #{}",
|
||||
"profile": "Profiel",
|
||||
"selectProfile": "Selecteer een profiel...",
|
||||
"profileRequired": "Selecteer een profiel om door te gaan.",
|
||||
"direction": "Richting {}°",
|
||||
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
|
||||
"temporarilyDisabled": "Bewerkingen zijn tijdelijk uitgeschakeld terwijl we een bug oplossen - excuses - controleer binnenkort opnieuw.",
|
||||
"mustBeLoggedIn": "U moet ingelogd zijn om nodes te bewerken. Log in via Instellingen.",
|
||||
"sandboxModeWarning": "Kan geen bewerkingen op productie nodes naar sandbox versturen. Schakel naar Productie modus in Instellingen om nodes te bewerken.",
|
||||
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nodes te bewerken.",
|
||||
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nodes te bewerken.",
|
||||
"loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.",
|
||||
"cannotMoveConstrainedNode": "Kan deze camera niet verplaatsen - het is verbonden met een ander kaart element (OSM weg/relatie). U kunt nog steeds de tags en richting bewerken.",
|
||||
"zoomInRequiredMessage": "Zoom in tot ten minste niveau {} om surveillance nodes toe te voegen of te bewerken. Dit zorgt voor precieze positionering voor nauwkeurige kartering.",
|
||||
"extractFromWay": "Haal node uit weg/relatie",
|
||||
"extractFromWaySubtitle": "Maak nieuwe node met dezelfde tags, sta verplaatsing naar nieuwe locatie toe",
|
||||
"refineTags": "Tags Verfijnen",
|
||||
"existingTags": "<Bestaande tags>",
|
||||
"noChangesDetected": "Geen wijzigingen gedetecteerd - niets om in te dienen",
|
||||
"noChangesTitle": "Geen Wijzigingen om In Te Dienen",
|
||||
"noChangesMessage": "U heeft geen wijzigingen gemaakt aan deze node. Om een bewerking in te dienen, moet u de locatie, profiel, richtingen of tags wijzigen."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Kaart Gebied",
|
||||
"maxZoomLevel": "Max zoom niveau",
|
||||
"storageEstimate": "Opslag schatting:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Min zoom:",
|
||||
"maxRecommendedZoom": "Max aanbevolen zoom: Z{}",
|
||||
"withinTileLimit": "Binnen {} tile limiet",
|
||||
"exceedsTileLimit": "Huidige selectie overschrijdt {} tile limiet",
|
||||
"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: {}",
|
||||
"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",
|
||||
"message": "Download gestart! Tiles en nodes ophalen...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Bekijk Voortgang in Instellingen"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Bestemming",
|
||||
"subtitle": "Kies waar camera's geüpload worden",
|
||||
"production": "Productie",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simuleren",
|
||||
"productionDescription": "Upload naar de live OSM database (zichtbaar voor alle gebruikers)",
|
||||
"sandboxDescription": "Uploads gaan naar de OSM Sandbox (veilig voor testen, wordt regelmatig gereset).",
|
||||
"simulateDescription": "Simuleer uploads (neemt geen contact op met OSM servers)",
|
||||
"cannotChangeWithQueue": "Kan upload bestemming niet wijzigen terwijl {} items in wachtrij staan. Wis eerst de wachtrij."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Account",
|
||||
"osmAccountSubtitle": "Beheer uw OSM login en bekijk uw bijdragen",
|
||||
"loggedInAs": "Ingelogd als {}",
|
||||
"loginToOSM": "Inloggen bij OpenStreetMap",
|
||||
"tapToLogout": "Tik om uit te loggen",
|
||||
"requiredToSubmit": "Vereist om camera gegevens in te dienen",
|
||||
"loggedOut": "Uitgelogd",
|
||||
"testConnection": "Test Verbinding",
|
||||
"testConnectionSubtitle": "Verifieer dat OSM credentials werken",
|
||||
"connectionOK": "Verbinding OK - credentials zijn geldig",
|
||||
"connectionFailed": "Verbinding mislukt - log alstublieft opnieuw in",
|
||||
"viewMyEdits": "Bekijk Mijn Bewerkingen op OSM",
|
||||
"viewMyEditsSubtitle": "Zie uw bewerkingsgeschiedenis op OpenStreetMap",
|
||||
"aboutOSM": "Over OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap is een collaboratief, open-source kaartproject waar bijdragers een gratis, bewerkbare kaart van de wereld maken en onderhouden. Uw surveillance apparaat bijdragen helpen deze infrastructuur zichtbaar en doorzoekbaar te maken.",
|
||||
"visitOSM": "Bezoek OpenStreetMap",
|
||||
"deleteAccount": "Verwijder OSM Account",
|
||||
"deleteAccountSubtitle": "Beheer uw OpenStreetMap account",
|
||||
"deleteAccountExplanation": "Om uw OpenStreetMap account te verwijderen, moet u de OpenStreetMap website bezoeken. Dit zal permanent uw OSM account en alle bijbehorende gegevens verwijderen.",
|
||||
"deleteAccountWarning": "Waarschuwing: Deze actie kan niet ongedaan worden gemaakt en zal permanent uw OSM account verwijderen.",
|
||||
"goToOSM": "Ga naar OpenStreetMap",
|
||||
"accountManagement": "Account Beheer",
|
||||
"accountManagementDescription": "Om uw OpenStreetMap account te verwijderen, moet u de juiste OpenStreetMap website bezoeken. Dit zal permanent uw account en alle bijbehorende gegevens verwijderen.",
|
||||
"currentDestinationProduction": "Momenteel verbonden met: Productie OpenStreetMap",
|
||||
"currentDestinationSandbox": "Momenteel verbonden met: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Momenteel in: Simuleer modus (geen echt account)",
|
||||
"viewMessages": "Bekijk Berichten op OSM",
|
||||
"unreadMessagesCount": "U heeft {} ongelezen berichten",
|
||||
"noUnreadMessages": "Geen ongelezen berichten",
|
||||
"reauthRequired": "Ververs Authenticatie",
|
||||
"reauthExplanation": "U moet uw authenticatie verversen om OSM bericht meldingen te ontvangen via de app.",
|
||||
"reauthBenefit": "Dit zal melding stippen inschakelen wanneer u ongelezen berichten heeft op OpenStreetMap.",
|
||||
"reauthNow": "Doe Dat Nu",
|
||||
"reauthLater": "Later"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Upload Wachtrij",
|
||||
"subtitle": "Beheer wachtende surveillance apparaat uploads",
|
||||
"pendingUploads": "Wachtende uploads: {}",
|
||||
"pendingItemsCount": "Wachtende Items: {}",
|
||||
"nothingInQueue": "Niets in wachtrij",
|
||||
"simulateModeEnabled": "Simuleer modus ingeschakeld – uploads gesimuleerd",
|
||||
"sandboxMode": "Sandbox modus – uploads gaan naar OSM Sandbox",
|
||||
"tapToViewQueue": "Tik om wachtrij te bekijken",
|
||||
"clearUploadQueue": "Wis Upload Wachtrij",
|
||||
"removeAllPending": "Verwijder alle {} wachtende uploads",
|
||||
"clearQueueTitle": "Wis Wachtrij",
|
||||
"clearQueueConfirm": "Alle {} wachtende uploads verwijderen?",
|
||||
"queueCleared": "Wachtrij gewist",
|
||||
"uploadQueueTitle": "Upload Wachtrij ({} items)",
|
||||
"queueIsEmpty": "Wachtrij is leeg",
|
||||
"itemWithIndex": "Item {}",
|
||||
"error": " (Fout)",
|
||||
"completing": " (Voltooien...)",
|
||||
"destination": "Bestemming: {}",
|
||||
"latitude": "Breedtegraad: {}",
|
||||
"longitude": "Lengtegraad: {}",
|
||||
"direction": "Richting: {}°",
|
||||
"attempts": "Pogingen: {}",
|
||||
"uploadFailedRetry": "Upload mislukt. Tik opnieuw proberen om nog eens te proberen.",
|
||||
"retryUpload": "Probeer upload opnieuw",
|
||||
"clearAll": "Wis Alles",
|
||||
"errorDetails": "Fout Details",
|
||||
"creatingChangeset": " (Changeset maken...)",
|
||||
"uploading": " (Uploaden...)",
|
||||
"closingChangeset": " (Changeset sluiten...)",
|
||||
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
|
||||
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
|
||||
"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",
|
||||
"tileTypesCount": "{} tile types",
|
||||
"apiKeyConfigured": "API Key geconfigureerd",
|
||||
"needsApiKey": "Heeft API key nodig",
|
||||
"editProvider": "Bewerk Provider",
|
||||
"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",
|
||||
"apiKey": "API Key (Optioneel)",
|
||||
"apiKeyHint": "Voer API key in indien vereist door tile types",
|
||||
"tileTypes": "Tile Types",
|
||||
"addType": "Voeg Type Toe",
|
||||
"noTileTypesConfigured": "Geen tile types geconfigureerd",
|
||||
"atLeastOneTileTypeRequired": "Minstens één tile type is vereist",
|
||||
"manageTileProviders": "Beheer Providers"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Bewerk Tile Type",
|
||||
"addTileType": "Voeg Tile Type Toe",
|
||||
"name": "Naam",
|
||||
"nameHint": "bijv., Satelliet",
|
||||
"nameRequired": "Naam is vereist",
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is vereist",
|
||||
"urlTemplatePlaceholders": "URL moet ofwel {quadkey} of {z}, {x}, en {y} placeholders bevatten",
|
||||
"attribution": "Attributie",
|
||||
"attributionHint": "© Kaart Provider",
|
||||
"attributionRequired": "Attributie is vereist",
|
||||
"maxZoom": "Max Zoom Niveau",
|
||||
"maxZoomHint": "Maximum zoom niveau (1-23)",
|
||||
"maxZoomRequired": "Max zoom is vereist",
|
||||
"maxZoomInvalid": "Max zoom moet een nummer zijn",
|
||||
"maxZoomRange": "Max zoom moet tussen {} en {} zijn",
|
||||
"fetchPreview": "Haal Voorbeeld Op",
|
||||
"previewTileLoaded": "Voorbeeld tile succesvol geladen",
|
||||
"previewTileFailed": "Kon voorbeeld niet ophalen: {}",
|
||||
"save": "Opslaan"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Node Profielen",
|
||||
"newProfile": "Nieuw Profiel",
|
||||
"builtIn": "Ingebouwd",
|
||||
"custom": "Aangepast",
|
||||
"view": "Bekijk",
|
||||
"deleteProfile": "Verwijder Profiel",
|
||||
"deleteProfileConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"profileDeleted": "Profiel verwijderd",
|
||||
"getMore": "Krijg meer...",
|
||||
"addProfileChoice": "Voeg Profiel Toe",
|
||||
"addProfileChoiceMessage": "Hoe wilt u een profiel toevoegen?",
|
||||
"createCustomProfile": "Maak Aangepast Profiel",
|
||||
"createCustomProfileDescription": "Bouw een profiel vanaf nul met uw eigen tags",
|
||||
"importFromWebsite": "Importeer van Website",
|
||||
"importFromWebsiteDescription": "Blader en importeer profielen van deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Kaart Tiles",
|
||||
"manageProviders": "Beheer Providers",
|
||||
"attribution": "Kaart Attributie",
|
||||
"mapAttribution": "Kaartbron: {}",
|
||||
"couldNotOpenLink": "Kon link niet openen",
|
||||
"openLicense": "Open licentie: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Bekijk Profiel",
|
||||
"newProfile": "Nieuw Profiel",
|
||||
"editProfile": "Bewerk Profiel",
|
||||
"profileName": "Profiel naam",
|
||||
"profileNameHint": "bijv., Aangepaste ALPR Camera",
|
||||
"profileNameRequired": "Profiel naam is vereist",
|
||||
"requiresDirection": "Vereist Richting",
|
||||
"requiresDirectionSubtitle": "Of camera's van dit type een richting tag nodig hebben",
|
||||
"fov": "Gezichtsveld",
|
||||
"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",
|
||||
"addTag": "Voeg tag toe",
|
||||
"saveProfile": "Sla Profiel Op",
|
||||
"keyHint": "sleutel",
|
||||
"valueHint": "waarde",
|
||||
"atLeastOneTagRequired": "Minstens één tag is vereist",
|
||||
"profileSaved": "Profiel \"{}\" opgeslagen"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nieuw Operator Profiel",
|
||||
"editOperatorProfile": "Bewerk Operator Profiel",
|
||||
"operatorName": "Operator naam",
|
||||
"operatorNameHint": "bijv., Amsterdam Politie",
|
||||
"operatorNameRequired": "Operator naam is vereist",
|
||||
"operatorProfileSaved": "Operator profiel \"{}\" opgeslagen"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operator Profielen",
|
||||
"noProfilesMessage": "Geen operator profielen gedefinieerd. Maak er een om operator tags toe te passen op node inzendingen.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Verwijder Operator Profiel",
|
||||
"deleteOperatorProfileConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
|
||||
"operatorProfileDeleted": "Operator profiel verwijderd"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline Gebieden",
|
||||
"noAreasTitle": "Geen offline gebieden",
|
||||
"noAreasSubtitle": "Download een kaart gebied voor offline gebruik.",
|
||||
"provider": "Provider",
|
||||
"maxZoom": "Max zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Breedtegraad",
|
||||
"longitude": "Lengtegraad",
|
||||
"tiles": "Tiles",
|
||||
"size": "Grootte",
|
||||
"nodes": "Nodes",
|
||||
"areaIdFallback": "Gebied {}...",
|
||||
"renameArea": "Hernoem gebied",
|
||||
"refreshWorldTiles": "Ververs/herdownload wereld tiles",
|
||||
"deleteOfflineArea": "Verwijder offline gebied",
|
||||
"cancelDownload": "Annuleer download",
|
||||
"renameAreaDialogTitle": "Hernoem Offline Gebied",
|
||||
"areaNameLabel": "Gebied Naam",
|
||||
"renameButton": "Hernoem",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Ververs gebied",
|
||||
"refreshAreaDialogTitle": "Ververs Offline Gebied",
|
||||
"refreshAreaDialogSubtitle": "Kies wat te verversen voor dit gebied:",
|
||||
"refreshTiles": "Ververs Kaart Tiles",
|
||||
"refreshTilesSubtitle": "Herdownload alle tiles voor bijgewerkte beelden",
|
||||
"refreshNodes": "Ververs Nodes",
|
||||
"refreshNodesSubtitle": "Haal node gegevens opnieuw op voor dit gebied",
|
||||
"startRefresh": "Start Verversen",
|
||||
"refreshStarted": "Verversen gestart!",
|
||||
"refreshFailed": "Verversen mislukt: {}",
|
||||
"disableConfirmTitle": "Bestaande Offline Gebiedsgegevens Verwijderen?",
|
||||
"disableConfirmMessage": "U heeft al bestaande offline gebiedsgegevens. Het uitschakelen van offline functies zal voorkomen dat de app deze gebruikt. Wilt u deze gegevens op het apparaat bewaren voor het geval u offline functies later weer inschakelt, of ze nu verwijderen om opslagruimte vrij te maken?",
|
||||
"keepData": "Gegevens Behouden",
|
||||
"deleteData": "Gegevens Verwijderen"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Verfijn Tags",
|
||||
"operatorProfile": "Operator Profiel",
|
||||
"done": "Klaar",
|
||||
"none": "Geen",
|
||||
"noAdditionalOperatorTags": "Geen aanvullende operator tags",
|
||||
"additionalTags": "aanvullende tags",
|
||||
"additionalTagsTitle": "Aanvullende Tags",
|
||||
"noTagsDefinedForProfile": "Geen tags gedefinieerd voor dit operator profiel.",
|
||||
"noOperatorProfiles": "Geen operator profielen gedefinieerd",
|
||||
"noOperatorProfilesMessage": "Maak operator profielen in Instellingen om aanvullende tags toe te passen op uw node inzendingen.",
|
||||
"profileTags": "Profiel Tags",
|
||||
"profileTagsDescription": "Vul deze optionele tag waarden in voor meer gedetailleerde inzendingen:",
|
||||
"selectValue": "Selecteer waarde...",
|
||||
"noValue": "(laat leeg)",
|
||||
"noSuggestions": "Geen suggesties beschikbaar",
|
||||
"existingTagsTitle": "Bestaande Tags",
|
||||
"existingTagsDescription": "Bewerk de bestaande tags op dit apparaat. Voeg toe, verwijder of wijzig elke tag:",
|
||||
"existingOperator": "<Bestaande operator>",
|
||||
"existingOperatorTags": "bestaande operator tags"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Kan tile types niet wijzigen tijdens het downloaden van offline gebieden",
|
||||
"selectMapLayer": "Selecteer Kaart Laag",
|
||||
"noTileProvidersAvailable": "Geen tile providers beschikbaar"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Geavanceerde Bewerkingsopties",
|
||||
"subtitle": "Deze editors bieden meer geavanceerde functies voor complexe bewerkingen.",
|
||||
"webEditors": "Web Editors",
|
||||
"mobileEditors": "Mobiele Editors",
|
||||
"iDEditor": "iD Editor",
|
||||
"iDEditorSubtitle": "Volledig uitgeruste web editor - werkt altijd",
|
||||
"rapidEditor": "RapiD Editor",
|
||||
"rapidEditorSubtitle": "AI-geassisteerde bewerking met Facebook data",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Geavanceerde Android OSM editor",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Enquête-gebaseerde mapping app",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Snelle POI bewerking",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM editor",
|
||||
"couldNotOpenEditor": "Kon editor niet openen - app is mogelijk niet geïnstalleerd",
|
||||
"couldNotOpenURL": "Kon URL niet openen",
|
||||
"couldNotOpenOSMWebsite": "Kon OSM website niet openen"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Toon netwerk status indicator",
|
||||
"showIndicatorSubtitle": "Toon surveillance data laden en fout status",
|
||||
"loading": "Surveillance data laden...",
|
||||
"timedOut": "Verzoek verlopen",
|
||||
"noData": "Geen offline data",
|
||||
"success": "Surveillance data geladen",
|
||||
"nodeDataSlow": "Surveillance data traag",
|
||||
"rateLimited": "Snelheid beperkt door server",
|
||||
"networkError": "Netwerk fout"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "{rendered} van {total} apparaten getoond",
|
||||
"editingDisabledMessage": "Te veel apparaten getoond om veilig te bewerken. Zoom verder in om het aantal zichtbare apparaten te verminderen, probeer dan opnieuw."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Zoek Locatie",
|
||||
"searchPlaceholder": "Zoek plaatsen of coördinaten...",
|
||||
"routeTo": "Route Naar",
|
||||
"routeFrom": "Route Vanaf",
|
||||
"selectLocation": "Selecteer Locatie",
|
||||
"calculatingRoute": "Route berekenen...",
|
||||
"routeCalculationFailed": "Route berekening mislukt",
|
||||
"start": "Start",
|
||||
"resume": "Hervatten",
|
||||
"endRoute": "Beëindig Route",
|
||||
"routeOverview": "Route Overzicht",
|
||||
"retry": "Opnieuw Proberen",
|
||||
"cancelSearch": "Annuleer zoeken",
|
||||
"noResultsFound": "Geen resultaten gevonden",
|
||||
"searching": "Zoeken...",
|
||||
"location": "Locatie",
|
||||
"startPoint": "Start",
|
||||
"endPoint": "Einde",
|
||||
"startSelect": "Start (selecteer)",
|
||||
"endSelect": "Einde (selecteer)",
|
||||
"distance": "Afstand: {} km",
|
||||
"routeActive": "Route actief",
|
||||
"locationsTooClose": "Start en eind locaties zijn te dicht bij elkaar",
|
||||
"navigationSettings": "Navigatie",
|
||||
"navigationSettingsSubtitle": "Route planning en vermijding instellingen",
|
||||
"avoidanceDistance": "Vermijding Afstand",
|
||||
"avoidanceDistanceSubtitle": "Minimum afstand om weg te blijven van surveillance apparaten",
|
||||
"searchHistory": "Max Zoekgeschiedenis",
|
||||
"searchHistorySubtitle": "Maximum aantal recente zoekopdrachten om te onthouden"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdachte Locaties",
|
||||
"showSuspectedLocations": "Toon Verdachte Locaties",
|
||||
"showSuspectedLocationsSubtitle": "Toon vraagteken markers voor verdachte surveillance sites van nutsbedrijf vergunning data",
|
||||
"lastUpdated": "Laatst Bijgewerkt",
|
||||
"refreshNow": "Ververs nu",
|
||||
"dataSource": "Gegevensbron",
|
||||
"dataSourceDescription": "Nutsbedrijf vergunning data die mogelijke surveillance infrastructuur installatie sites aangeeft",
|
||||
"dataSourceCredit": "Gegevens verzameling en hosting geleverd door alprwatch.org",
|
||||
"minimumDistance": "Minimum Afstand van Echte Nodes",
|
||||
"minimumDistanceSubtitle": "Verberg verdachte locaties binnen {} van bestaande surveillance apparaten",
|
||||
"updating": "Verdachte Locaties Bijwerken",
|
||||
"downloadingAndProcessing": "Data downloaden en verwerken...",
|
||||
"updateSuccess": "Verdachte locaties succesvol bijgewerkt",
|
||||
"updateFailed": "Kon verdachte locaties niet bijwerken",
|
||||
"neverFetched": "Nooit opgehaald",
|
||||
"daysAgo": "{} dagen geleden",
|
||||
"hoursAgo": "{} uur geleden",
|
||||
"minutesAgo": "{} minuten geleden",
|
||||
"justNow": "Zojuist"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Verdachte Locatie #{}",
|
||||
"ticketNo": "Ticket Nr",
|
||||
"address": "Adres",
|
||||
"street": "Straat",
|
||||
"city": "Stad",
|
||||
"state": "Provincie",
|
||||
"intersectingStreet": "Kruisende Straat",
|
||||
"workDoneFor": "Werk Gedaan Voor",
|
||||
"remarks": "Opmerkingen",
|
||||
"url": "URL",
|
||||
"coordinates": "Coördinaten",
|
||||
"noAddressAvailable": "Geen adres beschikbaar"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mijl",
|
||||
"metersLong": "meters",
|
||||
"feetLong": "voet",
|
||||
"kilometersLong": "kilometers",
|
||||
"milesLong": "mijlen",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperiaal",
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperiaal (mijl, ft)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Polski"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Przejrzystość Nadzoru",
|
||||
"description": "DeFlock to mobilna aplikacja skoncentrowana na prywatności do mapowania publicznej infrastruktury nadzoru przy użyciu OpenStreetMap. Dokumentuj kamery, ALPR, detektory wystrzałów i inne urządzenia nadzoru w swojej społeczności, aby uczynić tę infrastrukturę widoczną i możliwą do przeszukiwania.",
|
||||
"features": "• Mapowanie offline z obszarami do pobrania\n• Przesyłanie bezpośrednio do OpenStreetMap z OAuth2\n• Wbudowane profile dla głównych producentów\n• Szanujące prywatność - nie zbierane są dane użytkownika\n• Wielu dostawców kafelków map (OSM, zdjęcia satelitarne)",
|
||||
"initiative": "Część szerszej inicjatywy DeFlock promującej przejrzystość nadzoru.",
|
||||
"footer": "Odwiedź: deflock.me\nZbudowane z Flutter • Open Source",
|
||||
"showWelcome": "Pokaż Wiadomość Powitalną",
|
||||
"showSubmissionGuide": "Pokaż Przewodnik Zgłaszania",
|
||||
"viewReleaseNotes": "Zobacz Notatki Wydania"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Witamy w DeFlock",
|
||||
"description": "DeFlock został założony na idei, że narzędzia publicznego nadzoru powinny być przejrzyste. W tej aplikacji mobilnej, jak i na stronie internetowej, będziesz mógł przeglądać lokalizacje ALPR i innych infrastruktur nadzoru w Twoim lokalnym obszarze i za granicą.",
|
||||
"mission": "Jednak ten projekt nie jest zautomatyzowany; potrzeba nas wszystkich, aby ten projekt uczynić lepszym. Podczas przeglądania mapy możesz stuknąć \"Nowy Węzeł\", aby dodać wcześniej nieznaną instalację. Z Twoją pomocą możemy osiągnąć nasz cel zwiększonej przejrzystości i publicznej świadomości infrastruktury nadzoru.",
|
||||
"firsthandKnowledge": "WAŻNE: Dodawaj tylko urządzenia nadzoru, które osobiście obserwowałeś z pierwszej ręki. Polityki OpenStreetMap i Google zabraniają używania źródeł takich jak zdjęcia Street View do zgłoszeń. Twoje wkłady powinny być oparte na Twoich własnych bezpośrednich obserwacjach.",
|
||||
"privacy": "Uwaga o Prywatności: Ta aplikacja działa całkowicie lokalnie na Twoim urządzeniu i używa zewnętrznego API OpenStreetMap do przechowywania danych i zgłoszeń. DeFlock nie zbiera ani nie przechowuje żadnych danych użytkownika jakiegokolwiek rodzaju i nie jest odpowiedzialny za zarządzanie kontami.",
|
||||
"tileNote": "UWAGA: Darmowe kafelki map z OpenStreetMap mogą ładować się bardzo wolno. Alternatywni dostawcy kafelków mogą być skonfigurowani w Ustawieniach > Zaawansowane.",
|
||||
"moreInfo": "Więcej linków znajdziesz w Ustawieniach > O aplikacji.",
|
||||
"dontShowAgain": "Nie pokazuj ponownie tej wiadomości powitalnej",
|
||||
"getStarted": "Zacznijmy DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Najlepsze Praktyki Zgłaszania",
|
||||
"description": "Przed zgłoszeniem Twojego pierwszego urządzenia nadzoru, poświęć chwilę na przejrzenie tych ważnych wytycznych, aby zapewnić wysokiej jakości wkłady do OpenStreetMap.",
|
||||
"bestPractices": "• Mapuj tylko urządzenia, które osobiście obserwowałeś z pierwszej ręki\n• Poświęć czas na dokładną identyfikację typu urządzenia i producenta\n• Używaj precyzyjnego pozycjonowania - przybliż przed umieszczeniem znacznika\n• Dołączaj informacje o kierunku, gdy ma to zastosowanie\n• Sprawdź dwukrotnie swoje wybory tagów przed zgłoszeniem",
|
||||
"placementNote": "Pamiętaj: Dokładne dane z pierwszej ręki są kluczowe dla społeczności DeFlock i projektu OpenStreetMap.",
|
||||
"moreInfo": "Dla szczegółowego przewodnika po identyfikacji urządzeń i najlepszych praktykach mapowania:",
|
||||
"identificationGuide": "Przewodnik Identyfikacji",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Nie pokazuj ponownie tego przewodnika",
|
||||
"gotIt": "Rozumiem!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Doprecyzuj Swoją Lokalizację",
|
||||
"instructions": "Przeciągnij mapę, aby precyzyjnie ustawić znacznik urządzenia nad lokalizacją urządzenia nadzoru.",
|
||||
"hint": "Możesz przybliżyć dla lepszej dokładności przed pozycjonowaniem."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Nowy Węzeł",
|
||||
"download": "Pobierz",
|
||||
"settings": "Ustawienia",
|
||||
"edit": "Edytuj",
|
||||
"delete": "Usuń",
|
||||
"cancel": "Anuluj",
|
||||
"ok": "OK",
|
||||
"close": "Zamknij",
|
||||
"submit": "Zgłoś",
|
||||
"logIn": "Zaloguj",
|
||||
"saveEdit": "Zapisz Edycję",
|
||||
"clear": "Wyczyść",
|
||||
"viewOnOSM": "Zobacz w OSM",
|
||||
"advanced": "Zaawansowane",
|
||||
"useAdvancedEditor": "Użyj Zaawansowanego Edytora"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Węzeł Bardzo Blisko Istniejącego Urządzenia",
|
||||
"message": "Ten węzeł jest tylko {} metrów od istniejącego urządzenia nadzoru.",
|
||||
"suggestion": "Jeśli wiele urządzeń znajduje się na tym samym słupie, użyj wielu kierunków na jednym węźle zamiast tworzenia oddzielnych węzłów.",
|
||||
"nearbyNodes": "Znaleziono pobliskie urządzenie/urządzenia ({}):",
|
||||
"nodeInfo": "Węzeł #{} - {}",
|
||||
"andMore": "...i {} więcej",
|
||||
"goBack": "Wróć",
|
||||
"submitAnyway": "Zgłoś Mimo To",
|
||||
"nodeType": {
|
||||
"alpr": "Kamera ALPR/ANPR",
|
||||
"publicCamera": "Kamera Nadzoru Publicznego",
|
||||
"camera": "Kamera Nadzoru",
|
||||
"amenity": "{}",
|
||||
"device": "Urządzenie {}",
|
||||
"unknown": "Nieznane Urządzenie"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Włącz śledzenie",
|
||||
"follow": "Włącz śledzenie (obracające)",
|
||||
"rotating": "Wyłącz śledzenie"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia",
|
||||
"language": "Język i Region",
|
||||
"systemDefault": "Domyślne Systemowe",
|
||||
"aboutInfo": "O / Informacje",
|
||||
"aboutThisApp": "O Tej Aplikacji",
|
||||
"aboutSubtitle": "Informacje o aplikacji i autorzy",
|
||||
"languageSubtitle": "Wybierz preferowany język i jednostki",
|
||||
"distanceUnit": "Jednostki Odległości",
|
||||
"distanceUnitSubtitle": "Wybierz między jednostkami metrycznymi (km/m) lub imperialnymi (mila/stopa)",
|
||||
"metricUnits": "Metryczne (km, m)",
|
||||
"imperialUnits": "Imperialne (mila, stopa)",
|
||||
"maxNodes": "Maksymalna liczba rysowanych węzłów",
|
||||
"maxNodesSubtitle": "Ustaw górny limit liczby węzłów na mapie.",
|
||||
"maxNodesWarning": "Prawdopodobnie nie chcesz tego robić, chyba że jesteś absolutnie pewien, że masz dobry powód.",
|
||||
"offlineMode": "Tryb Offline",
|
||||
"offlineModeSubtitle": "Wyłącz wszystkie żądania sieciowe z wyjątkiem lokalnych/offline obszarów.",
|
||||
"pauseQueueProcessing": "Wstrzymaj Przetwarzanie Kolejki Przesyłania",
|
||||
"pauseQueueProcessingSubtitle": "Zatrzymaj przesyłanie kolejkowanych zmian zachowując dostęp do danych na żywo.",
|
||||
"offlineModeWarningTitle": "Aktywne Pobierania",
|
||||
"offlineModeWarningMessage": "Włączenie trybu offline anuluje wszystkie aktywne pobierania obszarów. Czy chcesz kontynuować?",
|
||||
"enableOfflineMode": "Włącz Tryb Offline",
|
||||
"profiles": "Profile",
|
||||
"profilesSubtitle": "Zarządzaj profilami węzłów i operatorów",
|
||||
"offlineSettings": "Ustawienia Offline",
|
||||
"offlineSettingsSubtitle": "Zarządzaj trybem offline i pobranymi obszarami",
|
||||
"advancedSettings": "Ustawienia Zaawansowane",
|
||||
"advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków",
|
||||
"proximityAlerts": "Alerty Bliskości",
|
||||
"keepScreenAwake": "Nie Wygaszaj Ekranu",
|
||||
"networkStatusIndicator": "Wskaźnik Stanu Sieci",
|
||||
"hideZoomControls": "Ukryj Kontrolki Zoomu",
|
||||
"enableOfflineFeatures": "Włącz Funkcje Offline",
|
||||
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
|
||||
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
|
||||
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Ukryj kontrolki zoomu",
|
||||
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
|
||||
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
|
||||
"notificationsEnabled": "✓ Powiadomienia włączone",
|
||||
"notificationsDisabled": "⚠ Powiadomienia wyłączone",
|
||||
"permissionRequired": "Wymagane uprawnienie do powiadomień",
|
||||
"permissionExplanation": "Powiadomienia push są wyłączone. Będziesz widzieć tylko alerty w aplikacji i nie będziesz powiadamiany, gdy aplikacja jest w tle.",
|
||||
"enableNotifications": "Włącz Powiadomienia",
|
||||
"checkingPermissions": "Sprawdzanie uprawnień...",
|
||||
"alertDistance": "Odległość alertu: ",
|
||||
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Węzeł #{}",
|
||||
"tagSheetTitle": "Tagi Urządzenia Nadzoru",
|
||||
"queuedForUpload": "Węzeł umieszczony w kolejce do przesłania",
|
||||
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
|
||||
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
|
||||
"confirmDeleteTitle": "Usuń Węzeł",
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
|
||||
"deleteReasonLabel": "Powód Usunięcia",
|
||||
"deleteReasonSelectHint": "Wybierz...",
|
||||
"deleteReasonDeviceRemoved": "Urządzenie usunięte",
|
||||
"deleteReasonDuplicateNode": "Zduplikowany węzeł",
|
||||
"deleteReasonMisidentifiedInfra": "Błędnie zidentyfikowana infrastruktura",
|
||||
"deleteReasonSpamVandalism": "Spam/wandalizm",
|
||||
"deleteReasonCustom": "Niestandardowy..",
|
||||
"deleteReasonHint": "Opisz powód...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
|
||||
"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...",
|
||||
"profileRequired": "Proszę wybrać profil, aby kontynuować.",
|
||||
"direction": "Kierunek {}°",
|
||||
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
|
||||
"mustBeLoggedIn": "Musisz być zalogowany, aby zgłaszać nowe węzły. Zaloguj się przez Ustawienia.",
|
||||
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby zgłaszać nowe węzły.",
|
||||
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby zgłaszać nowe węzły.",
|
||||
"loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.",
|
||||
"refineTags": "Doprecyzuj Tagi"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edytuj Węzeł #{}",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Wybierz profil...",
|
||||
"profileRequired": "Proszę wybrać profil, aby kontynuować.",
|
||||
"direction": "Kierunek {}°",
|
||||
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
|
||||
"temporarilyDisabled": "Edycje zostały tymczasowo wyłączone, gdy rozwiązujemy błąd - przepraszamy - sprawdź wkrótce.",
|
||||
"mustBeLoggedIn": "Musisz być zalogowany, aby edytować węzły. Zaloguj się przez Ustawienia.",
|
||||
"sandboxModeWarning": "Nie można przesyłać edycji węzłów produkcyjnych do piaskownicy. Przełącz na tryb Produkcyjny w Ustawieniach, aby edytować węzły.",
|
||||
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby edytować węzły.",
|
||||
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby edytować węzły.",
|
||||
"loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.",
|
||||
"cannotMoveConstrainedNode": "Nie można przenieść tej kamery - jest połączona z innym elementem mapy (droga/relacja OSM). Nadal możesz edytować jej tagi i kierunek.",
|
||||
"zoomInRequiredMessage": "Przybliż do co najmniej poziomu {}, aby dodawać lub edytować węzły nadzoru. Zapewnia to precyzyjne pozycjonowanie dla dokładnego mapowania.",
|
||||
"extractFromWay": "Wyciągnij węzeł z drogi/relacji",
|
||||
"extractFromWaySubtitle": "Utwórz nowy węzeł z tymi samymi tagami, pozwalając na przeniesienie do nowej lokalizacji",
|
||||
"refineTags": "Doprecyzuj Tagi",
|
||||
"existingTags": "<Istniejące tagi>",
|
||||
"noChangesDetected": "Nie wykryto zmian - nie ma nic do zgłoszenia",
|
||||
"noChangesTitle": "Brak Zmian do Zgłoszenia",
|
||||
"noChangesMessage": "Nie wprowadziłeś żadnych zmian do tego węzła. Aby zgłosić edycję, musisz zmienić lokalizację, profil, kierunki lub tagi."
|
||||
},
|
||||
"download": {
|
||||
"title": "Pobierz Obszar Mapy",
|
||||
"maxZoomLevel": "Maksymalny poziom przybliżenia",
|
||||
"storageEstimate": "Oszacowanie pamięci:",
|
||||
"tilesAndSize": "{} kafelków, {} MB",
|
||||
"minZoom": "Min przybliżenie:",
|
||||
"maxRecommendedZoom": "Maksymalne zalecane przybliżenie: Z{}",
|
||||
"withinTileLimit": "W granicach {} limitu kafelków",
|
||||
"exceedsTileLimit": "Obecny wybór przekracza {} limit kafelków",
|
||||
"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: {}",
|
||||
"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",
|
||||
"message": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...",
|
||||
"ok": "OK",
|
||||
"viewProgress": "Zobacz Postęp w Ustawieniach"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Miejsce Docelowe Przesyłania",
|
||||
"subtitle": "Wybierz gdzie przesyłane są kamery",
|
||||
"production": "Produkcja",
|
||||
"sandbox": "Piaskownica",
|
||||
"simulate": "Symulacja",
|
||||
"productionDescription": "Prześlij do aktywnej bazy danych OSM (widoczne dla wszystkich użytkowników)",
|
||||
"sandboxDescription": "Przesłania trafiają do Piaskownicy OSM (bezpieczne do testowania, regularne resetowanie).",
|
||||
"simulateDescription": "Symuluj przesyłanie (nie kontaktuje się z serwerami OSM)",
|
||||
"cannotChangeWithQueue": "Nie można zmienić miejsca docelowego przesyłania, gdy {} elementów jest w kolejce. Najpierw wyczyść kolejkę."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Konto OpenStreetMap",
|
||||
"osmAccountSubtitle": "Zarządzaj logowaniem OSM i przeglądaj swoje wkłady",
|
||||
"loggedInAs": "Zalogowany jako {}",
|
||||
"loginToOSM": "Zaloguj się do OpenStreetMap",
|
||||
"tapToLogout": "Stuknij aby się wylogować",
|
||||
"requiredToSubmit": "Wymagane do zgłaszania danych kamer",
|
||||
"loggedOut": "Wylogowany",
|
||||
"testConnection": "Testuj Połączenie",
|
||||
"testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają",
|
||||
"connectionOK": "Połączenie OK - dane logowania są ważne",
|
||||
"connectionFailed": "Połączenie nie powiodło się - zaloguj się ponownie",
|
||||
"viewMyEdits": "Zobacz Moje Edycje w OSM",
|
||||
"viewMyEditsSubtitle": "Zobacz swoją historię edycji w OpenStreetMap",
|
||||
"aboutOSM": "O OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap to współpracujący projekt mapowania open-source, gdzie współtwórcy tworzą i utrzymują darmową, edytowalną mapę świata. Twoje wkłady urządzeń nadzoru pomagają uczynić tę infrastrukturę widoczną i możliwą do przeszukiwania.",
|
||||
"visitOSM": "Odwiedź OpenStreetMap",
|
||||
"deleteAccount": "Usuń Konto OSM",
|
||||
"deleteAccountSubtitle": "Zarządzaj swoim kontem OpenStreetMap",
|
||||
"deleteAccountExplanation": "Aby usunąć swoje konto OpenStreetMap, musisz odwiedzić stronę OpenStreetMap. To trwale usunie twoje konto OSM i wszystkie powiązane dane.",
|
||||
"deleteAccountWarning": "Ostrzeżenie: Ta akcja nie może być cofnięta i trwale usunie twoje konto OSM.",
|
||||
"goToOSM": "Przejdź do OpenStreetMap",
|
||||
"accountManagement": "Zarządzanie Kontem",
|
||||
"accountManagementDescription": "Aby usunąć swoje konto OpenStreetMap, musisz odwiedzić odpowiednią stronę OpenStreetMap. To trwale usunie twoje konto i wszystkie powiązane dane.",
|
||||
"currentDestinationProduction": "Obecnie połączony z: Produkcyjny OpenStreetMap",
|
||||
"currentDestinationSandbox": "Obecnie połączony z: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Obecnie w: Trybie symulacji (brak rzeczywistego konta)",
|
||||
"viewMessages": "Zobacz Wiadomości w OSM",
|
||||
"unreadMessagesCount": "Masz {} nieprzeczytanych wiadomości",
|
||||
"noUnreadMessages": "Brak nieprzeczytanych wiadomości",
|
||||
"reauthRequired": "Odśwież Uwierzytelnienie",
|
||||
"reauthExplanation": "Musisz odświeżyć uwierzytelnienie, aby otrzymywać powiadomienia o wiadomościach OSM przez aplikację.",
|
||||
"reauthBenefit": "To włączy kropki powiadomień, gdy masz nieprzeczytane wiadomości w OpenStreetMap.",
|
||||
"reauthNow": "Zrób To Teraz",
|
||||
"reauthLater": "Później"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Kolejka Przesyłania",
|
||||
"subtitle": "Zarządzaj oczekującymi przesłaniami urządzeń nadzoru",
|
||||
"pendingUploads": "Oczekujące przesłania: {}",
|
||||
"pendingItemsCount": "Oczekujące Elementy: {}",
|
||||
"nothingInQueue": "Nic w kolejce",
|
||||
"simulateModeEnabled": "Tryb symulacji włączony – przesłania symulowane",
|
||||
"sandboxMode": "Tryb piaskownicy – przesłania idą do OSM Sandbox",
|
||||
"tapToViewQueue": "Stuknij aby zobaczyć kolejkę",
|
||||
"clearUploadQueue": "Wyczyść Kolejkę Przesyłania",
|
||||
"removeAllPending": "Usuń wszystkie {} oczekujące przesłania",
|
||||
"clearQueueTitle": "Wyczyść Kolejkę",
|
||||
"clearQueueConfirm": "Usunąć wszystkie {} oczekujące przesłania?",
|
||||
"queueCleared": "Kolejka wyczyszczona",
|
||||
"uploadQueueTitle": "Kolejka Przesyłania ({} elementów)",
|
||||
"queueIsEmpty": "Kolejka jest pusta",
|
||||
"itemWithIndex": "Element {}",
|
||||
"error": " (Błąd)",
|
||||
"completing": " (Kończenie...)",
|
||||
"destination": "Cel: {}",
|
||||
"latitude": "Szerokość: {}",
|
||||
"longitude": "Długość: {}",
|
||||
"direction": "Kierunek: {}°",
|
||||
"attempts": "Próby: {}",
|
||||
"uploadFailedRetry": "Przesyłanie nie powiodło się. Stuknij ponownie aby spróbować ponownie.",
|
||||
"retryUpload": "Spróbuj ponownie przesłać",
|
||||
"clearAll": "Wyczyść Wszystko",
|
||||
"errorDetails": "Szczegóły Błędu",
|
||||
"creatingChangeset": " (Tworzenie zestawu zmian...)",
|
||||
"uploading": " (Przesyłanie...)",
|
||||
"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.",
|
||||
"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",
|
||||
"tileTypesCount": "{} typów kafelków",
|
||||
"apiKeyConfigured": "Klucz API skonfigurowany",
|
||||
"needsApiKey": "Potrzebuje klucz API",
|
||||
"editProvider": "Edytuj Dostawcę",
|
||||
"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",
|
||||
"apiKey": "Klucz API (Opcjonalnie)",
|
||||
"apiKeyHint": "Wprowadź klucz API jeśli wymagany przez typy kafelków",
|
||||
"tileTypes": "Typy Kafelków",
|
||||
"addType": "Dodaj Typ",
|
||||
"noTileTypesConfigured": "Brak skonfigurowanych typów kafelków",
|
||||
"atLeastOneTileTypeRequired": "Przynajmniej jeden typ kafelka jest wymagany",
|
||||
"manageTileProviders": "Zarządzaj Dostawcami"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Edytuj Typ Kafelka",
|
||||
"addTileType": "Dodaj Typ Kafelka",
|
||||
"name": "Nazwa",
|
||||
"nameHint": "np., Satelita",
|
||||
"nameRequired": "Nazwa jest wymagana",
|
||||
"urlTemplate": "Szablon URL",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Szablon URL jest wymagany",
|
||||
"urlTemplatePlaceholders": "URL musi zawierać albo {quadkey} albo {z}, {x} i {y} symbole zastępcze",
|
||||
"attribution": "Atrybucja",
|
||||
"attributionHint": "© Dostawca Map",
|
||||
"attributionRequired": "Atrybucja jest wymagana",
|
||||
"maxZoom": "Maksymalny Poziom Przybliżenia",
|
||||
"maxZoomHint": "Maksymalny poziom przybliżenia (1-23)",
|
||||
"maxZoomRequired": "Maksymalne przybliżenie jest wymagane",
|
||||
"maxZoomInvalid": "Maksymalne przybliżenie musi być liczbą",
|
||||
"maxZoomRange": "Maksymalne przybliżenie musi być między {} a {}",
|
||||
"fetchPreview": "Pobierz Podgląd",
|
||||
"previewTileLoaded": "Kafelek podglądu załadowany pomyślnie",
|
||||
"previewTileFailed": "Nie udało się pobrać podglądu: {}",
|
||||
"save": "Zapisz"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profile Węzłów",
|
||||
"newProfile": "Nowy Profil",
|
||||
"builtIn": "Wbudowany",
|
||||
"custom": "Niestandardowy",
|
||||
"view": "Zobacz",
|
||||
"deleteProfile": "Usuń Profil",
|
||||
"deleteProfileConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"profileDeleted": "Profil usunięty",
|
||||
"getMore": "Pobierz więcej...",
|
||||
"addProfileChoice": "Dodaj Profil",
|
||||
"addProfileChoiceMessage": "Jak chciałbyś dodać profil?",
|
||||
"createCustomProfile": "Utwórz Niestandardowy Profil",
|
||||
"createCustomProfileDescription": "Zbuduj profil od zera z własnymi tagami",
|
||||
"importFromWebsite": "Importuj ze Strony",
|
||||
"importFromWebsiteDescription": "Przeglądaj i importuj profile z deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Kafelki Mapy",
|
||||
"manageProviders": "Zarządzaj Dostawcami",
|
||||
"attribution": "Atrybucja Mapy",
|
||||
"mapAttribution": "Źródło mapy: {}",
|
||||
"couldNotOpenLink": "Nie udało się otworzyć linku",
|
||||
"openLicense": "Otwórz licencję: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Zobacz Profil",
|
||||
"newProfile": "Nowy Profil",
|
||||
"editProfile": "Edytuj Profil",
|
||||
"profileName": "Nazwa profilu",
|
||||
"profileNameHint": "np., Niestandardowa Kamera ALPR",
|
||||
"profileNameRequired": "Nazwa profilu jest wymagana",
|
||||
"requiresDirection": "Wymaga Kierunku",
|
||||
"requiresDirectionSubtitle": "Czy kamery tego typu potrzebują tagu kierunku",
|
||||
"fov": "Pole Widzenia",
|
||||
"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",
|
||||
"addTag": "Dodaj tag",
|
||||
"saveProfile": "Zapisz Profil",
|
||||
"keyHint": "klucz",
|
||||
"valueHint": "wartość",
|
||||
"atLeastOneTagRequired": "Przynajmniej jeden tag jest wymagany",
|
||||
"profileSaved": "Profil \"{}\" zapisany"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nowy Profil Operatora",
|
||||
"editOperatorProfile": "Edytuj Profil Operatora",
|
||||
"operatorName": "Nazwa operatora",
|
||||
"operatorNameHint": "np., Policja Warszawska",
|
||||
"operatorNameRequired": "Nazwa operatora jest wymagana",
|
||||
"operatorProfileSaved": "Profil operatora \"{}\" zapisany"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profile Operatorów",
|
||||
"noProfilesMessage": "Brak zdefiniowanych profili operatorów. Utwórz jeden, aby zastosować tagi operatorów do zgłoszeń węzłów.",
|
||||
"tagsCount": "{} tagów",
|
||||
"deleteOperatorProfile": "Usuń Profil Operatora",
|
||||
"deleteOperatorProfileConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil operatora usunięty"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Obszary Offline",
|
||||
"noAreasTitle": "Brak obszarów offline",
|
||||
"noAreasSubtitle": "Pobierz obszar mapy do użytku offline.",
|
||||
"provider": "Dostawca",
|
||||
"maxZoom": "Maksymalne przybliżenie",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Szerokość",
|
||||
"longitude": "Długość",
|
||||
"tiles": "Kafelki",
|
||||
"size": "Rozmiar",
|
||||
"nodes": "Węzły",
|
||||
"areaIdFallback": "Obszar {}...",
|
||||
"renameArea": "Zmień nazwę obszaru",
|
||||
"refreshWorldTiles": "Odśwież/pobierz ponownie kafelki światowe",
|
||||
"deleteOfflineArea": "Usuń obszar offline",
|
||||
"cancelDownload": "Anuluj pobieranie",
|
||||
"renameAreaDialogTitle": "Zmień Nazwę Obszaru Offline",
|
||||
"areaNameLabel": "Nazwa Obszaru",
|
||||
"renameButton": "Zmień Nazwę",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Odśwież obszar",
|
||||
"refreshAreaDialogTitle": "Odśwież Obszar Offline",
|
||||
"refreshAreaDialogSubtitle": "Wybierz co odświeżyć dla tego obszaru:",
|
||||
"refreshTiles": "Odśwież Kafelki Mapy",
|
||||
"refreshTilesSubtitle": "Pobierz ponownie wszystkie kafelki dla zaktualizowanych obrazów",
|
||||
"refreshNodes": "Odśwież Węzły",
|
||||
"refreshNodesSubtitle": "Pobierz ponownie dane węzłów dla tego obszaru",
|
||||
"startRefresh": "Rozpocznij Odświeżanie",
|
||||
"refreshStarted": "Odświeżanie rozpoczęte!",
|
||||
"refreshFailed": "Odświeżanie nie powiodło się: {}",
|
||||
"disableConfirmTitle": "Usunąć Istniejące Dane Obszarów Offline?",
|
||||
"disableConfirmMessage": "Masz już istniejące dane obszarów offline. Wyłączenie funkcji offline uniemożliwi aplikacji ich używanie. Czy chcesz zachować te dane na urządzeniu na wypadek ponownego włączenia funkcji offline później, czy usunąć je teraz, aby zwolnić miejsce na dysku?",
|
||||
"keepData": "Zachowaj Dane",
|
||||
"deleteData": "Usuń Dane"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Doprecyzuj Tagi",
|
||||
"operatorProfile": "Profil Operatora",
|
||||
"done": "Gotowe",
|
||||
"none": "Brak",
|
||||
"noAdditionalOperatorTags": "Brak dodatkowych tagów operatora",
|
||||
"additionalTags": "dodatkowe tagi",
|
||||
"additionalTagsTitle": "Dodatkowe Tagi",
|
||||
"noTagsDefinedForProfile": "Brak tagów zdefiniowanych dla tego profilu operatora.",
|
||||
"noOperatorProfiles": "Brak zdefiniowanych profili operatorów",
|
||||
"noOperatorProfilesMessage": "Utwórz profile operatorów w Ustawieniach, aby zastosować dodatkowe tagi do swoich zgłoszeń węzłów.",
|
||||
"profileTags": "Tagi Profilu",
|
||||
"profileTagsDescription": "Wypełnij te opcjonalne wartości tagów dla bardziej szczegółowych zgłoszeń:",
|
||||
"selectValue": "Wybierz wartość...",
|
||||
"noValue": "(zostaw puste)",
|
||||
"noSuggestions": "Brak dostępnych sugestii",
|
||||
"existingTagsTitle": "Istniejące Tagi",
|
||||
"existingTagsDescription": "Edytuj istniejące tagi na tym urządzeniu. Dodaj, usuń lub zmodyfikuj dowolny tag:",
|
||||
"existingOperator": "<Istniejący operator>",
|
||||
"existingOperatorTags": "istniejące tagi operatora"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Nie można zmieniać typów kafelków podczas pobierania obszarów offline",
|
||||
"selectMapLayer": "Wybierz Warstwę Mapy",
|
||||
"noTileProvidersAvailable": "Brak dostępnych dostawców kafelków"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Zaawansowane Opcje Edycji",
|
||||
"subtitle": "Te edytory oferują bardziej zaawansowane funkcje dla złożonych edycji.",
|
||||
"webEditors": "Edytory Webowe",
|
||||
"mobileEditors": "Edytory Mobilne",
|
||||
"iDEditor": "Edytor iD",
|
||||
"iDEditorSubtitle": "W pełni funkcjonalny edytor webowy - zawsze działa",
|
||||
"rapidEditor": "Edytor RapiD",
|
||||
"rapidEditorSubtitle": "Edycja wspomagana AI z danymi Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Zaawansowany edytor OSM dla Androida",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Aplikacja do mapowania oparta na ankietach",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Szybka edycja POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "Edytor OSM dla iOS",
|
||||
"couldNotOpenEditor": "Nie można otworzyć edytora - aplikacja może nie być zainstalowana",
|
||||
"couldNotOpenURL": "Nie można otworzyć URL",
|
||||
"couldNotOpenOSMWebsite": "Nie można otworzyć strony OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Pokaż wskaźnik stanu sieci",
|
||||
"showIndicatorSubtitle": "Wyświetl status ładowania danych nadzoru i błędów",
|
||||
"loading": "Ładowanie danych nadzoru...",
|
||||
"timedOut": "Żądanie przekroczyło limit czasu",
|
||||
"noData": "Brak danych offline",
|
||||
"success": "Dane nadzoru załadowane",
|
||||
"nodeDataSlow": "Dane nadzoru powolne",
|
||||
"rateLimited": "Ograniczone przez serwer",
|
||||
"networkError": "Błąd sieci"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Pokazuje {rendered} z {total} urządzeń",
|
||||
"editingDisabledMessage": "Pokazano zbyt wiele urządzeń, aby bezpiecznie edytować. Przybliż dalej, aby zmniejszyć liczbę widocznych urządzeń, następnie spróbuj ponownie."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Szukaj Lokalizacji",
|
||||
"searchPlaceholder": "Szukaj miejsc lub współrzędnych...",
|
||||
"routeTo": "Trasa Do",
|
||||
"routeFrom": "Trasa Od",
|
||||
"selectLocation": "Wybierz Lokalizację",
|
||||
"calculatingRoute": "Obliczanie trasy...",
|
||||
"routeCalculationFailed": "Obliczanie trasy nie powiodło się",
|
||||
"start": "Start",
|
||||
"resume": "Wznów",
|
||||
"endRoute": "Zakończ Trasę",
|
||||
"routeOverview": "Przegląd Trasy",
|
||||
"retry": "Spróbuj Ponownie",
|
||||
"cancelSearch": "Anuluj wyszukiwanie",
|
||||
"noResultsFound": "Nie znaleziono wyników",
|
||||
"searching": "Wyszukiwanie...",
|
||||
"location": "Lokalizacja",
|
||||
"startPoint": "Start",
|
||||
"endPoint": "Koniec",
|
||||
"startSelect": "Start (wybierz)",
|
||||
"endSelect": "Koniec (wybierz)",
|
||||
"distance": "Odległość: {} km",
|
||||
"routeActive": "Trasa aktywna",
|
||||
"locationsTooClose": "Lokalizacje startu i mety są zbyt blisko siebie",
|
||||
"navigationSettings": "Nawigacja",
|
||||
"navigationSettingsSubtitle": "Planowanie tras i ustawienia unikania",
|
||||
"avoidanceDistance": "Odległość Unikania",
|
||||
"avoidanceDistanceSubtitle": "Minimalna odległość do utrzymania od urządzeń nadzoru",
|
||||
"searchHistory": "Maksymalna Historia Wyszukiwania",
|
||||
"searchHistorySubtitle": "Maksymalna liczba ostatnich wyszukiwań do zapamiętania"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Podejrzane Lokalizacje",
|
||||
"showSuspectedLocations": "Pokaż Podejrzane Lokalizacje",
|
||||
"showSuspectedLocationsSubtitle": "Pokaż znaczniki znaku zapytania dla podejrzanych miejsc nadzoru z danych pozwoleń użyteczności publicznej",
|
||||
"lastUpdated": "Ostatnio Zaktualizowane",
|
||||
"refreshNow": "Odśwież teraz",
|
||||
"dataSource": "Źródło Danych",
|
||||
"dataSourceDescription": "Dane pozwoleń użyteczności publicznej wskazujące potencjalne miejsca instalacji infrastruktury nadzoru",
|
||||
"dataSourceCredit": "Zbieranie danych i hosting zapewnione przez alprwatch.org",
|
||||
"minimumDistance": "Minimalna Odległość od Rzeczywistych Węzłów",
|
||||
"minimumDistanceSubtitle": "Ukryj podejrzane lokalizacje w promieniu {} od istniejących urządzeń nadzoru",
|
||||
"updating": "Aktualizowanie Podejrzanych Lokalizacji",
|
||||
"downloadingAndProcessing": "Pobieranie i przetwarzanie danych...",
|
||||
"updateSuccess": "Podejrzane lokalizacje zaktualizowane pomyślnie",
|
||||
"updateFailed": "Nie udało się zaktualizować podejrzanych lokalizacji",
|
||||
"neverFetched": "Nigdy nie pobrano",
|
||||
"daysAgo": "{} dni temu",
|
||||
"hoursAgo": "{} godzin temu",
|
||||
"minutesAgo": "{} minut temu",
|
||||
"justNow": "Właśnie teraz"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Podejrzana Lokalizacja #{}",
|
||||
"ticketNo": "Nr Biletu",
|
||||
"address": "Adres",
|
||||
"street": "Ulica",
|
||||
"city": "Miasto",
|
||||
"state": "Województwo",
|
||||
"intersectingStreet": "Przecinająca Ulica",
|
||||
"workDoneFor": "Praca Wykonana Dla",
|
||||
"remarks": "Uwagi",
|
||||
"url": "URL",
|
||||
"coordinates": "Współrzędne",
|
||||
"noAddressAvailable": "Brak dostępnego adresu"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mila",
|
||||
"metersLong": "metry",
|
||||
"feetLong": "stopy",
|
||||
"kilometersLong": "kilometry",
|
||||
"milesLong": "mile",
|
||||
"metric": "Metryczny",
|
||||
"imperial": "Imperialny",
|
||||
"metricDescription": "Metryczny (km, m)",
|
||||
"imperialDescription": "Imperialny (mila, ft)"
|
||||
}
|
||||
}
|
||||
+111
-27
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "Sobre / Informações",
|
||||
"aboutThisApp": "Sobre este App",
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"languageSubtitle": "Escolha seu idioma preferido e unidades",
|
||||
"distanceUnit": "Unidades de Distância",
|
||||
"distanceUnitSubtitle": "Escolha entre unidades métricas (km/m) ou imperiais (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa.",
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
@@ -107,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",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "Habilitar Notificações",
|
||||
"checkingPermissions": "Verificando permissões...",
|
||||
"alertDistance": "Distância de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
|
||||
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nó #{}",
|
||||
@@ -129,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...",
|
||||
@@ -140,8 +178,8 @@
|
||||
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"refineTags": "Refinar Tags"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nó #{}",
|
||||
@@ -155,12 +193,16 @@
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
|
||||
"cannotMoveConstrainedNode": "Não é possível mover esta câmera - ela está conectada a outro elemento do mapa (OSM way/relation). Você ainda pode editar suas tags e direção.",
|
||||
"zoomInRequiredMessage": "Amplie para pelo menos o nível {} para adicionar ou editar nós de vigilância. Isto garante um posicionamento preciso para mapeamento exato.",
|
||||
"extractFromWay": "Extrair nó do way/relation",
|
||||
"extractFromWaySubtitle": "Criar novo nó com as mesmas tags, permitir mover para nova localização",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
"existingTags": "<Tags existentes>",
|
||||
"noChangesDetected": "Nenhuma alteração detectada - nada para enviar",
|
||||
"noChangesTitle": "Nenhuma Alteração para Enviar",
|
||||
"noChangesMessage": "Você não fez nenhuma alteração neste nó. Para enviar uma edição, você precisa alterar a localização, o perfil, as direções ou as tags."
|
||||
},
|
||||
"download": {
|
||||
"title": "Baixar Área do Mapa",
|
||||
@@ -174,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",
|
||||
@@ -262,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",
|
||||
@@ -274,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",
|
||||
@@ -316,12 +369,22 @@
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Excluir Perfil",
|
||||
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"profileDeleted": "Perfil excluído"
|
||||
"profileDeleted": "Perfil excluído",
|
||||
"getMore": "Obter mais...",
|
||||
"addProfileChoice": "Adicionar Perfil",
|
||||
"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",
|
||||
"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",
|
||||
@@ -336,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",
|
||||
@@ -348,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",
|
||||
@@ -394,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",
|
||||
@@ -411,7 +481,11 @@
|
||||
"profileTagsDescription": "Especifique valores para tags que precisam de refinamento:",
|
||||
"selectValue": "Selecionar um valor...",
|
||||
"noValue": "(Sem valor)",
|
||||
"noSuggestions": "Nenhuma sugestão disponível"
|
||||
"noSuggestions": "Nenhuma sugestão disponível",
|
||||
"existingTagsTitle": "Tags Existentes",
|
||||
"existingTagsDescription": "Edite as tags existentes neste dispositivo. Adicione, remova ou modifique qualquer tag:",
|
||||
"existingOperator": "<Operador existente>",
|
||||
"existingOperatorTags": "tags de operador existentes"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
|
||||
@@ -446,7 +520,9 @@
|
||||
"timedOut": "Solicitação expirada",
|
||||
"noData": "Nenhum dado offline",
|
||||
"success": "Dados de vigilância carregados",
|
||||
"nodeDataSlow": "Dados de vigilância lentos"
|
||||
"nodeDataSlow": "Dados de vigilância lentos",
|
||||
"rateLimited": "Limitado pelo servidor",
|
||||
"networkError": "Erro de rede"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Mostrando {rendered} de {total} dispositivos",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "Distância de evasão",
|
||||
"avoidanceDistanceSubtitle": "Distância mínima para ficar longe de dispositivos de vigilância",
|
||||
"searchHistory": "Histórico máximo de busca",
|
||||
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar",
|
||||
"units": "Unidades",
|
||||
"unitsSubtitle": "Unidades de exibição para distâncias e medidas",
|
||||
"metric": "Métrico (km, m)",
|
||||
"imperial": "Imperial (mi, ft)",
|
||||
"meters": "metros",
|
||||
"feet": "pés"
|
||||
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Localizações Suspeitas",
|
||||
@@ -499,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"
|
||||
},
|
||||
@@ -514,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",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "Nenhum endereço disponível"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pés",
|
||||
"kilometersLong": "quilômetros",
|
||||
"milesLong": "milhas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Türkçe"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Gözetleme Şeffaflığı",
|
||||
"description": "DeFlock, OpenStreetMap kullanarak kamusal gözetleme altyapısını haritalamak için tasarlanmış gizlilik odaklı bir mobil uygulamadır. Topluluğunuzdaki kameraları, ALPR'leri, silah sesi dedektörlerini ve diğer gözetleme cihazlarını belgeleyerek bu altyapıyı görünür ve aranabilir hale getirin.",
|
||||
"features": "• İndirilebilir alanlarla çevrimdışı haritalama\n• OAuth2 ile doğrudan OpenStreetMap'e yükleme\n• Büyük üreticiler için yerleşik profiller\n• Gizlilik dostu - kullanıcı verisi toplanmaz\n• Çoklu harita döşemeleri (OSM, uydu görüntüleri)",
|
||||
"initiative": "Gözetleme şeffaflığını destekleyen daha geniş DeFlock girişiminin parçasıdır.",
|
||||
"footer": "Ziyaret edin: deflock.me\nFlutter ile yapıldı • Açık Kaynak",
|
||||
"showWelcome": "Hoş Geldin Mesajını Göster",
|
||||
"showSubmissionGuide": "Gönderim Rehberini Göster",
|
||||
"viewReleaseNotes": "Sürüm Notlarını Görüntüle"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "DeFlock'a Hoş Geldiniz",
|
||||
"description": "DeFlock, kamusal gözetleme araçlarının şeffaf olması gerektiği fikri üzerine kurulmuştur. Bu mobil uygulama içerisinde, web sitesinde olduğu gibi, yerel bölgenizdeki ve yurtdışındaki ALPR'lerin ve diğer gözetleme altyapılarının konumlarını görebileceksiniz.",
|
||||
"mission": "Ancak bu proje otomatik değil; bu projeyi daha iyi hale getirmek hepimize bağlı. Haritayı görüntülerken, daha önce bilinmeyen bir kurulumu eklemek için \"Yeni Düğüm\"e dokunabilirsiniz. Yardımınızla, gözetleme altyapısının şeffaflığını ve kamusal farkındalığını artırma hedefimize ulaşabiliriz.",
|
||||
"firsthandKnowledge": "ÖNEMLİ: Sadece kişisel olarak gözlemlediğiniz gözetleme cihazlarını ekleyin. OpenStreetMap ve Google politikaları, Street View görüntüleri gibi kaynaklarının gönderimler için kullanılmasını yasaklar. Katkılarınız kendi doğrudan gözlemlerinize dayanmalıdır.",
|
||||
"privacy": "Gizlilik Notu: Bu uygulama tamamen cihazınızda yerel olarak çalışır ve veri depolama ve gönderimler için üçüncü taraf OpenStreetMap API'sini kullanır. DeFlock herhangi bir kullanıcı verisini toplamaz veya saklamaz ve hesap yönetiminden sorumlu değildir.",
|
||||
"tileNote": "NOT: OpenStreetMap'den ücretsiz harita döşemeleri yüklenmesi çok yavaş olabilir. Alternatif döşeme sağlayıcıları Ayarlar > Gelişmiş'te yapılandırılabilir.",
|
||||
"moreInfo": "Daha fazla bağlantıyı Ayarlar > Hakkında'da bulabilirsiniz.",
|
||||
"dontShowAgain": "Bu hoş geldin mesajını bir daha gösterme",
|
||||
"getStarted": "Hadi DeFlocking'e Başlayalım!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Gönderim En İyi Uygulamaları",
|
||||
"description": "İlk gözetleme cihazınızı göndermeden önce, OpenStreetMap'e yüksek kaliteli katkılar sağlamak için bu önemli yönergeleri gözden geçirin.",
|
||||
"bestPractices": "• Sadece kişisel olarak gözlemlediğiniz cihazları haritalayın\n• Cihaz tipini ve üreticisini doğru şekilde belirlemeye zaman ayırın\n• Hassas konumlandırma kullanın - işaretçiyi yerleştirmeden önce yakınlaştırın\n• Uygun olduğunda yön bilgisini dahil edin\n• Göndermeden önce etiket seçimlerinizi iki kez kontrol edin",
|
||||
"placementNote": "Unutmayın: Doğru, ilk elden veriler DeFlock topluluğu ve OpenStreetMap projesi için temeldir.",
|
||||
"moreInfo": "Cihaz tanımlama ve haritalama en iyi uygulamaları için ayrıntılı rehberlik:",
|
||||
"identificationGuide": "Tanımlama Rehberi",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Bu rehberi bir daha gösterme",
|
||||
"gotIt": "Anladım!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Konumunuzu Hassaslaştırın",
|
||||
"instructions": "Cihaz işaretçisini gözetleme cihazının konumu üzerine tam olarak yerleştirmek için haritayı sürükleyin.",
|
||||
"hint": "Konumlandırmadan önce daha iyi doğruluk için yakınlaştırabilirsiniz."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Yeni Düğüm",
|
||||
"download": "İndir",
|
||||
"settings": "Ayarlar",
|
||||
"edit": "Düzenle",
|
||||
"delete": "Sil",
|
||||
"cancel": "İptal",
|
||||
"ok": "Tamam",
|
||||
"close": "Kapat",
|
||||
"submit": "Gönder",
|
||||
"logIn": "Giriş Yap",
|
||||
"saveEdit": "Düzenlemeyi Kaydet",
|
||||
"clear": "Temizle",
|
||||
"viewOnOSM": "OSM'de Görüntüle",
|
||||
"advanced": "Gelişmiş",
|
||||
"useAdvancedEditor": "Gelişmiş Düzenleyiciyi Kullan"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Düğüm Mevcut Cihaza Çok Yakın",
|
||||
"message": "Bu düğüm mevcut bir gözetleme cihazından sadece {} metre uzakta.",
|
||||
"suggestion": "Aynı direk üzerinde birden fazla cihaz varsa, ayrı düğümler oluşturmak yerine lütfen tek bir düğümde birden fazla yön kullanın.",
|
||||
"nearbyNodes": "Yakındaki cihaz(lar) bulundu ({}):",
|
||||
"nodeInfo": "Düğüm #{} - {}",
|
||||
"andMore": "...ve {} tane daha",
|
||||
"goBack": "Geri Dön",
|
||||
"submitAnyway": "Yine de Gönder",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Kamerası",
|
||||
"publicCamera": "Kamusal Güvenlik Kamerası",
|
||||
"camera": "Güvenlik Kamerası",
|
||||
"amenity": "{}",
|
||||
"device": "{} Cihazı",
|
||||
"unknown": "Bilinmeyen Cihaz"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Takip etmeyi etkinleştir",
|
||||
"follow": "Takip etmeyi etkinleştir (dönen)",
|
||||
"rotating": "Takip etmeyi devre dışı bırak"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil ve Bölge",
|
||||
"systemDefault": "Sistem Varsayılanı",
|
||||
"aboutInfo": "Hakkında / Bilgi",
|
||||
"aboutThisApp": "Bu Uygulama Hakkında",
|
||||
"aboutSubtitle": "Uygulama bilgileri ve krediler",
|
||||
"languageSubtitle": "Tercih ettiğiniz dili ve birimleri seçin",
|
||||
"distanceUnit": "Mesafe Birimleri",
|
||||
"distanceUnitSubtitle": "Metrik (km/m) veya imperial (mil/ft) birimler arasında seçim yapın",
|
||||
"metricUnits": "Metrik (km, m)",
|
||||
"imperialUnits": "İmperial (mil, ft)",
|
||||
"maxNodes": "Çizilen maksimum düğüm",
|
||||
"maxNodesSubtitle": "Haritadaki düğüm sayısı için üst limit belirleyin.",
|
||||
"maxNodesWarning": "Bunu yapmak için kesinlikle iyi bir nedeniniz olduğundan emin değilseniz, bunu yapmak istemezsiniz.",
|
||||
"offlineMode": "Çevrimdışı Mod",
|
||||
"offlineModeSubtitle": "Yerel/çevrimdışı alanlar dışındaki tüm ağ isteklerini devre dışı bırak.",
|
||||
"pauseQueueProcessing": "Yükleme Kuyruğunu Duraklat",
|
||||
"pauseQueueProcessingSubtitle": "Canlı veri erişimini korurken sıraya alınan değişiklikleri yüklemeyi durdur.",
|
||||
"offlineModeWarningTitle": "Aktif İndirmeler",
|
||||
"offlineModeWarningMessage": "Çevrimdışı modu etkinleştirmek aktif alan indirmelerini iptal edecektir. Devam etmek istiyor musunuz?",
|
||||
"enableOfflineMode": "Çevrimdışı Modu Etkinleştir",
|
||||
"profiles": "Profiller",
|
||||
"profilesSubtitle": "Düğüm ve operatör profillerini yönet",
|
||||
"offlineSettings": "Çevrimdışı Ayarlar",
|
||||
"offlineSettingsSubtitle": "Çevrimdışı mod ve indirilen alanları yönet",
|
||||
"advancedSettings": "Gelişmiş Ayarlar",
|
||||
"advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları",
|
||||
"proximityAlerts": "Yakınlık Uyarıları",
|
||||
"keepScreenAwake": "Ekranı Açık Tut",
|
||||
"networkStatusIndicator": "Ağ Durumu Göstergesi",
|
||||
"hideZoomControls": "Yakınlaştırma Kontrollerini Gizle",
|
||||
"enableOfflineFeatures": "Çevrimdışı Özellikleri Etkinleştir",
|
||||
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
|
||||
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Uygulamayı kullanırken ekranı açık tut",
|
||||
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Yakınlaştırma kontrollerini gizle",
|
||||
"subtitle": "+/- yakınlaştırma düğmelerini ve yakınlaştırma seviyesi göstergesini gizler. Yine de kıstırma hareketleriyle yakınlaştırabilirsiniz."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
|
||||
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
|
||||
"notificationsEnabled": "✓ Bildirimler etkinleştirildi",
|
||||
"notificationsDisabled": "⚠ Bildirimler devre dışı",
|
||||
"permissionRequired": "Bildirim izni gerekli",
|
||||
"permissionExplanation": "Push bildirimleri devre dışı. Sadece uygulama içi uyarıları göreceksiniz ve uygulama arka plandayken bilgilendirilmeyeceksiniz.",
|
||||
"enableNotifications": "Bildirimleri Etkinleştir",
|
||||
"checkingPermissions": "İzinler kontrol ediliyor...",
|
||||
"alertDistance": "Uyarı mesafesi: ",
|
||||
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Düğüm #{}",
|
||||
"tagSheetTitle": "Gözetleme Cihazı Etiketleri",
|
||||
"queuedForUpload": "Düğüm yükleme için sıraya alındı",
|
||||
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
|
||||
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
|
||||
"confirmDeleteTitle": "Düğümü Sil",
|
||||
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleteReasonLabel": "Silme Nedeni",
|
||||
"deleteReasonSelectHint": "Seçin...",
|
||||
"deleteReasonDeviceRemoved": "Cihaz kaldırıldı",
|
||||
"deleteReasonDuplicateNode": "Yinelenen düğüm",
|
||||
"deleteReasonMisidentifiedInfra": "Yanlış tanımlanmış altyapı",
|
||||
"deleteReasonSpamVandalism": "Spam/vandalizm",
|
||||
"deleteReasonCustom": "Özel..",
|
||||
"deleteReasonHint": "Lütfen nedeni açıklayın...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.",
|
||||
"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...",
|
||||
"profileRequired": "Devam etmek için lütfen bir profil seçin.",
|
||||
"direction": "Yön {}°",
|
||||
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
|
||||
"mustBeLoggedIn": "Yeni düğümler göndermek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
|
||||
"enableSubmittableProfile": "Yeni düğümler göndermek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
|
||||
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Yeni düğümler göndermek için lütfen gönderilebilir bir profil seçin.",
|
||||
"loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.",
|
||||
"refineTags": "Etiketleri Düzenle"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Düğümü Düzenle #{}",
|
||||
"profile": "Profil",
|
||||
"selectProfile": "Bir profil seçin...",
|
||||
"profileRequired": "Devam etmek için lütfen bir profil seçin.",
|
||||
"direction": "Yön {}°",
|
||||
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
|
||||
"temporarilyDisabled": "Bir hatayı çözmemiz sırasında düzenlemeler geçici olarak devre dışı bırakıldı - özür dileriz - yakında tekrar kontrol edin.",
|
||||
"mustBeLoggedIn": "Düğümleri düzenlemek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
|
||||
"sandboxModeWarning": "Üretim düğümlerinde düzenlemeleri sandbox'a gönderemezsiniz. Düğümleri düzenlemek için Ayarlar'da Üretim moduna geçin.",
|
||||
"enableSubmittableProfile": "Düğümleri düzenlemek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
|
||||
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Düğümleri düzenlemek için lütfen gönderilebilir bir profil seçin.",
|
||||
"loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.",
|
||||
"cannotMoveConstrainedNode": "Bu kamerayı taşıyamazsınız - başka bir harita öğesine (OSM yolu/ilişkisi) bağlı. Yine de etiketlerini ve yönünü düzenleyebilirsiniz.",
|
||||
"zoomInRequiredMessage": "Gözetleme düğümleri eklemek veya düzenlemek için en az {} seviyesine yakınlaştırın. Bu doğru haritalama için hassas konumlandırmayı sağlar.",
|
||||
"extractFromWay": "Düğümü yol/ilişkiden çıkar",
|
||||
"extractFromWaySubtitle": "Aynı etiketlerle yeni düğüm oluştur, yeni konuma taşımaya izin ver",
|
||||
"refineTags": "Etiketleri Düzenle",
|
||||
"existingTags": "<Mevcut etiketler>",
|
||||
"noChangesDetected": "Değişiklik tespit edilmedi - gönderilecek bir şey yok",
|
||||
"noChangesTitle": "Gönderilecek Değişiklik Yok",
|
||||
"noChangesMessage": "Bu düğümde herhangi bir değişiklik yapmadınız. Düzenleme göndermek için konumu, profili, yönleri veya etiketleri değiştirmeniz gerekir."
|
||||
},
|
||||
"download": {
|
||||
"title": "Harita Alanını İndir",
|
||||
"maxZoomLevel": "Maksimum yakınlaştırma seviyesi",
|
||||
"storageEstimate": "Depolama tahmini:",
|
||||
"tilesAndSize": "{} döşeme, {} MB",
|
||||
"minZoom": "Min yakınlaştırma:",
|
||||
"maxRecommendedZoom": "Maksimum önerilen yakınlaştırma: Z{}",
|
||||
"withinTileLimit": "{} döşeme sınırı içinde",
|
||||
"exceedsTileLimit": "Mevcut seçim {} döşeme sınırını aşıyor",
|
||||
"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ı: {}",
|
||||
"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ı",
|
||||
"message": "İndirme başladı! Döşemeler ve düğümler getiriliyor...",
|
||||
"ok": "Tamam",
|
||||
"viewProgress": "Ayarlarda İlerlemeyi Görüntüle"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Yükleme Hedefi",
|
||||
"subtitle": "Kameraların nereye yüklendiğini seçin",
|
||||
"production": "Üretim",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simülasyon",
|
||||
"productionDescription": "Canlı OSM veritabanına yükle (tüm kullanıcılara görünür)",
|
||||
"sandboxDescription": "Yüklemeler OSM Sandbox'ına gider (test için güvenli, düzenli olarak sıfırlanır).",
|
||||
"simulateDescription": "Yüklemeleri simüle et (OSM sunucularıyla iletişim kurmaz)",
|
||||
"cannotChangeWithQueue": "Sırada {} öğe varken yükleme hedefi değiştirilemez. Önce sırayı temizleyin."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "OpenStreetMap Hesabı",
|
||||
"osmAccountSubtitle": "OSM girişinizi yönetin ve katkılarınızı görüntüleyin",
|
||||
"loggedInAs": "{} olarak giriş yapıldı",
|
||||
"loginToOSM": "OpenStreetMap'e giriş yap",
|
||||
"tapToLogout": "Çıkış yapmak için dokun",
|
||||
"requiredToSubmit": "Kamera verisi göndermek için gerekli",
|
||||
"loggedOut": "Çıkış yapıldı",
|
||||
"testConnection": "Bağlantıyı Test Et",
|
||||
"testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın",
|
||||
"connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli",
|
||||
"connectionFailed": "Bağlantı başarısız - lütfen yeniden giriş yapın",
|
||||
"viewMyEdits": "OSM'deki Düzenlemelerimi Görüntüle",
|
||||
"viewMyEditsSubtitle": "OpenStreetMap'teki düzenleme geçmişinizi görün",
|
||||
"aboutOSM": "OpenStreetMap Hakkında",
|
||||
"aboutOSMDescription": "OpenStreetMap, katkıda bulunanların dünyanın ücretsiz, düzenlenebilir haritasını oluşturdukları ve sürdürdükleri işbirlikçi, açık kaynaklı bir haritalama projesidir. Gözetleme cihazı katkılarınız bu altyapıyı görünür ve aranabilir hale getirmeye yardımcı olur.",
|
||||
"visitOSM": "OpenStreetMap'i Ziyaret Et",
|
||||
"deleteAccount": "OSM Hesabını Sil",
|
||||
"deleteAccountSubtitle": "OpenStreetMap hesabınızı yönetin",
|
||||
"deleteAccountExplanation": "OpenStreetMap hesabınızı silmek için OpenStreetMap web sitesini ziyaret etmeniz gerekecek. Bu, OSM hesabınızı ve ilişkili tüm verileri kalıcı olarak kaldıracaktır.",
|
||||
"deleteAccountWarning": "Uyarı: Bu işlem geri alınamaz ve OSM hesabınızı kalıcı olarak silecektir.",
|
||||
"goToOSM": "OpenStreetMap'e Git",
|
||||
"accountManagement": "Hesap Yönetimi",
|
||||
"accountManagementDescription": "OpenStreetMap hesabınızı silmek için uygun OpenStreetMap web sitesini ziyaret etmeniz gerekecek. Bu, hesabınızı ve ilişkili tüm verileri kalıcı olarak kaldıracaktır.",
|
||||
"currentDestinationProduction": "Şu anda bağlı: Üretim OpenStreetMap",
|
||||
"currentDestinationSandbox": "Şu anda bağlı: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Şu anda: Simülasyon modu (gerçek hesap yok)",
|
||||
"viewMessages": "OSM'deki Mesajları Görüntüle",
|
||||
"unreadMessagesCount": "{} okunmamış mesajınız var",
|
||||
"noUnreadMessages": "Okunmamış mesaj yok",
|
||||
"reauthRequired": "Kimlik Doğrulamayı Yenile",
|
||||
"reauthExplanation": "Uygulama üzerinden OSM mesaj bildirimlerini alabilmek için kimlik doğrulamanızı yenilemeniz gerekir.",
|
||||
"reauthBenefit": "Bu, OpenStreetMap'te okunmamış mesajlarınız olduğunda bildirim noktalarını etkinleştirecek.",
|
||||
"reauthNow": "Şimdi Yap",
|
||||
"reauthLater": "Sonra"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Yükleme Kuyruğu",
|
||||
"subtitle": "Bekleyen gözetleme cihazı yüklemelerini yönet",
|
||||
"pendingUploads": "Bekleyen yüklemeler: {}",
|
||||
"pendingItemsCount": "Bekleyen Öğeler: {}",
|
||||
"nothingInQueue": "Kuyrukta hiçbir şey yok",
|
||||
"simulateModeEnabled": "Simülasyon modu etkin – yüklemeler simüle ediliyor",
|
||||
"sandboxMode": "Sandbox modu – yüklemeler OSM Sandbox'ına gidiyor",
|
||||
"tapToViewQueue": "Kuyruğu görüntülemek için dokun",
|
||||
"clearUploadQueue": "Yükleme Kuyruğunu Temizle",
|
||||
"removeAllPending": "Tüm {} bekleyen yüklemeyi kaldır",
|
||||
"clearQueueTitle": "Kuyruğu Temizle",
|
||||
"clearQueueConfirm": "Tüm {} bekleyen yükleme kaldırılsın mı?",
|
||||
"queueCleared": "Kuyruk temizlendi",
|
||||
"uploadQueueTitle": "Yükleme Kuyruğu ({} öğe)",
|
||||
"queueIsEmpty": "Kuyruk boş",
|
||||
"itemWithIndex": "Öğe {}",
|
||||
"error": " (Hata)",
|
||||
"completing": " (Tamamlanıyor...)",
|
||||
"destination": "Hedef: {}",
|
||||
"latitude": "Enlem: {}",
|
||||
"longitude": "Boylam: {}",
|
||||
"direction": "Yön: {}°",
|
||||
"attempts": "Denemeler: {}",
|
||||
"uploadFailedRetry": "Yükleme başarısız. Tekrar denemek için yeniden dene'ye dokun.",
|
||||
"retryUpload": "Yüklemeyi yeniden dene",
|
||||
"clearAll": "Tümünü Temizle",
|
||||
"errorDetails": "Hata Detayları",
|
||||
"creatingChangeset": " (Değişiklik seti oluşturuluyor...)",
|
||||
"uploading": " (Yükleniyor...)",
|
||||
"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ı.",
|
||||
"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ış",
|
||||
"tileTypesCount": "{} döşeme türü",
|
||||
"apiKeyConfigured": "API Anahtarı yapılandırıldı",
|
||||
"needsApiKey": "API anahtarı gerekiyor",
|
||||
"editProvider": "Sağlayıcıyı Düzenle",
|
||||
"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",
|
||||
"apiKey": "API Anahtarı (İsteğe Bağlı)",
|
||||
"apiKeyHint": "Döşeme türleri gerektiriyorsa API anahtarını girin",
|
||||
"tileTypes": "Döşeme Türleri",
|
||||
"addType": "Tür Ekle",
|
||||
"noTileTypesConfigured": "Döşeme türü yapılandırılmamış",
|
||||
"atLeastOneTileTypeRequired": "En az bir döşeme türü gerekli",
|
||||
"manageTileProviders": "Sağlayıcıları Yönet"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Döşeme Türünü Düzenle",
|
||||
"addTileType": "Döşeme Türü Ekle",
|
||||
"name": "Ad",
|
||||
"nameHint": "örn., Uydu",
|
||||
"nameRequired": "Ad gerekli",
|
||||
"urlTemplate": "URL Şablonu",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL şablonu gerekli",
|
||||
"urlTemplatePlaceholders": "URL ya {quadkey} ya da {z}, {x}, ve {y} yer tutucularını içermelidir",
|
||||
"attribution": "Atıf",
|
||||
"attributionHint": "© Harita Sağlayıcısı",
|
||||
"attributionRequired": "Atıf gerekli",
|
||||
"maxZoom": "Maksimum Yakınlaştırma Seviyesi",
|
||||
"maxZoomHint": "Maksimum yakınlaştırma seviyesi (1-23)",
|
||||
"maxZoomRequired": "Maksimum yakınlaştırma gerekli",
|
||||
"maxZoomInvalid": "Maksimum yakınlaştırma bir sayı olmalıdır",
|
||||
"maxZoomRange": "Maksimum yakınlaştırma {} ile {} arasında olmalıdır",
|
||||
"fetchPreview": "Önizleme Getir",
|
||||
"previewTileLoaded": "Önizleme döşemesi başarıyla yüklendi",
|
||||
"previewTileFailed": "Önizleme getirilemedi: {}",
|
||||
"save": "Kaydet"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Düğüm Profilleri",
|
||||
"newProfile": "Yeni Profil",
|
||||
"builtIn": "Yerleşik",
|
||||
"custom": "Özel",
|
||||
"view": "Görüntüle",
|
||||
"deleteProfile": "Profili Sil",
|
||||
"deleteProfileConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"profileDeleted": "Profil silindi",
|
||||
"getMore": "Daha fazla al...",
|
||||
"addProfileChoice": "Profil Ekle",
|
||||
"addProfileChoiceMessage": "Nasıl bir profil eklemek istersiniz?",
|
||||
"createCustomProfile": "Özel Profil Oluştur",
|
||||
"createCustomProfileDescription": "Kendi etiketlerinizle sıfırdan bir profil oluşturun",
|
||||
"importFromWebsite": "Web Sitesinden İçe Aktar",
|
||||
"importFromWebsiteDescription": "deflock.me/identify'dan profilleri inceleyin ve içe aktarın"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Harita Döşemeleri",
|
||||
"manageProviders": "Sağlayıcıları Yönet",
|
||||
"attribution": "Harita Atfı",
|
||||
"mapAttribution": "Harita kaynağı: {}",
|
||||
"couldNotOpenLink": "Bağlantı açılamadı",
|
||||
"openLicense": "Lisansı aç: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profili Görüntüle",
|
||||
"newProfile": "Yeni Profil",
|
||||
"editProfile": "Profili Düzenle",
|
||||
"profileName": "Profil adı",
|
||||
"profileNameHint": "örn., Özel ALPR Kamerası",
|
||||
"profileNameRequired": "Profil adı gerekli",
|
||||
"requiresDirection": "Yön Gerektirir",
|
||||
"requiresDirectionSubtitle": "Bu türdeki kameraların yön etiketi gerekip gerekmediği",
|
||||
"fov": "Görüş Alanı",
|
||||
"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",
|
||||
"addTag": "Etiket ekle",
|
||||
"saveProfile": "Profili Kaydet",
|
||||
"keyHint": "anahtar",
|
||||
"valueHint": "değer",
|
||||
"atLeastOneTagRequired": "En az bir etiket gerekli",
|
||||
"profileSaved": "\"{}\" profili kaydedildi"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Yeni Operatör Profili",
|
||||
"editOperatorProfile": "Operatör Profilini Düzenle",
|
||||
"operatorName": "Operatör adı",
|
||||
"operatorNameHint": "örn., Ankara Polis Müdürlüğü",
|
||||
"operatorNameRequired": "Operatör adı gerekli",
|
||||
"operatorProfileSaved": "\"{}\" operatör profili kaydedildi"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operatör Profilleri",
|
||||
"noProfilesMessage": "Operatör profili tanımlanmamış. Düğüm gönderimlerine operatör etiketleri uygulamak için bir tane oluşturun.",
|
||||
"tagsCount": "{} etiket",
|
||||
"deleteOperatorProfile": "Operatör Profilini Sil",
|
||||
"deleteOperatorProfileConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
|
||||
"operatorProfileDeleted": "Operatör profili silindi"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Çevrimdışı Alanlar",
|
||||
"noAreasTitle": "Çevrimdışı alan yok",
|
||||
"noAreasSubtitle": "Çevrimdışı kullanım için bir harita alanı indirin.",
|
||||
"provider": "Sağlayıcı",
|
||||
"maxZoom": "Maksimum yakınlaştırma",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Enlem",
|
||||
"longitude": "Boylam",
|
||||
"tiles": "Döşemeler",
|
||||
"size": "Boyut",
|
||||
"nodes": "Düğümler",
|
||||
"areaIdFallback": "Alan {}...",
|
||||
"renameArea": "Alanı yeniden adlandır",
|
||||
"refreshWorldTiles": "Dünya döşemelerini yenile/yeniden indir",
|
||||
"deleteOfflineArea": "Çevrimdışı alanı sil",
|
||||
"cancelDownload": "İndirmeyi iptal et",
|
||||
"renameAreaDialogTitle": "Çevrimdışı Alanı Yeniden Adlandır",
|
||||
"areaNameLabel": "Alan Adı",
|
||||
"renameButton": "Yeniden Adlandır",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Alanı yenile",
|
||||
"refreshAreaDialogTitle": "Çevrimdışı Alanı Yenile",
|
||||
"refreshAreaDialogSubtitle": "Bu alan için ne yenilenecek seçin:",
|
||||
"refreshTiles": "Harita Döşemelerini Yenile",
|
||||
"refreshTilesSubtitle": "Güncellenmiş görüntüler için tüm döşemeleri yeniden indir",
|
||||
"refreshNodes": "Düğümleri Yenile",
|
||||
"refreshNodesSubtitle": "Bu alan için düğüm verisini yeniden getir",
|
||||
"startRefresh": "Yenilemeyi Başlat",
|
||||
"refreshStarted": "Yenileme başladı!",
|
||||
"refreshFailed": "Yenileme başarısız: {}",
|
||||
"disableConfirmTitle": "Mevcut Çevrimdışı Alan Verileri Silinsin mi?",
|
||||
"disableConfirmMessage": "Zaten mevcut çevrimdışı alan verileriniz var. Çevrimdışı özellikleri devre dışı bırakmak, uygulamanın bunları kullanmasını engelleyecektir. Daha sonra çevrimdışı özellikleri tekrar etkinleştirme ihtimaline karşı bu verileri cihazda saklamak mı istersiniz, yoksa depolama alanını boşaltmak için şimdi silmek mi istersiniz?",
|
||||
"keepData": "Verileri Sakla",
|
||||
"deleteData": "Verileri Sil"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Etiketleri Düzenle",
|
||||
"operatorProfile": "Operatör Profili",
|
||||
"done": "Tamam",
|
||||
"none": "Hiçbiri",
|
||||
"noAdditionalOperatorTags": "Ek operatör etiketi yok",
|
||||
"additionalTags": "ek etiketler",
|
||||
"additionalTagsTitle": "Ek Etiketler",
|
||||
"noTagsDefinedForProfile": "Bu operatör profili için etiket tanımlanmamış.",
|
||||
"noOperatorProfiles": "Operatör profili tanımlanmamış",
|
||||
"noOperatorProfilesMessage": "Düğüm gönderimlerinize ek etiketler uygulamak için Ayarlar'da operatör profilleri oluşturun.",
|
||||
"profileTags": "Profil Etiketleri",
|
||||
"profileTagsDescription": "Daha detaylı gönderimler için bu isteğe bağlı etiket değerlerini tamamlayın:",
|
||||
"selectValue": "Değer seçin...",
|
||||
"noValue": "(boş bırak)",
|
||||
"noSuggestions": "Öneri bulunmuyor",
|
||||
"existingTagsTitle": "Mevcut Etiketler",
|
||||
"existingTagsDescription": "Bu cihazdaki mevcut etiketleri düzenleyin. Herhangi bir etiketi ekleyin, kaldırın veya değiştirin:",
|
||||
"existingOperator": "<Mevcut operatör>",
|
||||
"existingOperatorTags": "mevcut operatör etiketleri"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Çevrimdışı alanlar indirilirken döşeme türleri değiştirilemez",
|
||||
"selectMapLayer": "Harita Katmanını Seç",
|
||||
"noTileProvidersAvailable": "Döşeme sağlayıcısı mevcut değil"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Gelişmiş Düzenleme Seçenekleri",
|
||||
"subtitle": "Bu editörler karmaşık düzenlemeler için daha gelişmiş özellikler sunar.",
|
||||
"webEditors": "Web Editörleri",
|
||||
"mobileEditors": "Mobil Editörler",
|
||||
"iDEditor": "iD Editörü",
|
||||
"iDEditorSubtitle": "Tam özellikli web editörü - her zaman çalışır",
|
||||
"rapidEditor": "RapiD Editörü",
|
||||
"rapidEditorSubtitle": "Facebook verileriyle AI destekli düzenleme",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Gelişmiş Android OSM editörü",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Anket tabanlı haritalama uygulaması",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Hızlı POI düzenleme",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM editörü",
|
||||
"couldNotOpenEditor": "Editör açılamadı - uygulama yüklü olmayabilir",
|
||||
"couldNotOpenURL": "URL açılamadı",
|
||||
"couldNotOpenOSMWebsite": "OSM web sitesi açılamadı"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Ağ durumu göstergesini göster",
|
||||
"showIndicatorSubtitle": "Gözetleme verisi yükleme ve hata durumunu göster",
|
||||
"loading": "Gözetleme verisi yükleniyor...",
|
||||
"timedOut": "İstek zaman aşımına uğradı",
|
||||
"noData": "Çevrimdışı veri yok",
|
||||
"success": "Gözetleme verisi yüklendi",
|
||||
"nodeDataSlow": "Gözetleme verisi yavaş",
|
||||
"rateLimited": "Sunucu tarafından hız sınırlandı",
|
||||
"networkError": "Ağ hatası"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "{total} cihazdan {rendered} tanesi gösteriliyor",
|
||||
"editingDisabledMessage": "Güvenli düzenleme için çok fazla cihaz gösteriliyor. Görünen cihaz sayısını azaltmak için daha fazla yakınlaştırın, sonra tekrar deneyin."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Konum Ara",
|
||||
"searchPlaceholder": "Yerler veya koordinatlar ara...",
|
||||
"routeTo": "Buraya Yol Tarifi",
|
||||
"routeFrom": "Buradan Yol Tarifi",
|
||||
"selectLocation": "Konum Seç",
|
||||
"calculatingRoute": "Rota hesaplanıyor...",
|
||||
"routeCalculationFailed": "Rota hesaplama başarısız",
|
||||
"start": "Başlat",
|
||||
"resume": "Devam Et",
|
||||
"endRoute": "Rotayı Bitir",
|
||||
"routeOverview": "Rota Özeti",
|
||||
"retry": "Yeniden Dene",
|
||||
"cancelSearch": "Aramayı iptal et",
|
||||
"noResultsFound": "Sonuç bulunamadı",
|
||||
"searching": "Aranıyor...",
|
||||
"location": "Konum",
|
||||
"startPoint": "Başlangıç",
|
||||
"endPoint": "Bitiş",
|
||||
"startSelect": "Başlangıç (seç)",
|
||||
"endSelect": "Bitiş (seç)",
|
||||
"distance": "Mesafe: {} km",
|
||||
"routeActive": "Rota aktif",
|
||||
"locationsTooClose": "Başlangıç ve bitiş konumları birbirine çok yakın",
|
||||
"navigationSettings": "Navigasyon",
|
||||
"navigationSettingsSubtitle": "Rota planlama ve kaçınma ayarları",
|
||||
"avoidanceDistance": "Kaçınma Mesafesi",
|
||||
"avoidanceDistanceSubtitle": "Gözetleme cihazlarından uzak durmak için minimum mesafe",
|
||||
"searchHistory": "Maksimum Arama Geçmişi",
|
||||
"searchHistorySubtitle": "Hatırlanacak son aramaların maksimum sayısı"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Şüpheli Konumlar",
|
||||
"showSuspectedLocations": "Şüpheli Konumları Göster",
|
||||
"showSuspectedLocationsSubtitle": "Altyapı izin verilerinden şüpheli gözetleme siteleri için soru işareti işaretçilerini göster",
|
||||
"lastUpdated": "Son Güncellenme",
|
||||
"refreshNow": "Şimdi yenile",
|
||||
"dataSource": "Veri Kaynağı",
|
||||
"dataSourceDescription": "Potansiyel gözetleme altyapısı kurulum sitelerini gösteren altyapı izin verileri",
|
||||
"dataSourceCredit": "Veri toplama ve barındırma alprwatch.org tarafından sağlanır",
|
||||
"minimumDistance": "Gerçek Düğümlerden Minimum Mesafe",
|
||||
"minimumDistanceSubtitle": "Mevcut gözetleme cihazlarının {} yakınındaki şüpheli konumları gizle",
|
||||
"updating": "Şüpheli Konumlar Güncelleniyor",
|
||||
"downloadingAndProcessing": "Veri indiriliyor ve işleniyor...",
|
||||
"updateSuccess": "Şüpheli konumlar başarıyla güncellendi",
|
||||
"updateFailed": "Şüpheli konumları güncelleme başarısız",
|
||||
"neverFetched": "Hiç getirilmedi",
|
||||
"daysAgo": "{} gün önce",
|
||||
"hoursAgo": "{} saat önce",
|
||||
"minutesAgo": "{} dakika önce",
|
||||
"justNow": "Şimdi"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Şüpheli Konum #{}",
|
||||
"ticketNo": "Bilet No",
|
||||
"address": "Adres",
|
||||
"street": "Sokak",
|
||||
"city": "Şehir",
|
||||
"state": "Eyalet",
|
||||
"intersectingStreet": "Kesişen Sokak",
|
||||
"workDoneFor": "İş Yapılan",
|
||||
"remarks": "Açıklamalar",
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinatlar",
|
||||
"noAddressAvailable": "Adres mevcut değil"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mil",
|
||||
"metersLong": "metre",
|
||||
"feetLong": "fit",
|
||||
"kilometersLong": "kilometre",
|
||||
"milesLong": "mil",
|
||||
"metric": "Metrik",
|
||||
"imperial": "İmperial",
|
||||
"metricDescription": "Metrik (km, m)",
|
||||
"imperialDescription": "İmperial (mil, ft)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Українська"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"about": {
|
||||
"title": "DeFlock - Прозорість Спостереження",
|
||||
"description": "DeFlock - це мобільний додаток, зосереджений на конфіденційності, для картування громадської інфраструктури спостереження з використанням OpenStreetMap. Документуйте камери, ALPR, детектори пострілів та інші пристрої спостереження у вашій громаді, щоб зробити цю інфраструктуру видимою та доступною для пошуку.",
|
||||
"features": "• Картування в офлайн-режимі з завантажуваними областями\n• Завантаження безпосередньо в OpenStreetMap через OAuth2\n• Вбудовані профілі для великих виробників\n• Повага до приватності - дані користувача не збираються\n• Кілька постачальників карт (OSM, супутникові знімки)",
|
||||
"initiative": "Частина ширшої ініціативи DeFlock з просування прозорості спостереження.",
|
||||
"footer": "Відвідайте: deflock.me\nПобудовано з Flutter • Відкритий код",
|
||||
"showWelcome": "Показати Привітальне Повідомлення",
|
||||
"showSubmissionGuide": "Показати Посібник Подання",
|
||||
"viewReleaseNotes": "Переглянути Примітки Випуску"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Ласкаво просимо до DeFlock",
|
||||
"description": "DeFlock був заснований на ідеї, що інструменти громадського спостереження повинні бути прозорими. В цьому мобільному додатку, як і на веб-сайті, ви зможете переглядати місця розташування ALPR та іншої інфраструктури спостереження у вашому районі та за кордоном.",
|
||||
"mission": "Однак цей проект не автоматизований; потрібні зусилля всіх нас, щоб покращити цей проект. Переглядаючи карту, ви можете натиснути \"Новий Вузол\", щоб додати раніше невідому установку. З вашою допомогою ми можемо досягти нашої мети підвищення прозорості та громадської обізнаності щодо інфраструктури спостереження.",
|
||||
"firsthandKnowledge": "ВАЖЛИВО: Додавайте лише пристрої спостереження, які ви особисто спостерігали власними очима. Політика OpenStreetMap та Google забороняє використання таких джерел, як зображення Street View для подань. Ваші внески повинні базуватися на ваших власних безпосередніх спостереженнях.",
|
||||
"privacy": "Примітка щодо конфіденційності: Цей додаток працює повністю локально на вашому пристрої та використовує сторонній API OpenStreetMap для зберігання даних та подань. DeFlock не збирає та не зберігає жодних даних користувачів і не несе відповідальності за управління обліковими записами.",
|
||||
"tileNote": "ПРИМІТКА: Безкоштовні плитки карт з OpenStreetMap можуть завантажуватися дуже повільно. Альтернативні постачальники плиток можна налаштувати в Налаштуваннях > Розширені.",
|
||||
"moreInfo": "Більше посилань можна знайти в Налаштуваннях > Про програму.",
|
||||
"dontShowAgain": "Не показувати це привітальне повідомлення знову",
|
||||
"getStarted": "Давайте почнемо DeFlocking!"
|
||||
},
|
||||
"submissionGuide": {
|
||||
"title": "Найкращі Практики Подання",
|
||||
"description": "Перш ніж подати ваш перший пристрій спостереження, будь ласка, приділіть хвилину для перегляду цих важливих вказівок, щоб забезпечити високоякісні внески в OpenStreetMap.",
|
||||
"bestPractices": "• Картуйте лише пристрої, які ви особисто спостерігали\n• Приділіть час точному визначенню типу пристрою та виробника\n• Використовуйте точне позиціонування - збільшуйте масштаб перед розміщенням маркера\n• Включайте інформацію про напрямок, коли це застосовно\n• Двічі перевірте ваші вибори тегів перед поданням",
|
||||
"placementNote": "Пам'ятайте: Точні дані з перших рук є важливими для спільноти DeFlock та проекту OpenStreetMap.",
|
||||
"moreInfo": "Для детального керівництва з ідентифікації пристроїв та найкращих практик картування:",
|
||||
"identificationGuide": "Посібник Ідентифікації",
|
||||
"osmWiki": "OpenStreetMap Wiki",
|
||||
"dontShowAgain": "Не показувати цей посібник знову",
|
||||
"gotIt": "Зрозуміло!"
|
||||
},
|
||||
"positioningTutorial": {
|
||||
"title": "Уточнити Ваше Місце",
|
||||
"instructions": "Перетягніть карту, щоб точно розмістити маркер пристрою над місцем розташування пристрою спостереження.",
|
||||
"hint": "Ви можете збільшити масштаб для кращої точності перед позиціонуванням."
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Новий Вузол",
|
||||
"download": "Завантажити",
|
||||
"settings": "Налаштування",
|
||||
"edit": "Редагувати",
|
||||
"delete": "Видалити",
|
||||
"cancel": "Скасувати",
|
||||
"ok": "ОК",
|
||||
"close": "Закрити",
|
||||
"submit": "Подати",
|
||||
"logIn": "Увійти",
|
||||
"saveEdit": "Зберегти Редагування",
|
||||
"clear": "Очистити",
|
||||
"viewOnOSM": "Переглянути в OSM",
|
||||
"advanced": "Розширені",
|
||||
"useAdvancedEditor": "Використати Розширений Редактор"
|
||||
},
|
||||
"proximityWarning": {
|
||||
"title": "Вузол Дуже Близько до Існуючого Пристрою",
|
||||
"message": "Цей вузол знаходиться лише в {} метрах від існуючого пристрою спостереження.",
|
||||
"suggestion": "Якщо кілька пристроїв знаходяться на одному стовпі, будь ласка, використовуйте кілька напрямків на одному вузлі замість створення окремих вузлів.",
|
||||
"nearbyNodes": "Знайдено близькі пристрої ({}):",
|
||||
"nodeInfo": "Вузол #{} - {}",
|
||||
"andMore": "...та ще {}",
|
||||
"goBack": "Повернутися",
|
||||
"submitAnyway": "Все одно Подати",
|
||||
"nodeType": {
|
||||
"alpr": "ALPR/ANPR Камера",
|
||||
"publicCamera": "Камера Громадського Спостереження",
|
||||
"camera": "Камера Спостереження",
|
||||
"amenity": "{}",
|
||||
"device": "Пристрій {}",
|
||||
"unknown": "Невідомий Пристрій"
|
||||
}
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Увімкнути слідування",
|
||||
"follow": "Увімкнути слідування (обертання)",
|
||||
"rotating": "Вимкнути слідування"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Налаштування",
|
||||
"language": "Мова та Регіон",
|
||||
"systemDefault": "Системна За Замовчуванням",
|
||||
"aboutInfo": "Про / Інформація",
|
||||
"aboutThisApp": "Про Цей Додаток",
|
||||
"aboutSubtitle": "Інформація про додаток та автори",
|
||||
"languageSubtitle": "Оберіть вашу бажану мову та одиниці вимірювання",
|
||||
"distanceUnit": "Одиниці Відстані",
|
||||
"distanceUnitSubtitle": "Оберіть між метричними (км/м) або імперськими (миля/фут) одиницями",
|
||||
"metricUnits": "Метричні (км, м)",
|
||||
"imperialUnits": "Імперські (миля, фут)",
|
||||
"maxNodes": "Максимум намальованих вузлів",
|
||||
"maxNodesSubtitle": "Встановити верхню межу для кількості вузлів на карті.",
|
||||
"maxNodesWarning": "Ви, мабуть, не хочете робити це, якщо ви абсолютно не впевнені, що у вас є вагома причина для цього.",
|
||||
"offlineMode": "Офлайн Режим",
|
||||
"offlineModeSubtitle": "Вимкнути всі мережеві запити, крім локальних/офлайн областей.",
|
||||
"pauseQueueProcessing": "Призупинити Обробку Черги Завантаження",
|
||||
"pauseQueueProcessingSubtitle": "Припинити завантаження змін у черзі, зберігаючи доступ до живих даних.",
|
||||
"offlineModeWarningTitle": "Активні Завантаження",
|
||||
"offlineModeWarningMessage": "Включення офлайн режиму скасує всі активні завантаження областей. Ви хочете продовжити?",
|
||||
"enableOfflineMode": "Увімкнути Офлайн Режим",
|
||||
"profiles": "Профілі",
|
||||
"profilesSubtitle": "Управління профілями вузлів та операторів",
|
||||
"offlineSettings": "Офлайн Налаштування",
|
||||
"offlineSettingsSubtitle": "Управління офлайн режимом та завантаженими областями",
|
||||
"advancedSettings": "Розширені Налаштування",
|
||||
"advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток",
|
||||
"proximityAlerts": "Сповіщення Про Близькість",
|
||||
"keepScreenAwake": "Тримати екран активним",
|
||||
"networkStatusIndicator": "Індикатор Стану Мережі",
|
||||
"hideZoomControls": "Приховати Елементи Керування Масштабом",
|
||||
"enableOfflineFeatures": "Увімкнути Офлайн-Функції",
|
||||
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
|
||||
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "Тримати екран активним під час використання програми",
|
||||
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "Приховати елементи керування масштабом",
|
||||
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
|
||||
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
|
||||
"notificationsEnabled": "✓ Сповіщення увімкнено",
|
||||
"notificationsDisabled": "⚠ Сповіщення вимкнено",
|
||||
"permissionRequired": "Потрібен дозвіл на сповіщення",
|
||||
"permissionExplanation": "Push-сповіщення вимкнено. Ви бачитимете лише сповіщення в додатку і не будете сповіщені, коли додаток працює у фоновому режимі.",
|
||||
"enableNotifications": "Увімкнути Сповіщення",
|
||||
"checkingPermissions": "Перевірка дозволів...",
|
||||
"alertDistance": "Відстань сповіщення: ",
|
||||
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Вузол #{}",
|
||||
"tagSheetTitle": "Теги Пристрою Спостереження",
|
||||
"queuedForUpload": "Вузол поставлено в чергу для завантаження",
|
||||
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
|
||||
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
|
||||
"confirmDeleteTitle": "Видалити Вузол",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
|
||||
"deleteReasonLabel": "Причина Видалення",
|
||||
"deleteReasonSelectHint": "Вибрати...",
|
||||
"deleteReasonDeviceRemoved": "Пристрій видалено",
|
||||
"deleteReasonDuplicateNode": "Дубльований вузол",
|
||||
"deleteReasonMisidentifiedInfra": "Неправильно ідентифікована інфраструктура",
|
||||
"deleteReasonSpamVandalism": "Спам/вандалізм",
|
||||
"deleteReasonCustom": "Власний..",
|
||||
"deleteReasonHint": "Опишіть причину...",
|
||||
|
||||
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
|
||||
"uploadPending": "Завантаження очікує",
|
||||
"uploadCreatingChangeset": "Створення набору змін...",
|
||||
"uploadUploading": "Завантаження...",
|
||||
"uploadClosingChangeset": "Закриття набору змін...",
|
||||
"uploadError": "Помилка завантаження",
|
||||
"uploadComplete": "Завантаження завершено",
|
||||
"viewInQueue": "Переглянути в Черзі",
|
||||
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
|
||||
},
|
||||
|
||||
|
||||
"addNode": {
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
"profileRequired": "Будь ласка, оберіть профіль для продовження.",
|
||||
"direction": "Напрямок {}°",
|
||||
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
|
||||
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.",
|
||||
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.",
|
||||
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.",
|
||||
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
|
||||
"refineTags": "Уточнити Теги"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Редагувати Вузол #{}",
|
||||
"profile": "Профіль",
|
||||
"selectProfile": "Оберіть профіль...",
|
||||
"profileRequired": "Будь ласка, оберіть профіль для продовження.",
|
||||
"direction": "Напрямок {}°",
|
||||
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
|
||||
"temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.",
|
||||
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.",
|
||||
"sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.",
|
||||
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.",
|
||||
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.",
|
||||
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
|
||||
"cannotMoveConstrainedNode": "Неможливо перемістити цю камеру - вона підключена до іншого елементу карти (OSM шлях/відношення). Ви все ще можете редагувати її теги та напрямок.",
|
||||
"zoomInRequiredMessage": "Збільште масштаб до принаймні рівня {} для додавання або редагування вузлів спостереження. Це забезпечує точне позиціонування для точного картування.",
|
||||
"extractFromWay": "Витягнути вузол з шляху/відношення",
|
||||
"extractFromWaySubtitle": "Створити новий вузол з тими ж тегами, дозволити переміщення до нового місця",
|
||||
"refineTags": "Уточнити Теги",
|
||||
"existingTags": "<Існуючі теги>",
|
||||
"noChangesDetected": "Зміни не виявлено - нічого подавати",
|
||||
"noChangesTitle": "Немає Змін для Подання",
|
||||
"noChangesMessage": "Ви не внесли жодних змін до цього вузла. Щоб подати редагування, вам потрібно змінити місце, профіль, напрямки або теги."
|
||||
},
|
||||
"download": {
|
||||
"title": "Завантажити Область Карти",
|
||||
"maxZoomLevel": "Максимальний рівень масштабування",
|
||||
"storageEstimate": "Оцінка сховища:",
|
||||
"tilesAndSize": "{} плиток, {} МБ",
|
||||
"minZoom": "Мін масштаб:",
|
||||
"maxRecommendedZoom": "Максимальний рекомендований масштаб: Z{}",
|
||||
"withinTileLimit": "В межах {} ліміту плиток",
|
||||
"exceedsTileLimit": "Поточний вибір перевищує {} ліміт плиток",
|
||||
"offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.",
|
||||
"areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.",
|
||||
"downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"downloadFailed": "Не вдалося почати завантаження: {}",
|
||||
"offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).",
|
||||
"currentTileProvider": "поточна плитка",
|
||||
"noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області."
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "Завантаження Почалося",
|
||||
"message": "Завантаження почалося! Отримання плиток та вузлів...",
|
||||
"ok": "ОК",
|
||||
"viewProgress": "Переглянути Прогрес в Налаштуваннях"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Місце Призначення Завантаження",
|
||||
"subtitle": "Оберіть, куди завантажуються камери",
|
||||
"production": "Виробництво",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Симуляція",
|
||||
"productionDescription": "Завантажити в активну базу даних OSM (видима всім користувачам)",
|
||||
"sandboxDescription": "Завантаження йдуть в OSM Sandbox (безпечно для тестування, регулярно скидається).",
|
||||
"simulateDescription": "Симулювати завантаження (не зв'язується з серверами OSM)",
|
||||
"cannotChangeWithQueue": "Неможливо змінити місце призначення завантаження, поки в черзі є {} елементів. Спочатку очистіть чергу."
|
||||
},
|
||||
"auth": {
|
||||
"osmAccountTitle": "Обліковий Запис OpenStreetMap",
|
||||
"osmAccountSubtitle": "Управління входом OSM та перегляд ваших внесків",
|
||||
"loggedInAs": "Увійшли як {}",
|
||||
"loginToOSM": "Увійти в OpenStreetMap",
|
||||
"tapToLogout": "Натисніть для виходу",
|
||||
"requiredToSubmit": "Потрібно для подання даних камер",
|
||||
"loggedOut": "Вихід здійснено",
|
||||
"testConnection": "Тестувати З'єднання",
|
||||
"testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють",
|
||||
"connectionOK": "З'єднання в порядку - облікові дані дійсні",
|
||||
"connectionFailed": "З'єднання не вдалося - будь ласка, увійдіть знову",
|
||||
"viewMyEdits": "Переглянути Мої Редагування в OSM",
|
||||
"viewMyEditsSubtitle": "Побачити вашу історію редагувань в OpenStreetMap",
|
||||
"aboutOSM": "Про OpenStreetMap",
|
||||
"aboutOSMDescription": "OpenStreetMap - це колаборативний проект картування з відкритим кодом, де учасники створюють і підтримують безкоштовну, редаговану карту світу. Ваші внески пристроїв спостереження допомагають зробити цю інфраструктуру видимою та доступною для пошуку.",
|
||||
"visitOSM": "Відвідати OpenStreetMap",
|
||||
"deleteAccount": "Видалити Обліковий Запис OSM",
|
||||
"deleteAccountSubtitle": "Управління обліковим записом OpenStreetMap",
|
||||
"deleteAccountExplanation": "Щоб видалити ваш обліковий запис OpenStreetMap, вам потрібно відвідати веб-сайт OpenStreetMap. Це назавжди видалить ваш обліковий запис OSM та всі пов'язані дані.",
|
||||
"deleteAccountWarning": "Попередження: Цю дію не можна скасувати і вона назавжди видалить ваш обліковий запис OSM.",
|
||||
"goToOSM": "Перейти до OpenStreetMap",
|
||||
"accountManagement": "Управління Обліковим Записом",
|
||||
"accountManagementDescription": "Щоб видалити ваш обліковий запис OpenStreetMap, вам потрібно відвідати відповідний веб-сайт OpenStreetMap. Це назавжди видалить ваш обліковий запис та всі пов'язані дані.",
|
||||
"currentDestinationProduction": "Зараз підключено до: Виробничий OpenStreetMap",
|
||||
"currentDestinationSandbox": "Зараз підключено до: Sandbox OpenStreetMap",
|
||||
"currentDestinationSimulate": "Зараз в: Режимі симуляції (без справжнього облікового запису)",
|
||||
"viewMessages": "Переглянути Повідомлення в OSM",
|
||||
"unreadMessagesCount": "У вас {} непрочитаних повідомлень",
|
||||
"noUnreadMessages": "Немає непрочитаних повідомлень",
|
||||
"reauthRequired": "Оновити Автентифікацію",
|
||||
"reauthExplanation": "Ви повинні оновити вашу автентифікацію, щоб отримувати сповіщення про повідомлення OSM через додаток.",
|
||||
"reauthBenefit": "Це дозволить показувати точки сповіщень, коли у вас є непрочитані повідомлення в OpenStreetMap.",
|
||||
"reauthNow": "Зробити Зараз",
|
||||
"reauthLater": "Пізніше"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Черга Завантаження",
|
||||
"subtitle": "Управління очікуваними завантаженнями пристроїв спостереження",
|
||||
"pendingUploads": "Очікувані завантаження: {}",
|
||||
"pendingItemsCount": "Очікуючі Елементи: {}",
|
||||
"nothingInQueue": "Нічого в черзі",
|
||||
"simulateModeEnabled": "Режим симуляції увімкнено – завантаження симулюються",
|
||||
"sandboxMode": "Режим sandbox – завантаження йдуть в OSM Sandbox",
|
||||
"tapToViewQueue": "Натисніть для перегляду черги",
|
||||
"clearUploadQueue": "Очистити Чергу Завантаження",
|
||||
"removeAllPending": "Видалити всі {} очікувані завантаження",
|
||||
"clearQueueTitle": "Очистити Чергу",
|
||||
"clearQueueConfirm": "Видалити всі {} очікувані завантаження?",
|
||||
"queueCleared": "Чергу очищено",
|
||||
"uploadQueueTitle": "Черга Завантаження ({} елементів)",
|
||||
"queueIsEmpty": "Черга порожня",
|
||||
"itemWithIndex": "Елемент {}",
|
||||
"error": " (Помилка)",
|
||||
"completing": " (Завершуємо...)",
|
||||
"destination": "Місце призначення: {}",
|
||||
"latitude": "Широта: {}",
|
||||
"longitude": "Довгота: {}",
|
||||
"direction": "Напрямок: {}°",
|
||||
"attempts": "Спроби: {}",
|
||||
"uploadFailedRetry": "Завантаження не вдалося. Натисніть повторити, щоб спробувати знову.",
|
||||
"retryUpload": "Повторити завантаження",
|
||||
"clearAll": "Очистити Все",
|
||||
"errorDetails": "Деталі Помилки",
|
||||
"creatingChangeset": " (Створення набору змін...)",
|
||||
"uploading": " (Завантаження...)",
|
||||
"closingChangeset": " (Закриття набору змін...)",
|
||||
"processingPaused": "Обробка Черги Призупинена",
|
||||
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
|
||||
"pausedByUser": "Обробка завантаження призупинена вручну.",
|
||||
"inFlightWaitTitle": "Завершення Подання",
|
||||
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
|
||||
"inFlightCompleteTitle": "Подання Завершено",
|
||||
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "Постачальники Плиток",
|
||||
"noProvidersConfigured": "Постачальників плиток не налаштовано",
|
||||
"tileTypesCount": "{} типів плиток",
|
||||
"apiKeyConfigured": "API ключ налаштовано",
|
||||
"needsApiKey": "Потрібен API ключ",
|
||||
"editProvider": "Редагувати Постачальника",
|
||||
"addProvider": "Додати Постачальника",
|
||||
"deleteProvider": "Видалити Постачальника",
|
||||
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"clearCaches": "Очистити Кеш",
|
||||
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
|
||||
"cachesCleared": "Кеш очищено для \"{}\"",
|
||||
"providerName": "Назва Постачальника",
|
||||
"providerNameHint": "напр., Кастомні Карти ТОВ",
|
||||
"providerNameRequired": "Назва постачальника обов'язкова",
|
||||
"apiKey": "API Ключ (Опціонально)",
|
||||
"apiKeyHint": "Введіть API ключ, якщо потрібно для типів плиток",
|
||||
"tileTypes": "Типи Плиток",
|
||||
"addType": "Додати Тип",
|
||||
"noTileTypesConfigured": "Типи плиток не налаштовано",
|
||||
"atLeastOneTileTypeRequired": "Потрібен принаймні один тип плитки",
|
||||
"manageTileProviders": "Управляти Постачальниками"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Редагувати Тип Плитки",
|
||||
"addTileType": "Додати Тип Плитки",
|
||||
"name": "Назва",
|
||||
"nameHint": "напр., Супутник",
|
||||
"nameRequired": "Назва обов'язкова",
|
||||
"urlTemplate": "Шаблон URL",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Шаблон URL обов'язковий",
|
||||
"urlTemplatePlaceholders": "URL повинен містити або {quadkey}, або {z}, {x} і {y} заповнювачі",
|
||||
"attribution": "Атрибуція",
|
||||
"attributionHint": "© Постачальник Карт",
|
||||
"attributionRequired": "Атрибуція обов'язкова",
|
||||
"maxZoom": "Максимальний Рівень Масштабування",
|
||||
"maxZoomHint": "Максимальний рівень масштабування (1-23)",
|
||||
"maxZoomRequired": "Максимальний масштаб обов'язковий",
|
||||
"maxZoomInvalid": "Максимальний масштаб повинен бути числом",
|
||||
"maxZoomRange": "Максимальний масштаб повинен бути між {} і {}",
|
||||
"fetchPreview": "Отримати Попередній Перегляд",
|
||||
"previewTileLoaded": "Плитка попереднього перегляду успішно завантажена",
|
||||
"previewTileFailed": "Не вдалося отримати попередній перегляд: {}",
|
||||
"save": "Зберегти"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Профілі Вузлів",
|
||||
"newProfile": "Новий Профіль",
|
||||
"builtIn": "Вбудований",
|
||||
"custom": "Власний",
|
||||
"view": "Переглянути",
|
||||
"deleteProfile": "Видалити Профіль",
|
||||
"deleteProfileConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"profileDeleted": "Профіль видалено",
|
||||
"getMore": "Отримати більше...",
|
||||
"addProfileChoice": "Додати Профіль",
|
||||
"addProfileChoiceMessage": "Як би ви хотіли додати профіль?",
|
||||
"createCustomProfile": "Створити Власний Профіль",
|
||||
"createCustomProfileDescription": "Побудувати профіль з нуля з власними тегами",
|
||||
"importFromWebsite": "Імпортувати з Веб-сайту",
|
||||
"importFromWebsiteDescription": "Переглянути та імпортувати профілі з deflock.me/identify"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Плитки Карти",
|
||||
"manageProviders": "Управляти Постачальниками",
|
||||
"attribution": "Атрибуція Карти",
|
||||
"mapAttribution": "Джерело карти: {}",
|
||||
"couldNotOpenLink": "Не вдалося відкрити посилання",
|
||||
"openLicense": "Відкрити ліцензію: {}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Переглянути Профіль",
|
||||
"newProfile": "Новий Профіль",
|
||||
"editProfile": "Редагувати Профіль",
|
||||
"profileName": "Назва профілю",
|
||||
"profileNameHint": "напр., Власна ALPR Камера",
|
||||
"profileNameRequired": "Назва профілю обов'язкова",
|
||||
"requiresDirection": "Потребує Напрямку",
|
||||
"requiresDirectionSubtitle": "Чи потрібен тег напрямку для камер цього типу",
|
||||
"fov": "Поле Зору",
|
||||
"fovHint": "FOV в градусах (залиште порожнім для значення за замовчуванням)",
|
||||
"fovSubtitle": "Поле зору камери - використовується для ширини конуса та формату подання діапазону",
|
||||
"fovInvalid": "FOV повинно бути між 1 і 360 градусами",
|
||||
"fov360": "Поле Зору 360°",
|
||||
"fov360Subtitle": "Камера має всенаправлене поле зору на 360 градусів",
|
||||
"submittable": "Можна Подавати",
|
||||
"submittableSubtitle": "Чи можна використовувати цей профіль для подань камер",
|
||||
"osmTags": "OSM Теги",
|
||||
"addTag": "Додати тег",
|
||||
"saveProfile": "Зберегти Профіль",
|
||||
"keyHint": "ключ",
|
||||
"valueHint": "значення",
|
||||
"atLeastOneTagRequired": "Потрібен принаймні один тег",
|
||||
"profileSaved": "Профіль \"{}\" збережено"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Новий Профіль Оператора",
|
||||
"editOperatorProfile": "Редагувати Профіль Оператора",
|
||||
"operatorName": "Назва оператора",
|
||||
"operatorNameHint": "напр., Поліція Києва",
|
||||
"operatorNameRequired": "Назва оператора обов'язкова",
|
||||
"operatorProfileSaved": "Профіль оператора \"{}\" збережено"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Профілі Операторів",
|
||||
"noProfilesMessage": "Профілі операторів не визначено. Створіть один для застосування тегів операторів до подань вузлів.",
|
||||
"tagsCount": "{} тегів",
|
||||
"deleteOperatorProfile": "Видалити Профіль Оператора",
|
||||
"deleteOperatorProfileConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
|
||||
"operatorProfileDeleted": "Профіль оператора видалено"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Офлайн Області",
|
||||
"noAreasTitle": "Немає офлайн областей",
|
||||
"noAreasSubtitle": "Завантажте область карти для офлайн використання.",
|
||||
"provider": "Постачальник",
|
||||
"maxZoom": "Максимальний масштаб",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Широта",
|
||||
"longitude": "Довгота",
|
||||
"tiles": "Плитки",
|
||||
"size": "Розмір",
|
||||
"nodes": "Вузли",
|
||||
"areaIdFallback": "Область {}...",
|
||||
"renameArea": "Перейменувати область",
|
||||
"refreshWorldTiles": "Оновити/перезавантажити світові плитки",
|
||||
"deleteOfflineArea": "Видалити офлайн область",
|
||||
"cancelDownload": "Скасувати завантаження",
|
||||
"renameAreaDialogTitle": "Перейменувати Офлайн Область",
|
||||
"areaNameLabel": "Назва Області",
|
||||
"renameButton": "Перейменувати",
|
||||
"megabytes": "МБ",
|
||||
"kilobytes": "КБ",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Оновити область",
|
||||
"refreshAreaDialogTitle": "Оновити Офлайн Область",
|
||||
"refreshAreaDialogSubtitle": "Виберіть що оновити для цієї області:",
|
||||
"refreshTiles": "Оновити Плитки Карти",
|
||||
"refreshTilesSubtitle": "Перезавантажити всі плитки для оновлених зображень",
|
||||
"refreshNodes": "Оновити Вузли",
|
||||
"refreshNodesSubtitle": "Повторно отримати дані вузлів для цієї області",
|
||||
"startRefresh": "Почати Оновлення",
|
||||
"refreshStarted": "Оновлення розпочато!",
|
||||
"refreshFailed": "Оновлення не вдалося: {}",
|
||||
"disableConfirmTitle": "Видалити Наявні Дані Офлайн-Областей?",
|
||||
"disableConfirmMessage": "У вас вже є наявні дані офлайн-областей. Вимкнення офлайн-функцій завадить додатку використовувати їх. Бажаєте зберегти ці дані на пристрої на випадок, якщо ви знову увімкнете офлайн-функції пізніше, чи видалити їх зараз, щоб звільнити місце на диску?",
|
||||
"keepData": "Зберегти Дані",
|
||||
"deleteData": "Видалити Дані"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "Уточнити Теги",
|
||||
"operatorProfile": "Профіль Оператора",
|
||||
"done": "Готово",
|
||||
"none": "Немає",
|
||||
"noAdditionalOperatorTags": "Немає додаткових тегів оператора",
|
||||
"additionalTags": "додаткові теги",
|
||||
"additionalTagsTitle": "Додаткові Теги",
|
||||
"noTagsDefinedForProfile": "Для цього профілю оператора не визначено тегів.",
|
||||
"noOperatorProfiles": "Профілі операторів не визначено",
|
||||
"noOperatorProfilesMessage": "Створіть профілі операторів в Налаштуваннях для застосування додаткових тегів до ваших подань вузлів.",
|
||||
"profileTags": "Теги Профілю",
|
||||
"profileTagsDescription": "Заповніть ці опціональні значення тегів для більш детальних подань:",
|
||||
"selectValue": "Вибрати значення...",
|
||||
"noValue": "(залишити порожнім)",
|
||||
"noSuggestions": "Немає доступних пропозицій",
|
||||
"existingTagsTitle": "Існуючі Теги",
|
||||
"existingTagsDescription": "Редагуйте існуючі теги на цьому пристрої. Додайте, видаліть або змініть будь-який тег:",
|
||||
"existingOperator": "<Існуючий оператор>",
|
||||
"existingOperatorTags": "існуючі теги оператора"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "Неможливо змінити типи плиток під час завантаження офлайн областей",
|
||||
"selectMapLayer": "Вибрати Шар Карти",
|
||||
"noTileProvidersAvailable": "Немає доступних постачальників плиток"
|
||||
},
|
||||
"advancedEdit": {
|
||||
"title": "Розширені Опції Редагування",
|
||||
"subtitle": "Ці редактори пропонують більш розширені можливості для складних редагувань.",
|
||||
"webEditors": "Веб Редактори",
|
||||
"mobileEditors": "Мобільні Редактори",
|
||||
"iDEditor": "iD Редактор",
|
||||
"iDEditorSubtitle": "Повнофункціональний веб редактор - завжди працює",
|
||||
"rapidEditor": "RapiD Редактор",
|
||||
"rapidEditorSubtitle": "AI-асистоване редагування з даними Facebook",
|
||||
"vespucci": "Vespucci",
|
||||
"vespucciSubtitle": "Розширений Android OSM редактор",
|
||||
"streetComplete": "StreetComplete",
|
||||
"streetCompleteSubtitle": "Додаток для картування на основі опитувань",
|
||||
"everyDoor": "EveryDoor",
|
||||
"everyDoorSubtitle": "Швидке редагування POI",
|
||||
"goMap": "Go Map!!",
|
||||
"goMapSubtitle": "iOS OSM редактор",
|
||||
"couldNotOpenEditor": "Не вдалося відкрити редактор - додаток може бути не встановлений",
|
||||
"couldNotOpenURL": "Не вдалося відкрити URL",
|
||||
"couldNotOpenOSMWebsite": "Не вдалося відкрити веб-сайт OSM"
|
||||
},
|
||||
"networkStatus": {
|
||||
"showIndicator": "Показувати індикатор стану мережі",
|
||||
"showIndicatorSubtitle": "Відображати стан завантаження та помилки даних спостереження",
|
||||
"loading": "Завантаження даних спостереження...",
|
||||
"timedOut": "Запит перевищив час очікування",
|
||||
"noData": "Немає офлайн даних",
|
||||
"success": "Дані спостереження завантажено",
|
||||
"nodeDataSlow": "Повільні дані спостереження",
|
||||
"rateLimited": "Обмежено швидкість сервером",
|
||||
"networkError": "Помилка мережі"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "Показано {rendered} з {total} пристроїв",
|
||||
"editingDisabledMessage": "Показано забагато пристроїв для безпечного редагування. Збільште масштаб далі, щоб зменшити кількість видимих пристроїв, потім спробуйте знову."
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "Пошук Локації",
|
||||
"searchPlaceholder": "Шукати місця або координати...",
|
||||
"routeTo": "Маршрут До",
|
||||
"routeFrom": "Маршрут Від",
|
||||
"selectLocation": "Вибрати Локацію",
|
||||
"calculatingRoute": "Розрахунок маршруту...",
|
||||
"routeCalculationFailed": "Розрахунок маршруту не вдався",
|
||||
"start": "Почати",
|
||||
"resume": "Відновити",
|
||||
"endRoute": "Завершити Маршрут",
|
||||
"routeOverview": "Огляд Маршруту",
|
||||
"retry": "Повторити",
|
||||
"cancelSearch": "Скасувати пошук",
|
||||
"noResultsFound": "Результатів не знайдено",
|
||||
"searching": "Пошук...",
|
||||
"location": "Локація",
|
||||
"startPoint": "Початок",
|
||||
"endPoint": "Кінець",
|
||||
"startSelect": "Початок (вибрати)",
|
||||
"endSelect": "Кінець (вибрати)",
|
||||
"distance": "Відстань: {} км",
|
||||
"routeActive": "Маршрут активний",
|
||||
"locationsTooClose": "Початкова та кінцева локації занадто близько одна до одної",
|
||||
"navigationSettings": "Навігація",
|
||||
"navigationSettingsSubtitle": "Планування маршруту та налаштування уникнення",
|
||||
"avoidanceDistance": "Відстань Уникнення",
|
||||
"avoidanceDistanceSubtitle": "Мінімальна відстань для уникнення пристроїв спостереження",
|
||||
"searchHistory": "Макс Історія Пошуку",
|
||||
"searchHistorySubtitle": "Максимальна кількість нещодавніх пошуків для запам'ятовування"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Підозрілі Локації",
|
||||
"showSuspectedLocations": "Показувати Підозрілі Локації",
|
||||
"showSuspectedLocationsSubtitle": "Показувати маркери знаку питання для підозрілих сайтів спостереження з даних дозволів комунальних служб",
|
||||
"lastUpdated": "Останнє Оновлення",
|
||||
"refreshNow": "Оновити зараз",
|
||||
"dataSource": "Джерело Даних",
|
||||
"dataSourceDescription": "Дані дозволів комунальних служб, що вказують на потенційні сайти встановлення інфраструктури спостереження",
|
||||
"dataSourceCredit": "Збір даних та хостинг надається alprwatch.org",
|
||||
"minimumDistance": "Мінімальна Відстань від Реальних Вузлів",
|
||||
"minimumDistanceSubtitle": "Приховати підозрілі локації в межах {} від існуючих пристроїв спостереження",
|
||||
"updating": "Оновлення Підозрілих Локацій",
|
||||
"downloadingAndProcessing": "Завантаження та обробка даних...",
|
||||
"updateSuccess": "Підозрілі локації успішно оновлено",
|
||||
"updateFailed": "Не вдалося оновити підозрілі локації",
|
||||
"neverFetched": "Ніколи не отримувалося",
|
||||
"daysAgo": "{} днів тому",
|
||||
"hoursAgo": "{} годин тому",
|
||||
"minutesAgo": "{} хвилин тому",
|
||||
"justNow": "Щойно"
|
||||
},
|
||||
"suspectedLocation": {
|
||||
"title": "Підозріла Локація #{}",
|
||||
"ticketNo": "Номер Квитка",
|
||||
"address": "Адреса",
|
||||
"street": "Вулиця",
|
||||
"city": "Місто",
|
||||
"state": "Область",
|
||||
"intersectingStreet": "Перехрещувана Вулиця",
|
||||
"workDoneFor": "Робота Виконана Для",
|
||||
"remarks": "Зауваження",
|
||||
"url": "URL",
|
||||
"coordinates": "Координати",
|
||||
"noAddressAvailable": "Адреса недоступна"
|
||||
},
|
||||
"units": {
|
||||
"meters": "м",
|
||||
"feet": "фут",
|
||||
"kilometers": "км",
|
||||
"miles": "миля",
|
||||
"metersLong": "метри",
|
||||
"feetLong": "фути",
|
||||
"kilometersLong": "кілометри",
|
||||
"milesLong": "милі",
|
||||
"metric": "Метричні",
|
||||
"imperial": "Імперські",
|
||||
"metricDescription": "Метричні (км, м)",
|
||||
"imperialDescription": "Імперські (миля, фут)"
|
||||
}
|
||||
}
|
||||
+111
-27
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "关于 / 信息",
|
||||
"aboutThisApp": "关于此应用",
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"languageSubtitle": "选择您的首选语言和单位",
|
||||
"distanceUnit": "距离单位",
|
||||
"distanceUnitSubtitle": "选择公制 (公里/米) 或英制 (英里/英尺) 单位",
|
||||
"metricUnits": "公制 (公里, 米)",
|
||||
"imperialUnits": "英制 (英里, 英尺)",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
@@ -107,8 +111,23 @@
|
||||
"advancedSettings": "高级设置",
|
||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||
"proximityAlerts": "邻近警报",
|
||||
"networkStatusIndicator": "网络状态指示器"
|
||||
"keepScreenAwake": "保持屏幕常亮",
|
||||
"networkStatusIndicator": "网络状态指示器",
|
||||
"hideZoomControls": "隐藏缩放控件",
|
||||
"enableOfflineFeatures": "启用离线功能",
|
||||
"enableOfflineFeaturesTitle": "启用离线功能",
|
||||
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
|
||||
},
|
||||
|
||||
"keepScreenAwake": {
|
||||
"title": "使用应用时保持屏幕常亮",
|
||||
"subtitle": "防止在使用应用期间屏幕自动熄灭"
|
||||
},
|
||||
"hideZoomControls": {
|
||||
"title": "隐藏缩放控件",
|
||||
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
|
||||
},
|
||||
|
||||
"proximityAlerts": {
|
||||
"getNotified": "接近监控设备时接收通知",
|
||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||
@@ -119,8 +138,7 @@
|
||||
"enableNotifications": "启用通知",
|
||||
"checkingPermissions": "检查权限中...",
|
||||
"alertDistance": "警报距离:",
|
||||
"meters": "米",
|
||||
"rangeInfo": "范围:{}-{} 米(默认:{})"
|
||||
"rangeInfo": "范围:{}-{} {}(默认:{})"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点 #{}",
|
||||
@@ -129,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": "选择配置文件...",
|
||||
@@ -140,8 +178,8 @@
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"refineTags": "细化标签"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "编辑节点 #{}",
|
||||
@@ -155,12 +193,16 @@
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
|
||||
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素(OSM way/relation)。您仍可以编辑其标签和方向。",
|
||||
"zoomInRequiredMessage": "请放大至至少第{}级来添加或编辑监控节点。这确保精确定位以便准确制图。",
|
||||
"extractFromWay": "从way/relation中提取节点",
|
||||
"extractFromWaySubtitle": "创建具有相同标签的新节点,允许移动到新位置",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
"existingTags": "<现有标签>",
|
||||
"noChangesDetected": "未检测到更改 - 无需提交",
|
||||
"noChangesTitle": "无更改可提交",
|
||||
"noChangesMessage": "您尚未对此节点进行任何更改。要提交编辑,您需要更改位置、配置文件、方向或标签。"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载地图区域",
|
||||
@@ -174,7 +216,10 @@
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
"downloadFailed": "启动下载失败:{}",
|
||||
"offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。",
|
||||
"currentTileProvider": "当前瓦片",
|
||||
"noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。"
|
||||
},
|
||||
"downloadStarted": {
|
||||
"title": "下载已开始",
|
||||
@@ -262,8 +307,13 @@
|
||||
"closingChangeset": " (关闭变更集...)",
|
||||
"processingPaused": "队列处理已暂停",
|
||||
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
|
||||
"pausedByUser": "上传处理已手动暂停。"
|
||||
"pausedByUser": "上传处理已手动暂停。",
|
||||
"inFlightWaitTitle": "正在完成提交",
|
||||
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
|
||||
"inFlightCompleteTitle": "提交完成",
|
||||
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
|
||||
},
|
||||
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
"noProvidersConfigured": "未配置瓦片提供商",
|
||||
@@ -274,6 +324,9 @@
|
||||
"addProvider": "添加提供商",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"clearCaches": "清除缓存",
|
||||
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
|
||||
"cachesCleared": "已清除 \"{}\" 的缓存",
|
||||
"providerName": "提供商名称",
|
||||
"providerNameHint": "例如,自定义地图公司",
|
||||
"providerNameRequired": "提供商名称为必填项",
|
||||
@@ -316,12 +369,22 @@
|
||||
"view": "查看",
|
||||
"deleteProfile": "删除配置文件",
|
||||
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"profileDeleted": "配置文件已删除"
|
||||
"profileDeleted": "配置文件已删除",
|
||||
"getMore": "获取更多...",
|
||||
"addProfileChoice": "添加配置文件",
|
||||
"addProfileChoiceMessage": "您希望如何添加配置文件?",
|
||||
"createCustomProfile": "创建自定义配置文件",
|
||||
"createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件",
|
||||
"importFromWebsite": "从网站导入",
|
||||
"importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商",
|
||||
"attribution": "地图归属"
|
||||
"attribution": "地图归属",
|
||||
"mapAttribution": "地图来源:{}",
|
||||
"couldNotOpenLink": "无法打开链接",
|
||||
"openLicense": "打开许可证:{}"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
@@ -336,6 +399,8 @@
|
||||
"fovHint": "视场角度数(留空使用默认值)",
|
||||
"fovSubtitle": "摄像头视场角 - 用于锥体宽度和范围提交格式",
|
||||
"fovInvalid": "视场角必须在1到360度之间",
|
||||
"fov360": "360°视场角",
|
||||
"fov360Subtitle": "摄像头具有360度全方向视场角",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
@@ -348,7 +413,7 @@
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
@@ -394,8 +459,13 @@
|
||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||
"startRefresh": "开始刷新",
|
||||
"refreshStarted": "刷新已开始!",
|
||||
"refreshFailed": "刷新失败:{}"
|
||||
"refreshFailed": "刷新失败:{}",
|
||||
"disableConfirmTitle": "删除现有的离线区域数据?",
|
||||
"disableConfirmMessage": "您已有现有的离线区域数据。禁用离线功能将阻止应用使用这些数据。您是想将这些数据保留在设备上,以便日后重新启用离线功能时使用,还是现在删除以释放存储空间?",
|
||||
"keepData": "保留数据",
|
||||
"deleteData": "删除数据"
|
||||
},
|
||||
|
||||
"refineTagsSheet": {
|
||||
"title": "细化标签",
|
||||
"operatorProfile": "运营商配置文件",
|
||||
@@ -411,7 +481,11 @@
|
||||
"profileTagsDescription": "为需要细化的标签指定值:",
|
||||
"selectValue": "选择值...",
|
||||
"noValue": "(无值)",
|
||||
"noSuggestions": "无建议可用"
|
||||
"noSuggestions": "无建议可用",
|
||||
"existingTagsTitle": "现有标签",
|
||||
"existingTagsDescription": "编辑此设备上的现有标签。添加、删除或修改任何标签:",
|
||||
"existingOperator": "<现有运营商>",
|
||||
"existingOperatorTags": "现有运营商标签"
|
||||
},
|
||||
"layerSelector": {
|
||||
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
|
||||
@@ -446,7 +520,9 @@
|
||||
"timedOut": "请求超时",
|
||||
"noData": "无离线数据",
|
||||
"success": "监控数据已加载",
|
||||
"nodeDataSlow": "监控数据缓慢"
|
||||
"nodeDataSlow": "监控数据缓慢",
|
||||
"rateLimited": "服务器限流",
|
||||
"networkError": "网络错误"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "显示 {rendered} / {total} 设备",
|
||||
@@ -481,13 +557,7 @@
|
||||
"avoidanceDistance": "回避距离",
|
||||
"avoidanceDistanceSubtitle": "与监控设备保持的最小距离",
|
||||
"searchHistory": "最大搜索历史",
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数",
|
||||
"units": "单位",
|
||||
"unitsSubtitle": "距离和测量的显示单位",
|
||||
"metric": "公制(公里,米)",
|
||||
"imperial": "英制(英里,英尺)",
|
||||
"meters": "米",
|
||||
"feet": "英尺"
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "疑似位置",
|
||||
@@ -499,14 +569,14 @@
|
||||
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
|
||||
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
|
||||
"minimumDistance": "与真实节点的最小距离",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
|
||||
"minimumDistanceSubtitle": "隐藏现有监控设备{}范围内的疑似位置",
|
||||
"updating": "正在更新疑似位置",
|
||||
"downloadingAndProcessing": "正在下载和处理数据...",
|
||||
"updateSuccess": "疑似位置更新成功",
|
||||
"updateFailed": "疑似位置更新失败",
|
||||
"neverFetched": "从未获取",
|
||||
"daysAgo": "{}天前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"hoursAgo": "{}小时前",
|
||||
"minutesAgo": "{}分钟前",
|
||||
"justNow": "刚刚"
|
||||
},
|
||||
@@ -514,7 +584,7 @@
|
||||
"title": "疑似位置 #{}",
|
||||
"ticketNo": "工单号",
|
||||
"address": "地址",
|
||||
"street": "街道",
|
||||
"street": "街道",
|
||||
"city": "城市",
|
||||
"state": "州/省",
|
||||
"intersectingStreet": "交叉街道",
|
||||
@@ -523,5 +593,19 @@
|
||||
"url": "网址",
|
||||
"coordinates": "坐标",
|
||||
"noAddressAvailable": "无可用地址"
|
||||
},
|
||||
"units": {
|
||||
"meters": "米",
|
||||
"feet": "英尺",
|
||||
"kilometers": "公里",
|
||||
"miles": "英里",
|
||||
"metersLong": "米",
|
||||
"feetLong": "英尺",
|
||||
"kilometersLong": "公里",
|
||||
"milesLong": "英里",
|
||||
"metric": "公制",
|
||||
"imperial": "英制",
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-3
@@ -14,19 +14,60 @@ 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);
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => AppState(),
|
||||
@@ -68,6 +109,7 @@ class DeFlockApp extends StatelessWidget {
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
navigatorKey: _navigatorKey,
|
||||
routes: {
|
||||
'/': (context) => const HomeScreen(),
|
||||
'/settings': (context) => const SettingsScreen(),
|
||||
@@ -82,7 +124,25 @@ 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Global navigator key for deep link navigation
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
+109
-2
@@ -1,12 +1,14 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
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.
|
||||
@@ -115,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) {
|
||||
@@ -128,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 {
|
||||
@@ -147,7 +254,7 @@ class OneTimeMigrations {
|
||||
debugPrint('[Migration] Stack trace: $stackTrace');
|
||||
|
||||
// Nuclear option: clear everything and show non-dismissible error dialog
|
||||
if (context != null) {
|
||||
if (context != null && context.mounted) {
|
||||
NuclearResetDialog.show(context, error, stackTrace);
|
||||
} else {
|
||||
// If no context available, just log and hope for the best
|
||||
|
||||
@@ -9,7 +9,7 @@ class DirectionFov {
|
||||
DirectionFov(this.centerDegrees, this.fovDegrees);
|
||||
|
||||
@override
|
||||
String toString() => 'DirectionFov(center: ${centerDegrees}°, fov: ${fovDegrees}°)';
|
||||
String toString() => 'DirectionFov(center: $centerDegrees°, fov: $fovDegrees°)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'osm_node.dart';
|
||||
|
||||
/// Sentinel value for copyWith methods to distinguish between null and not provided
|
||||
const Object _notProvided = Object();
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
@@ -217,7 +220,7 @@ class NodeProfile {
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
double? fov,
|
||||
Object? fov = _notProvided,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -227,7 +230,7 @@ class NodeProfile {
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
fov: fov ?? this.fov,
|
||||
fov: fov == _notProvided ? this.fov : fov as double?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -261,5 +264,53 @@ class NodeProfile {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Create a temporary empty profile for editing existing nodes
|
||||
/// Used as the default `<Existing tags>` option when editing nodes
|
||||
/// All existing tags will flow through as additionalExistingTags
|
||||
static NodeProfile createExistingTagsProfile(OsmNode node) {
|
||||
// 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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NodeProfile(
|
||||
id: 'temp-empty-${node.id}',
|
||||
name: '<Existing tags>', // Will be localized in UI
|
||||
tags: {}, // Completely empty - all existing tags become additional
|
||||
builtin: false,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: false,
|
||||
fov: calculatedFov, // Only use FOV if original had explicit range notation
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'osm_node.dart';
|
||||
|
||||
/// A bundle of OSM tags that describe a particular surveillance operator.
|
||||
/// These are applied on top of camera profile tags during submissions.
|
||||
@@ -76,4 +76,56 @@ class OperatorProfile {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// Create a temporary operator profile from existing operator tags on a node
|
||||
/// First tries to match against saved operator profiles, otherwise creates temporary one
|
||||
/// Used as the default operator profile when editing nodes
|
||||
static OperatorProfile? createExistingOperatorProfile(OsmNode node, List<OperatorProfile> savedProfiles) {
|
||||
final operatorTags = _extractOperatorTags(node.tags);
|
||||
if (operatorTags.isEmpty) return null;
|
||||
|
||||
// First, try to find a perfect match among saved profiles
|
||||
for (final savedProfile in savedProfiles) {
|
||||
if (_tagsMatch(savedProfile.tags, operatorTags)) {
|
||||
return savedProfile;
|
||||
}
|
||||
}
|
||||
|
||||
// No perfect match found, create temporary profile
|
||||
final operatorName = operatorTags['operator'] ?? '<existing>';
|
||||
|
||||
return OperatorProfile(
|
||||
id: 'temp-existing-operator-${node.id}',
|
||||
name: operatorName,
|
||||
tags: operatorTags,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two tag maps are identical
|
||||
static bool _tagsMatch(Map<String, String> tags1, Map<String, String> tags2) {
|
||||
if (tags1.length != tags2.length) return false;
|
||||
|
||||
for (final entry in tags1.entries) {
|
||||
if (tags2[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Extract all operator-related tags from a node's tags
|
||||
static Map<String, String> _extractOperatorTags(Map<String, String> tags) {
|
||||
final operatorTags = <String, String>{};
|
||||
|
||||
for (final entry in tags.entries) {
|
||||
// Include operator= and any operator:*= tags
|
||||
if (entry.key == 'operator' || entry.key.startsWith('operator:')) {
|
||||
operatorTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return operatorTags;
|
||||
}
|
||||
|
||||
/// Returns true if this is a temporary "existing operator" profile
|
||||
bool get isExistingOperatorProfile => id.startsWith('temp-existing-operator-');
|
||||
}
|
||||
@@ -107,6 +107,11 @@ class OsmNode {
|
||||
start = ((start % 360) + 360) % 360;
|
||||
end = ((end % 360) + 360) % 360;
|
||||
|
||||
// Special case: if start equals end, this represents 360° FOV
|
||||
if (start == end) {
|
||||
return DirectionFov(start, 360.0);
|
||||
}
|
||||
|
||||
double width, center;
|
||||
|
||||
if (start > end) {
|
||||
|
||||
@@ -22,6 +22,8 @@ class PendingUpload {
|
||||
final NodeProfile? profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final Map<String, String> refinedTags; // User-selected values for empty profile tags
|
||||
final Map<String, String> additionalExistingTags; // Tags that exist on node but not in profile
|
||||
final String changesetComment; // User-editable changeset comment
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
@@ -45,6 +47,8 @@ class PendingUpload {
|
||||
this.profile,
|
||||
this.operatorProfile,
|
||||
Map<String, String>? refinedTags,
|
||||
Map<String, String>? additionalExistingTags,
|
||||
required this.changesetComment,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
@@ -62,6 +66,7 @@ class PendingUpload {
|
||||
this.nodeSubmissionAttempts = 0,
|
||||
this.lastNodeSubmissionAttemptAt,
|
||||
}) : refinedTags = refinedTags ?? {},
|
||||
additionalExistingTags = additionalExistingTags ?? {},
|
||||
assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation == UploadOperation.create) || (originalNodeId != null),
|
||||
@@ -222,7 +227,7 @@ class PendingUpload {
|
||||
return DateTime.now().isAfter(nextRetryTime);
|
||||
}
|
||||
|
||||
// Get combined tags from node profile, operator profile, and refined tags
|
||||
// Get combined tags from node profile, operator profile, refined tags, and additional existing tags
|
||||
Map<String, String> getCombinedTags() {
|
||||
// Deletions don't need tags
|
||||
if (operation == UploadOperation.delete || profile == null) {
|
||||
@@ -231,15 +236,21 @@ class PendingUpload {
|
||||
|
||||
final tags = Map<String, String>.from(profile!.tags);
|
||||
|
||||
// Add additional existing tags first (these have lower precedence)
|
||||
tags.addAll(additionalExistingTags);
|
||||
|
||||
// Apply profile tags again to ensure they take precedence over additional existing tags
|
||||
tags.addAll(profile!.tags);
|
||||
|
||||
// Apply refined tags (these fill in empty values from the profile)
|
||||
for (final entry in refinedTags.entries) {
|
||||
// Only apply refined tags if the profile tag value is empty
|
||||
if (tags.containsKey(entry.key) && tags[entry.key]?.trim().isEmpty == true) {
|
||||
if (profile!.tags.containsKey(entry.key) && profile!.tags[entry.key]?.trim().isEmpty == true) {
|
||||
tags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
// Add operator profile tags (they override everything if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
@@ -269,6 +280,8 @@ class PendingUpload {
|
||||
'profile': profile?.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'refinedTags': refinedTags,
|
||||
'additionalExistingTags': additionalExistingTags,
|
||||
'changesetComment': changesetComment,
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
@@ -299,6 +312,10 @@ class PendingUpload {
|
||||
refinedTags: j['refinedTags'] != null
|
||||
? Map<String, String>.from(j['refinedTags'])
|
||||
: {}, // Default empty map for legacy entries
|
||||
additionalExistingTags: j['additionalExistingTags'] != null
|
||||
? Map<String, String>.from(j['additionalExistingTags'])
|
||||
: {}, // Default empty map for legacy entries
|
||||
changesetComment: j['changesetComment'] ?? _generateLegacyComment(j), // Default for legacy entries
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
@@ -338,5 +355,25 @@ class PendingUpload {
|
||||
if (error) return UploadState.error;
|
||||
return UploadState.pending;
|
||||
}
|
||||
|
||||
/// Generate a default changeset comment for legacy uploads that don't have one
|
||||
static String _generateLegacyComment(Map<String, dynamic> j) {
|
||||
final operation = j['operation'] != null
|
||||
? UploadOperation.values[j['operation']]
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create);
|
||||
|
||||
final profileName = j['profile']?['name'] ?? 'surveillance';
|
||||
|
||||
switch (operation) {
|
||||
case UploadOperation.create:
|
||||
return 'Add $profileName surveillance node';
|
||||
case UploadOperation.modify:
|
||||
return 'Update $profileName surveillance node';
|
||||
case UploadOperation.delete:
|
||||
return 'Delete $profileName surveillance node';
|
||||
case UploadOperation.extract:
|
||||
return 'Extract $profileName surveillance node';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// A suspected surveillance location from the CSV data
|
||||
@@ -35,8 +36,8 @@ class SuspectedLocation {
|
||||
bounds = coordinates.bounds;
|
||||
} catch (e) {
|
||||
// If GeoJSON parsing fails, use default coordinates
|
||||
print('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
|
||||
print('[SuspectedLocation] Location string: $locationString');
|
||||
debugPrint('[SuspectedLocation] Failed to parse GeoJSON for ticket $ticketNo: $e');
|
||||
debugPrint('[SuspectedLocation] Location string: $locationString');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ class SuspectedLocation {
|
||||
// The geoJson IS the geometry object (not wrapped in a 'geometry' property)
|
||||
final coordinates = geoJson['coordinates'] as List?;
|
||||
if (coordinates == null || coordinates.isEmpty) {
|
||||
print('[SuspectedLocation] No coordinates found in GeoJSON');
|
||||
debugPrint('[SuspectedLocation] No coordinates found in GeoJSON');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
|
||||
@@ -109,7 +110,7 @@ class SuspectedLocation {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unsupported geometry type: $type');
|
||||
debugPrint('Unsupported geometry type: $type');
|
||||
}
|
||||
|
||||
if (points.isEmpty) {
|
||||
@@ -127,7 +128,7 @@ class SuspectedLocation {
|
||||
|
||||
return (centroid: centroid, bounds: points);
|
||||
} catch (e) {
|
||||
print('Error extracting coordinates from GeoJSON: $e');
|
||||
debugPrint('Error extracting coordinates from GeoJSON: $e');
|
||||
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +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/network_status_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 {
|
||||
@@ -33,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,8 @@ class NavigationCoordinator {
|
||||
// Enter search mode
|
||||
try {
|
||||
final center = mapController.mapController.camera.center;
|
||||
appState.enterSearchMode(center);
|
||||
final viewbox = mapController.mapController.camera.visibleBounds;
|
||||
appState.enterSearchMode(center, viewbox: viewbox);
|
||||
} catch (e) {
|
||||
debugPrint('[NavigationCoordinator] Could not get map center for search: $e');
|
||||
// Fallback to default location
|
||||
|
||||
@@ -25,6 +25,9 @@ class SheetCoordinator {
|
||||
|
||||
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
|
||||
bool _transitioningToEdit = false;
|
||||
|
||||
// Follow-me state restoration
|
||||
FollowMeMode? _followMeModeBeforeSheet;
|
||||
|
||||
// Getters for accessing heights
|
||||
double get addSheetHeight => _addSheetHeight;
|
||||
@@ -88,7 +91,8 @@ class SheetCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable follow-me when adding a node so the map doesn't jump around
|
||||
// Save current follow-me mode and disable it while sheet is open
|
||||
_followMeModeBeforeSheet = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
@@ -113,13 +117,15 @@ class SheetCoordinator {
|
||||
controller.closed.then((_) {
|
||||
_addSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.session != null) {
|
||||
debugPrint('[SheetCoordinator] AddNodeSheet dismissed - canceling session');
|
||||
appState.cancelSession();
|
||||
}
|
||||
|
||||
// Restore follow-me mode that was active before sheet opened
|
||||
_restoreFollowMeMode(appState);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,7 +138,8 @@ class SheetCoordinator {
|
||||
}) {
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
// Disable follow-me when editing a node so the map doesn't jump around
|
||||
// Save current follow-me mode and disable it while sheet is open
|
||||
_followMeModeBeforeSheet = appState.followMeMode;
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
@@ -178,13 +185,15 @@ class SheetCoordinator {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
onStateChanged();
|
||||
|
||||
|
||||
// Handle dismissal by canceling session if still active
|
||||
final appState = context.read<AppState>();
|
||||
if (appState.editSession != null) {
|
||||
debugPrint('[SheetCoordinator] EditNodeSheet dismissed - canceling edit session');
|
||||
appState.cancelEditSession();
|
||||
}
|
||||
|
||||
// Restore follow-me mode that was active before sheet opened
|
||||
_restoreFollowMeMode(appState);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,4 +259,16 @@ class SheetCoordinator {
|
||||
_tagSheetHeight = 0.0;
|
||||
onStateChanged();
|
||||
}
|
||||
|
||||
/// Restore the follow-me mode that was active before opening a node sheet
|
||||
void _restoreFollowMeMode(AppState appState) {
|
||||
if (_followMeModeBeforeSheet != null) {
|
||||
debugPrint('[SheetCoordinator] Restoring follow-me mode: $_followMeModeBeforeSheet');
|
||||
appState.setFollowMeMode(_followMeModeBeforeSheet!);
|
||||
_followMeModeBeforeSheet = null; // Clear stored state
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any node editing/viewing sheet is currently open
|
||||
bool get hasActiveNodeSheet => _addSheetHeight > 0 || _editSheetHeight > 0 || _tagSheetHeight > 0;
|
||||
}
|
||||
+152
-86
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_animations/flutter_map_animations.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -9,13 +8,12 @@ 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/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/download_area_dialog.dart';
|
||||
import '../widgets/measured_sheet.dart';
|
||||
import '../widgets/navigation_sheet.dart';
|
||||
import '../widgets/search_bar.dart';
|
||||
import '../widgets/suspected_location_sheet.dart';
|
||||
import '../widgets/welcome_dialog.dart';
|
||||
@@ -24,9 +22,11 @@ import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
import '../models/search_result.dart';
|
||||
import '../services/changelog_service.dart';
|
||||
import '../services/deep_link_service.dart';
|
||||
import 'coordinators/sheet_coordinator.dart';
|
||||
import 'coordinators/navigation_coordinator.dart';
|
||||
import 'coordinators/map_interaction_handler.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -61,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();
|
||||
}
|
||||
@@ -153,6 +155,33 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
// Request location permission on first launch
|
||||
Future<void> _requestLocationPermissionIfFirstLaunch() async {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
// Only request on first launch or if user has never seen welcome
|
||||
final isFirstLaunch = await ChangelogService().isFirstLaunch();
|
||||
final hasSeenWelcome = await ChangelogService().hasSeenWelcome();
|
||||
|
||||
if (isFirstLaunch || !hasSeenWelcome) {
|
||||
// Check if location services are enabled
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('[HomeScreen] Location services disabled - skipping permission request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Request location permission (this will show system dialog if needed)
|
||||
final permission = await Geolocator.requestPermission();
|
||||
debugPrint('[HomeScreen] First launch location permission result: $permission');
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle errors to avoid breaking the app launch
|
||||
debugPrint('[HomeScreen] Error requesting location permission: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for and display welcome/changelog popup
|
||||
Future<void> _checkForPopup() async {
|
||||
if (!mounted) return;
|
||||
@@ -162,6 +191,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
// Run any needed migrations first
|
||||
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
|
||||
if (!mounted) return;
|
||||
for (final version in versionsNeedingMigration) {
|
||||
await ChangelogService().runMigration(version, appState, context);
|
||||
}
|
||||
@@ -178,10 +208,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const WelcomeDialog(),
|
||||
);
|
||||
|
||||
// Request location permission right after welcome dialog on first launch
|
||||
if (!mounted) return;
|
||||
await _requestLocationPermissionIfFirstLaunch();
|
||||
break;
|
||||
|
||||
case PopupType.changelog:
|
||||
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
|
||||
if (!mounted) return;
|
||||
if (changelogContent != null) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -220,35 +255,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
|
||||
try {
|
||||
LatLng centerLocation;
|
||||
|
||||
if (followMeEnabled && userLocation != null) {
|
||||
// Center on user if follow-me is enabled
|
||||
centerLocation = userLocation;
|
||||
debugPrint('[HomeScreen] Centering on user location for route start');
|
||||
} else if (routeStart != null) {
|
||||
// Center on start pin if user is far away or no GPS
|
||||
centerLocation = routeStart;
|
||||
debugPrint('[HomeScreen] Centering on route start pin');
|
||||
} else {
|
||||
debugPrint('[HomeScreen] No valid location to center on');
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate to zoom 14 and center location
|
||||
_mapController.animateTo(
|
||||
dest: centerLocation,
|
||||
zoom: 14.0,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[HomeScreen] Could not zoom/center for route: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onResumeRoute() {
|
||||
_navigationCoordinator.resumeRoute(
|
||||
context: context,
|
||||
@@ -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(
|
||||
@@ -370,9 +410,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
);
|
||||
|
||||
// Reset height and clear selection when sheet is dismissed
|
||||
final appState = context.read<AppState>();
|
||||
controller.closed.then((_) {
|
||||
if (!mounted) return;
|
||||
_sheetCoordinator.resetTagSheetHeight(() => setState(() {}));
|
||||
context.read<AppState>().clearSuspectedLocationSelection();
|
||||
appState.clearSuspectedLocationSelection();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -415,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;
|
||||
|
||||
@@ -433,7 +491,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
IconButton(
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
|
||||
onPressed: _mapViewKey.currentState?.hasLocation == true
|
||||
onPressed: (_mapViewKey.currentState?.hasLocation == true && !_sheetCoordinator.hasActiveNodeSheet)
|
||||
? () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
@@ -444,7 +502,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
}
|
||||
: null, // Grey out when no location
|
||||
: null, // Grey out when no location or when node sheet is open
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
@@ -496,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
|
||||
@@ -546,7 +605,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
color: Theme.of(context).shadowColor.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
@@ -554,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,79 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/distance_service.dart';
|
||||
import '../app_state.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class NavigationSettingsScreen extends StatelessWidget {
|
||||
class NavigationSettingsScreen extends StatefulWidget {
|
||||
const NavigationSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NavigationSettingsScreen> createState() => _NavigationSettingsScreenState();
|
||||
}
|
||||
|
||||
class _NavigationSettingsScreenState extends State<NavigationSettingsScreen> {
|
||||
late TextEditingController _distanceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_distanceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateDistance(AppState appState, String value) {
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 250.0 : 820.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setNavigationAvoidanceDistance(metersValue.round().clamp(0, 2000));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('navigation.avoidanceDistance')),
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.navigationAvoidanceDistance.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('navigation.avoidanceDistance')),
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
onSubmitted: (value) => _updateDistance(appState, value),
|
||||
onEditingComplete: () => _updateDistance(appState, _distanceController.text),
|
||||
)
|
||||
)
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 250;
|
||||
appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000));
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.history,
|
||||
title: locService.t('navigation.searchHistory'),
|
||||
subtitle: locService.t('navigation.searchHistorySubtitle'),
|
||||
value: '10 searches',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.history,
|
||||
title: locService.t('navigation.searchHistory'),
|
||||
subtitle: locService.t('navigation.searchHistorySubtitle'),
|
||||
value: '10 searches',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
_buildDisabledSetting(
|
||||
context,
|
||||
icon: Icons.straighten,
|
||||
title: locService.t('navigation.units'),
|
||||
subtitle: locService.t('navigation.unitsSubtitle'),
|
||||
value: locService.t('navigation.metric'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +132,7 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -87,10 +93,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -103,7 +105,6 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -154,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),
|
||||
@@ -275,7 +234,7 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Row(
|
||||
@@ -308,7 +267,7 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
label: Text(locService.t('auth.deleteAccount')),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error.withOpacity(0.5)),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -354,10 +313,10 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
|
||||
@@ -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
|
||||
@@ -69,6 +72,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
actions: widget.profile.editable ? [
|
||||
TextButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@@ -88,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')),
|
||||
@@ -135,11 +184,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -152,7 +196,6 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -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) {
|
||||
|
||||
@@ -99,7 +99,7 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -142,8 +142,8 @@ class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrentVersion
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
|
||||
: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)
|
||||
: Theme.of(context).dividerColor.withValues(alpha: 0.3),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
@@ -20,27 +23,29 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
_loadLanguageNames();
|
||||
}
|
||||
|
||||
_loadSelectedLanguage() async {
|
||||
Future<void> _loadSelectedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedLanguage = prefs.getString('language_code');
|
||||
});
|
||||
}
|
||||
|
||||
_loadLanguageNames() async {
|
||||
Future<void> _loadLanguageNames() async {
|
||||
final locService = LocalizationService.instance;
|
||||
final Map<String, String> names = {};
|
||||
|
||||
|
||||
for (String langCode in locService.availableLanguages) {
|
||||
names[langCode] = await locService.getLanguageDisplayName(langCode);
|
||||
}
|
||||
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_languageNames = names;
|
||||
});
|
||||
}
|
||||
|
||||
_setLanguage(String? languageCode) async {
|
||||
Future<void> _setLanguage(String? languageCode) async {
|
||||
await LocalizationService.instance.setLanguage(languageCode);
|
||||
setState(() {
|
||||
_selectedLanguage = languageCode;
|
||||
@@ -49,43 +54,95 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Language section
|
||||
RadioGroup<String?>(
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
child: Column(
|
||||
children: [
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
),
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String?>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
),
|
||||
// Other language options (excluding English since it's already shown)
|
||||
...locService.availableLanguages
|
||||
.where((langCode) => langCode != 'en')
|
||||
.map((langCode) =>
|
||||
RadioListTile<String?>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Divider between language and units
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Distance Units section
|
||||
Text(
|
||||
locService.t('settings.distanceUnit'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('settings.distanceUnitSubtitle'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
RadioGroup<DistanceUnit>(
|
||||
groupValue: appState.distanceUnit,
|
||||
onChanged: (unit) {
|
||||
if (unit != null) {
|
||||
appState.setDistanceUnit(unit);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// Metric option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.metricDescription')),
|
||||
value: DistanceUnit.metric,
|
||||
),
|
||||
|
||||
// Imperial option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.imperialDescription')),
|
||||
value: DistanceUnit.imperial,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Other language options (excluding English since it's already shown)
|
||||
...locService.availableLanguages
|
||||
.where((langCode) => langCode != 'en')
|
||||
.map((langCode) =>
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../models/node_profile.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../widgets/profile_add_choice_dialog.dart';
|
||||
import '../../profile_editor.dart';
|
||||
|
||||
class NodeProfilesSection extends StatelessWidget {
|
||||
@@ -27,93 +28,107 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () => _showAddProfileDialog(context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profiles.newProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
...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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -121,6 +136,34 @@ class NodeProfilesSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddProfileDialog(BuildContext context) async {
|
||||
final result = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => const ProfileAddChoiceDialog(),
|
||||
);
|
||||
|
||||
// If user chose to create custom profile, open the profile editor
|
||||
if (result == 'create' && context.mounted) {
|
||||
_createNewProfile(context);
|
||||
}
|
||||
// If user chose import from website, ProfileAddChoiceDialog handles opening the URL
|
||||
}
|
||||
|
||||
void _createNewProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(
|
||||
profile: NodeProfile(
|
||||
id: const Uuid().v4(),
|
||||
name: '',
|
||||
tags: const {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.read<AppState>();
|
||||
|
||||
@@ -87,9 +87,9 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: "${(area.sizeBytes / 1024).toStringAsFixed(1)} ${locService.t('offlineAreas.kilobytes')}"
|
||||
: '--';
|
||||
|
||||
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n' +
|
||||
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}' + '\n' +
|
||||
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n' +
|
||||
String subtitle = '${locService.t('offlineAreas.provider')}: ${area.tileProviderDisplay}\n'
|
||||
'${locService.t('offlineAreas.maxZoom')}: Z${area.maxZoom}\n'
|
||||
'${locService.t('offlineAreas.latitude')}: ${area.bounds.southWest.latitude.toStringAsFixed(3)}, ${area.bounds.southWest.longitude.toStringAsFixed(3)}\n'
|
||||
'${locService.t('offlineAreas.latitude')}: ${area.bounds.northEast.latitude.toStringAsFixed(3)}, ${area.bounds.northEast.longitude.toStringAsFixed(3)}';
|
||||
|
||||
if (area.status == OfflineAreaStatus.downloading) {
|
||||
@@ -207,7 +207,7 @@ class _OfflineAreasSectionState extends State<OfflineAreasSection> {
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
@@ -77,27 +86,7 @@ class OfflineModeSection extends StatelessWidget {
|
||||
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.pause_circle_outline,
|
||||
color: appState.offlineMode
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
title: Text(
|
||||
locService.t('settings.pauseQueueProcessingSubtitle'),
|
||||
style: appState.offlineMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: Switch(
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => appState.setPauseQueueProcessing(value),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/proximity_alert_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
import '../../../dev_config.dart';
|
||||
|
||||
/// Settings section for proximity alerts configuration
|
||||
@@ -25,8 +27,13 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
// Convert meters to display units for the text field
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: appState.proximityAlertDistance.toString(),
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
_checkNotificationPermissions();
|
||||
}
|
||||
@@ -69,12 +76,18 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
|
||||
void _updateDistance(AppState appState) {
|
||||
final text = _distanceController.text.trim();
|
||||
final distance = int.tryParse(text);
|
||||
if (distance != null) {
|
||||
appState.setProximityAlertDistance(distance);
|
||||
final displayValue = double.tryParse(text);
|
||||
if (displayValue != null) {
|
||||
// Convert from display units back to meters for storage
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setProximityAlertDistance(metersValue.round());
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
_distanceController.text = appState.proximityAlertDistance.toString();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +97,15 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
builder: (context, appState, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -118,9 +140,9 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -171,49 +193,50 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
),
|
||||
],
|
||||
|
||||
// Distance setting (only show when enabled)
|
||||
if (appState.proximityAlertsEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(locService.t('proximityAlerts.alertDistance')),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
// Distance setting (only show when enabled)
|
||||
if (appState.proximityAlertsEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(locService.t('proximityAlerts.alertDistance')),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
onSubmitted: (_) => _updateDistance(appState),
|
||||
onEditingComplete: () => _updateDistance(appState),
|
||||
),
|
||||
onSubmitted: (_) => _updateDistance(appState),
|
||||
onEditingComplete: () => _updateDistance(appState),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('proximityAlerts.meters')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.rangeInfo', params: [
|
||||
kProximityAlertMinDistance.toString(),
|
||||
kProximityAlertMaxDistance.toString(),
|
||||
kProximityAlertDefaultDistance.toString(),
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.rangeInfo', params: [
|
||||
DistanceService.convertFromMeters(kProximityAlertMinDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
DistanceService.convertFromMeters(kProximityAlertMaxDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}'),
|
||||
DistanceService.convertFromMeters(kProximityAlertDefaultDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class QueueSection extends StatelessWidget {
|
||||
const QueueSection({super.key});
|
||||
@@ -112,20 +111,20 @@ class QueueSection extends StatelessWidget {
|
||||
? Colors.red
|
||||
: _getUploadModeColor(upload.uploadMode),
|
||||
),
|
||||
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
|
||||
title: Text(locService.t('queue.itemWithIndex', params: [(index + 1).toString()]) +
|
||||
(upload.error ? locService.t('queue.error') : "") +
|
||||
(upload.completing ? locService.t('queue.completing') : "")),
|
||||
subtitle: Text(
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
'${locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)])}\n'
|
||||
'${locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
])}\n'
|
||||
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
|
||||
'${upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class SuspectedLocationsSection extends StatefulWidget {
|
||||
const SuspectedLocationsSection({super.key});
|
||||
@@ -188,22 +190,28 @@ class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('suspectedLocations.minimumDistance')),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [
|
||||
DistanceService.formatDistance(appState.suspectedLocationMinDistance.toDouble(), appState.distanceUnit)
|
||||
])),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||
initialValue: DistanceService.convertFromMeters(
|
||||
appState.suspectedLocationMinDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
).round().toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 100;
|
||||
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 100.0 : 328.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setSuspectedLocationMinDistance(metersValue.round().clamp(0, 1000));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -44,7 +44,7 @@ class TileProviderSection extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
...providers.map((provider) => _buildProviderTile(context, provider, appState)).toList(),
|
||||
...providers.map((provider) => _buildProviderTile(context, provider, appState)),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -89,7 +89,7 @@ class TileProviderSection extends StatelessWidget {
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surfaceVariant,
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.map,
|
||||
color: isSelected
|
||||
@@ -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,
|
||||
@@ -81,7 +83,7 @@ class UploadModeSection extends StatelessWidget {
|
||||
fontSize: 12,
|
||||
color: appState.pendingCount > 0
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)
|
||||
)
|
||||
);
|
||||
case UploadMode.sandbox:
|
||||
@@ -95,7 +97,6 @@ class UploadModeSection extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return Text(
|
||||
locService.t('uploadMode.simulateDescription'),
|
||||
style: TextStyle(
|
||||
|
||||
@@ -102,7 +102,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
child: Text(
|
||||
'Version: ${VersionService().version}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart';
|
||||
import '../services/http_client.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../dev_config.dart';
|
||||
|
||||
@@ -408,6 +407,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
_isLoadingPreview = true;
|
||||
});
|
||||
|
||||
final client = UserAgentClient();
|
||||
try {
|
||||
// Create a temporary TileType to use the getTileUrl method
|
||||
final tempTileType = TileType(
|
||||
@@ -416,21 +416,21 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
urlTemplate: _urlController.text.trim(),
|
||||
attribution: 'Preview',
|
||||
);
|
||||
|
||||
|
||||
final url = tempTileType.getTileUrl(
|
||||
kPreviewTileZoom,
|
||||
kPreviewTileX,
|
||||
kPreviewTileY,
|
||||
apiKey: null, // Don't use API key for preview
|
||||
);
|
||||
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
|
||||
final response = await client.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() {
|
||||
_previewTile = response.bodyBytes;
|
||||
});
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))),
|
||||
@@ -446,6 +446,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
setState(() {
|
||||
_isLoadingPreview = false;
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import 'package:provider/provider.dart';
|
||||
import '../app_state.dart';
|
||||
import '../models/pending_upload.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import '../widgets/in_flight_upload_wait_dialog.dart';
|
||||
|
||||
|
||||
class UploadQueueScreen extends StatelessWidget {
|
||||
const UploadQueueScreen({super.key});
|
||||
@@ -85,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(
|
||||
@@ -114,8 +131,8 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
border: Border.all(color: Colors.orange.withOpacity(0.3)),
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -148,6 +165,32 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Pause Upload Queue Toggle
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.pause_circle_outline,
|
||||
color: appState.offlineMode
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
title: Text(locService.t('settings.pauseQueueProcessing')),
|
||||
subtitle: Text(
|
||||
locService.t('settings.pauseQueueProcessingSubtitle'),
|
||||
style: appState.offlineMode
|
||||
? TextStyle(color: Theme.of(context).disabledColor)
|
||||
: null,
|
||||
),
|
||||
trailing: Switch(
|
||||
value: appState.pauseQueueProcessing,
|
||||
onChanged: appState.offlineMode
|
||||
? null // Disable when offline mode is on
|
||||
: (value) => _handlePauseQueueChange(context, appState, value),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Clear Upload Queue button - always visible
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -180,7 +223,7 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: Text(locService.t('queue.clearUploadQueue')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withOpacity(0.1),
|
||||
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -199,13 +242,13 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.4),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
locService.t('queue.nothingInQueue'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -247,16 +290,16 @@ class UploadQueueScreen extends StatelessWidget {
|
||||
_getUploadStateText(upload, locService)
|
||||
),
|
||||
subtitle: Text(
|
||||
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
|
||||
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
|
||||
locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
'${locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)])}\n'
|
||||
'${locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)])}\n'
|
||||
'${locService.t('queue.direction', params: [
|
||||
upload.direction is String
|
||||
? upload.direction.toString()
|
||||
: upload.direction.round().toString()
|
||||
]) + '\n' +
|
||||
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
|
||||
(upload.uploadState == UploadState.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
|
||||
])}\n'
|
||||
'${locService.t('queue.attempts', params: [upload.attempts.toString()])}'
|
||||
'${upload.uploadState == UploadState.error ? "\n${locService.t('queue.uploadFailedRetry')}" : ""}'
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:oauth2_client/oauth2_client.dart';
|
||||
import 'package:oauth2_client/oauth2_helper.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Handles PKCE OAuth login with OpenStreetMap.
|
||||
import '../keys.dart';
|
||||
import '../app_state.dart' show UploadMode;
|
||||
import 'http_client.dart';
|
||||
|
||||
class AuthService {
|
||||
// Both client IDs from keys.dart
|
||||
@@ -30,13 +30,13 @@ class AuthService {
|
||||
case UploadMode.sandbox:
|
||||
return 'osm_token_sandbox';
|
||||
case UploadMode.simulate:
|
||||
default:
|
||||
return 'osm_token_simulate';
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -86,21 +86,21 @@ 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);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
|
||||
_displayName = await _fetchUsername(token!.accessToken!);
|
||||
_displayName = await _fetchUsername(token.accessToken!);
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: OAuth login failed: $e');
|
||||
debugPrint('AuthService: OAuth login failed: $e');
|
||||
log('OAuth login failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class AuthService {
|
||||
_displayName = await _fetchUsername(accessToken);
|
||||
return _displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error restoring login with stored token: $e');
|
||||
debugPrint('AuthService: Error restoring login with stored token: $e');
|
||||
log('Error restoring login with stored token: $e');
|
||||
// Token might be expired or invalid, clear it
|
||||
await logout();
|
||||
@@ -151,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();
|
||||
}
|
||||
@@ -179,9 +181,11 @@ class AuthService {
|
||||
: 'https://api.openstreetmap.org';
|
||||
}
|
||||
|
||||
final _client = UserAgentClient();
|
||||
|
||||
Future<String?> _fetchUsername(String accessToken) async {
|
||||
try {
|
||||
final resp = await http.get(
|
||||
final resp = await _client.get(
|
||||
Uri.parse('$_apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
@@ -194,7 +198,7 @@ class AuthService {
|
||||
final displayName = userData['user']?['display_name'];
|
||||
return displayName;
|
||||
} catch (e) {
|
||||
print('AuthService: Error fetching username: $e');
|
||||
debugPrint('AuthService: Error fetching username: $e');
|
||||
log('Error fetching username: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -226,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');
|
||||
// }
|
||||
@@ -304,8 +324,8 @@ class ChangelogService {
|
||||
final v2Parts = v2.split('.').map(int.parse).toList();
|
||||
|
||||
// Ensure we have at least 3 parts (major.minor.patch)
|
||||
while (v1Parts.length < 3) v1Parts.add(0);
|
||||
while (v2Parts.length < 3) v2Parts.add(0);
|
||||
while (v1Parts.length < 3) { v1Parts.add(0); }
|
||||
while (v2Parts.length < 3) { v2Parts.add(0); }
|
||||
|
||||
// Compare major version first
|
||||
if (v1Parts[0] < v2Parts[0]) return -1;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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();
|
||||
factory DeepLinkService() => _instance;
|
||||
DeepLinkService._internal();
|
||||
|
||||
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 {
|
||||
_appLinks = AppLinks();
|
||||
|
||||
// Set up stream listener for links when app is already running
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen(
|
||||
_processLink,
|
||||
onError: (err) {
|
||||
debugPrint('[DeepLinkService] Link stream error: $err');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Process a deep link
|
||||
void _processLink(Uri uri) {
|
||||
debugPrint('[DeepLinkService] Processing deep link: $uri');
|
||||
|
||||
// Only handle deflockapp scheme
|
||||
if (uri.scheme != 'deflockapp') {
|
||||
debugPrint('[DeepLinkService] Ignoring non-deflockapp scheme: ${uri.scheme}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Route based on path
|
||||
switch (uri.host) {
|
||||
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');
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown deep link host: ${uri.host}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for initial link after app is fully ready
|
||||
Future<void> checkInitialLink() async {
|
||||
debugPrint('[DeepLinkService] Checking for initial link...');
|
||||
|
||||
try {
|
||||
final initialLink = await _appLinks.getInitialLink();
|
||||
if (initialLink != null) {
|
||||
debugPrint('[DeepLinkService] Found initial link: $initialLink');
|
||||
_processLink(initialLink);
|
||||
} else {
|
||||
debugPrint('[DeepLinkService] No initial link found');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[DeepLinkService] Failed to get initial link: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
if (segments.isEmpty) {
|
||||
debugPrint('[DeepLinkService] No path segments in profiles link');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (segments[0]) {
|
||||
case 'add':
|
||||
_handleAddProfileLink(uri);
|
||||
break;
|
||||
default:
|
||||
debugPrint('[DeepLinkService] Unknown profiles path: ${segments[0]}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
_showError('Invalid profile link: missing profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse profile from base64
|
||||
final profile = ProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (profile == null) {
|
||||
_showError('Invalid profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to profile editor with the imported profile
|
||||
_navigateToProfileEditor(profile);
|
||||
}
|
||||
|
||||
/// Handle operator profile import from deep link
|
||||
void _handleOperatorProfileImport(String base64Data) {
|
||||
// Parse operator profile from base64
|
||||
final operatorProfile = OperatorProfileImportService.parseProfileFromBase64(base64Data);
|
||||
|
||||
if (operatorProfile == null) {
|
||||
_showError('Invalid operator profile data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to operator profile editor with the imported profile
|
||||
_navigateToOperatorProfileEditor(operatorProfile);
|
||||
}
|
||||
|
||||
/// Navigate to profile editor with pre-filled profile data
|
||||
void _navigateToProfileEditor(NodeProfile profile) {
|
||||
final context = _navigatorKey?.currentContext;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] No navigator context available');
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ProfileEditor(profile: profile),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
if (context == null) {
|
||||
debugPrint('[DeepLinkService] Error (no context): $message');
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Global navigator key for navigation
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
/// Set the global navigator key
|
||||
void setNavigatorKey(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
void dispose() {
|
||||
_linkSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,471 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
import 'map_data_provider.dart';
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
|
||||
///
|
||||
/// This replaces the complex HTTP interception approach with a clean TileProvider
|
||||
/// implementation that directly interfaces with our MapDataProvider system.
|
||||
class DeflockTileProvider extends TileProvider {
|
||||
final MapDataProvider _mapDataProvider = MapDataProvider();
|
||||
|
||||
/// 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 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;
|
||||
|
||||
/// 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,
|
||||
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({
|
||||
required String providerId,
|
||||
required models.TileType tileType,
|
||||
String? apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
VoidCallback? onNetworkSuccess,
|
||||
String configFingerprint = '',
|
||||
}) {
|
||||
final client = UserAgentClient(RetryClient(Client()));
|
||||
return DeflockTileProvider._(
|
||||
httpClient: client,
|
||||
providerId: providerId,
|
||||
tileType: tileType,
|
||||
apiKey: apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
configFingerprint: configFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
// Get current provider info to include in cache key
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockTileImageProvider(
|
||||
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
|
||||
return tileType.getTileUrl(
|
||||
coordinates.z,
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageProvider getImageWithCancelLoadingSupport(
|
||||
TileCoordinates coordinates,
|
||||
TileLayer options,
|
||||
Future<void> cancelLoading,
|
||||
) {
|
||||
if (!_shouldCheckOfflineCache(coordinates.z)) {
|
||||
// Common path: no offline areas — delegate to NetworkTileProvider's
|
||||
// full pipeline (disk cache, ETag, RetryClient, abort support).
|
||||
return super.getImageWithCancelLoadingSupport(
|
||||
coordinates,
|
||||
options,
|
||||
cancelLoading,
|
||||
);
|
||||
}
|
||||
|
||||
// Offline-first path: check local tiles first, fall back to network.
|
||||
return DeflockOfflineTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
mapDataProvider: _mapDataProvider,
|
||||
httpClient: _sharedHttpClient,
|
||||
headers: headers,
|
||||
cancelLoading: cancelLoading,
|
||||
isOfflineOnly: AppState.instance.offlineMode,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeId: tileType.id,
|
||||
tileUrl: getTileUrl(coordinates, options),
|
||||
cachingProvider: _cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// Determine if we should check offline cache for this tile request.
|
||||
/// Only returns true if:
|
||||
/// 1. We're in offline mode (forced), OR
|
||||
/// 2. We have offline areas for the current provider/type
|
||||
///
|
||||
/// This avoids the offline-first path (and its filesystem searches) when
|
||||
/// browsing online with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache(int zoom) {
|
||||
// Always use offline path in offline mode
|
||||
if (AppState.instance.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only use offline path if we have relevant offline data
|
||||
// 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.hasOfflineAreasForProviderAtZoom(
|
||||
providerId,
|
||||
tileType.id,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
// 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 that fetches tiles through our MapDataProvider.
|
||||
///
|
||||
/// This handles the actual tile fetching using our existing offline/online
|
||||
/// routing logic without any HTTP interception complexity.
|
||||
class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
|
||||
/// Image provider for the offline-first path.
|
||||
///
|
||||
/// 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;
|
||||
final TileLayer options;
|
||||
final MapDataProvider mapDataProvider;
|
||||
final Client httpClient;
|
||||
final Map<String, String> headers;
|
||||
final Future<void> cancelLoading;
|
||||
final bool isOfflineOnly;
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
|
||||
const DeflockTileImageProvider({
|
||||
final String tileUrl;
|
||||
final MapCachingProvider? cachingProvider;
|
||||
final VoidCallback? onNetworkSuccess;
|
||||
|
||||
const DeflockOfflineTileImageProvider({
|
||||
required this.coordinates,
|
||||
required this.options,
|
||||
required this.mapDataProvider,
|
||||
required this.httpClient,
|
||||
required this.headers,
|
||||
required this.cancelLoading,
|
||||
required this.isOfflineOnly,
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
required this.tileUrl,
|
||||
this.cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Future<DeflockTileImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DeflockTileImageProvider>(this);
|
||||
Future<DeflockOfflineTileImageProvider> obtainKey(
|
||||
ImageConfiguration configuration) {
|
||||
return SynchronousFuture<DeflockOfflineTileImageProvider>(this);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) {
|
||||
ImageStreamCompleter loadImage(
|
||||
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode, chunkEvents),
|
||||
// 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(
|
||||
DeflockTileImageProvider key,
|
||||
DeflockOfflineTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async {
|
||||
Future<Codec> decodeBytes(Uint8List bytes) =>
|
||||
ImmutableBuffer.fromUint8List(bytes).then(decode);
|
||||
|
||||
// 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 {
|
||||
// Get current tile provider and type from app state
|
||||
final appState = AppState.instance;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
|
||||
if (selectedProvider == null || selectedTileType == null) {
|
||||
throw Exception('No tile provider configured');
|
||||
if (isOfflineOnly) {
|
||||
return await _loadOffline(decodeBytes, cancelled);
|
||||
}
|
||||
|
||||
// Smart cache routing: only check offline cache when needed
|
||||
final MapSource source = _shouldCheckOfflineCache(appState)
|
||||
? MapSource.auto // Check offline first, then network
|
||||
: MapSource.remote; // Skip offline cache, go directly to network
|
||||
|
||||
final tileBytes = await mapDataProvider.getTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
source: source,
|
||||
);
|
||||
|
||||
// Decode the image bytes
|
||||
final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes));
|
||||
return await decode(buffer);
|
||||
|
||||
return await _loadOnline(decodeBytes, cancelled);
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses to avoid console spam
|
||||
if (!e.toString().contains('offline mode is enabled')) {
|
||||
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
|
||||
// This is better than trying to provide fallback images
|
||||
throw e;
|
||||
|
||||
// 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 DeflockTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
return other is DeflockOfflineTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId &&
|
||||
other.isOfflineOnly == isOfflineOnly;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
|
||||
/// Determine if we should check offline cache for this tile request.
|
||||
/// Only check offline cache if:
|
||||
/// 1. We're in offline mode (forced), OR
|
||||
/// 2. We have offline areas for the current provider/type
|
||||
///
|
||||
/// This avoids expensive filesystem searches when browsing online
|
||||
/// with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache(AppState appState) {
|
||||
// Always check offline cache in offline mode
|
||||
if (appState.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only check if we might actually have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check: do we have any offline areas for this provider/type?
|
||||
// This avoids the expensive per-tile filesystem search in fetchLocalTile
|
||||
final offlineService = OfflineAreaService();
|
||||
final hasRelevantAreas = offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
);
|
||||
|
||||
return hasRelevantAreas;
|
||||
}
|
||||
}
|
||||
int get hashCode =>
|
||||
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Service for distance unit conversions and formatting
|
||||
///
|
||||
/// Follows brutalist principles: simple, explicit conversions without fancy abstractions.
|
||||
/// All APIs work in metric units (meters/km), this service only handles display formatting.
|
||||
class DistanceService {
|
||||
// Conversion constants
|
||||
static const double _metersToFeet = 3.28084;
|
||||
static const double _metersToMiles = 0.000621371;
|
||||
|
||||
/// Format distance for display based on unit preference
|
||||
///
|
||||
/// For metric: uses meters for < 1000m, kilometers for >= 1000m
|
||||
/// For imperial: uses feet for < 5280ft (1 mile), miles for >= 5280ft
|
||||
static String formatDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
if (distanceInMeters < 1000) {
|
||||
return '${distanceInMeters.round()} m';
|
||||
} else {
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInFeet = distanceInMeters * _metersToFeet;
|
||||
if (distanceInFeet < 5280) {
|
||||
return '${distanceInFeet.round()} ft';
|
||||
} else {
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format large distances (like route distances) for display
|
||||
///
|
||||
/// Always uses the larger unit (km/miles) for routes
|
||||
static String formatRouteDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the unit suffix for small distances (used in form fields, etc.)
|
||||
static String getSmallDistanceUnit(DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return 'm';
|
||||
case DistanceUnit.imperial:
|
||||
return 'ft';
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert displayed distance value back to meters for API usage
|
||||
///
|
||||
/// This is for form fields where users enter values in their preferred units
|
||||
static double convertToMeters(double value, DistanceUnit unit, {bool isSmallDistance = true}) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return isSmallDistance ? value : value * 1000; // m or km to m
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
if (isSmallDistance) {
|
||||
return value / _metersToFeet; // ft to m
|
||||
} else {
|
||||
return value / _metersToMiles; // miles to m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert meters to the preferred small distance unit for form display
|
||||
static double convertFromMeters(double meters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return meters;
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
return meters * _metersToFeet;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../dev_config.dart';
|
||||
import 'version_service.dart';
|
||||
|
||||
/// An [http.BaseClient] that injects a User-Agent header into every request.
|
||||
///
|
||||
/// Reads the app name and version dynamically from [VersionService] so the UA
|
||||
/// string stays in sync with pubspec.yaml without hard-coding values.
|
||||
///
|
||||
/// Uses [putIfAbsent] so a manually-set User-Agent is never overwritten.
|
||||
class UserAgentClient extends http.BaseClient {
|
||||
final http.Client _inner;
|
||||
|
||||
UserAgentClient([http.Client? inner]) : _inner = inner ?? http.Client();
|
||||
|
||||
/// The User-Agent string sent with every request.
|
||||
///
|
||||
/// Format follows OSM tile usage policy recommendations:
|
||||
/// `AppName/version (+homepage; contact: email)`
|
||||
static String get userAgent {
|
||||
final vs = VersionService();
|
||||
return '${vs.appName}/${vs.version} (+$kHomepageUrl; contact: $kContactEmail)';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
request.headers.putIfAbsent('User-Agent', () => userAgent);
|
||||
return _inner.send(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() => _inner.close();
|
||||
}
|
||||
@@ -25,42 +25,32 @@ class LocalizationService extends ChangeNotifier {
|
||||
|
||||
Future<void> _discoverAvailableLanguages() async {
|
||||
_availableLanguages = [];
|
||||
|
||||
|
||||
try {
|
||||
// Get the asset manifest to find all localization files
|
||||
final manifestContent = await rootBundle.loadString('AssetManifest.json');
|
||||
final Map<String, dynamic> manifestMap = json.decode(manifestContent);
|
||||
|
||||
// Find all .json files in lib/localizations/
|
||||
final localizationFiles = manifestMap.keys
|
||||
.where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json'))
|
||||
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
||||
final localizationAssets = assetManifest.listAssets()
|
||||
.where((path) => path.startsWith('lib/localizations/') && path.endsWith('.json'))
|
||||
.toList();
|
||||
|
||||
for (final filePath in localizationFiles) {
|
||||
// Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt')
|
||||
final fileName = filePath.split('/').last;
|
||||
final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json'
|
||||
|
||||
|
||||
for (final assetPath in localizationAssets) {
|
||||
try {
|
||||
// Try to load and parse the file to ensure it's valid
|
||||
final jsonString = await rootBundle.loadString(filePath);
|
||||
final jsonString = await rootBundle.loadString(assetPath);
|
||||
final parsedJson = json.decode(jsonString);
|
||||
|
||||
// Basic validation - ensure it has the expected structure
|
||||
|
||||
if (parsedJson is Map && parsedJson.containsKey('language')) {
|
||||
final languageCode = assetPath.split('/').last.replaceAll('.json', '');
|
||||
_availableLanguages.add(languageCode);
|
||||
debugPrint('Found localization: $languageCode');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to load localization file $filePath: $e');
|
||||
debugPrint('Failed to load localization file $assetPath: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to read AssetManifest.json: $e');
|
||||
// If manifest reading fails, we'll have an empty list
|
||||
// The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage
|
||||
debugPrint('Failed to load asset manifest: $e');
|
||||
_availableLanguages = ['en'];
|
||||
}
|
||||
|
||||
|
||||
debugPrint('Available languages: $_availableLanguages');
|
||||
}
|
||||
|
||||
@@ -119,28 +109,31 @@ class LocalizationService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String t(String key, {List<String>? params}) {
|
||||
String t(String key, {List<String>? params}) =>
|
||||
lookup(_strings, key, params: params);
|
||||
|
||||
/// Pure lookup function used by [t] and available for testing.
|
||||
static String lookup(Map<String, dynamic> strings, String key,
|
||||
{List<String>? params}) {
|
||||
List<String> keys = key.split('.');
|
||||
dynamic current = _strings;
|
||||
|
||||
dynamic current = strings;
|
||||
|
||||
for (String k in keys) {
|
||||
if (current is Map && current.containsKey(k)) {
|
||||
current = current[k];
|
||||
} else {
|
||||
// Return the key as fallback for missing translations
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String result = current is String ? current : key;
|
||||
|
||||
// Replace parameters if provided - replace first occurrence only for each parameter
|
||||
|
||||
if (params != null) {
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
result = result.replaceFirst('{}', params[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/tiles_from_remote.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'network_status.dart';
|
||||
import 'prefetch_area_service.dart';
|
||||
import 'node_data_manager.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
|
||||
enum MapSource { local, remote, auto } // For future use
|
||||
|
||||
@@ -27,120 +23,45 @@ class MapDataProvider {
|
||||
factory MapDataProvider() => _instance;
|
||||
MapDataProvider._();
|
||||
|
||||
// REMOVED: AppState get _appState => AppState();
|
||||
final NodeDataManager _nodeDataManager = NodeDataManager();
|
||||
final UserAgentClient _httpClient = UserAgentClient();
|
||||
|
||||
bool get isOfflineMode => AppState.instance.offlineMode;
|
||||
void setOfflineMode(bool enabled) {
|
||||
AppState.instance.setOfflineMode(enabled);
|
||||
}
|
||||
|
||||
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||
/// Fetch surveillance nodes using the new simplified system.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
MapSource source = MapSource.auto,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
|
||||
// Explicit remote request: error if offline, else always remote
|
||||
if (source == MapSource.remote) {
|
||||
if (offline) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit local request: always use local
|
||||
if (source == MapSource.local) {
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
}
|
||||
|
||||
// AUTO: In offline mode, behavior depends on upload mode
|
||||
if (offline) {
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
|
||||
return <OsmNode>[];
|
||||
} else {
|
||||
// Offline + Production = use local cache
|
||||
return fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: 0, // No limit - get all available data
|
||||
);
|
||||
}
|
||||
} else if (uploadMode == UploadMode.sandbox) {
|
||||
// Sandbox mode: Only fetch from sandbox API, ignore local production nodes
|
||||
debugPrint('[MapDataProvider] Sandbox mode: fetching only from sandbox API, ignoring local cache');
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit - fetch all available data
|
||||
);
|
||||
} else {
|
||||
// Production mode: use pre-fetch service for efficient area loading
|
||||
final preFetchService = PrefetchAreaService();
|
||||
|
||||
// Always get local nodes first (fast, from cache)
|
||||
final localNodes = await fetchLocalNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxNodes: AppState.instance.maxNodes,
|
||||
);
|
||||
|
||||
// Check if we need to trigger a new pre-fetch (spatial or temporal)
|
||||
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
|
||||
preFetchService.isDataStale();
|
||||
|
||||
if (needsFetch) {
|
||||
// Outside area OR data stale - start pre-fetch with loading state
|
||||
debugPrint('[MapDataProvider] Starting pre-fetch with loading state');
|
||||
NetworkStatus.instance.setWaiting();
|
||||
preFetchService.requestPreFetchIfNeeded(
|
||||
viewBounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
} else {
|
||||
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
|
||||
}
|
||||
|
||||
// Return all local nodes without any rendering limit
|
||||
// Rendering limits are applied at the UI layer
|
||||
return localNodes;
|
||||
}
|
||||
return _nodeDataManager.getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: isUserInitiated,
|
||||
);
|
||||
}
|
||||
|
||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
||||
/// Only use for offline area download, not for map browsing! Ignores maxNodes config.
|
||||
/// Bulk node fetch for offline downloads using new system
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
int maxResults = 0, // 0 = no limit for offline downloads
|
||||
int maxTries = 3,
|
||||
}) async {
|
||||
final offline = AppState.instance.offlineMode;
|
||||
if (offline) {
|
||||
|
||||
if (AppState.instance.offlineMode) {
|
||||
throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode.");
|
||||
}
|
||||
return _fetchRemoteNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults, // Pass 0 for unlimited
|
||||
);
|
||||
|
||||
// For downloads, always fetch fresh data (don't use cache)
|
||||
return _nodeDataManager.fetchWithSplitting(bounds, profiles);
|
||||
}
|
||||
|
||||
/// Fetch tile image bytes. Default is to try local first, then remote if not offline. Honors explicit source.
|
||||
@@ -177,82 +98,68 @@ class MapDataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch remote tile using current provider from AppState
|
||||
/// Fetch remote tile using current provider from AppState.
|
||||
/// Only used by offline area downloader — the main tile pipeline now goes
|
||||
/// through NetworkTileProvider (see DeflockTileProvider).
|
||||
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
// We guarantee that a provider and tile type are always selected
|
||||
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
throw Exception('No tile provider selected - this should never happen');
|
||||
}
|
||||
|
||||
|
||||
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
|
||||
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
|
||||
final resp = await _httpClient.get(Uri.parse(tileUrl));
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
return resp.bodyBytes;
|
||||
}
|
||||
throw Exception('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
|
||||
}
|
||||
|
||||
/// Clear any queued tile requests (call when map view changes significantly)
|
||||
void clearTileQueue() {
|
||||
clearRemoteTileQueue();
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the current bounds
|
||||
void clearTileQueueSelective(LatLngBounds currentBounds) {
|
||||
clearRemoteTileQueueSelective(currentBounds);
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_nodeDataManager.addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
/// NodeCache compatibility - alias for addOrUpdateNodes
|
||||
void addOrUpdate(List<OsmNode> nodes) {
|
||||
addOrUpdateNodes(nodes);
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_nodeDataManager.removeNodeById(nodeId);
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change)
|
||||
void clearCache() {
|
||||
_nodeDataManager.clearCache();
|
||||
}
|
||||
|
||||
/// Force refresh current area (manual retry)
|
||||
Future<void> refreshArea({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// For sandbox mode, skip Overpass and go directly to OSM API
|
||||
// (Overpass doesn't have sandbox data)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[MapDataProvider] Sandbox mode detected, using OSM API directly');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
|
||||
// For production mode, try Overpass first, then fallback to OSM API
|
||||
try {
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
// If Overpass returns nodes, we're good
|
||||
if (nodes.isNotEmpty) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// If Overpass returns empty (could be no data or could be an issue),
|
||||
// try OSM API as well to be thorough
|
||||
debugPrint('[MapDataProvider] Overpass returned no nodes, trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[MapDataProvider] Overpass failed ($e), trying OSM API fallback');
|
||||
return fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
);
|
||||
}
|
||||
return _nodeDataManager.refreshArea(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods for upload queue
|
||||
/// These all delegate to the singleton cache to ensure consistency
|
||||
OsmNode? getNodeById(int nodeId) => NodeSpatialCache().getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => NodeSpatialCache().removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => NodeSpatialCache().removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => NodeSpatialCache().removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
NodeSpatialCache().findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area (prevents submission in uncovered areas)
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) => NodeSpatialCache().hasDataFor(bounds);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
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';
|
||||
@@ -8,7 +7,8 @@ import 'package:xml/xml.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../network_status.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).
|
||||
@@ -20,9 +20,6 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
try {
|
||||
final nodes = await _fetchFromOsmApi(
|
||||
bounds: bounds,
|
||||
@@ -31,27 +28,15 @@ Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
maxResults: maxResults,
|
||||
);
|
||||
|
||||
// Only report success at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
// Only report errors at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
final _client = UserAgentClient();
|
||||
|
||||
/// Internal method that performs the actual OSM API fetch.
|
||||
Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
required LatLngBounds bounds,
|
||||
@@ -75,30 +60,38 @@ Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await http.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
|
||||
throw e; // Re-throw to let caller handle
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/pending_upload.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../../dev_config.dart';
|
||||
import '../network_status.dart';
|
||||
import '../overpass_node_limit_exception.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||
/// If the query fails due to too many nodes, automatically splits the area and retries.
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
}) async {
|
||||
// Check if this is a user-initiated fetch (indicated by loading state)
|
||||
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
|
||||
|
||||
try {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: maxResults,
|
||||
splitDepth: 0,
|
||||
reportStatus: wasUserInitiated, // Only top level reports status
|
||||
);
|
||||
|
||||
// Only report success at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
}
|
||||
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
// Only report errors at the top level if this was user-initiated
|
||||
if (wasUserInitiated) {
|
||||
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
|
||||
NetworkStatus.instance.setTimeoutError();
|
||||
} else {
|
||||
NetworkStatus.instance.setNetworkError();
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Top-level operation failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method that handles splitting when node limit is exceeded.
|
||||
Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
required int maxResults,
|
||||
required int splitDepth,
|
||||
required bool reportStatus, // Only true for top level
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
|
||||
|
||||
try {
|
||||
return await _fetchSingleOverpassQuery(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
maxResults: maxResults,
|
||||
reportStatus: reportStatus,
|
||||
);
|
||||
} on OverpassRateLimitException catch (e) {
|
||||
// Rate limits should NOT be split - just fail with extended backoff
|
||||
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
|
||||
|
||||
// Wait longer for rate limits before giving up entirely
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return []; // Return empty rather than rethrowing - let caller handle error reporting
|
||||
} on OverpassNodeLimitException {
|
||||
// If we've hit max split depth, give up to avoid infinite recursion
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
|
||||
return []; // Return empty - let caller handle error reporting
|
||||
}
|
||||
|
||||
// Split the bounds into 4 quadrants and try each separately
|
||||
debugPrint('[fetchOverpassNodes] Splitting area into quadrants (depth: $splitDepth)');
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final List<OsmNode> allNodes = [];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
final nodes = await _fetchOverpassNodesWithSplitting(
|
||||
bounds: quadrant,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
|
||||
splitDepth: splitDepth + 1,
|
||||
reportStatus: false, // Sub-requests don't report status
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
}
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Collected ${allNodes.length} nodes from ${quadrants.length} quadrants');
|
||||
return allNodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a single Overpass query without splitting logic.
|
||||
Future<List<OsmNode>> _fetchSingleOverpassQuery({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required int maxResults,
|
||||
required bool reportStatus,
|
||||
}) async {
|
||||
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
// Build the Overpass query
|
||||
final query = _buildOverpassQuery(bounds, profiles, maxResults);
|
||||
|
||||
try {
|
||||
debugPrint('[fetchOverpassNodes] Querying Overpass for surveillance nodes...');
|
||||
debugPrint('[fetchOverpassNodes] Query:\n$query');
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(overpassEndpoint),
|
||||
body: {'data': query.trim()}
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = response.body;
|
||||
debugPrint('[fetchOverpassNodes] Overpass API error: $errorBody');
|
||||
|
||||
// Check if it's specifically the 50k node limit error (HTTP 400)
|
||||
// Exact message: "You requested too many nodes (limit is 50000)"
|
||||
if (errorBody.contains('too many nodes') &&
|
||||
errorBody.contains('50000')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected 50k node limit error, will attempt splitting');
|
||||
throw OverpassNodeLimitException('Query exceeded node limit', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for timeout errors that indicate query complexity (should split)
|
||||
// Common timeout messages from Overpass
|
||||
if (errorBody.contains('timeout') ||
|
||||
errorBody.contains('runtime limit exceeded') ||
|
||||
errorBody.contains('Query timed out')) {
|
||||
debugPrint('[fetchOverpassNodes] Detected timeout error, will attempt splitting to reduce complexity');
|
||||
throw OverpassNodeLimitException('Query timed out', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Check for rate limiting (should NOT split - needs longer backoff)
|
||||
if (errorBody.contains('rate limited') ||
|
||||
errorBody.contains('too many requests') ||
|
||||
response.statusCode == 429) {
|
||||
debugPrint('[fetchOverpassNodes] Rate limited by Overpass API - needs extended backoff');
|
||||
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
|
||||
}
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
throw Exception('Overpass API error: $errorBody');
|
||||
}
|
||||
|
||||
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
|
||||
final elements = data['elements'] as List<dynamic>;
|
||||
|
||||
if (elements.length > 20) {
|
||||
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
|
||||
}
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
|
||||
// Parse response to determine which nodes are constrained
|
||||
final nodes = _parseOverpassResponseWithConstraints(elements);
|
||||
|
||||
// Clean up any pending uploads that now appear in Overpass results
|
||||
_cleanupCompletedUploads(nodes);
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
// Re-throw OverpassNodeLimitException so splitting logic can catch it
|
||||
if (e is OverpassNodeLimitException) rethrow;
|
||||
|
||||
debugPrint('[fetchOverpassNodes] Exception: $e');
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
throw e; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an Overpass API query for surveillance nodes matching the given profiles within bounds.
|
||||
/// Also fetches ways and relations that reference these nodes to determine constraint status.
|
||||
String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int maxResults) {
|
||||
// Deduplicate profiles to reduce query complexity - broader profiles subsume more specific ones
|
||||
final deduplicatedProfiles = _deduplicateProfilesForQuery(profiles);
|
||||
|
||||
// Safety check: if deduplication removed all profiles (edge case), fall back to original list
|
||||
final profilesToQuery = deduplicatedProfiles.isNotEmpty ? deduplicatedProfiles : profiles;
|
||||
|
||||
if (deduplicatedProfiles.length < profiles.length) {
|
||||
debugPrint('[Overpass] Deduplicated ${profiles.length} profiles to ${deduplicatedProfiles.length} for query efficiency');
|
||||
}
|
||||
|
||||
// Build node clauses for deduplicated profiles only
|
||||
final nodeClauses = profilesToQuery.map((profile) {
|
||||
// Convert profile tags to Overpass filter format, excluding empty values
|
||||
final tagFilters = profile.tags.entries
|
||||
.where((entry) => entry.value.trim().isNotEmpty) // Skip empty values
|
||||
.map((entry) => '["${entry.key}"="${entry.value}"]')
|
||||
.join();
|
||||
|
||||
// Build the node query with tag filters and bounding box
|
||||
return 'node$tagFilters(${bounds.southWest.latitude},${bounds.southWest.longitude},${bounds.northEast.latitude},${bounds.northEast.longitude});';
|
||||
}).join('\n ');
|
||||
|
||||
return '''
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
$nodeClauses
|
||||
);
|
||||
out body ${maxResults > 0 ? maxResults : ''};
|
||||
(
|
||||
way(bn);
|
||||
rel(bn);
|
||||
);
|
||||
out meta;
|
||||
''';
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// This optimization reduces query complexity while returning the same nodes (since broader
|
||||
/// profiles capture all nodes that more specific profiles would).
|
||||
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 break queries
|
||||
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
|
||||
result.removeWhere((existing) => _profileSubsumes(candidate, existing));
|
||||
result.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if broaderProfile subsumes specificProfile.
|
||||
/// Returns true if all non-empty tags in broaderProfile exist in specificProfile with identical values.
|
||||
bool _profileSubsumes(NodeProfile broaderProfile, NodeProfile specificProfile) {
|
||||
// Get non-empty tags from both profiles
|
||||
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 (would match everything)
|
||||
if (broaderTags.isEmpty) return false;
|
||||
|
||||
// If broader has more non-empty tags than specific, it can't subsume
|
||||
if (broaderTags.length > specificTags.length) return false;
|
||||
|
||||
// Check if all broader tags exist in specific with same values
|
||||
for (final entry in broaderTags.entries) {
|
||||
if (specificTags[entry.key] != entry.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Split a LatLngBounds into 4 quadrants (NW, NE, SW, SE).
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest quadrant (bottom-left)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, bounds.west),
|
||||
LatLng(centerLat, centerLng),
|
||||
),
|
||||
// Southeast quadrant (bottom-right)
|
||||
LatLngBounds(
|
||||
LatLng(bounds.south, centerLng),
|
||||
LatLng(centerLat, bounds.east),
|
||||
),
|
||||
// Northwest quadrant (top-left)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, bounds.west),
|
||||
LatLng(bounds.north, centerLng),
|
||||
),
|
||||
// Northeast quadrant (top-right)
|
||||
LatLngBounds(
|
||||
LatLng(centerLat, centerLng),
|
||||
LatLng(bounds.north, bounds.east),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse Overpass response elements to create OsmNode objects with constraint information.
|
||||
List<OsmNode> _parseOverpassResponseWithConstraints(List<dynamic> elements) {
|
||||
final nodeElements = <Map<String, dynamic>>[];
|
||||
final constrainedNodeIds = <int>{};
|
||||
|
||||
// First pass: collect surveillance nodes and identify constrained nodes
|
||||
for (final element in elements.whereType<Map<String, dynamic>>()) {
|
||||
final type = element['type'] as String?;
|
||||
|
||||
if (type == 'node') {
|
||||
// This is a surveillance node - collect it
|
||||
nodeElements.add(element);
|
||||
} else if (type == 'way' || type == 'relation') {
|
||||
// This is a way/relation that references some of our nodes
|
||||
final refs = element['nodes'] as List<dynamic>? ??
|
||||
element['members']?.where((m) => m['type'] == 'node').map((m) => m['ref']) ?? [];
|
||||
|
||||
// Mark all referenced nodes as constrained
|
||||
for (final ref in refs) {
|
||||
if (ref is int) {
|
||||
constrainedNodeIds.add(ref);
|
||||
} else if (ref is String) {
|
||||
final nodeId = int.tryParse(ref);
|
||||
if (nodeId != null) constrainedNodeIds.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create OsmNode objects with constraint info
|
||||
final nodes = nodeElements.map((element) {
|
||||
final nodeId = element['id'] as int;
|
||||
final isConstrained = constrainedNodeIds.contains(nodeId);
|
||||
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: isConstrained,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final constrainedCount = nodes.where((n) => n.isConstrained).length;
|
||||
if (constrainedCount > 0) {
|
||||
debugPrint('[fetchOverpassNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
final appState = AppState.instance;
|
||||
final pendingUploads = appState.pendingUploads;
|
||||
|
||||
if (pendingUploads.isEmpty) return;
|
||||
|
||||
final overpassNodeIds = overpassNodes.map((n) => n.id).toSet();
|
||||
|
||||
// Find pending uploads whose submitted node IDs now appear in Overpass results
|
||||
final uploadsToRemove = <PendingUpload>[];
|
||||
|
||||
for (final upload in pendingUploads) {
|
||||
if (upload.submittedNodeId != null &&
|
||||
overpassNodeIds.contains(upload.submittedNodeId!)) {
|
||||
uploadsToRemove.add(upload);
|
||||
debugPrint('[OverpassCleanup] Found submitted node ${upload.submittedNodeId} in Overpass results, removing from pending queue');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the completed uploads from the queue
|
||||
for (final upload in uploadsToRemove) {
|
||||
appState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
if (uploadsToRemove.isNotEmpty) {
|
||||
debugPrint('[OverpassCleanup] Cleaned up ${uploadsToRemove.length} completed uploads');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[OverpassCleanup] Error during cleanup: $e');
|
||||
// Don't let cleanup errors break the main functionality
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import 'dart:io';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
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 current provider, or throw if not found.
|
||||
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
|
||||
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
|
||||
///
|
||||
/// When [providerId] and [tileTypeId] are supplied the lookup is pinned to
|
||||
/// those values (avoids a race when the user switches provider mid-flight).
|
||||
/// Otherwise falls back to the current AppState selection.
|
||||
Future<List<int>> fetchLocalTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
String? providerId,
|
||||
String? tileTypeId,
|
||||
}) async {
|
||||
final appState = AppState.instance;
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
|
||||
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -19,29 +32,58 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
|
||||
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 != currentProvider?.id || area.tileTypeId != currentTileType?.id) 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));
|
||||
}
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
|
||||
|
||||
// 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) {
|
||||
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
|
||||
throw Exception('Tile $z/$x/$y from provider $currentProviderId/$currentTileTypeId not found in any offline area');
|
||||
}
|
||||
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Global semaphore to limit simultaneous tile fetches
|
||||
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
|
||||
|
||||
/// Clear queued tile requests when map view changes significantly
|
||||
void clearRemoteTileQueue() {
|
||||
final clearedCount = _tileFetchSemaphore.clearQueue();
|
||||
// Only log if we actually cleared something significant
|
||||
if (clearedCount > 5) {
|
||||
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear only tile requests that are no longer visible in the given bounds
|
||||
void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
|
||||
final clearedCount = _tileFetchSemaphore.clearStaleRequests((z, x, y) {
|
||||
// Return true if tile should be cleared (i.e., is NOT visible)
|
||||
return !_isTileVisible(z, x, y, currentBounds);
|
||||
});
|
||||
|
||||
if (clearedCount > 0) {
|
||||
debugPrint('[RemoteTiles] Selectively cleared $clearedCount non-visible tile requests');
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate retry delay using configurable backoff strategy.
|
||||
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
|
||||
int _calculateRetryDelay(int attempt, Random random) {
|
||||
// Calculate exponential backoff
|
||||
final baseDelay = (kTileFetchInitialDelayMs *
|
||||
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
|
||||
|
||||
// Add random jitter to avoid thundering herd
|
||||
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
|
||||
|
||||
// Apply max delay cap
|
||||
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
|
||||
}
|
||||
|
||||
/// Convert tile coordinates to lat/lng bounds for spatial filtering
|
||||
class _TileBounds {
|
||||
final double north, south, east, west;
|
||||
_TileBounds({required this.north, required this.south, required this.east, required this.west});
|
||||
}
|
||||
|
||||
/// Calculate the lat/lng bounds for a given tile
|
||||
_TileBounds _tileToBounds(int z, int x, int y) {
|
||||
final n = pow(2, z);
|
||||
final lon1 = (x / n) * 360.0 - 180.0;
|
||||
final lon2 = ((x + 1) / n) * 360.0 - 180.0;
|
||||
final lat1 = _yToLatitude(y, z);
|
||||
final lat2 = _yToLatitude(y + 1, z);
|
||||
|
||||
return _TileBounds(
|
||||
north: max(lat1, lat2),
|
||||
south: min(lat1, lat2),
|
||||
east: max(lon1, lon2),
|
||||
west: min(lon1, lon2),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert tile Y coordinate to latitude
|
||||
double _yToLatitude(int y, int z) {
|
||||
final n = pow(2, z);
|
||||
final latRad = atan(_sinh(pi * (1 - 2 * y / n)));
|
||||
return latRad * 180.0 / pi;
|
||||
}
|
||||
|
||||
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
|
||||
double _sinh(double x) {
|
||||
return (exp(x) - exp(-x)) / 2;
|
||||
}
|
||||
|
||||
/// Check if a tile intersects with the current view bounds
|
||||
bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
|
||||
final tileBounds = _tileToBounds(z, x, y);
|
||||
|
||||
// Check if tile bounds intersect with view bounds
|
||||
return !(tileBounds.east < viewBounds.west ||
|
||||
tileBounds.west > viewBounds.east ||
|
||||
tileBounds.north < viewBounds.south ||
|
||||
tileBounds.south > viewBounds.north);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Fetches a tile from any remote provider with unlimited retries.
|
||||
/// Returns tile image bytes. Retries forever until success.
|
||||
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
|
||||
Future<List<int>> fetchRemoteTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
required String url,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
final random = Random();
|
||||
final hostInfo = Uri.parse(url).host; // For logging
|
||||
|
||||
while (true) {
|
||||
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
|
||||
try {
|
||||
// Only log on first attempt
|
||||
if (attempt == 0) {
|
||||
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
|
||||
}
|
||||
attempt++;
|
||||
final resp = await http.get(Uri.parse(url));
|
||||
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
// Success!
|
||||
if (attempt > 1) {
|
||||
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
|
||||
}
|
||||
return resp.bodyBytes;
|
||||
} else {
|
||||
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
|
||||
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
// Calculate delay and retry (no attempt limit - keep trying forever)
|
||||
final delay = _calculateRetryDelay(attempt, random);
|
||||
if (attempt == 1) {
|
||||
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
|
||||
} else if (attempt % 10 == 0) {
|
||||
// Log every 10th attempt to show we're still working
|
||||
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
|
||||
}
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
} finally {
|
||||
_tileFetchSemaphore.release(z: z, x: x, y: y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy function for backward compatibility
|
||||
@Deprecated('Use fetchRemoteTile instead')
|
||||
Future<List<int>> fetchOSMTile({
|
||||
required int z,
|
||||
required int x,
|
||||
required int y,
|
||||
}) async {
|
||||
return fetchRemoteTile(
|
||||
z: z,
|
||||
x: x,
|
||||
y: y,
|
||||
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
|
||||
);
|
||||
}
|
||||
|
||||
/// Enhanced tile request entry that tracks coordinates for spatial filtering
|
||||
class _TileRequest {
|
||||
final int z, x, y;
|
||||
final VoidCallback callback;
|
||||
|
||||
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
|
||||
}
|
||||
|
||||
/// Spatially-aware counting semaphore for tile requests with deduplication
|
||||
class _SimpleSemaphore {
|
||||
final int _max;
|
||||
int _current = 0;
|
||||
final List<_TileRequest> _queue = [];
|
||||
final Set<String> _inFlightTiles = {}; // Track in-flight requests for deduplication
|
||||
_SimpleSemaphore(this._max);
|
||||
|
||||
Future<void> acquire({int? z, int? x, int? y}) async {
|
||||
// Create tile key for deduplication
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
|
||||
// If this tile is already in flight, skip the request
|
||||
if (_inFlightTiles.contains(tileKey)) {
|
||||
debugPrint('[SimpleSemaphore] Skipping duplicate request for $tileKey');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to in-flight tracking
|
||||
_inFlightTiles.add(tileKey);
|
||||
|
||||
if (_current < _max) {
|
||||
_current++;
|
||||
return;
|
||||
} else {
|
||||
// Check queue size limit to prevent memory bloat
|
||||
if (_queue.length >= kTileFetchMaxQueueSize) {
|
||||
// Remove oldest request to make room
|
||||
final oldRequest = _queue.removeAt(0);
|
||||
final oldKey = '${oldRequest.z}/${oldRequest.x}/${oldRequest.y}';
|
||||
_inFlightTiles.remove(oldKey);
|
||||
debugPrint('[SimpleSemaphore] Queue full, dropped oldest request: $oldKey');
|
||||
}
|
||||
|
||||
final c = Completer<void>();
|
||||
final request = _TileRequest(
|
||||
z: z ?? -1,
|
||||
x: x ?? -1,
|
||||
y: y ?? -1,
|
||||
callback: () => c.complete(),
|
||||
);
|
||||
_queue.add(request);
|
||||
await c.future;
|
||||
}
|
||||
}
|
||||
|
||||
void release({int? z, int? x, int? y}) {
|
||||
// Remove from in-flight tracking
|
||||
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
|
||||
_inFlightTiles.remove(tileKey);
|
||||
|
||||
if (_queue.isNotEmpty) {
|
||||
final request = _queue.removeAt(0);
|
||||
request.callback();
|
||||
} else {
|
||||
_current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all queued requests (call when view changes significantly)
|
||||
int clearQueue() {
|
||||
final clearedCount = _queue.length;
|
||||
_queue.clear();
|
||||
_inFlightTiles.clear(); // Also clear deduplication tracking
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
/// Clear only tiles that don't pass the visibility filter
|
||||
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
|
||||
final initialCount = _queue.length;
|
||||
final initialInFlightCount = _inFlightTiles.length;
|
||||
|
||||
// Remove stale requests from queue
|
||||
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
|
||||
|
||||
// Remove stale tiles from in-flight tracking
|
||||
_inFlightTiles.removeWhere((tileKey) {
|
||||
final parts = tileKey.split('/');
|
||||
if (parts.length == 3) {
|
||||
final z = int.tryParse(parts[0]) ?? -1;
|
||||
final x = int.tryParse(parts[1]) ?? -1;
|
||||
final y = int.tryParse(parts[2]) ?? -1;
|
||||
return isStale(z, x, y);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
final queueClearedCount = initialCount - _queue.length;
|
||||
final inFlightClearedCount = initialInFlightCount - _inFlightTiles.length;
|
||||
|
||||
if (queueClearedCount > 0 || inFlightClearedCount > 0) {
|
||||
debugPrint('[SimpleSemaphore] Cleared $queueClearedCount stale queue + $inFlightClearedCount stale in-flight, kept ${_queue.length}');
|
||||
}
|
||||
|
||||
return queueClearedCount + inFlightClearedCount;
|
||||
}
|
||||
}
|
||||
@@ -1,225 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../app_state.dart';
|
||||
|
||||
enum NetworkIssueType { overpassApi }
|
||||
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
|
||||
|
||||
|
||||
/// Simple enum-based network status for surveillance data requests.
|
||||
/// Only tracks the latest user-initiated request - background requests are ignored.
|
||||
enum NetworkRequestStatus {
|
||||
idle, // No active requests
|
||||
loading, // Request in progress
|
||||
splitting, // Request being split due to limits/timeouts
|
||||
success, // Data loaded successfully
|
||||
timeout, // Request timed out
|
||||
rateLimited, // API rate limited
|
||||
noData, // No offline data available
|
||||
error, // Other network errors
|
||||
}
|
||||
|
||||
class NetworkStatus extends ChangeNotifier {
|
||||
static final NetworkStatus instance = NetworkStatus._();
|
||||
NetworkStatus._();
|
||||
|
||||
bool _overpassHasIssues = false;
|
||||
bool _isWaitingForData = false;
|
||||
bool _isTimedOut = false;
|
||||
bool _hasNoData = false;
|
||||
bool _hasSuccess = false;
|
||||
int _recentOfflineMisses = 0;
|
||||
Timer? _overpassRecoveryTimer;
|
||||
Timer? _waitingTimer;
|
||||
Timer? _noDataResetTimer;
|
||||
Timer? _successResetTimer;
|
||||
// Getters
|
||||
bool get hasAnyIssues => _overpassHasIssues;
|
||||
bool get overpassHasIssues => _overpassHasIssues;
|
||||
bool get isWaitingForData => _isWaitingForData;
|
||||
bool get isTimedOut => _isTimedOut;
|
||||
bool get hasNoData => _hasNoData;
|
||||
bool get hasSuccess => _hasSuccess;
|
||||
NetworkRequestStatus _status = NetworkRequestStatus.idle;
|
||||
Timer? _autoResetTimer;
|
||||
|
||||
NetworkStatusType get currentStatus {
|
||||
// Simple single-path status logic
|
||||
if (hasAnyIssues) return NetworkStatusType.issues;
|
||||
if (_isWaitingForData) return NetworkStatusType.waiting;
|
||||
if (_isTimedOut) return NetworkStatusType.timedOut;
|
||||
if (_hasNoData) return NetworkStatusType.noData;
|
||||
if (_hasSuccess) return NetworkStatusType.success;
|
||||
return NetworkStatusType.ready;
|
||||
}
|
||||
|
||||
NetworkIssueType? get currentIssueType {
|
||||
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Report Overpass API issues
|
||||
void reportOverpassIssue() {
|
||||
if (!_overpassHasIssues) {
|
||||
_overpassHasIssues = true;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues detected');
|
||||
/// Current network status
|
||||
NetworkRequestStatus get status => _status;
|
||||
|
||||
/// Set status and handle auto-reset timers
|
||||
void _setStatus(NetworkRequestStatus newStatus) {
|
||||
if (_status == newStatus) return;
|
||||
|
||||
_status = newStatus;
|
||||
_autoResetTimer?.cancel();
|
||||
|
||||
// Auto-reset certain statuses after a delay
|
||||
switch (newStatus) {
|
||||
case NetworkRequestStatus.success:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.timeout:
|
||||
case NetworkRequestStatus.error:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.noData:
|
||||
_autoResetTimer = Timer(const Duration(seconds: 3), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
case NetworkRequestStatus.rateLimited:
|
||||
_autoResetTimer = Timer(const Duration(minutes: 2), () {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// No auto-reset for idle, loading, splitting
|
||||
break;
|
||||
}
|
||||
|
||||
// Reset recovery timer
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_overpassRecoveryTimer = Timer(const Duration(minutes: 2), () {
|
||||
_overpassHasIssues = false;
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] Overpass API issues cleared');
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Report successful operations to potentially clear issues faster
|
||||
void reportOverpassSuccess() {
|
||||
if (_overpassHasIssues) {
|
||||
// Quietly clear - don't log routine success
|
||||
_overpassHasIssues = false;
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set waiting status (show when loading tiles/cameras)
|
||||
void setWaiting() {
|
||||
// Clear any previous timeout/no-data state when starting new wait
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_noDataResetTimer?.cancel();
|
||||
|
||||
if (!_isWaitingForData) {
|
||||
_isWaitingForData = true;
|
||||
notifyListeners();
|
||||
// Don't log routine waiting - only log if we stay waiting too long
|
||||
}
|
||||
|
||||
// Set timeout for genuine network issues (not 404s)
|
||||
_waitingTimer?.cancel();
|
||||
_waitingTimer = Timer(const Duration(seconds: 8), () {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
debugPrint('[NetworkStatus] Request timed out - likely network issues');
|
||||
notifyListeners();
|
||||
});
|
||||
/// Start loading surveillance data
|
||||
void setLoading() {
|
||||
debugPrint('[NetworkStatus] Loading surveillance data');
|
||||
_setStatus(NetworkRequestStatus.loading);
|
||||
}
|
||||
|
||||
/// Show success status briefly when data loads
|
||||
/// Request is being split due to complexity/limits
|
||||
void setSplitting() {
|
||||
debugPrint('[NetworkStatus] Splitting request due to complexity');
|
||||
_setStatus(NetworkRequestStatus.splitting);
|
||||
}
|
||||
|
||||
/// Data loaded successfully
|
||||
void setSuccess() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = true;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear success status after 2 seconds
|
||||
_successResetTimer?.cancel();
|
||||
_successResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasSuccess) {
|
||||
_hasSuccess = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show no-data status briefly when tiles aren't available
|
||||
void setNoData() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasSuccess = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
|
||||
// Auto-clear no-data status after 2 seconds
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (_hasNoData) {
|
||||
_hasNoData = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
|
||||
void clearWaiting() {
|
||||
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_recentOfflineMisses = 0;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
debugPrint('[NetworkStatus] Surveillance data loaded successfully');
|
||||
_setStatus(NetworkRequestStatus.success);
|
||||
}
|
||||
|
||||
/// Set timeout error state
|
||||
void setTimeoutError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = true;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
notifyListeners();
|
||||
/// Request timed out
|
||||
void setTimeout() {
|
||||
debugPrint('[NetworkStatus] Request timed out');
|
||||
|
||||
// Auto-clear timeout after 5 seconds
|
||||
Timer(const Duration(seconds: 5), () {
|
||||
if (_isTimedOut) {
|
||||
_isTimedOut = false;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
_setStatus(NetworkRequestStatus.timeout);
|
||||
}
|
||||
|
||||
/// Set network error state (rate limits, connection issues, etc.)
|
||||
void setNetworkError() {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = false;
|
||||
_hasSuccess = false;
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
|
||||
// Use existing issue reporting system
|
||||
reportOverpassIssue();
|
||||
/// Rate limited by API
|
||||
void setRateLimited() {
|
||||
debugPrint('[NetworkStatus] Rate limited by API');
|
||||
_setStatus(NetworkRequestStatus.rateLimited);
|
||||
}
|
||||
|
||||
/// No offline data available
|
||||
void setNoData() {
|
||||
debugPrint('[NetworkStatus] No offline data available');
|
||||
_setStatus(NetworkRequestStatus.noData);
|
||||
}
|
||||
|
||||
/// Network or other error
|
||||
void setError() {
|
||||
debugPrint('[NetworkStatus] Network error occurred');
|
||||
_setStatus(NetworkRequestStatus.error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Report that a tile was not available offline
|
||||
void reportOfflineMiss() {
|
||||
_recentOfflineMisses++;
|
||||
debugPrint('[NetworkStatus] Offline miss #$_recentOfflineMisses');
|
||||
|
||||
// If we get several misses in a short time, show "no data" status
|
||||
if (_recentOfflineMisses >= 3 && !_hasNoData) {
|
||||
_isWaitingForData = false;
|
||||
_isTimedOut = false;
|
||||
_hasNoData = true;
|
||||
_waitingTimer?.cancel();
|
||||
notifyListeners();
|
||||
debugPrint('[NetworkStatus] No offline data available for this area');
|
||||
}
|
||||
|
||||
// Reset the miss counter after some time
|
||||
_noDataResetTimer?.cancel();
|
||||
_noDataResetTimer = Timer(const Duration(seconds: 5), () {
|
||||
_recentOfflineMisses = 0;
|
||||
});
|
||||
/// Clear status (force to idle)
|
||||
void clear() {
|
||||
_setStatus(NetworkRequestStatus.idle);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overpassRecoveryTimer?.cancel();
|
||||
_waitingTimer?.cancel();
|
||||
_noDataResetTimer?.cancel();
|
||||
_successResetTimer?.cancel();
|
||||
_autoResetTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
@@ -85,7 +86,7 @@ class NodeCache {
|
||||
/// Remove a node by ID from the cache (used for successful deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
if (_nodes.remove(nodeId) != null) {
|
||||
print('[NodeCache] Removed node $nodeId from cache (successful deletion)');
|
||||
debugPrint('[NodeCache] Removed node $nodeId from cache (successful deletion)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +112,19 @@ class NodeCache {
|
||||
}
|
||||
|
||||
if (nodesToRemove.isNotEmpty) {
|
||||
print('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
debugPrint('[NodeCache] Removed ${nodesToRemove.length} temp nodes at coordinate ${coord.latitude}, ${coord.longitude}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID (for queue item-specific cleanup)
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
print('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
debugPrint('[NodeCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
print('[NodeCache] Removed specific temp node $tempNodeId from cache');
|
||||
debugPrint('[NodeCache] Removed specific temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'overpass_service.dart';
|
||||
import 'node_spatial_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import 'map_data_submodules/nodes_from_osm_api.dart';
|
||||
import 'map_data_submodules/nodes_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
|
||||
/// Coordinates node data fetching between cache, Overpass, and OSM API.
|
||||
/// Simple interface: give me nodes for this view with proper caching and error handling.
|
||||
class NodeDataManager extends ChangeNotifier {
|
||||
static final NodeDataManager _instance = NodeDataManager._();
|
||||
factory NodeDataManager() => _instance;
|
||||
NodeDataManager._();
|
||||
|
||||
final OverpassService _overpassService = OverpassService();
|
||||
final NodeSpatialCache _cache = NodeSpatialCache();
|
||||
|
||||
// Track ongoing user-initiated requests for status reporting
|
||||
final Set<String> _userInitiatedRequests = <String>{};
|
||||
|
||||
/// Get nodes for the given bounds and profiles.
|
||||
/// Returns cached data immediately if available, otherwise fetches from appropriate source.
|
||||
Future<List<OsmNode>> getNodesFor({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
// Handle offline mode - no loading states needed, data is instant
|
||||
if (AppState.instance.offlineMode) {
|
||||
// Clear any existing loading states since offline data is instant
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.clear();
|
||||
}
|
||||
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
// Offline + Sandbox = no nodes (local cache is production data)
|
||||
debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes');
|
||||
return [];
|
||||
} else {
|
||||
// Offline + Production = use local offline areas (instant)
|
||||
final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles);
|
||||
|
||||
// Add offline nodes to cache so they integrate with the rest of the system
|
||||
if (offlineNodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(offlineNodes);
|
||||
// Mark this area as having coverage for submit button logic
|
||||
_cache.markAreaAsFetched(bounds, offlineNodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Show brief success for user-initiated offline loads with data
|
||||
if (isUserInitiated && offlineNodes.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
} else if (isUserInitiated && offlineNodes.isEmpty) {
|
||||
// Show no data briefly for offline areas with no surveillance devices
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setNoData();
|
||||
});
|
||||
}
|
||||
|
||||
return offlineNodes;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI)
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API');
|
||||
|
||||
// Track user-initiated requests for status reporting
|
||||
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
|
||||
|
||||
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
|
||||
debugPrint('[NodeDataManager] Sandbox request already in progress for this area');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Start status tracking for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.add(requestKey);
|
||||
NetworkStatus.instance.setLoading();
|
||||
debugPrint('[NodeDataManager] Starting user-initiated sandbox request');
|
||||
} else {
|
||||
debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)');
|
||||
}
|
||||
|
||||
try {
|
||||
final nodes = await fetchOsmApiNodes(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0,
|
||||
);
|
||||
|
||||
// Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches)
|
||||
if (nodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
_cache.markAreaAsFetched(bounds, nodes);
|
||||
} else {
|
||||
// Mark area as fetched even with no nodes so UI knows we've checked this area
|
||||
_cache.markAreaAsFetched(bounds, []);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
notifyListeners();
|
||||
|
||||
// Set success after the next frame renders, but only for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Sandbox fetch failed: $e');
|
||||
|
||||
// Only report errors for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
if (e is RateLimitError) {
|
||||
NetworkStatus.instance.setRateLimited();
|
||||
} else if (e.toString().contains('timeout')) {
|
||||
NetworkStatus.instance.setTimeout();
|
||||
} else {
|
||||
NetworkStatus.instance.setError();
|
||||
}
|
||||
debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e');
|
||||
}
|
||||
|
||||
// Return whatever we have in cache for this area (likely empty for sandbox)
|
||||
return _cache.getNodesFor(bounds);
|
||||
} finally {
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.remove(requestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production mode: check cache first
|
||||
if (_cache.hasDataFor(bounds)) {
|
||||
debugPrint('[NodeDataManager] Using cached data for bounds');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Not cached - need to fetch
|
||||
final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode';
|
||||
|
||||
// Only allow one user-initiated request per area at a time
|
||||
if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) {
|
||||
debugPrint('[NodeDataManager] User request already in progress for this area');
|
||||
return _cache.getNodesFor(bounds);
|
||||
}
|
||||
|
||||
// Start status tracking for user-initiated requests only
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.add(requestKey);
|
||||
NetworkStatus.instance.setLoading();
|
||||
debugPrint('[NodeDataManager] Starting user-initiated request');
|
||||
} else {
|
||||
debugPrint('[NodeDataManager] Starting background request (no status reporting)');
|
||||
}
|
||||
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated);
|
||||
|
||||
// Update cache and notify listeners
|
||||
notifyListeners();
|
||||
|
||||
// Set success after the next frame renders, but only for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
NetworkStatus.instance.setSuccess();
|
||||
});
|
||||
debugPrint('[NodeDataManager] User-initiated request completed successfully');
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Fetch failed: $e');
|
||||
|
||||
// Only report errors for user-initiated requests
|
||||
if (isUserInitiated) {
|
||||
if (e is RateLimitError) {
|
||||
NetworkStatus.instance.setRateLimited();
|
||||
} else if (e.toString().contains('timeout')) {
|
||||
NetworkStatus.instance.setTimeout();
|
||||
} else {
|
||||
NetworkStatus.instance.setError();
|
||||
}
|
||||
debugPrint('[NodeDataManager] User-initiated request failed: $e');
|
||||
}
|
||||
|
||||
// Return whatever we have in cache for this area
|
||||
return _cache.getNodesFor(bounds);
|
||||
} finally {
|
||||
if (isUserInitiated) {
|
||||
_userInitiatedRequests.remove(requestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, {
|
||||
int splitDepth = 0,
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
const maxSplitDepth = 3; // 4^3 = 64 max sub-areas
|
||||
|
||||
try {
|
||||
// Expand bounds slightly to reduce edge effects
|
||||
final expandedBounds = _expandBounds(bounds, 1.2);
|
||||
|
||||
final nodes = await _overpassService.fetchNodes(
|
||||
bounds: expandedBounds,
|
||||
profiles: profiles,
|
||||
);
|
||||
|
||||
// Success - cache the data for the expanded area
|
||||
_cache.markAreaAsFetched(expandedBounds, nodes);
|
||||
return nodes;
|
||||
|
||||
} on NodeLimitError {
|
||||
// Hit the deterministic 50k node limit - split area if not too deep
|
||||
if (splitDepth >= maxSplitDepth) {
|
||||
debugPrint('[NodeDataManager] Max split depth reached, giving up');
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)');
|
||||
|
||||
// Only report splitting status for user-initiated requests
|
||||
if (isUserInitiated && splitDepth == 0) {
|
||||
NetworkStatus.instance.setSplitting();
|
||||
}
|
||||
|
||||
return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated);
|
||||
|
||||
} on RateLimitError {
|
||||
// Rate limited - back off without splitting the area. Splitting here
|
||||
// would multiply request volume against a server that just told us to
|
||||
// slow down. We still show the same "slow" status indicator so the
|
||||
// user gets feedback, but there is no recursive quadrant fetching.
|
||||
debugPrint('[NodeDataManager] Rate limited, backing off (no area splitting)');
|
||||
if (isUserInitiated) {
|
||||
NetworkStatus.instance.setSplitting();
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 30));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Fetch data by splitting area into quadrants
|
||||
Future<List<OsmNode>> _fetchSplitAreas(
|
||||
LatLngBounds bounds,
|
||||
List<NodeProfile> profiles,
|
||||
int splitDepth, {
|
||||
bool isUserInitiated = false,
|
||||
}) async {
|
||||
final quadrants = _splitBounds(bounds);
|
||||
final allNodes = <OsmNode>[];
|
||||
|
||||
for (final quadrant in quadrants) {
|
||||
try {
|
||||
final nodes = await fetchWithSplitting(
|
||||
quadrant,
|
||||
profiles,
|
||||
splitDepth: splitDepth,
|
||||
isUserInitiated: isUserInitiated,
|
||||
);
|
||||
allNodes.addAll(nodes);
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Quadrant fetch failed: $e');
|
||||
// Continue with other quadrants
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes');
|
||||
return allNodes;
|
||||
}
|
||||
|
||||
/// Split bounds into 4 quadrants
|
||||
List<LatLngBounds> _splitBounds(LatLngBounds bounds) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
return [
|
||||
// Southwest
|
||||
LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)),
|
||||
// Southeast
|
||||
LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)),
|
||||
// Northwest
|
||||
LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)),
|
||||
// Northeast
|
||||
LatLngBounds(LatLng(centerLat, centerLng), LatLng(bounds.north, bounds.east)),
|
||||
];
|
||||
}
|
||||
|
||||
/// Expand bounds by given factor around center point
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double factor) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * factor / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * factor / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan),
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add or update nodes in cache (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove node from cache (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
_cache.removeNodeById(nodeId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear cache (when profiles change significantly)
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force refresh for current view (manual retry)
|
||||
Future<void> refreshArea({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
}) async {
|
||||
// Clear any cached data for this area
|
||||
_cache.clear();
|
||||
|
||||
// Re-fetch as user-initiated request
|
||||
await getNodesFor(
|
||||
bounds: bounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
isUserInitiated: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// NodeCache compatibility methods
|
||||
OsmNode? getNodeById(int nodeId) => _cache.getNodeById(nodeId);
|
||||
void removePendingEditMarker(int nodeId) => _cache.removePendingEditMarker(nodeId);
|
||||
void removePendingDeletionMarker(int nodeId) => _cache.removePendingDeletionMarker(nodeId);
|
||||
void removeTempNodeById(int tempNodeId) => _cache.removeTempNodeById(tempNodeId);
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) =>
|
||||
_cache.findNodesWithinDistance(coord, distanceMeters, excludeNodeId: excludeNodeId);
|
||||
|
||||
/// Check if we have good cache coverage for the given area
|
||||
bool hasGoodCoverageFor(LatLngBounds bounds) {
|
||||
return _cache.hasDataFor(bounds);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// Load nodes from this offline area
|
||||
final nodes = await fetchLocalNodes(
|
||||
bounds: area.bounds,
|
||||
profiles: [], // Empty profiles = load all nodes
|
||||
);
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
_cache.addOrUpdateNodes(nodes);
|
||||
// Mark the offline area as having coverage so submit buttons work
|
||||
_cache.markAreaAsFetched(area.bounds, nodes);
|
||||
debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[NodeDataManager] Error preloading offline nodes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
String get cacheStats => _cache.stats.toString();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
|
||||
const Distance _distance = Distance();
|
||||
|
||||
/// Simple spatial cache that tracks which areas have been successfully fetched.
|
||||
/// No temporal expiration - data stays cached until app restart or explicit clear.
|
||||
class NodeSpatialCache {
|
||||
static final NodeSpatialCache _instance = NodeSpatialCache._();
|
||||
factory NodeSpatialCache() => _instance;
|
||||
NodeSpatialCache._();
|
||||
|
||||
final List<CachedArea> _fetchedAreas = [];
|
||||
final Map<int, OsmNode> _nodes = {}; // nodeId -> node
|
||||
|
||||
/// Check if we have cached data covering the given bounds
|
||||
bool hasDataFor(LatLngBounds bounds) {
|
||||
return _fetchedAreas.any((area) => area.bounds.containsBounds(bounds));
|
||||
}
|
||||
|
||||
/// Record that we successfully fetched data for this area
|
||||
void markAreaAsFetched(LatLngBounds bounds, List<OsmNode> nodes) {
|
||||
// Add the fetched area
|
||||
_fetchedAreas.add(CachedArea(bounds, DateTime.now()));
|
||||
|
||||
// Update nodes in cache
|
||||
for (final node in nodes) {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
|
||||
debugPrint('[NodeSpatialCache] Cached ${nodes.length} nodes for area ${bounds.south.toStringAsFixed(3)},${bounds.west.toStringAsFixed(3)} to ${bounds.north.toStringAsFixed(3)},${bounds.east.toStringAsFixed(3)}');
|
||||
debugPrint('[NodeSpatialCache] Total areas cached: ${_fetchedAreas.length}, total nodes: ${_nodes.length}');
|
||||
}
|
||||
|
||||
/// Get all cached nodes within the given bounds
|
||||
List<OsmNode> getNodesFor(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => bounds.contains(node.coord))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Add or update individual nodes (for upload queue integration)
|
||||
void addOrUpdateNodes(List<OsmNode> nodes) {
|
||||
for (final node in nodes) {
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
// Preserve any tags starting with underscore when updating existing nodes
|
||||
final mergedTags = Map<String, String>.from(node.tags);
|
||||
for (final entry in existing.tags.entries) {
|
||||
if (entry.key.startsWith('_')) {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
} else {
|
||||
_nodes[node.id] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a node by ID (for deletions)
|
||||
void removeNodeById(int nodeId) {
|
||||
if (_nodes.remove(nodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed node $nodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific node by ID (returns null if not found)
|
||||
OsmNode? getNodeById(int nodeId) {
|
||||
return _nodes[nodeId];
|
||||
}
|
||||
|
||||
/// Remove the _pending_edit marker from a specific node
|
||||
void removePendingEditMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_edit')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_edit');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the _pending_deletion marker from a specific node
|
||||
void removePendingDeletionMarker(int nodeId) {
|
||||
final node = _nodes[nodeId];
|
||||
if (node != null && node.tags.containsKey('_pending_deletion')) {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_deletion');
|
||||
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
isConstrained: node.isConstrained,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific temporary node by its ID
|
||||
void removeTempNodeById(int tempNodeId) {
|
||||
if (tempNodeId >= 0) {
|
||||
debugPrint('[NodeSpatialCache] Warning: Attempted to remove non-temp node ID $tempNodeId');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nodes.remove(tempNodeId) != null) {
|
||||
debugPrint('[NodeSpatialCache] Removed temp node $tempNodeId from cache');
|
||||
}
|
||||
}
|
||||
|
||||
/// Find nodes within distance of a coordinate (for proximity warnings)
|
||||
List<OsmNode> findNodesWithinDistance(LatLng coord, double distanceMeters, {int? excludeNodeId}) {
|
||||
final nearbyNodes = <OsmNode>[];
|
||||
|
||||
for (final node in _nodes.values) {
|
||||
// Skip the excluded node
|
||||
if (excludeNodeId != null && node.id == excludeNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip nodes marked for deletion
|
||||
if (node.tags.containsKey('_pending_deletion')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceInMeters = _distance.as(LengthUnit.Meter, coord, node.coord);
|
||||
if (distanceInMeters <= distanceMeters) {
|
||||
nearbyNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyNodes;
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
void clear() {
|
||||
_fetchedAreas.clear();
|
||||
_nodes.clear();
|
||||
debugPrint('[NodeSpatialCache] Cache cleared');
|
||||
}
|
||||
|
||||
/// Get cache statistics for debugging
|
||||
CacheStats get stats => CacheStats(
|
||||
areasCount: _fetchedAreas.length,
|
||||
nodesCount: _nodes.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// Represents an area that has been successfully fetched
|
||||
class CachedArea {
|
||||
final LatLngBounds bounds;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
CachedArea(this.bounds, this.fetchedAt);
|
||||
}
|
||||
|
||||
/// Cache statistics for debugging
|
||||
class CacheStats {
|
||||
final int areasCount;
|
||||
final int nodesCount;
|
||||
|
||||
CacheStats({required this.areasCount, required this.nodesCount});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheStats(areas: $areasCount, nodes: $nodesCount)';
|
||||
}
|
||||
|
||||
/// Extension to check if one bounds completely contains another
|
||||
extension LatLngBoundsExtension on LatLngBounds {
|
||||
bool containsBounds(LatLngBounds other) {
|
||||
return north >= other.north &&
|
||||
south <= other.south &&
|
||||
east >= other.east &&
|
||||
west <= other.west;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Service for fetching tag value suggestions from OpenStreetMap Name Suggestion Index
|
||||
class NSIService {
|
||||
@@ -11,7 +11,7 @@ class NSIService {
|
||||
factory NSIService() => _instance;
|
||||
NSIService._();
|
||||
|
||||
static const String _userAgent = 'DeFlock/2.1.0 (OSM surveillance mapping app)';
|
||||
final _client = UserAgentClient();
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
|
||||
// Cache to avoid repeated API calls
|
||||
@@ -55,10 +55,7 @@ class NSIService {
|
||||
'rp': '15', // Get top 15 most commonly used values
|
||||
});
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'User-Agent': _userAgent},
|
||||
).timeout(_timeout);
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('TagInfo API returned status ${response.statusCode}');
|
||||
@@ -84,8 +81,7 @@ class NSIService {
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to top 10 suggestions for UI performance
|
||||
if (suggestions.length >= 10) break;
|
||||
if (suggestions.length >= kNSIMaxSuggestions) break;
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'offline_areas/offline_area_models.dart';
|
||||
import 'offline_areas/offline_tile_utils.dart';
|
||||
import 'offline_areas/offline_area_downloader.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.dart';
|
||||
import 'package:deflockapp/dev_config.dart';
|
||||
|
||||
/// Service for managing download, storage, and retrieval of offline map areas and cameras.
|
||||
class OfflineAreaService {
|
||||
@@ -32,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();
|
||||
@@ -216,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,10 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../state/settings_state.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
/// Service for checking OSM user messages
|
||||
class OSMMessagesService {
|
||||
static const _messageCheckCacheDuration = Duration(minutes: 5);
|
||||
final _client = UserAgentClient();
|
||||
|
||||
DateTime? _lastCheck;
|
||||
int? _lastUnreadCount;
|
||||
@@ -38,7 +39,7 @@ class OSMMessagesService {
|
||||
|
||||
try {
|
||||
final apiHost = _getApiHost(uploadMode);
|
||||
final response = await http.get(
|
||||
final response = await _client.get(
|
||||
Uri.parse('$apiHost/api/0.6/user/details.json'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'dart:convert';
|
||||
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 '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Simple Overpass API client with retry and fallback logic.
|
||||
/// Single responsibility: Make requests, handle network errors, return data.
|
||||
class OverpassService {
|
||||
static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter';
|
||||
static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter';
|
||||
static const _policy = ResiliencePolicy(
|
||||
maxRetries: 3,
|
||||
httpTimeout: Duration(seconds: 45),
|
||||
);
|
||||
|
||||
final http.Client _client;
|
||||
/// Optional override endpoint. When null, uses [defaultEndpoint].
|
||||
final String? _endpointOverride;
|
||||
|
||||
OverpassService({http.Client? client, String? endpoint})
|
||||
: _client = client ?? UserAgentClient(),
|
||||
_endpointOverride = endpoint;
|
||||
|
||||
/// Resolve the primary endpoint: constructor override or default.
|
||||
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
|
||||
|
||||
/// Fetch surveillance nodes from Overpass API with retry and fallback.
|
||||
///
|
||||
/// Throws:
|
||||
/// - [NodeLimitError] when the query would exceed Overpass's hard 50k node
|
||||
/// limit. This is the *only* case where the caller should split the
|
||||
/// query area into smaller regions, since it's a deterministic function
|
||||
/// of how much data lives in the requested bounds.
|
||||
/// - [RateLimitError] when rate limited (HTTP 429 or equivalent). Callers
|
||||
/// should back off, not split/retry — splitting would only amplify load
|
||||
/// on a server that just told us to slow down.
|
||||
/// - [NetworkError] for timeouts and other retryable HTTP/network failures.
|
||||
/// Timeouts are usually a sign of server load or an expensive query, not
|
||||
/// "too much area" — retrying with a hail of smaller sub-requests makes
|
||||
/// the problem worse, so timeouts are treated like any other transient
|
||||
/// network failure (handled by the existing retry/backoff/fallback logic).
|
||||
Future<List<OsmNode>> fetchNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
ResiliencePolicy? policy,
|
||||
}) async {
|
||||
if (profiles.isEmpty) return [];
|
||||
|
||||
final query = _buildQuery(bounds, profiles);
|
||||
final endpoint = _primaryEndpoint;
|
||||
final canFallback = _endpointOverride == null;
|
||||
final effectivePolicy = policy ?? _policy;
|
||||
|
||||
return executeWithFallback<List<OsmNode>>(
|
||||
primaryUrl: endpoint,
|
||||
fallbackUrl: canFallback ? fallbackEndpoint : null,
|
||||
execute: (url) => _attemptFetch(url, query, effectivePolicy),
|
||||
classifyError: _classifyError,
|
||||
policy: effectivePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
|
||||
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
|
||||
debugPrint('[OverpassService] POST $endpoint');
|
||||
|
||||
try {
|
||||
final response = await _client.post(
|
||||
Uri.parse(endpoint),
|
||||
body: {'data': query},
|
||||
).timeout(policy.httpTimeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return _parseResponse(response.body);
|
||||
}
|
||||
|
||||
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 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();
|
||||
}).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}];
|
||||
(
|
||||
$allNodesClauses
|
||||
)->.allNodes;
|
||||
.allNodes out body;
|
||||
way(bn.allNodes); node(w)->.wayNodes;
|
||||
relation(bn.allNodes); node(r)->.relNodes;
|
||||
(.wayNodes;.relNodes;)->.resNodes;
|
||||
(
|
||||
$resNodesClauses
|
||||
);
|
||||
convert restriction ::id = id();
|
||||
out;
|
||||
''';
|
||||
}
|
||||
|
||||
|
||||
/// 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 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 == '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;
|
||||
return OsmNode(
|
||||
id: nodeId,
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
isConstrained: constrainedNodeIds.contains(nodeId),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
debugPrint('[OverpassService] Parsed ${nodes.length} nodes, ${constrainedNodeIds.length} constrained');
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Error thrown when a query would exceed Overpass's 50k node limit.
|
||||
/// The caller should split the query area into smaller regions to resolve this
|
||||
/// — it's a deterministic function of how much data lives within the bounds.
|
||||
class NodeLimitError extends Error {
|
||||
final String message;
|
||||
NodeLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'NodeLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown when rate limited - should not retry immediately
|
||||
class RateLimitError extends Error {
|
||||
final String message;
|
||||
RateLimitError(this.message);
|
||||
@override
|
||||
String toString() => 'RateLimitError: $message';
|
||||
}
|
||||
|
||||
/// Error thrown for network/HTTP issues (including timeouts) - retryable
|
||||
class NetworkError extends Error {
|
||||
final String message;
|
||||
NetworkError(this.message);
|
||||
@override
|
||||
String toString() => 'NetworkError: $message';
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../models/node_profile.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||
import 'node_cache.dart';
|
||||
import 'network_status.dart';
|
||||
import '../widgets/node_provider_with_cache.dart';
|
||||
|
||||
/// Manages pre-fetching larger areas to reduce Overpass API calls.
|
||||
/// Uses zoom level 10 areas and automatically splits if hitting node limits.
|
||||
class PrefetchAreaService {
|
||||
static final PrefetchAreaService _instance = PrefetchAreaService._();
|
||||
factory PrefetchAreaService() => _instance;
|
||||
PrefetchAreaService._();
|
||||
|
||||
// Current pre-fetched area and associated data
|
||||
LatLngBounds? _preFetchedArea;
|
||||
List<NodeProfile>? _preFetchedProfiles;
|
||||
UploadMode? _preFetchedUploadMode;
|
||||
DateTime? _lastFetchTime;
|
||||
bool _preFetchInProgress = false;
|
||||
|
||||
// Debounce timer to avoid rapid requests while user is panning
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Configuration from dev_config
|
||||
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
|
||||
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
|
||||
|
||||
/// Check if the given bounds are fully within the current pre-fetched area.
|
||||
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
|
||||
if (_preFetchedArea == null || _preFetchedProfiles == null || _preFetchedUploadMode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if profiles and upload mode match
|
||||
if (_preFetchedUploadMode != uploadMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_profileListsEqual(_preFetchedProfiles!, profiles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if bounds are fully contained within pre-fetched area
|
||||
return bounds.north <= _preFetchedArea!.north &&
|
||||
bounds.south >= _preFetchedArea!.south &&
|
||||
bounds.east <= _preFetchedArea!.east &&
|
||||
bounds.west >= _preFetchedArea!.west;
|
||||
}
|
||||
|
||||
/// Check if cached data is stale (older than configured refresh interval).
|
||||
bool isDataStale() {
|
||||
if (_lastFetchTime == null) return true;
|
||||
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
|
||||
}
|
||||
|
||||
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
|
||||
/// Uses debouncing to avoid rapid requests while user is panning.
|
||||
void requestPreFetchIfNeeded({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) {
|
||||
// Skip if already in progress
|
||||
if (_preFetchInProgress) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check both spatial and temporal conditions
|
||||
final isWithinArea = isWithinPreFetchedArea(viewBounds, profiles, uploadMode);
|
||||
final isStale = isDataStale();
|
||||
|
||||
if (isWithinArea && !isStale) {
|
||||
debugPrint('[PrefetchAreaService] Current view within fresh pre-fetched area, no fetch needed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
|
||||
} else {
|
||||
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
|
||||
}
|
||||
|
||||
// Cancel any pending debounced request
|
||||
_debounceTimer?.cancel();
|
||||
|
||||
// Debounce to avoid rapid requests while user is still moving
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 800), () {
|
||||
_startPreFetch(
|
||||
viewBounds: viewBounds,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the actual pre-fetch operation.
|
||||
Future<void> _startPreFetch({
|
||||
required LatLngBounds viewBounds,
|
||||
required List<NodeProfile> profiles,
|
||||
required UploadMode uploadMode,
|
||||
}) async {
|
||||
if (_preFetchInProgress) return;
|
||||
|
||||
_preFetchInProgress = true;
|
||||
|
||||
try {
|
||||
// Calculate expanded area for pre-fetching
|
||||
final preFetchArea = _expandBounds(viewBounds, _areaExpansionMultiplier);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Starting pre-fetch for area: ${preFetchArea.south},${preFetchArea.west} to ${preFetchArea.north},${preFetchArea.east}');
|
||||
|
||||
// Fetch nodes for the expanded area (unlimited - let splitting handle 50k limit)
|
||||
final nodes = await fetchOverpassNodes(
|
||||
bounds: preFetchArea,
|
||||
profiles: profiles,
|
||||
uploadMode: uploadMode,
|
||||
maxResults: 0, // Unlimited - our splitting system handles the 50k limit gracefully
|
||||
);
|
||||
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch completed: ${nodes.length} nodes retrieved');
|
||||
|
||||
// Update cache with new nodes (fresh data overwrites stale, but preserves underscore tags)
|
||||
if (nodes.isNotEmpty) {
|
||||
NodeCache.instance.addOrUpdate(nodes);
|
||||
}
|
||||
|
||||
// Store the pre-fetched area info and timestamp
|
||||
_preFetchedArea = preFetchArea;
|
||||
_preFetchedProfiles = List.from(profiles);
|
||||
_preFetchedUploadMode = uploadMode;
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
// The overpass module already reported success/failure during fetching
|
||||
// We just need to handle the successful result here
|
||||
|
||||
// Notify UI that cache has been updated with fresh data
|
||||
NodeProviderWithCache.instance.refreshDisplay();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
|
||||
// The overpass module already reported the error status
|
||||
// Don't update pre-fetched area info on failure
|
||||
} finally {
|
||||
_preFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand bounds by the given multiplier, maintaining center point.
|
||||
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
|
||||
final centerLat = (bounds.north + bounds.south) / 2;
|
||||
final centerLng = (bounds.east + bounds.west) / 2;
|
||||
|
||||
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
|
||||
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
|
||||
|
||||
return LatLngBounds(
|
||||
LatLng(centerLat - latSpan, centerLng - lngSpan), // Southwest
|
||||
LatLng(centerLat + latSpan, centerLng + lngSpan), // Northeast
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if two profile lists are equal by comparing IDs.
|
||||
bool _profileListsEqual(List<NodeProfile> list1, List<NodeProfile> list2) {
|
||||
if (list1.length != list2.length) return false;
|
||||
final ids1 = list1.map((p) => p.id).toSet();
|
||||
final ids2 = list2.map((p) => p.id).toSet();
|
||||
return ids1.length == ids2.length && ids1.containsAll(ids2);
|
||||
}
|
||||
|
||||
/// Clear the pre-fetched area (e.g., when profiles change significantly).
|
||||
void clearPreFetchedArea() {
|
||||
_preFetchedArea = null;
|
||||
_preFetchedProfiles = null;
|
||||
_preFetchedUploadMode = null;
|
||||
_lastFetchTime = null;
|
||||
debugPrint('[PrefetchAreaService] Pre-fetched area cleared');
|
||||
}
|
||||
|
||||
/// Dispose of resources.
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:convert';
|
||||
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)
|
||||
static const int maxBase64Length = 70000;
|
||||
|
||||
/// Parse and validate a profile from a base64-encoded JSON string
|
||||
/// Returns null if parsing/validation fails
|
||||
static NodeProfile? parseProfileFromBase64(String base64Data) {
|
||||
try {
|
||||
// Basic size validation before expensive decode
|
||||
if (base64Data.length > maxBase64Length) {
|
||||
debugPrint('[ProfileImportService] 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('[ProfileImportService] Failed to parse profile from base64: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate profile structure and sanitize all string values
|
||||
static NodeProfile? _validateAndSanitizeProfile(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Extract and sanitize required fields
|
||||
final name = _sanitizeString(data['name']);
|
||||
if (name == null || name.isEmpty) {
|
||||
debugPrint('[ProfileImportService] Profile name is required');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract and sanitize tags
|
||||
final tagsData = data['tags'];
|
||||
if (tagsData is! Map<String, dynamic>) {
|
||||
debugPrint('[ProfileImportService] 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('[ProfileImportService] Profile must have at least one valid tag');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract optional fields with defaults
|
||||
final requiresDirection = data['requiresDirection'] ?? true;
|
||||
final submittable = data['submittable'] ?? true;
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NodeProfile(
|
||||
id: const Uuid().v4(), // Always generate new ID for imported profiles
|
||||
name: name,
|
||||
tags: sanitizedTags,
|
||||
builtin: false, // Imported profiles are always custom
|
||||
requiresDirection: requiresDirection is bool ? requiresDirection : true,
|
||||
submittable: submittable is bool ? submittable : true,
|
||||
editable: true, // Imported profiles are always editable
|
||||
fov: fov,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[ProfileImportService] Failed to validate 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user