Compare commits

..

109 Commits

Author SHA1 Message Date
stopflock
1ac43b0c4e Only show appropriate external editors on each platform, redirect to appstore on error 2025-11-19 19:50:39 -06:00
stopflock
3174e0bfe1 Adjust gesture thresholds 2025-11-19 16:24:51 -06:00
stopflock
5404daa704 Gesture race! 2025-11-19 14:24:51 -06:00
stopflock
20870623f0 Compass adjust for search box 2025-11-19 13:36:04 -06:00
stopflock
8ed92dcd7e Home screen respect safe areas in all orientations 2025-11-19 13:32:40 -06:00
stopflock
0143c74415 Reasonable size limits for tag text boxes in sheets 2025-11-18 16:21:31 -06:00
stopflock
6c53d988de Further improve tag views, implement upload queue pause toggle 2025-11-17 13:37:48 -06:00
stopflock
26cebcc60e Localizations for new features 2025-11-16 21:26:35 -06:00
stopflock
7c2b9ea087 Configurable max height for node tags box, localizations for new UX strings 2025-11-16 18:16:50 -06:00
stopflock
b2645f1341 Limit tag list size, make changelog use a list instead of \n, make links clickable in node tags 2025-11-16 17:30:24 -06:00
stopflock
05eedbb910 Link to OSM in node_details sheet. Add option to open node in other editors. 2025-11-16 16:45:54 -06:00
stopflock
3ea6d6b2ff Add TODOs learned from discord discussion 2025-11-16 15:33:25 -06:00
stopflock
326b7ec523 Fix restriction on moving provisional edit nodes which are part of a way (pinch/fling) 2025-11-16 10:27:18 -06:00
stopflock
192c6e5158 Disallow editing location of nodes attached to ways/relations 2025-11-16 00:17:53 -06:00
stopflock
ac53f7f74e Reorder builtin profiles 2025-11-16 00:11:42 -06:00
stopflock
5b9810b9de Add Rekor, Axon profiles 2025-11-15 20:37:05 -06:00
stopflock
49e9c673b1 Bottom offsets for android 2025-11-15 15:41:07 -06:00
stopflock
fb8260d346 Add feature flag to disable edits temporarily during bugfix 2025-11-15 14:39:27 -06:00
stopflock
fee557330d Update actions workflow, disable dev mode 2025-11-15 13:23:37 -06:00
stopflock
4c0e3b822c De-vibe changelog 2025-11-14 12:34:07 -06:00
stopflock
181852766a update TODOs 2025-11-14 11:46:16 -06:00
stopflock
f108929dce Always show add/cycle/delete direction buttons 2025-11-13 20:17:29 -06:00
stopflock
2cf840e74d Improvements to suspected locations 2025-11-13 13:22:46 -06:00
stopflock
3810dfa8d2 configurable button width, always enable network status indicator, new version migration logic available through changelog_service 2025-11-12 15:53:14 -06:00
stopflock
d57b2f64b1 Bump version 2025-11-09 14:32:46 -06:00
stopflock
e45f10e496 Make more room for direction slider next to add/remove/cycle buttons 2025-11-09 14:32:05 -06:00
stopflock
4ae0737016 Fix upload queue view for multi-direction submissions 2025-11-09 14:31:58 -06:00
stopflock
ae93cff719 Same as prev - forgot dev_config 2025-11-09 13:59:05 -06:00
stopflock
abdd494727 Give up on configurable tap+drag zoom. Breaks double tap zoom. 2025-11-09 13:59:05 -06:00
stopflock
4ccf3cace3 Wrap a few things trying to prevent UI / main thread hang we saw one time 2025-11-09 13:59:05 -06:00
stopflock
ca049033e4 Merge pull request #23 from Pugsrgreat/main
Added App Store + Google Play embeds to readme
2025-11-09 09:20:09 -06:00
Pugsrgreat
5cf8bb7725 Revise app store links and badges in README
Updated app store links and images in README.
2025-11-09 09:55:08 -05:00
Pugsrgreat
e5ff4ac233 Update README.md 2025-11-09 09:51:37 -05:00
Pugsrgreat
4040429865 Add files via upload 2025-11-09 09:37:53 -05:00
Pugsrgreat
90b7783aaf Add app image link to README
Added an image link to the README for the app.
2025-11-09 09:22:34 -05:00
stopflock
65cc6747bf bump version 2025-11-07 15:45:16 -06:00
stopflock
5bd450eb14 Fix setting integers in settings on iOS 2025-11-07 15:45:09 -06:00
stopflock
b0a4128bb7 Configurable zoom behaviors, desensitize double tap + drag 2025-11-07 14:29:08 -06:00
stopflock
4cdbb9f404 iOS location message accuracy 2025-11-07 14:26:28 -06:00
Pugsrgreat
8d05406ef5 Add Google Play Store link to README
Added information about Google Play Store availability.
2025-11-05 19:47:58 -05:00
stopflock
7842848152 welcome note about firsthand knowledge 2025-11-02 16:02:16 -06:00
stopflock
07ced1bc11 add to changelog 2025-11-02 15:39:12 -06:00
stopflock
335eb33613 disable dev mode, temporarily pull 811 from stopflock.com 2025-11-02 15:37:35 -06:00
stopflock
c9a7045212 Accept cardinal type directions in osm data 2025-10-29 12:53:56 -05:00
stopflock
e861d00b68 update docs 2025-10-29 12:35:28 -05:00
stopflock
d9f415c527 Multiple cameras on one pole 2025-10-29 12:17:16 -05:00
stopflock
1993714752 Show all fields from suspected locations CSV in details sheet 2025-10-28 20:45:51 -05:00
stopflock
b80e1094af deletions no longer using stub profile, more handling of builtin profiles by lists. 2025-10-24 18:28:39 -05:00
stopflock
0db4c0f80d all profiles as lists, better handling, stop using a fallback profile for broken submissions without one 2025-10-24 17:31:04 -05:00
stopflock
f1f145a35f Operator profiles as list. Add simon property group. 2025-10-24 17:11:43 -05:00
stopflock
618d31d016 immediately enable suspected locations when commanded 2025-10-24 16:52:14 -05:00
stopflock
c8ae925dc1 Add new builtin profiles, better handline of initialization, bump version 2025-10-24 16:23:09 -05:00
stopflock
2a7004e5a2 require profile selection 2025-10-24 13:49:48 -05:00
stopflock
c7f4164f12 update changelog 2025-10-22 18:47:56 -05:00
stopflock
27c404687a Fix north-up UX 2025-10-22 18:46:48 -05:00
stopflock
c8e2727f69 bump ver, get rid of buggy north-lock 2025-10-22 17:36:34 -05:00
stopflock
728ec08ab0 bump ver 2025-10-22 15:53:34 -05:00
stopflock
23a056bfe5 Validate localizations on build 2025-10-22 15:52:50 -05:00
stopflock
76d0ece314 always show welcome checkbox to not show again 2025-10-22 15:51:25 -05:00
stopflock
cd2ab00042 North-up compass and rotation lock 2025-10-22 15:27:28 -05:00
stopflock
ee3906df80 Update dev notes and changelog 2025-10-22 12:09:00 -05:00
stopflock
ca68bd6059 fix network indicator, simplify overpass fetching 2025-10-22 11:56:01 -05:00
stopflock
aea4ac1102 update readme 2025-10-21 15:51:59 -05:00
stopflock
62cf70e36e fix swiping away add/edit sheets 2025-10-21 15:33:51 -05:00
stopflock
de0bd7f275 too much, sorry 2025-10-21 15:11:50 -05:00
stopflock
2ccd01c691 Pre-fetch larger areas to reduce calls to overpass; split large requests up 2025-10-21 10:22:04 -05:00
stopflock
d696e1dfb6 Add welcome popup, changelog system, and account deletion button 2025-10-20 19:27:55 -05:00
stopflock
07fe869eec Update readme/dev notes 2025-10-16 21:47:58 -05:00
stopflock
5176c62e72 stupid if true 2025-10-16 16:07:02 -05:00
stopflock
3f35c2d6a1 Bump ver 2025-10-16 15:56:00 -05:00
stopflock
60b826d00e Explain why we need location permission on iOS 2025-10-16 15:52:52 -05:00
stopflock
ca63aa95e3 Debug cleanup 2025-10-16 15:25:13 -05:00
stopflock
fe0f298c0e debug ASC API key 2025-10-16 12:35:30 -05:00
stopflock
0ac158eb4a Send to apple on successful tag build 2025-10-16 12:21:12 -05:00
stopflock
7eb680c677 Fin 2025-10-16 11:19:21 -05:00
stopflock
a30dace404 Build worked - now provisioning profile for .ipa packaging 2025-10-16 11:11:40 -05:00
stopflock
50d2c6cbf6 Brain wipe - take 2 2025-10-16 10:55:19 -05:00
stopflock
925804e546 <Sad Mac> Steve is dead :( 2025-10-16 10:18:48 -05:00
stopflock
4076d9657a Actually use keychain we create 2025-10-16 09:54:42 -05:00
stopflock
789930049a keychain hell 2025-10-16 09:48:32 -05:00
stopflock
09019915e7 Allow manual GH actions 2025-10-16 00:17:17 -05:00
stopflock
16e1927ff1 add debug output for ios builds 2025-10-16 00:05:22 -05:00
stopflock
02e43f78c3 Now trying to follow an actual guide 2025-10-15 23:39:51 -05:00
stopflock
8a109029ca Please lord apple codesigning 2025-10-15 23:20:38 -05:00
stopflock
cd5315b919 Specify team ID and provisioning profile for signing apple builds 2025-10-15 22:56:47 -05:00
stopflock
03f3419f72 Code signing for apple 2025-10-15 22:26:02 -05:00
stopflock
7ace123b4b auto-refresh suspected csv, put url in dev_config 2025-10-14 22:15:52 -05:00
stopflock
08f017fb0f TODOs 3 2025-10-13 17:04:03 -05:00
stopflock
7a199a3258 TODOs 2 2025-10-13 15:03:02 -05:00
stopflock
8c999c04cd TODOs 2025-10-13 14:55:55 -05:00
stopflock
dc8dc9f11b visual tweaking 2025-10-09 19:31:32 -05:00
stopflock
93f0d9edae Proper fix for building locally and through GH actions 2025-10-09 19:29:05 -05:00
stopflock
793e735452 Bump version, actually account for HiDiPi displays. Previous commit does not build. 2025-10-09 12:58:19 -05:00
stopflock
6a2c1230d2 This it the right way to do client IDs / secrets and local builds 2025-10-09 11:15:21 -05:00
stopflock
b8834cd256 Account for HiDiPi displays 2025-10-09 10:42:07 -05:00
stopflock
b8b9d4b797 deflock-imize node icons fully 2025-10-09 00:02:21 -05:00
stopflock
4b1111a0a3 node colors 2025-10-08 22:40:12 -05:00
stopflock
f80f125599 macos debug builds w/o signing 2025-10-08 22:39:56 -05:00
stopflock
afa0ff94b2 Remove keys.dart, update workflow 2025-10-08 10:43:48 -05:00
stopflock
02f3cb0077 center help links on about page, bump version 2025-10-07 23:00:59 -05:00
stopflock
c671f29930 OSM client IDs/keys as repo secrets, more links on about/info screen. 2025-10-07 16:33:33 -05:00
stopflock
68068214bb deflock links on about screen 2025-10-07 15:15:09 -05:00
stopflock
b00db130d7 Suspected location localizations, credit alprwatch 2025-10-07 14:03:11 -05:00
stopflock
5c28057fa1 All nodes stop rendering below zoom 10, more cameras -> nodes 2025-10-07 10:02:57 -05:00
stopflock
106277faf4 Suspected nodes respect min zoom level, max number rendered 2025-10-06 22:51:30 -05:00
stopflock
f9351ba272 Suspected location exclusion zone, drawn under real nodes, and update progress bar 2025-10-06 22:21:05 -05:00
stopflock
4a44ab96d6 Sorta working suspected locations 2025-10-06 21:07:08 -05:00
stopflock
904af42cbf Fix asset filenames version field 2025-10-06 19:43:43 -05:00
stopflock
cc0386ee97 UX and bones of suspected locations 2025-10-06 19:36:54 -05:00
91 changed files with 6769 additions and 1073 deletions

View File

@@ -1,32 +1,40 @@
name: Build Release
name: Build and Release
on:
push:
tags:
- '*'
release:
types: [published]
permissions:
contents: write
jobs:
get-version:
name: Get Version
name: Get Version and Release Info
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
is_prerelease: ${{ steps.set-info.outputs.is_prerelease }}
should_upload_to_stores: ${{ steps.set-info.outputs.should_upload_to_stores }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Get version from lib/dev_config.dart
- name: Get version from pubspec.yaml
id: set-version
run: |
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') >> $GITHUB_OUTPUT
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) >> $GITHUB_OUTPUT
# - name: Extract version from pubspec.yaml
# id: extract_version
# run: |
# version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
# echo "VERSION=$version" >> $GITHUB_ENV
- name: Determine release actions
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"
else
echo "should_upload_to_stores=true" >> $GITHUB_OUTPUT
echo "✅ Full release - will build, attach assets, and upload to stores"
fi
build-android-apk:
name: Build Android APK
@@ -50,6 +58,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
@@ -67,7 +78,7 @@ jobs:
echo "storeFile=keystore.jks" >> android/key.properties
- name: Build Android .apk
run: flutter build apk --release
run: flutter build apk --release --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
- name: Upload .apk artifact
uses: actions/upload-artifact@v4
@@ -99,6 +110,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
@@ -116,7 +130,7 @@ jobs:
echo "storeFile=keystore.jks" >> android/key.properties
- name: Build Android appBundle
run: flutter build appbundle
run: flutter build appbundle --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
- name: Upload .aab artifact
uses: actions/upload-artifact@v4
@@ -141,18 +155,96 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate localizations
run: dart run scripts/validate_localizations.dart
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
# - name: Build iOS .ipa
# run: flutter build ipa --release
- name: Build iOS .app
- name: Install Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
P12_PASSWORD: ""
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
run: |
flutter build ios --release --no-codesign
./app2ipa.sh build/ios/iphoneos/Runner.app
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# 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
# 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
# Debug: Check what we actually have
echo "=== Certificates in keychain ==="
security find-identity -v -p codesigning $KEYCHAIN_PATH
echo "=== Provisioning profiles ==="
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
echo "=== Profile UUID extracted: $UUID ==="
- name: Create export options
run: |
cat > ios/exportOptions.plist << EOF
<?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>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>7XG8T28436</string>
<key>provisioningProfiles</key>
<dict>
<key>me.deflock.deflockapp</key>
<string>61f9fdb9-bf2d-4d94-b249-63155ee71e74</string>
</dict>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
EOF
- name: Build iOS .ipa
run: |
flutter build ipa --release \
--export-options-plist=ios/exportOptions.plist \
--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' \
--dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
cp build/ios/ipa/*.ipa Runner.ipa
- name: Clean up keychain and provisioning profile
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
rm ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
@@ -194,3 +286,54 @@ jobs:
deflock_v${{ needs.get-version.outputs.version }}.apk
deflock_v${{ needs.get-version.outputs.version }}.aab
deflock_v${{ needs.get-version.outputs.version }}.ipa
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
if: needs.get-version.outputs.should_upload_to_stores == 'true'
steps:
- name: Download AAB artifact for Google Play
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.aab
- name: Download IPA artifact for App Store
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
# Temporarily disabled - uncomment when Google Play service account is ready
# - name: Upload to Google Play Store
# uses: r0adkll/upload-google-play@v1
# with:
# serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
# packageName: me.deflock.deflockapp
# releaseFiles: app-release.aab
# track: internal # Uploads to Internal Testing track for review before production
# status: completed
# inAppUpdatePriority: 0
- name: Upload to App Store Connect
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
# Create the private keys directory and decode API key
mkdir -p ~/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
# Upload to App Store Connect / TestFlight
xcrun altool --upload-app \
--type ios \
--file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
# Clean up sensitive files
rm -rf ~/private_keys
- name: Clean up artifacts
run: |
rm -f app-release.aab Runner.ipa

3
.gitignore vendored
View File

@@ -93,6 +93,9 @@ Thumbs.db
*.keystore
.env
# Local OSM client ID configuration (contains secrets)
build_keys.conf
# ───────────────────────────────
# For now - not targeting these
# ───────────────────────────────

View File

@@ -33,7 +33,10 @@ AppState (main coordinator)
├── ProfileState (node profiles & toggles)
├── SessionState (add/edit sessions)
├── SettingsState (preferences & tile providers)
── UploadQueueState (pending operations)
── UploadQueueState (pending operations)
├── SuspectedLocationState (permit data & display)
├── NavigationState (routing & search)
└── SearchState (location search results)
```
**Why this approach:**
@@ -62,25 +65,75 @@ External APIs (OSM, Overpass, Tile providers)
---
## Changelog & First Launch System
The app includes a comprehensive system for welcoming new users and notifying existing users of updates.
### Components
- **ChangelogService**: Manages version tracking and changelog loading
- **WelcomeDialog**: First launch popup with privacy information and quick links
- **ChangelogDialog**: Update notification popup for version changes
- **ReleaseNotesScreen**: Settings page for viewing all changelog history
### Content Management
Changelog content is stored in `assets/changelog.json`:
```json
{
"1.2.4": {
"content": "• New feature description\n• Bug fixes\n• Other improvements"
},
"1.2.3": {
"content": "" // Empty string = skip popup for this version
}
}
```
### Developer Workflow
1. **For each release**: Add entry to `changelog.json` with version from `pubspec.yaml`
2. **Content required**: Every version must have an entry (can be empty string to skip)
3. **Localization**: Welcome dialog supports i18n, changelog content is English-only
4. **Testing**: Clear app data to test first launch, change version to test updates
### User Experience Flow
- **First Launch**: Welcome popup with "don't show again" option
- **Version Updates**: Changelog popup (only if content exists, no "don't show again")
- **Settings Access**: Complete changelog history available in Settings > About > Release Notes
### Privacy Integration
The welcome popup explains that the app:
- Runs entirely locally on device
- Uses OpenStreetMap API for data storage only
- DeFlock collects no user data
- DeFlock is not responsible for OSM account management
---
## Core Components
### 1. MapDataProvider
### 1. MapDataProvider & Smart Area Caching
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
**Purpose**: Unified interface for fetching map tiles and surveillance nodes with intelligent area caching
**Design decisions:**
- **Pluggable sources**: Local (cached) vs Remote (live API)
- **Offline-first**: Always try local first, graceful degradation
- **Single fetch strategy**: Uses PrefetchAreaService for smart 3x area caching instead of dual immediate/background fetching
- **Spatial + temporal refresh**: Fetches larger areas (3x visible bounds) and refreshes stale data (>60s old)
- **Offline-first**: Always try local cache first, graceful degradation
- **Mode-aware**: Different behavior for production vs sandbox
- **Failure handling**: Never crash the UI, always provide fallbacks
**Key methods:**
- `getNodes()`: Smart fetching with local/remote merging
- `getTile()`: Tile fetching with caching
- `getNodes()`: Returns cache immediately, triggers pre-fetch if needed (spatial or temporal)
- `getTile()`: Tile fetching with enhanced retry strategy (6 attempts, 1-8s delays)
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
**Why unified interface:**
The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities.
**Smart caching flow:**
1. Check if current view within cached area AND data <60s old
2. If not: trigger pre-fetch of 3x larger area, show loading state
3. Return cache immediately for responsive UI
4. When pre-fetch completes: update cache, refresh UI, report success
**Why this approach:**
Reduces API load by 3-4x while ensuring data freshness. User sees instant responses from cache while background fetching keeps data current. Eliminates complex dual-path logic in favor of simple spatial/temporal triggers.
### 2. Node Operations (Create/Edit/Delete)
@@ -95,10 +148,29 @@ enum UploadOperation { create, modify, delete }
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
**Session Pattern:**
- `AddNodeSession`: For creating new nodes
- `EditNodeSession`: For modifying existing nodes
- `AddNodeSession`: For creating new nodes with single or multiple directions
- `EditNodeSession`: For modifying existing nodes, preserving all existing directions
- No "DeleteSession": Deletions are immediate (simpler)
**Multi-Direction Support:**
Sessions use a simple model for handling multiple directions:
```dart
class AddNodeSession {
List<double> directions; // [90, 180, 270] - all directions
int currentDirectionIndex; // Which direction is being edited
// Slider always shows the current direction
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(value) => directions[currentDirectionIndex] = value;
}
```
**Direction Interaction:**
- **Add**: New directions start at 0° and are automatically selected for editing
- **Remove**: Current direction removed from list (minimum 1 direction)
- **Cycle**: Switch between existing directions in the list
- **Submit**: All directions combined as semicolon-separated string (e.g., "90;180;270")
**Why no delete session:**
Deletions don't need position dragging or tag editing - they just need confirmation and queuing. A session would add complexity without benefit.
@@ -129,6 +201,11 @@ Users expect instant response to their actions. By immediately updating the cach
- **Orange ring**: Node currently being edited
- **Red ring**: Nodes pending deletion
**Direction cone visual states:**
- **Full opacity**: Active session direction (currently being edited)
- **Reduced opacity (40%)**: Inactive session directions
- **Standard opacity**: Existing node directions (when not in edit mode)
**Cache tags for state tracking:**
```dart
'_pending_upload' // New node waiting to upload
@@ -137,10 +214,21 @@ Users expect instant response to their actions. By immediately updating the cach
'_original_node_id' // For drawing connection lines
```
**Multi-direction parsing:**
The app supports nodes with multiple directions specified as semicolon-separated values:
```dart
// OSM tag: direction="90;180;270"
List<double> get directionDeg {
final raw = tags['direction'] ?? tags['camera:direction'];
// Splits on semicolons, parses each direction, normalizes to 0-359°
return [90.0, 180.0, 270.0]; // Results in multiple FOV cones
}
```
**Why underscore prefix:**
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
### 5. Multi-API Data Sources
### 5. Enhanced Overpass Integration & Error Handling
**Production mode:** Overpass API → OSM API fallback
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
@@ -149,8 +237,19 @@ These are internal app tags, not OSM tags. The underscore prefix makes this expl
- **Production (Overpass)**: Zoom ≥ 10 (established limit)
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
**Why different zoom limits:**
The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer.
**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
**Query optimization:**
- **Pre-fetch limit**: 4x user's display limit (e.g., 1000 nodes for 250 display limit)
- **User-initiated detection**: Only reports loading status for user-facing operations
- **Background operations**: Pre-fetch runs silently, doesn't trigger loading states
**Why this approach:**
Dense urban areas (SF, NYC) with many profiles enabled can easily exceed both 50k node limits and 25s timeouts. Splitting reduces query complexity while surgical error detection avoids unnecessary API load from network issues.
### 6. Offline vs Online Mode Behavior
@@ -165,6 +264,87 @@ Sandbox + Offline → No nodes (cache is production data)
**Why sandbox + offline = no nodes:**
Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials.
### 7. Proximity Alerts & Background Monitoring
**Design approach:**
- **Simple cooldown system**: In-memory tracking to prevent notification spam
- **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
**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
### 8. Compass Indicator & North Lock
**Purpose**: Visual compass showing map orientation with optional north-lock functionality
**Design decisions:**
- **Separate from follow mode**: North lock is independent of GPS following behavior
- **Smart rotation detection**: Distinguishes intentional rotation (>5°) from zoom gestures
- **Visual feedback**: Clear skeumorphic compass design with red north indicator
- **Mode awareness**: Disabled during follow+rotate mode (incompatible)
**Key behaviors:**
- **North indicator**: Red arrow always points toward true north regardless of map rotation
- **Tap to toggle**: Enable/disable north lock with visual animation to north
- **Auto-disable**: North lock turns off when switching to follow+rotate mode
- **Gesture intelligence**: Only disables on significant rotation changes, ignores zoom artifacts
**Visual states:**
- **Normal**: White background, grey border, red north arrow
- **North locked**: White background, blue border, bright red north arrow
- **Disabled**: Grey background, muted colors (during follow+rotate mode)
**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.
### 9. Suspected Locations
**Data pipeline:**
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
- **Dynamic field parsing**: Stores all CSV columns (except `location` and `ticket_no`) for flexible display
- **GeoJSON processing**: Handles Point, Polygon, and MultiPolygon geometries
- **Proximity filtering**: Hides suspected locations near confirmed devices
- **Regional availability**: Currently select locations, expanding regularly
**Display approach:**
- **Required fields**: `ticket_no` (for heading) and `location` (for map positioning)
- **Dynamic display**: All other CSV fields shown automatically, no hardcoded field list
- **Server control**: Field names and content controlled server-side via CSV headers
- **Brutalist rendering**: Fields displayed as-is from CSV, empty fields hidden
**Why utility permits:**
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.
### 10. Upload Mode Simplification
**Release vs Debug builds:**
- **Release builds**: Production OSM only (simplified UX)
- **Debug builds**: Full sandbox/simulate options available
Most users should contribute to production; testing modes add complexity
**Implementation:**
```dart
// Upload mode selection disabled in release builds
bool get showUploadModeSelector => kDebugMode;
```
### 11. Navigation & Routing (Implemented, Awaiting Integration)
**Current state:**
- **Search functionality**: Fully implemented and active
- **Basic routing**: Complete but disabled pending API integration
- **Avoidance routing**: Awaiting alprwatch.org/directions API
- **Offline routing**: Requires vector map tiles
**Architecture:**
- NavigationState manages routing computation and turn-by-turn instructions
- RoutingService handles API communication and route calculation
- SearchService provides location lookup and geocoding
---
## Key Design Decisions & Rationales
@@ -227,6 +407,22 @@ Local cache contains production data. Showing production nodes in sandbox mode w
- **Battery life**: Excessive network requests drain battery
- **Clear feedback**: Users understand why nodes aren't showing
### 6. Why Separate Compass Indicator from Follow Mode?
**Alternative**: Combined "follow with north up" mode
**Why separate controls:**
- **Clear user mental model**: "Follow me" vs "lock to north" are distinct concepts
- **Flexible combinations**: Users can follow without north lock, or vice versa
- **Avoid mode conflicts**: Follow+rotate is incompatible with north lock
- **Reduced confusion**: Previous "north up" mode didn't actually keep north up
**Design benefits:**
- **Brutalist approach**: Two simple, independent features instead of complex mode combinations
- **Visual feedback**: Compass shows exact map orientation regardless of follow state
- **Smart gesture detection**: Differentiates intentional rotation from zoom artifacts
- **Predictable behavior**: Each control does exactly what it says
---
## Development Guidelines
@@ -298,6 +494,84 @@ void updateMultipleThings() {
---
## Release Process & GitHub Actions
The app uses a **clean, release-triggered workflow** that rebuilds from scratch for maximum reliability:
### How It Works
**Trigger: GitHub Release Creation**
- Create a GitHub release → Workflow automatically builds, attaches assets, and optionally uploads to stores
- **Pre-release checkbox** controls store uploads:
-**Checked** → Build + attach assets (no store uploads)
-**Unchecked** → Build + attach assets + upload to App/Play stores
### Release Types
**Development/Beta Releases**
1. Create GitHub release from any tag/branch
2.**Check "pre-release"** checkbox
3. Publish → Assets built and attached, no store uploads
**Production Releases**
1. Create GitHub release from main/stable branch
2.**Leave "pre-release" unchecked**
3. Publish → Assets built and attached + uploaded to stores
### Store Upload Destinations
**Google Play Store:**
- Uploads to **Internal Testing** track
- Requires manual promotion to Beta/Production
- You maintain full control over public release
**App Store Connect:**
- Uploads to **TestFlight**
- Requires manual App Store submission
- You maintain full control over public release
### Required Secrets
**For Google Play Store Upload:**
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` - Complete JSON service account key (plain text)
**For iOS App Store Upload:**
- `APP_STORE_CONNECT_API_KEY_ID` - App Store Connect API key ID
- `APP_STORE_CONNECT_ISSUER_ID` - App Store Connect issuer ID
- `APP_STORE_CONNECT_API_KEY_BASE64` - Base64-encoded .p8 API key file
**For Building:**
- `OSM_PROD_CLIENTID` - OpenStreetMap production OAuth2 client ID
- `OSM_SANDBOX_CLIENTID` - OpenStreetMap sandbox OAuth2 client ID
- Android signing secrets (keystore, passwords, etc.)
- iOS signing certificates and provisioning profiles
### Google Play Store Setup
1. **Google Cloud Console:**
- Create Service Account with "Project Editor" role
- Enable Google Play Android Developer API
- Download JSON key file
2. **Google Play Console:**
- Add service account email to Users & Permissions
- Grant "Release Manager" permissions for your app
- Complete first manual release to activate app listing
3. **GitHub Secrets:**
- Store entire JSON key as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` (plain text)
### Workflow Benefits
**Brutalist simplicity** - One trigger, clear behavior
**No external dependencies** - Only uses trusted `r0adkll/upload-google-play@v1`
**Explicit control** - GitHub's UI checkbox controls store uploads
**Always rebuilds** - No stale artifacts or cross-workflow complexity
**Safe defaults** - Pre-release prevents accidental production uploads
**No tag coordination** - Works with any commit, tag, or branch
---
## Build & Development Setup
### Prerequisites

View File

@@ -6,6 +6,14 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
<a href="https://apps.apple.com/us/app/deflock-me/id6752760780" style="display: inline-block;">
<img src="https://toolbox.marketingtools.apple.com/api/v2/badges/download-on-the-app-store/black/en-us?releaseDate=1695859200" alt="Download on the App Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
</a>
<a href="https://play.google.com/store/apps/details?id=me.deflock.deflockapp" style="display: inline-block;">
<img src="assets/GetItOnGooglePlay_Badge_Web_color_English.png" alt="Download on the Google Play Store" style="width: 246px; height: 82px; vertical-align: middle; object-fit: contain;" />
</a>
---
## What This App Does
@@ -24,15 +32,21 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
### Map & Navigation
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
- **Offline-first design**: Download a region for complete offline operation
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
### Device Management
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
- **Full CRUD operations**: Create, edit, and delete surveillance devices
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
- **Multi-direction support**: Devices can have multiple viewing directions (e.g. "90;180") with individual field-of-view cones
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles with opacity-based selection
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
### Surveillance Intelligence
- **Suspected locations**: Display potential surveillance sites from utility permit data with dynamic field display (select locations, more added regularly)
- **Proximity alerts**: Get notified when approaching mapped surveillance devices, with configurable distance and background notifications
- **Location search**: Find addresses and points of interest to aid in mapping missions
### Professional Upload & Sync
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
@@ -49,14 +63,16 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## Quick Start
1. **Install** the app on iOS or Android
1. **Install** the app on iOS or Android - a welcome popup will guide you through key information
2. **Enable location** permissions
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction(s), select a profile, and tap submit
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
**App Updates**: The app will automatically show you what's new when you update. You can always view release notes in Settings > About.
---
## For Developers
@@ -64,6 +80,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
- Architecture overview and design decisions
- Development setup and build instructions
- Release process and GitHub Actions automation
- Code organization and contribution guidelines
- Debugging tips and troubleshooting
@@ -74,40 +91,43 @@ cp lib/keys.dart.example lib/keys.dart
# Add OAuth2 client IDs, then: flutter run
```
**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.
---
## Roadmap
### Needed Bugfixes
- Are offline areas really working? Are they preferred for fast loading even when online? Check working.
- Are offline areas preferred for fast loading even when online? Check working.
- Fix network indicator - only done when fetch queue is empty!
### Current Development
- Add some builtin satellite tile provider
- Option to pull in profiles from NSI (man_made=surveillance only)
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
- Tutorial / info guide before submitting first node
- Link to "my changes" on osm (username edit history)
- Option to "extract node from way" for nodes attached to a way to allow moving
### On Pause
- Suspected locations expansion to more regions
- Import/Export map providers
- Swap in alprwatch.org/directions avoidance routing API
- Help button with links to email, discord, and website
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Offline navigation
- Offline navigation (pending vector map tiles)
### Maybes
- Yellow ring for devices missing specific tag details?
- "Cache accumulating" offline area?
- "Offline areas" as tile provider?
- Optional custom icons for camera profiles?
- Upgrade device marker design? (considering nullplate's svg)
- Custom device providers and OSM/Overpass alternatives?
- More map data providers:
https://gis.sanramon.ca.gov/arcgis_js_api/sdk/jsapi/esri.basemaps-amd.html#osm
https://www.icgc.cat/en/Geoinformation-and-Maps/Base-Map-Service
https://github.com/CartoDB/basemap-styles
https://forum.inductiveautomation.com/t/perspective-map-theming-internet-tile-server-options/40164
https://github.com/roblabs/xyz-raster-sources
https://github.com/geopandas/xyzservices/blob/main/provider_sources/xyzservices-providers.json
https://medium.com/@go2garret/free-basemap-tiles-for-maplibre-18374fab60cb
- Yellow ring for devices missing specific tag details
- "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
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

104
assets/changelog.json Normal file
View File

@@ -0,0 +1,104 @@
{
"1.4.0": {
"content": [
"• IMPROVED: Advanced editing options now only show apps available on your platform (iOS/Android)",
"• IMPROVED: When an OSM editor app isn't installed, automatically redirect to the appropriate app store",
"• IMPROVED: Better error handling for external editor launches with app store fallback",
"• Supported editors: Vespucci (Android), StreetComplete (Android), EveryDoor (both), Go Map!! (iOS)",
"• Web editors (iD, RapiD) remain available on all platforms as before"
]
},
"1.3.4": {
"content": [
"• NEW: 'Pause Upload Queue' toggle in Offline Settings - stops uploads while keeping live data access",
"• Useful for metered connections or when you want to batch uploads later",
"• Upload queue is now disabled if either full offline mode OR pause queue processing is enabled",
"• FIXED: Sheet buttons now remain visible when rotating from portrait to landscape mode",
"• FIXED: Sheets now properly resize when rotating between orientations without requiring user interaction",
"• IMPROVED: Tag list height adapts automatically for landscape orientation to prevent covering map",
"• IMPROVED: Sheets with few tags now shrink to appropriate size rather than maintaining fixed height",
"• IMPROVED: More reliable sheet layout using proper flexible height constraints",
"• CLEANED: Fixed minor code formatting inconsistencies"
]
},
"1.3.3": {
"content": [
"• NEW: Added builtin surveillance device profiles for Rekor and Axis Communications ALPR cameras",
"• Both profiles include proper OSM tags for manufacturer identification and require direction setting",
"• NEW: Advanced editing options - access iD Editor, RapiD, Vespucci, StreetComplete, and other OSM editors",
"• NEW: 'View on OSM' links to see nodes directly on OpenStreetMap website",
"• UX: Constrained nodes (part of ways/relations) cannot be moved to prevent data corruption",
"• UX: Auto-clickable URLs in all tag values - any URL becomes a tappable link",
"• UX: Tag lists now scroll with max height to keep buttons and map visible",
"• UX: Improved button layout on mobile with two rows for better accessibility",
"• UX: Localized network status messages in all supported languages",
"• FIXED: Duplicate changelog service calls eliminated"
]
},
"1.3.2": {
"content": [
"• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved",
"• UX: Fixed Android navigation bar covering settings page content"
]
},
"1.3.1": {
"content": [
"• UX: Network status indicator always enabled",
"• UX: Direction slider wider on small screens",
"• UX: Fixed iOS keyboard missing 'Done' in settings",
"• UX: Fixed multi-direction nodes in upload queue",
"• UX: Improved suspected locations loading indicator; removed popup, fixed stuck spinner"
]
},
"1.2.8": {
"content": [
"• UX: Profile selection is now a required step to prevent accidental submission of default profile",
"• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)",
"• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)",
"• NEW: Support for cardinal directions in OSM data, multiple directions on a node"
]
},
"1.2.7": {
"content": [
"• NEW: Compass indicator shows map orientation; tap to spin north-up",
"• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing",
"• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably",
"• Better network status: Simplified loading indicator logic",
"• Instant node display: Surveillance devices now appear immediately when data finishes loading",
"• Node limit alerts: Get notified when some nodes are not drawn"
]
},
"1.2.4": {
"content": [
"• New welcome popup for first-time users with essential privacy information",
"• Automatic changelog display when app updates (like this one!)",
"• Added Release Notes viewer in Settings > About",
"• Enhanced user onboarding and transparency about data handling",
"• Improved documentation for contributors"
]
},
"1.2.3": {
"content": [
"• Enhanced map performance and stability",
"• Improved offline sync reliability",
"• Added better error handling for uploads",
"• Various bug fixes and improvements"
]
},
"1.2.2": {
"content": [
"• New surveillance device profiles added",
"• Improved tile loading performance",
"• Fixed issue with GPS accuracy",
"• Updated translations"
]
},
"1.2.0": {
"content": [
"• Major UI improvements",
"• Added proximity alerts",
"• Enhanced offline capabilities",
"• New suspected locations feature"
]
}
}

10
build_keys.conf.example Normal file
View File

@@ -0,0 +1,10 @@
# Local OSM client ID configuration for builds
# Copy this file to build_keys.conf and fill in your values
# This file is gitignored to keep your keys secret
#
# Get your client IDs from:
# Production: https://www.openstreetmap.org/oauth2/applications
# Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications
OSM_PROD_CLIENTID=your_production_client_id_here
OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here

View File

@@ -4,6 +4,35 @@
BUILD_IOS=true
BUILD_ANDROID=true
# Function to read key=value from file
read_from_file() {
local key="$1"
local file="build_keys.conf"
if [ ! -f "$file" ]; then
return 1
fi
# Read key=value pairs, ignoring comments and empty lines
while IFS='=' read -r k v; do
# Skip comments and empty lines
if [[ "$k" =~ ^[[:space:]]*# ]] || [[ -z "$k" ]]; then
continue
fi
# Remove leading/trailing whitespace
k=$(echo "$k" | xargs)
v=$(echo "$v" | xargs)
if [ "$k" = "$key" ]; then
echo "$v"
return 0
fi
done < "$file"
return 1
}
# Parse arguments
for arg in "$@"; do
case $arg in
@@ -18,19 +47,52 @@ for arg in "$@"; do
echo " --ios Build only iOS"
echo " --android Build only Android"
echo " (default builds both)"
echo ""
echo "OSM client IDs must be configured in build_keys.conf"
echo "See build_keys.conf.example for format"
exit 1
;;
esac
done
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ')
# Load client IDs from build_keys.conf
if [ ! -f "build_keys.conf" ]; then
echo "Error: build_keys.conf not found"
echo "Copy build_keys.conf.example to build_keys.conf and fill in your OSM client IDs"
exit 1
fi
echo "Loading OSM client IDs from build_keys.conf..."
OSM_PROD_CLIENTID=$(read_from_file "OSM_PROD_CLIENTID")
OSM_SANDBOX_CLIENTID=$(read_from_file "OSM_SANDBOX_CLIENTID")
# Check required keys
if [ -z "$OSM_PROD_CLIENTID" ]; then
echo "Error: OSM_PROD_CLIENTID not found in build_keys.conf"
exit 1
fi
if [ -z "$OSM_SANDBOX_CLIENTID" ]; then
echo "Error: OSM_SANDBOX_CLIENTID not found in build_keys.conf"
exit 1
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
echo
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
echo
echo "Building app version ${appver}..."
echo
if [ "$BUILD_IOS" = true ]; then
echo "Building iOS..."
flutter build ios --no-codesign || exit 1
flutter build ios --no-codesign $DART_DEFINE_ARGS || exit 1
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
@@ -42,7 +104,7 @@ fi
if [ "$BUILD_ANDROID" = true ]; then
echo "Building Android..."
flutter build apk || exit 1
flutter build apk $DART_DEFINE_ARGS || exit 1
echo "Moving Android files..."
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1

View File

@@ -470,7 +470,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -479,6 +482,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -652,7 +656,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -661,6 +668,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -674,7 +682,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -683,6 +694,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";

View File

@@ -25,7 +25,9 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs your location to show nearby cameras.</string>
<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>

View File

@@ -1,16 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/node_profile.dart';
import 'models/operator_profile.dart';
import 'models/osm_node.dart';
import 'models/pending_upload.dart';
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/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/profile_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
@@ -19,6 +24,7 @@ import 'state/profile_state.dart';
import 'state/search_state.dart';
import 'state/session_state.dart';
import 'state/settings_state.dart';
import 'state/suspected_location_state.dart';
import 'state/upload_queue_state.dart';
// Re-export types
@@ -38,6 +44,7 @@ class AppState extends ChangeNotifier {
late final SearchState _searchState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
late final SuspectedLocationState _suspectedLocationState;
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
@@ -51,6 +58,7 @@ class AppState extends ChangeNotifier {
_searchState = SearchState();
_sessionState = SessionState();
_settingsState = SettingsState();
_suspectedLocationState = SuspectedLocationState();
_uploadQueueState = UploadQueueState();
// Set up state change listeners
@@ -61,6 +69,7 @@ class AppState extends ChangeNotifier {
_searchState.addListener(_onStateChanged);
_sessionState.addListener(_onStateChanged);
_settingsState.addListener(_onStateChanged);
_suspectedLocationState.addListener(_onStateChanged);
_uploadQueueState.addListener(_onStateChanged);
_init();
@@ -121,12 +130,15 @@ class AppState extends ChangeNotifier {
// Settings state
bool get offlineMode => _settingsState.offlineMode;
bool get pauseQueueProcessing => _settingsState.pauseQueueProcessing;
int get maxCameras => _settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -139,6 +151,12 @@ class AppState extends ChangeNotifier {
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
// Suspected location state
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;
DateTime? get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
void _onStateChanged() {
notifyListeners();
}
@@ -148,11 +166,33 @@ class AppState extends ChangeNotifier {
// Initialize all state modules
await _settingsState.init();
// Initialize changelog service
await ChangelogService().init();
// Attempt to fetch missing tile type preview tiles (fails silently)
_fetchMissingTilePreviews();
await _operatorProfileState.init();
await _profileState.init();
// Check if we should add default profiles (first launch OR no profiles of each type exist)
final prefs = await SharedPreferences.getInstance();
const firstLaunchKey = 'profiles_defaults_initialized';
final isFirstLaunch = !(prefs.getBool(firstLaunchKey) ?? false);
// Load existing profiles to check each type independently
final existingOperatorProfiles = await OperatorProfileService().load();
final existingNodeProfiles = await ProfileService().load();
final shouldAddOperatorDefaults = isFirstLaunch || existingOperatorProfiles.isEmpty;
final shouldAddNodeDefaults = isFirstLaunch || existingNodeProfiles.isEmpty;
await _operatorProfileState.init(addDefaults: shouldAddOperatorDefaults);
await _profileState.init(addDefaults: shouldAddNodeDefaults);
// Mark defaults as initialized if this was first launch
if (isFirstLaunch) {
await prefs.setBool(firstLaunchKey, true);
}
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -246,6 +286,20 @@ class AppState extends ChangeNotifier {
);
}
void addDirection() {
_sessionState.addDirection();
}
void removeDirection() {
_sessionState.removeDirection();
}
void cycleDirection() {
_sessionState.cycleDirection();
}
void cancelSession() {
_sessionState.cancelSession();
}
@@ -358,6 +412,15 @@ class AppState extends ChangeNotifier {
}
}
Future<void> setPauseQueueProcessing(bool enabled) async {
await _settingsState.setPauseQueueProcessing(enabled);
if (!enabled) {
_startUploader(); // Resume upload queue processing
} else {
_uploadQueueState.stopUploader(); // Stop uploader when paused
}
}
set maxCameras(int n) {
_settingsState.maxCameras = n;
}
@@ -392,7 +455,7 @@ class AppState extends ChangeNotifier {
Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode);
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
await _settingsState.setProximityAlertsEnabled(enabled);
@@ -408,6 +471,11 @@ class AppState extends ChangeNotifier {
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
}
/// Set suspected location minimum distance from real nodes
Future<void> setSuspectedLocationMinDistance(int distance) async {
await _settingsState.setSuspectedLocationMinDistance(distance);
}
// ---------- Queue Methods ----------
void clearQueue() {
_uploadQueueState.clearQueue();
@@ -422,6 +490,37 @@ class AppState extends ChangeNotifier {
_startUploader(); // resume uploader if not busy
}
// ---------- Suspected Location Methods ----------
Future<void> setSuspectedLocationsEnabled(bool enabled) async {
await _suspectedLocationState.setEnabled(enabled);
}
Future<bool> refreshSuspectedLocations() async {
return await _suspectedLocationState.refreshData();
}
void selectSuspectedLocation(SuspectedLocation location) {
_suspectedLocationState.selectLocation(location);
}
void clearSuspectedLocationSelection() {
_suspectedLocationState.clearSelection();
}
List<SuspectedLocation> getSuspectedLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _suspectedLocationState.getLocationsInBounds(
north: north,
south: south,
east: east,
west: west,
);
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {
@@ -435,6 +534,7 @@ class AppState extends ChangeNotifier {
void _startUploader() {
_uploadQueueState.startUploader(
offlineMode: offlineMode,
pauseQueueProcessing: pauseQueueProcessing,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
);
@@ -449,6 +549,7 @@ class AppState extends ChangeNotifier {
_searchState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);
_suspectedLocationState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
_uploadQueueState.dispose();

View File

@@ -12,9 +12,12 @@ const int kPreviewTileY = 101300;
const int kPreviewTileX = 41904;
// Direction cone for map view
const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
const double kDirectionConeHalfAngle = 35.0; // degrees
const double kDirectionConeBaseLength = 5; // multiplier
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
// Base values for thickness - use helper functions below for pixel-ratio scaling
const double _kDirectionConeBorderWidthBase = 1.6;
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
@@ -31,18 +34,37 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// Helper to get left positioning that accounts for safe area (for landscape mode)
double leftPositionWithSafeArea(double baseLeft, EdgeInsets safeArea) {
return baseLeft + safeArea.left;
}
// Helper to get right positioning that accounts for safe area (for landscape mode)
double rightPositionWithSafeArea(double baseRight, EdgeInsets safeArea) {
return baseRight + safeArea.right;
}
// Helper to get top positioning that accounts for safe area
double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
return baseTop + safeArea.top;
}
// Client name for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
// Node editing features - set to false to temporarily disable editing
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
@@ -58,24 +80,48 @@ const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sa
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
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 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
// Follow-me mode smooth transitions
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
// Sheet content configuration
const double kMaxTagListHeightRatioPortrait = 0.3; // Maximum height for tag lists in portrait mode
const double kMaxTagListHeightRatioLandscape = 0.2; // Maximum height for tag lists in landscape mode
/// Get appropriate tag list height ratio based on screen orientation
double getTagListHeightRatio(BuildContext context) {
final size = MediaQuery.of(context).size;
final isLandscape = size.width > size.height;
return isLandscape ? kMaxTagListHeightRatioLandscape : kMaxTagListHeightRatioPortrait;
}
// Proximity alerts configuration
const int kProximityAlertDefaultDistance = 200; // meters
const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
const int kTileFetchJitter1Ms = 1000;
const int kTileFetchSecondDelayMs = 15000;
const int kTileFetchJitter2Ms = 4000;
const int kTileFetchThirdDelayMs = 60000;
const int kTileFetchJitter3Ms = 5000;
// Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)
const double kScrollWheelVelocity = 0.01; // Mouse scroll wheel zoom speed (default 0.005)
const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom (reduced for gesture race)
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 retry parameters (configurable backoff system)
const int kTileFetchMaxAttempts = 16; // Number of retry attempts before giving up
const int kTileFetchInitialDelayMs = 500; // Base delay for first retry (1 second)
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
const int kTileFetchMaxDelayMs = 10000; // Cap delays at this value (8 seconds max)
const int kTileFetchRandomJitterMs = 250; // Random fuzz to add (0 to 500ms)
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
const int kMaxUserDownloadZoomSpan = 7;
@@ -86,12 +132,27 @@ const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 23;
// Node icon configuration
const double kNodeIconDiameter = 20.0;
const double kNodeRingThickness = 4.0;
const double kNodeDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kNodeRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
const Color kNodeRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
const Color kNodeRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kNodeRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kNodeRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
const Color kNodeRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
const double kNodeIconDiameter = 18.0;
const double _kNodeRingThicknessBase = 2.5;
const double kNodeDotOpacity = 0.3; // 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 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
const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent
// Direction slider control buttons configuration
const double kDirectionButtonMinWidth = 22.0;
const double kDirectionButtonMinHeight = 32.0;
// Helper functions for pixel-ratio scaling
double getDirectionConeBorderWidth(BuildContext context) {
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
return _kDirectionConeBorderWidthBase;
}
double getNodeRingThickness(BuildContext context) {
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
return _kNodeRingThicknessBase;
}

View File

@@ -1,7 +1,16 @@
// OpenStreetMap OAuth client IDs for this app.
//
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
// These must be provided via --dart-define at build time.
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
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');
}
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');
}

View File

@@ -1,3 +0,0 @@
// Example OSM OAuth key config
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';

View File

@@ -16,11 +16,14 @@
"close": "Schließen",
"submit": "Senden",
"saveEdit": "Bearbeitung Speichern",
"clear": "Löschen"
"clear": "Löschen",
"viewOnOSM": "Auf OSM anzeigen",
"advanced": "Erweitert",
"useAdvancedEditor": "Erweiterten Editor verwenden"
},
"followMe": {
"off": "Verfolgung aktivieren (Norden oben)",
"northUp": "Verfolgung aktivieren (Rotation)",
"off": "Verfolgung aktivieren",
"follow": "Verfolgung aktivieren (Rotation)",
"rotating": "Verfolgung deaktivieren"
},
"settings": {
@@ -36,6 +39,8 @@
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
"offlineMode": "Offline-Modus",
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
"pauseQueueProcessing": "Upload-Warteschlange pausieren",
"pauseQueueProcessingSubtitle": "Upload von wartenden Änderungen stoppen, aber Live-Datenzugriff beibehalten.",
"offlineModeWarningTitle": "Aktive Downloads",
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
"enableOfflineMode": "Offline-Modus Aktivieren",
@@ -72,6 +77,8 @@
},
"addNode": {
"profile": "Profil",
"selectProfile": "Profil auswählen...",
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
@@ -83,12 +90,16 @@
"editNode": {
"title": "Knoten #{} Bearbeiten",
"profile": "Profil",
"selectProfile": "Profil auswählen...",
"profileRequired": "Bitte wählen Sie ein Profil aus, um fortzufahren.",
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"temporarilyDisabled": "Bearbeitungen wurden vorübergehend deaktiviert, während wir einen Fehler beheben - Entschuldigung - schauen Sie bald wieder vorbei.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
"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.",
"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.",
"refineTags": "Tags Verfeinern",
"refineTagsWithProfile": "Tags Verfeinern ({})"
},
@@ -124,7 +135,12 @@
"testConnection": "Verbindung Testen",
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
"deleteAccount": "OSM-Konto Löschen",
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
"deleteAccountWarning": "Warnung: Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr OSM-Konto dauerhaft.",
"goToOSM": "Zu OpenStreetMap gehen"
},
"queue": {
"pendingUploads": "Ausstehende Uploads: {}",
@@ -295,9 +311,38 @@
"selectMapLayer": "Kartenschicht Auswählen",
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
},
"advancedEdit": {
"title": "Erweiterte Bearbeitungsoptionen",
"subtitle": "Diese Editoren bieten erweiterte Funktionen für komplexe Bearbeitungen.",
"webEditors": "Web-Editoren",
"mobileEditors": "Mobile Editoren",
"iDEditor": "iD Editor",
"iDEditorSubtitle": "Voll ausgestatteter Web-Editor - funktioniert immer",
"rapidEditor": "RapiD Editor",
"rapidEditorSubtitle": "KI-unterstütztes Bearbeiten mit Facebook-Daten",
"vespucci": "Vespucci",
"vespucciSubtitle": "Erweiterte Android OSM-Editor",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Umfragebasierte Mapping-App",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Schnelle POI-Bearbeitung",
"goMap": "Go Map!!",
"goMapSubtitle": "iOS OSM-Editor",
"couldNotOpenEditor": "Editor konnte nicht geöffnet werden - App möglicherweise nicht installiert",
"couldNotOpenURL": "URL konnte nicht geöffnet werden",
"couldNotOpenOSMWebsite": "OSM-Website konnte nicht geöffnet werden"
},
"networkStatus": {
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen"
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
"loading": "Lädt...",
"timedOut": "Zeitüberschreitung",
"noData": "Keine Kacheln hier",
"success": "Fertig",
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
"tileProviderSlow": "Kartenanbieter langsam",
"nodeDataSlow": "Knotendaten langsam",
"networkIssues": "Netzwerkprobleme"
},
"about": {
"title": "DeFlock - Überwachungs-Transparenz",
@@ -306,6 +351,17 @@
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
},
"welcome": {
"title": "Willkommen bei DeFlock",
"description": "DeFlock wurde auf der Idee gegründet, dass öffentliche Überwachungsinstrumente transparent sein sollten. In dieser mobilen App, wie auch auf der Website, können Sie die Standorte von ALPRs und anderer Überwachungsinfrastruktur in Ihrer Umgebung und weltweit einsehen.",
"mission": "Dieses Projekt ist jedoch nicht automatisiert; es braucht uns alle, um dieses Projekt zu verbessern. Bei der Kartenansicht können Sie auf \"Neuer Knoten\" tippen, um eine bisher unbekannte Installation hinzuzufügen. Mit Ihrer Hilfe können wir unser Ziel erreichen: mehr Transparenz und öffentliches Bewusstsein für Überwachungsinfrastruktur.",
"firsthandKnowledge": "WICHTIG: Tragen Sie nur Überwachungsgeräte bei, die Sie persönlich aus erster Hand beobachtet haben. OpenStreetMap- und Google-Richtlinien verbieten die Nutzung von Quellen wie Street View-Bildern für Beiträge. Ihre Beiträge sollten auf Ihren eigenen direkten, persönlichen Beobachtungen basieren.",
"privacy": "Datenschutzhinweis: Diese App läuft vollständig lokal auf Ihrem Gerät und nutzt die OpenStreetMap-API von Drittanbietern nur für Datenspeicherung und Übermittlungen. DeFlock sammelt oder speichert keinerlei Nutzerdaten und ist nicht für die Kontoverwaltung verantwortlich.",
"tileNote": "HINWEIS: Die kostenlosen Kartenkacheln von OpenStreetMap können sehr langsam laden. Alternative Kartenanbieter können unter Einstellungen > Erweitert konfiguriert werden.",
"moreInfo": "Weitere Links finden Sie unter Einstellungen > Über.",
"dontShowAgain": "Diese Willkommensnachricht nicht mehr anzeigen",
"getStarted": "Los geht's mit DeFlocking!"
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",
@@ -341,5 +397,40 @@
"imperial": "Britisch (mi, ft)",
"meters": "Meter",
"feet": "Fuß"
},
"suspectedLocations": {
"title": "Verdächtige Standorte",
"showSuspectedLocations": "Verdächtige Standorte anzeigen",
"showSuspectedLocationsSubtitle": "Fragezeichen-Marker für vermutete Überwachungsstandorte aus Versorgungsgenehmigungsdaten anzeigen",
"lastUpdated": "Zuletzt aktualisiert",
"refreshNow": "Jetzt aktualisieren",
"dataSource": "Datenquelle",
"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",
"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",
"minutesAgo": "vor {} Minuten",
"justNow": "Gerade eben"
},
"suspectedLocation": {
"title": "Verdächtiger Standort #{}",
"ticketNo": "Ticket-Nr.",
"address": "Adresse",
"street": "Straße",
"city": "Stadt",
"state": "Bundesland",
"intersectingStreet": "Kreuzende Straße",
"workDoneFor": "Arbeit ausgeführt für",
"remarks": "Bemerkungen",
"url": "URL",
"coordinates": "Koordinaten",
"noAddressAvailable": "Keine Adresse verfügbar"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
},
"welcome": {
"title": "Welcome to DeFlock",
"description": "DeFlock was founded on the idea that public surveillance tools should be transparent. Within this mobile app, as on the website, you will be able to view the location of ALPRs and other surveillance infrastructure in your local area and abroad.",
"mission": "However, this project isn't automated; it takes all of us to make this project better. When viewing the map, you can tap \"New Node\" to add a previously unknown installation. With your help, we can achieve our goal of increased transparency and public awareness of surveillance infrastructure.",
"firsthandKnowledge": "IMPORTANT: Only contribute surveillance devices that you have personally observed firsthand. OpenStreetMap and Google policies prohibit using sources like Street View imagery for submissions. Your contributions should be based on your own direct observations.",
"privacy": "Privacy Note: This app runs entirely locally on your device and uses the third-party OpenStreetMap API for data storage and submissions. DeFlock does not collect or store any user data of any kind, and is not responsible for account management.",
"tileNote": "NOTE: The free map tiles from OpenStreetMap can be very slow to load. Alternate tile providers can be configured in Settings > Advanced.",
"moreInfo": "You can find more links under Settings > About.",
"dontShowAgain": "Don't show this welcome message again",
"getStarted": "Let's Get DeFlocking!"
},
"actions": {
"tagNode": "New Node",
"download": "Download",
@@ -23,11 +34,14 @@
"close": "Close",
"submit": "Submit",
"saveEdit": "Save Edit",
"clear": "Clear"
"clear": "Clear",
"viewOnOSM": "View on OSM",
"advanced": "Advanced",
"useAdvancedEditor": "Use Advanced Editor"
},
"followMe": {
"off": "Enable follow-me (north up)",
"northUp": "Enable follow-me (rotating)",
"off": "Enable follow-me",
"follow": "Enable follow-me (rotating)",
"rotating": "Disable follow-me"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
"offlineMode": "Offline Mode",
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
"pauseQueueProcessing": "Pause Upload Queue",
"pauseQueueProcessingSubtitle": "Stop uploading queued changes while keeping live data access.",
"offlineModeWarningTitle": "Active Downloads",
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
"enableOfflineMode": "Enable Offline Mode",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "Profile",
"selectProfile": "Select a profile...",
"profileRequired": "Please select a profile to continue.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
@@ -90,12 +108,16 @@
"editNode": {
"title": "Edit Node #{}",
"profile": "Profile",
"selectProfile": "Select a profile...",
"profileRequired": "Please select a profile to continue.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"temporarilyDisabled": "Edits have been temporarily disabled while we sort out a bug - apologies - check back soon.",
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
"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.",
"cannotMoveConstrainedNode": "Cannot move this camera - it's connected to another map element (OSM way/relation). You can still edit its tags and direction.",
"refineTags": "Refine Tags",
"refineTagsWithProfile": "Refine Tags ({})"
},
@@ -131,7 +153,12 @@
"testConnection": "Test Connection",
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
"connectionFailed": "Connection failed - please re-login"
"connectionFailed": "Connection failed - please re-login",
"deleteAccount": "Delete OSM Account",
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
"deleteAccountWarning": "Warning: This action cannot be undone and will permanently delete your OSM account.",
"goToOSM": "Go to OpenStreetMap"
},
"queue": {
"pendingUploads": "Pending uploads: {}",
@@ -302,9 +329,38 @@
"selectMapLayer": "Select Map Layer",
"noTileProvidersAvailable": "No tile providers available"
},
"advancedEdit": {
"title": "Advanced Editing Options",
"subtitle": "These editors offer more advanced features for complex edits.",
"webEditors": "Web Editors",
"mobileEditors": "Mobile Editors",
"iDEditor": "iD Editor",
"iDEditorSubtitle": "Full-featured web editor - always works",
"rapidEditor": "RapiD Editor",
"rapidEditorSubtitle": "AI-assisted editing with Facebook data",
"vespucci": "Vespucci",
"vespucciSubtitle": "Advanced Android OSM editor",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Survey-based mapping app",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Fast POI editing",
"goMap": "Go Map!!",
"goMapSubtitle": "iOS OSM editor",
"couldNotOpenEditor": "Could not open editor - app may not be installed",
"couldNotOpenURL": "Could not open URL",
"couldNotOpenOSMWebsite": "Could not open OSM website"
},
"networkStatus": {
"showIndicator": "Show network status indicator",
"showIndicatorSubtitle": "Display network loading and error status on the map"
"showIndicatorSubtitle": "Display network loading and error status on the map",
"loading": "Loading...",
"timedOut": "Timed out",
"noData": "No tiles here",
"success": "Done",
"nodeLimitReached": "Showing limit - increase in settings",
"tileProviderSlow": "Tile provider slow",
"nodeDataSlow": "Node data slow",
"networkIssues": "Network issues"
},
"navigation": {
"searchLocation": "Search Location",
@@ -341,5 +397,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "meters",
"feet": "feet"
},
"suspectedLocations": {
"title": "Suspected Locations",
"showSuspectedLocations": "Show Suspected Locations",
"showSuspectedLocationsSubtitle": "Show question mark markers for suspected surveillance sites from utility permit data",
"lastUpdated": "Last Updated",
"refreshNow": "Refresh now",
"dataSource": "Data Source",
"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",
"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",
"minutesAgo": "{} minutes ago",
"justNow": "Just now"
},
"suspectedLocation": {
"title": "Suspected Location #{}",
"ticketNo": "Ticket No",
"address": "Address",
"street": "Street",
"city": "City",
"state": "State",
"intersectingStreet": "Intersecting Street",
"workDoneFor": "Work Done For",
"remarks": "Remarks",
"url": "URL",
"coordinates": "Coordinates",
"noAddressAvailable": "No address available"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.",
"footer": "Visita: deflock.me\nConstruido con Flutter • Código Abierto"
},
"welcome": {
"title": "Bienvenido a DeFlock",
"description": "DeFlock fue fundado sobre la idea de que las herramientas de vigilancia pública deben ser transparentes. Dentro de esta aplicación móvil, como en el sitio web, podrás ver la ubicación de ALPRs y otra infraestructura de vigilancia en tu área local y en el extranjero.",
"mission": "Sin embargo, este proyecto no es automatizado; todos nosotros somos necesarios para mejorarlo. Al ver el mapa, puedes tocar \"Nuevo Nodo\" para agregar una instalación previamente desconocida. Con tu ayuda, podemos lograr nuestro objetivo de mayor transparencia y conciencia pública sobre la infraestructura de vigilancia.",
"firsthandKnowledge": "IMPORTANTE: Solo contribuye con dispositivos de vigilancia que hayas observado personalmente de primera mano. Las políticas de OpenStreetMap y Google prohíben el uso de fuentes como imágenes de Street View para las contribuciones. Tus contribuciones deben basarse en tus propias observaciones directas y en persona.",
"privacy": "Nota de Privacidad: Esta aplicación funciona completamente de forma local en tu dispositivo y utiliza la API de terceros de OpenStreetMap solo para almacenamiento y envío de datos. DeFlock no recopila ni almacena ningún tipo de datos de usuario, y no es responsable de la gestión de cuentas.",
"tileNote": "NOTA: Los mosaicos gratuitos de mapa de OpenStreetMap pueden tardar mucho en cargar. Se pueden configurar proveedores alternativos de mosaicos en Configuración > Avanzado.",
"moreInfo": "Puedes encontrar más enlaces en Configuración > Acerca de.",
"dontShowAgain": "No mostrar este mensaje de bienvenida otra vez",
"getStarted": "¡Comencemos con DeFlock!"
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
@@ -23,11 +34,14 @@
"close": "Cerrar",
"submit": "Enviar",
"saveEdit": "Guardar Edición",
"clear": "Limpiar"
"clear": "Limpiar",
"viewOnOSM": "Ver en OSM",
"advanced": "Avanzado",
"useAdvancedEditor": "Usar Editor Avanzado"
},
"followMe": {
"off": "Activar seguimiento (norte arriba)",
"northUp": "Activar seguimiento (rotación)",
"off": "Activar seguimiento",
"follow": "Activar seguimiento (rotación)",
"rotating": "Desactivar seguimiento"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
"offlineMode": "Modo Sin Conexión",
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
"pauseQueueProcessing": "Pausar Cola de Subida",
"pauseQueueProcessingSubtitle": "Detener la subida de cambios en cola manteniendo acceso a datos en vivo.",
"offlineModeWarningTitle": "Descargas Activas",
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
"enableOfflineMode": "Habilitar Modo Sin Conexión",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
"profileRequired": "Por favor, seleccione un perfil para continuar.",
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
@@ -90,12 +108,16 @@
"editNode": {
"title": "Editar Nodo #{}",
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
"profileRequired": "Por favor, seleccione un perfil para continuar.",
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"temporarilyDisabled": "Las ediciones han sido temporalmente deshabilitadas mientras solucionamos un error - disculpas - regrese pronto.",
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
"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.",
"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.",
"refineTags": "Refinar Etiquetas",
"refineTagsWithProfile": "Refinar Etiquetas ({})"
},
@@ -131,7 +153,12 @@
"testConnection": "Probar Conexión",
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
"deleteAccount": "Eliminar Cuenta OSM",
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
"deleteAccountWarning": "Advertencia: Esta acción no se puede deshacer y eliminará permanentemente tu cuenta OSM.",
"goToOSM": "Ir a OpenStreetMap"
},
"queue": {
"pendingUploads": "Subidas pendientes: {}",
@@ -302,9 +329,38 @@
"selectMapLayer": "Seleccionar Capa del Mapa",
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
},
"advancedEdit": {
"title": "Opciones de Edición Avanzada",
"subtitle": "Estos editores ofrecen funciones más avanzadas para ediciones complejas.",
"webEditors": "Editores Web",
"mobileEditors": "Editores Móviles",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - siempre funciona",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Edición asistida por IA con datos de Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avanzado para Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Aplicación de mapeo basada en encuestas",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Edición rápida de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM para iOS",
"couldNotOpenEditor": "No se pudo abrir el editor - la aplicación puede no estar instalada",
"couldNotOpenURL": "No se pudo abrir la URL",
"couldNotOpenOSMWebsite": "No se pudo abrir el sitio web de OSM"
},
"networkStatus": {
"showIndicator": "Mostrar indicador de estado de red",
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa"
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
"loading": "Cargando...",
"timedOut": "Tiempo agotado",
"noData": "Sin mosaicos aquí",
"success": "Hecho",
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
"tileProviderSlow": "Proveedor de mosaicos lento",
"nodeDataSlow": "Datos de nodo lentos",
"networkIssues": "Problemas de red"
},
"navigation": {
"searchLocation": "Buscar ubicación",
@@ -341,5 +397,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pies"
},
"suspectedLocations": {
"title": "Ubicaciones Sospechosas",
"showSuspectedLocations": "Mostrar Ubicaciones Sospechosas",
"showSuspectedLocationsSubtitle": "Mostrar marcadores de interrogación para sitios de vigilancia sospechosos de datos de permisos de servicios públicos",
"lastUpdated": "Última Actualización",
"refreshNow": "Actualizar ahora",
"dataSource": "Fuente de Datos",
"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",
"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",
"minutesAgo": "hace {} minutos",
"justNow": "Ahora mismo"
},
"suspectedLocation": {
"title": "Ubicación Sospechosa #{}",
"ticketNo": "No. de Ticket",
"address": "Dirección",
"street": "Calle",
"city": "Ciudad",
"state": "Estado",
"intersectingStreet": "Calle que Intersecta",
"workDoneFor": "Trabajo Realizado Para",
"remarks": "Observaciones",
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "No hay dirección disponible"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
},
"welcome": {
"title": "Bienvenue dans DeFlock",
"description": "DeFlock a été fondé sur l'idée que les outils de surveillance publique devraient être transparents. Dans cette application mobile, comme sur le site web, vous pourrez voir l'emplacement des ALPRs et autres infrastructures de surveillance dans votre région et à l'étranger.",
"mission": "Cependant, ce projet n'est pas automatisé ; il nous faut tous pour améliorer ce projet. En visualisant la carte, vous pouvez appuyer sur \"Nouveau Nœud\" pour ajouter une installation précédemment inconnue. Avec votre aide, nous pouvons atteindre notre objectif d'augmenter la transparence et la sensibilisation du public à l'infrastructure de surveillance.",
"firsthandKnowledge": "IMPORTANT : Ne contribuez qu'aux dispositifs de surveillance que vous avez personnellement observés de première main. Les politiques d'OpenStreetMap et de Google interdisent l'utilisation de sources comme les images Street View pour les contributions. Vos contributions doivent être basées sur vos propres observations directes et en personne.",
"privacy": "Note de Confidentialité : Cette application fonctionne entièrement localement sur votre appareil et utilise l'API tierce OpenStreetMap uniquement pour le stockage et la soumission de données. DeFlock ne collecte ni ne stocke aucune donnée utilisateur de quelque nature que ce soit, et n'est pas responsable de la gestion des comptes.",
"tileNote": "NOTE : Les tuiles de carte gratuites d'OpenStreetMap peuvent être très lentes à charger. Des fournisseurs de tuiles alternatifs peuvent être configurés dans Paramètres > Avancé.",
"moreInfo": "Vous pouvez trouver plus de liens sous Paramètres > À propos.",
"dontShowAgain": "Ne plus afficher ce message de bienvenue",
"getStarted": "Commençons le DeFlock !"
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
@@ -23,11 +34,14 @@
"close": "Fermer",
"submit": "Soumettre",
"saveEdit": "Sauvegarder Modification",
"clear": "Effacer"
"clear": "Effacer",
"viewOnOSM": "Voir sur OSM",
"advanced": "Avancé",
"useAdvancedEditor": "Utiliser l'Éditeur Avancé"
},
"followMe": {
"off": "Activer le suivi (nord en haut)",
"northUp": "Activer le suivi (rotation)",
"off": "Activer le suivi",
"follow": "Activer le suivi (rotation)",
"rotating": "Désactiver le suivi"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
"offlineMode": "Mode Hors Ligne",
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
"pauseQueueProcessing": "Suspendre la File d'Upload",
"pauseQueueProcessingSubtitle": "Arrêter l'upload des modifications en attente tout en gardant l'accès aux données en direct.",
"offlineModeWarningTitle": "Téléchargements Actifs",
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
"enableOfflineMode": "Activer le Mode Hors Ligne",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
@@ -90,12 +108,16 @@
"editNode": {
"title": "Modifier Nœud #{}",
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
"profileRequired": "Veuillez sélectionner un profil pour continuer.",
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"temporarilyDisabled": "Les modifications ont été temporairement désactivées pendant que nous résolvons un bug - désolés - revenez bientôt.",
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
"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.",
"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.",
"refineTags": "Affiner Balises",
"refineTagsWithProfile": "Affiner Balises ({})"
},
@@ -131,7 +153,12 @@
"testConnection": "Tester Connexion",
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
"deleteAccount": "Supprimer Compte OSM",
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
"deleteAccountWarning": "Attention : Cette action ne peut pas être annulée et supprimera définitivement votre compte OSM.",
"goToOSM": "Aller à OpenStreetMap"
},
"queue": {
"pendingUploads": "Téléchargements en attente: {}",
@@ -302,9 +329,38 @@
"selectMapLayer": "Sélectionner la Couche de Carte",
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
},
"advancedEdit": {
"title": "Options d'Édition Avancées",
"subtitle": "Ces éditeurs offrent des fonctionnalités plus avancées pour les modifications complexes.",
"webEditors": "Éditeurs Web",
"mobileEditors": "Éditeurs Mobiles",
"iDEditor": "Éditeur iD",
"iDEditorSubtitle": "Éditeur web complet - fonctionne toujours",
"rapidEditor": "Éditeur RapiD",
"rapidEditorSubtitle": "Édition assistée par IA avec des données Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Éditeur OSM avancé Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Application de cartographie basée sur des enquêtes",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Édition rapide de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Éditeur OSM iOS",
"couldNotOpenEditor": "Impossible d'ouvrir l'éditeur - l'application peut ne pas être installée",
"couldNotOpenURL": "Impossible d'ouvrir l'URL",
"couldNotOpenOSMWebsite": "Impossible d'ouvrir le site web OSM"
},
"networkStatus": {
"showIndicator": "Afficher l'indicateur de statut réseau",
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte"
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
"loading": "Chargement...",
"timedOut": "Temps dépassé",
"noData": "Aucune tuile ici",
"success": "Terminé",
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
"tileProviderSlow": "Fournisseur de tuiles lent",
"nodeDataSlow": "Données de nœud lentes",
"networkIssues": "Problèmes réseau"
},
"navigation": {
"searchLocation": "Rechercher lieu",
@@ -341,5 +397,40 @@
"imperial": "Impérial (mi, ft)",
"meters": "mètres",
"feet": "pieds"
},
"suspectedLocations": {
"title": "Emplacements Suspects",
"showSuspectedLocations": "Afficher les Emplacements Suspects",
"showSuspectedLocationsSubtitle": "Afficher des marqueurs en point d'interrogation pour les sites de surveillance suspectés à partir des données de permis de services publics",
"lastUpdated": "Dernière Mise à Jour",
"refreshNow": "Actualiser maintenant",
"dataSource": "Source de Données",
"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",
"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",
"minutesAgo": "il y a {} minutes",
"justNow": "À l'instant"
},
"suspectedLocation": {
"title": "Emplacement Suspect #{}",
"ticketNo": "N° de Ticket",
"address": "Adresse",
"street": "Rue",
"city": "Ville",
"state": "État",
"intersectingStreet": "Rue Transversale",
"workDoneFor": "Travail Effectué Pour",
"remarks": "Remarques",
"url": "URL",
"coordinates": "Coordonnées",
"noAddressAvailable": "Aucune adresse disponible"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
},
"welcome": {
"title": "Benvenuto in DeFlock",
"description": "DeFlock è stato fondato sull'idea che gli strumenti di sorveglianza pubblica dovrebbero essere trasparenti. All'interno di questa app mobile, come sul sito web, sarai in grado di visualizzare la posizione di ALPR e altre infrastrutture di sorveglianza nella tua zona locale e all'estero.",
"mission": "Tuttavia, questo progetto non è automatizzato; servono tutti noi per migliorare questo progetto. Durante la visualizzazione della mappa, puoi toccare \"Nuovo Nodo\" per aggiungere un'installazione precedentemente sconosciuta. Con il tuo aiuto, possiamo raggiungere il nostro obiettivo di maggiore trasparenza e consapevolezza pubblica dell'infrastruttura di sorveglianza.",
"firsthandKnowledge": "IMPORTANTE: Contribuisci solo con dispositivi di sorveglianza che hai osservato personalmente di prima mano. Le politiche di OpenStreetMap e Google vietano l'uso di fonti come le immagini di Street View per i contributi. I tuoi contributi dovrebbero essere basati sulle tue osservazioni dirette e di persona.",
"privacy": "Nota sulla Privacy: Questa app funziona interamente localmente sul tuo dispositivo e utilizza l'API di terze parti OpenStreetMap solo per l'archiviazione e l'invio dei dati. DeFlock non raccoglie né memorizza alcun tipo di dati utente e non è responsabile della gestione degli account.",
"tileNote": "NOTA: Le tessere mappa gratuite di OpenStreetMap possono essere molto lente a caricare. Fornitori di tessere alternativi possono essere configurati in Impostazioni > Avanzate.",
"moreInfo": "Puoi trovare altri collegamenti in Impostazioni > Informazioni.",
"dontShowAgain": "Non mostrare più questo messaggio di benvenuto",
"getStarted": "Iniziamo con DeFlock!"
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
@@ -23,11 +34,14 @@
"close": "Chiudi",
"submit": "Invia",
"saveEdit": "Salva Modifica",
"clear": "Pulisci"
"clear": "Pulisci",
"viewOnOSM": "Visualizza su OSM",
"advanced": "Avanzato",
"useAdvancedEditor": "Usa Editor Avanzato"
},
"followMe": {
"off": "Attiva seguimi (nord in alto)",
"northUp": "Attiva seguimi (rotazione)",
"off": "Attiva seguimi",
"follow": "Attiva seguimi (rotazione)",
"rotating": "Disattiva seguimi"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
"offlineMode": "Modalità Offline",
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
"pauseQueueProcessing": "Pausa Coda Upload",
"pauseQueueProcessingSubtitle": "Ferma l'upload delle modifiche in coda mantenendo l'accesso ai dati dal vivo.",
"offlineModeWarningTitle": "Download Attivi",
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
"enableOfflineMode": "Attiva Modalità Offline",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
"profileRequired": "Per favore seleziona un profilo per continuare.",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
@@ -90,12 +108,16 @@
"editNode": {
"title": "Modifica Nodo #{}",
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
"profileRequired": "Per favore seleziona un profilo per continuare.",
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"temporarilyDisabled": "Le modifiche sono state temporaneamente disabilitate mentre risolviamo un bug - scuse - torna presto.",
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
"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.",
"cannotMoveConstrainedNode": "Impossibile spostare questa telecamera - è collegata a un altro elemento della mappa (OSM way/relation). Puoi ancora modificare i suoi tag e direzione.",
"refineTags": "Affina Tag",
"refineTagsWithProfile": "Affina Tag ({})"
},
@@ -131,7 +153,12 @@
"testConnection": "Testa Connessione",
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
"deleteAccount": "Elimina Account OSM",
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
"deleteAccountWarning": "Attenzione: Questa azione non può essere annullata e eliminerà permanentemente il tuo account OSM.",
"goToOSM": "Vai a OpenStreetMap"
},
"queue": {
"pendingUploads": "Upload in sospeso: {}",
@@ -302,9 +329,38 @@
"selectMapLayer": "Seleziona Livello Mappa",
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
},
"advancedEdit": {
"title": "Opzioni di Modifica Avanzate",
"subtitle": "Questi editor offrono funzionalità più avanzate per modifiche complesse.",
"webEditors": "Editor Web",
"mobileEditors": "Editor Mobili",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - funziona sempre",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Modifica assistita da IA con dati Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avanzato Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "App di mappatura basata su sondaggi",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Modifica rapida POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM iOS",
"couldNotOpenEditor": "Impossibile aprire l'editor - l'app potrebbe non essere installata",
"couldNotOpenURL": "Impossibile aprire l'URL",
"couldNotOpenOSMWebsite": "Impossibile aprire il sito web OSM"
},
"networkStatus": {
"showIndicator": "Mostra indicatore di stato di rete",
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa"
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
"loading": "Caricamento...",
"timedOut": "Tempo scaduto",
"noData": "Nessuna tessera qui",
"success": "Fatto",
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
"tileProviderSlow": "Provider di tessere lento",
"nodeDataSlow": "Dati del nodo lenti",
"networkIssues": "Problemi di rete"
},
"navigation": {
"searchLocation": "Cerca posizione",
@@ -341,5 +397,40 @@
"imperial": "Imperiale (mi, ft)",
"meters": "metri",
"feet": "piedi"
},
"suspectedLocations": {
"title": "Posizioni Sospette",
"showSuspectedLocations": "Mostra Posizioni Sospette",
"showSuspectedLocationsSubtitle": "Mostra marcatori punto interrogativo per siti di sorveglianza sospetti dai dati dei permessi dei servizi pubblici",
"lastUpdated": "Ultimo Aggiornamento",
"refreshNow": "Aggiorna ora",
"dataSource": "Fonte Dati",
"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",
"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",
"minutesAgo": "{} minuti fa",
"justNow": "Proprio ora"
},
"suspectedLocation": {
"title": "Posizione Sospetta #{}",
"ticketNo": "N. Ticket",
"address": "Indirizzo",
"street": "Via",
"city": "Città",
"state": "Stato",
"intersectingStreet": "Via che Interseca",
"workDoneFor": "Lavoro Svolto Per",
"remarks": "Osservazioni",
"url": "URL",
"coordinates": "Coordinate",
"noAddressAvailable": "Nessun indirizzo disponibile"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "Parte da iniciativa mais ampla DeFlock para promover transparência na vigilância.",
"footer": "Visite: deflock.me\nConstruído com Flutter • Código Aberto"
},
"welcome": {
"title": "Bem-vindo ao DeFlock",
"description": "DeFlock foi fundado na ideia de que ferramentas de vigilância pública devem ser transparentes. Dentro deste aplicativo móvel, como no site, você poderá ver a localização de ALPRs e outras infraestruturas de vigilância em sua área local e no exterior.",
"mission": "No entanto, este projeto não é automatizado; precisamos de todos nós para tornar este projeto melhor. Ao visualizar o mapa, você pode tocar em \"Novo Nó\" para adicionar uma instalação anteriormente desconhecida. Com sua ajuda, podemos alcançar nosso objetivo de maior transparência e conscientização pública sobre infraestrutura de vigilância.",
"firsthandKnowledge": "IMPORTANTE: Contribua apenas com dispositivos de vigilância que você observou pessoalmente em primeira mão. As políticas do OpenStreetMap e Google proíbem o uso de fontes como imagens do Street View para contribuições. Suas contribuições devem ser baseadas em suas próprias observações diretas e presenciais.",
"privacy": "Nota de Privacidade: Este aplicativo funciona inteiramente localmente em seu dispositivo e usa a API de terceiros OpenStreetMap apenas para armazenamento e envio de dados. DeFlock não coleta nem armazena qualquer tipo de dados do usuário e não é responsável pelo gerenciamento de contas.",
"tileNote": "NOTA: Os tiles gratuitos de mapa do OpenStreetMap podem ser muito lentos para carregar. Provedores alternativos de tiles podem ser configurados em Configurações > Avançado.",
"moreInfo": "Você pode encontrar mais links em Configurações > Sobre.",
"dontShowAgain": "Não mostrar esta mensagem de boas-vindas novamente",
"getStarted": "Vamos começar com o DeFlock!"
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
@@ -23,11 +34,14 @@
"close": "Fechar",
"submit": "Enviar",
"saveEdit": "Salvar Edição",
"clear": "Limpar"
"clear": "Limpar",
"viewOnOSM": "Ver no OSM",
"advanced": "Avançado",
"useAdvancedEditor": "Usar Editor Avançado"
},
"followMe": {
"off": "Ativar seguir-me (norte para cima)",
"northUp": "Ativar seguir-me (rotação)",
"off": "Ativar seguir-me",
"follow": "Ativar seguir-me (rotação)",
"rotating": "Desativar seguir-me"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
"offlineMode": "Modo Offline",
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
"pauseQueueProcessing": "Pausar Fila de Upload",
"pauseQueueProcessingSubtitle": "Parar upload de alterações na fila mantendo acesso a dados ao vivo.",
"offlineModeWarningTitle": "Downloads Ativos",
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
"enableOfflineMode": "Ativar Modo Offline",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
"profileRequired": "Por favor, selecione um perfil para continuar.",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
@@ -90,12 +108,16 @@
"editNode": {
"title": "Editar Nó #{}",
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
"profileRequired": "Por favor, selecione um perfil para continuar.",
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"temporarilyDisabled": "As edições foram temporariamente desabilitadas enquanto resolvemos um bug - desculpe - volte em breve.",
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
"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.",
"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.",
"refineTags": "Refinar Tags",
"refineTagsWithProfile": "Refinar Tags ({})"
},
@@ -131,7 +153,12 @@
"testConnection": "Testar Conexão",
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
"deleteAccount": "Excluir Conta OSM",
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
"deleteAccountWarning": "Aviso: Esta ação não pode ser desfeita e excluirá permanentemente sua conta OSM.",
"goToOSM": "Ir para OpenStreetMap"
},
"queue": {
"pendingUploads": "Uploads pendentes: {}",
@@ -302,9 +329,38 @@
"selectMapLayer": "Selecionar Camada do Mapa",
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
},
"advancedEdit": {
"title": "Opções de Edição Avançada",
"subtitle": "Estes editores oferecem recursos mais avançados para edições complexas.",
"webEditors": "Editores Web",
"mobileEditors": "Editores Móveis",
"iDEditor": "Editor iD",
"iDEditorSubtitle": "Editor web completo - sempre funciona",
"rapidEditor": "Editor RapiD",
"rapidEditorSubtitle": "Edição assistida por IA com dados do Facebook",
"vespucci": "Vespucci",
"vespucciSubtitle": "Editor OSM avançado para Android",
"streetComplete": "StreetComplete",
"streetCompleteSubtitle": "Aplicativo de mapeamento baseado em pesquisas",
"everyDoor": "EveryDoor",
"everyDoorSubtitle": "Edição rápida de POI",
"goMap": "Go Map!!",
"goMapSubtitle": "Editor OSM iOS",
"couldNotOpenEditor": "Não foi possível abrir o editor - aplicativo pode não estar instalado",
"couldNotOpenURL": "Não foi possível abrir a URL",
"couldNotOpenOSMWebsite": "Não foi possível abrir o site do OSM"
},
"networkStatus": {
"showIndicator": "Exibir indicador de status de rede",
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa"
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
"loading": "Carregando...",
"timedOut": "Tempo esgotado",
"noData": "Nenhum tile aqui",
"success": "Concluído",
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
"tileProviderSlow": "Provedor de tiles lento",
"nodeDataSlow": "Dados do nó lentos",
"networkIssues": "Problemas de rede"
},
"navigation": {
"searchLocation": "Buscar localização",
@@ -341,5 +397,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pés"
},
"suspectedLocations": {
"title": "Localizações Suspeitas",
"showSuspectedLocations": "Mostrar Localizações Suspeitas",
"showSuspectedLocationsSubtitle": "Mostrar marcadores de ponto de interrogação para sites de vigilância suspeitos de dados de licenças de serviços públicos",
"lastUpdated": "Última Atualização",
"refreshNow": "Atualizar agora",
"dataSource": "Fonte de Dados",
"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",
"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",
"minutesAgo": "{} minutos atrás",
"justNow": "Agora mesmo"
},
"suspectedLocation": {
"title": "Localização Suspeita #{}",
"ticketNo": "N° do Ticket",
"address": "Endereço",
"street": "Rua",
"city": "Cidade",
"state": "Estado",
"intersectingStreet": "Rua que Cruza",
"workDoneFor": "Trabalho Feito Para",
"remarks": "Observações",
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "Nenhum endereço disponível"
}
}

View File

@@ -12,6 +12,17 @@
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
"footer": "访问deflock.me\n使用 Flutter 构建 • 开源"
},
"welcome": {
"title": "欢迎使用 DeFlock",
"description": "DeFlock 的创立基于公共监控工具应该透明的理念。在这个移动应用程序中,就像在网站上一样,您将能够查看您当地和国外的车牌识别系统和其他监控基础设施的位置。",
"mission": "然而,这个项目不是自动化的;需要我们所有人来改善这个项目。在查看地图时,您可以点击\"新建节点\"来添加一个之前未知的装置。在您的帮助下,我们可以实现增强监控基础设施透明度和公众意识的目标。",
"firsthandKnowledge": "重要提醒只贡献您亲自第一手观察到的监控设备。OpenStreetMap 和 Google 的政策禁止使用街景图像等来源进行贡献。您的贡献应该基于您自己的直接、亲身观察。",
"privacy": "隐私说明:此应用程序完全在您的设备上本地运行,仅使用第三方 OpenStreetMap API 进行数据存储和提交。DeFlock 不收集或存储任何类型的用户数据,也不负责账户管理。",
"tileNote": "注意:来自 OpenStreetMap 的免费地图图块可能加载很慢。可以在设置 > 高级中配置替代图块提供商。",
"moreInfo": "您可以在设置 > 关于中找到更多链接。",
"dontShowAgain": "不再显示此欢迎消息",
"getStarted": "开始使用 DeFlock"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
@@ -23,11 +34,14 @@
"close": "关闭",
"submit": "提交",
"saveEdit": "保存编辑",
"clear": "清空"
"clear": "清空",
"viewOnOSM": "在OSM上查看",
"advanced": "高级",
"useAdvancedEditor": "使用高级编辑器"
},
"followMe": {
"off": "启用跟随模式(北向上)",
"northUp": "启用跟随模式(旋转)",
"off": "启用跟随模式",
"follow": "启用跟随模式(旋转)",
"rotating": "禁用跟随模式"
},
"settings": {
@@ -43,6 +57,8 @@
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
"offlineMode": "离线模式",
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
"pauseQueueProcessing": "暂停上传队列",
"pauseQueueProcessingSubtitle": "停止上传排队的更改,同时保持实时数据访问。",
"offlineModeWarningTitle": "活动下载",
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
"enableOfflineMode": "启用离线模式",
@@ -79,6 +95,8 @@
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
@@ -90,12 +108,16 @@
"editNode": {
"title": "编辑节点 #{}",
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
"cannotMoveConstrainedNode": "无法移动此相机 - 它连接到另一个地图元素OSM way/relation。您仍可以编辑其标签和方向。",
"refineTags": "细化标签",
"refineTagsWithProfile": "细化标签({}"
},
@@ -131,7 +153,12 @@
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录"
"connectionFailed": "连接失败 - 请重新登录",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"pendingUploads": "待上传:{}",
@@ -302,9 +329,38 @@
"selectMapLayer": "选择地图图层",
"noTileProvidersAvailable": "无可用瓦片提供商"
},
"advancedEdit": {
"title": "高级编辑选项",
"subtitle": "这些编辑器为复杂编辑提供更高级的功能。",
"webEditors": "网页编辑器",
"mobileEditors": "移动编辑器",
"iDEditor": "iD 编辑器",
"iDEditorSubtitle": "功能完整的网页编辑器 - 始终有效",
"rapidEditor": "RapiD 编辑器",
"rapidEditorSubtitle": "使用Facebook数据的AI辅助编辑",
"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": "在地图上显示网络加载和错误状态"
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
"loading": "加载中...",
"timedOut": "超时",
"noData": "这里没有瓦片",
"success": "完成",
"nodeLimitReached": "显示限制 - 在设置中增加",
"tileProviderSlow": "瓦片提供商缓慢",
"nodeDataSlow": "节点数据缓慢",
"networkIssues": "网络问题"
},
"navigation": {
"searchLocation": "搜索位置",
@@ -341,5 +397,40 @@
"imperial": "英制(英里,英尺)",
"meters": "米",
"feet": "英尺"
},
"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": "网址",
"coordinates": "坐标",
"noAddressAvailable": "无可用地址"
}
}

View File

@@ -10,6 +10,7 @@ import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'screens/release_notes_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
@@ -74,6 +75,7 @@ class DeFlockApp extends StatelessWidget {
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
'/settings/release-notes': (context) => const ReleaseNotesScreen(),
},
initialRoute: '/',
);

View File

@@ -20,161 +20,182 @@ class NodeProfile {
this.editable = true,
});
/// Builtin default: Generic ALPR camera (customizable template, not submittable)
factory NodeProfile.genericAlpr() => NodeProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
submittable: false,
editable: false,
);
/// Get all built-in default node profiles
static List<NodeProfile> getDefaults() => [
NodeProfile(
id: 'builtin-generic-alpr',
name: 'Generic ALPR',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'ALPR',
},
builtin: true,
requiresDirection: true,
submittable: false,
editable: false,
),
NodeProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-rekor',
name: 'Rekor',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Rekor',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-axis',
name: 'Axis Communications',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Axis Communications',
'manufacturer:wikidata': 'Q2347731',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
submittable: false,
editable: false,
),
NodeProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
),
NodeProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
),
];
/// Builtin: Flock Safety ALPR camera
factory NodeProfile.flock() => NodeProfile(
id: 'builtin-flock',
name: 'Flock',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Flock Safety',
'manufacturer:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Motorola Solutions/Vigilant ALPR camera
factory NodeProfile.motorola() => NodeProfile(
id: 'builtin-motorola',
name: 'Motorola/Vigilant',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Motorola Solutions',
'manufacturer:wikidata': 'Q634815',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Genetec ALPR camera
factory NodeProfile.genetec() => NodeProfile(
id: 'builtin-genetec',
name: 'Genetec',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Genetec',
'manufacturer:wikidata': 'Q30295174',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Leonardo/ELSAG ALPR camera
factory NodeProfile.leonardo() => NodeProfile(
id: 'builtin-leonardo',
name: 'Leonardo/ELSAG',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Leonardo',
'manufacturer:wikidata': 'Q910379',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Neology ALPR camera
factory NodeProfile.neology() => NodeProfile(
id: 'builtin-neology',
name: 'Neology',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'ALPR',
'surveillance:zone': 'traffic',
'camera:type': 'fixed',
'manufacturer': 'Neology, Inc.',
},
builtin: true,
requiresDirection: true,
submittable: true,
editable: true,
);
/// Builtin: Generic gunshot detector (customizable template, not submittable)
factory NodeProfile.genericGunshotDetector() => NodeProfile(
id: 'builtin-generic-gunshot',
name: 'Generic Gunshot Detector',
tags: const {
'man_made': 'surveillance',
'surveillance:type': 'gunshot_detector',
},
builtin: true,
requiresDirection: false,
submittable: false,
editable: false,
);
/// Builtin: ShotSpotter gunshot detector
factory NodeProfile.shotspotter() => NodeProfile(
id: 'builtin-shotspotter',
name: 'ShotSpotter',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'surveillance:brand': 'ShotSpotter',
'surveillance:brand:wikidata': 'Q107740188',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Builtin: Flock Raven gunshot detector
factory NodeProfile.flockRaven() => NodeProfile(
id: 'builtin-flock-raven',
name: 'Flock Raven',
tags: const {
'man_made': 'surveillance',
'surveillance': 'public',
'surveillance:type': 'gunshot_detector',
'brand': 'Flock Safety',
'brand:wikidata': 'Q108485435',
},
builtin: true,
requiresDirection: false,
submittable: true,
editable: true,
);
/// Returns true if this profile can be used for submissions
bool get isSubmittable => submittable;

View File

@@ -13,6 +13,37 @@ class OperatorProfile {
required this.tags,
});
/// Get all built-in default operator profiles
static List<OperatorProfile> getDefaults() => [
OperatorProfile(
id: 'builtin-lowes',
name: "Lowe's",
tags: const {
'operator': "Lowe's",
'operator:wikidata': 'Q1373493',
'operator:type': 'private',
},
),
OperatorProfile(
id: 'builtin-home-depot',
name: 'The Home Depot',
tags: const {
'operator': 'The Home Depot',
'operator:wikidata': 'Q864407',
'operator:type': 'private',
},
),
OperatorProfile(
id: 'builtin-simon-property-group',
name: 'Simon Property Group',
tags: const {
'operator': 'Simon Property Group',
'operator:wikidata': 'Q2287759',
'operator:type': 'private',
},
),
];
OperatorProfile copyWith({
String? id,
String? name,

View File

@@ -4,11 +4,13 @@ class OsmNode {
final int id;
final LatLng coord;
final Map<String, String> tags;
final bool isConstrained; // true if part of any way/relation
OsmNode({
required this.id,
required this.coord,
required this.tags,
this.isConstrained = false, // Default to unconstrained for backward compatibility
});
Map<String, dynamic> toJson() => {
@@ -16,6 +18,7 @@ class OsmNode {
'lat': coord.latitude,
'lon': coord.longitude,
'tags': tags,
'isConstrained': isConstrained,
};
factory OsmNode.fromJson(Map<String, dynamic> json) {
@@ -29,26 +32,51 @@ class OsmNode {
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
tags: tags,
isConstrained: json['isConstrained'] as bool? ?? false, // Default to false for backward compatibility
);
}
bool get hasDirection =>
tags.containsKey('direction') || tags.containsKey('camera:direction');
bool get hasDirection => directionDeg.isNotEmpty;
double? get directionDeg {
List<double> get directionDeg {
final raw = tags['direction'] ?? tags['camera:direction'];
if (raw == null) return null;
if (raw == null) return [];
// Keep digits, optional dot, optional leading sign.
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(raw);
if (match == null) return null;
// Compass direction to degree mapping
const compassDirections = {
'N': 0.0, 'NNE': 22.5, 'NE': 45.0, 'ENE': 67.5,
'E': 90.0, 'ESE': 112.5, 'SE': 135.0, 'SSE': 157.5,
'S': 180.0, 'SSW': 202.5, 'SW': 225.0, 'WSW': 247.5,
'W': 270.0, 'WNW': 292.5, 'NW': 315.0, 'NNW': 337.5,
};
final numStr = match.group(0);
final val = double.tryParse(numStr ?? '');
if (val == null) return null;
// Split on semicolons and parse each direction
final directions = <double>[];
final parts = raw.split(';');
for (final part in parts) {
final trimmed = part.trim().toUpperCase();
if (trimmed.isEmpty) continue;
// First try compass direction lookup
if (compassDirections.containsKey(trimmed)) {
directions.add(compassDirections[trimmed]!);
continue;
}
// Then try numeric parsing
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(trimmed);
if (match == null) continue;
// Normalize: wrap negative or >360 into 0359 range.
final normalized = ((val % 360) + 360) % 360;
return normalized;
final numStr = match.group(0);
final val = double.tryParse(numStr ?? '');
if (val == null) continue;
// Normalize: wrap negative or >360 into 0359 range
final normalized = ((val % 360) + 360) % 360;
directions.add(normalized);
}
return directions;
}
}

View File

@@ -7,8 +7,8 @@ enum UploadOperation { create, modify, delete }
class PendingUpload {
final LatLng coord;
final double direction;
final NodeProfile profile;
final dynamic direction; // Can be double or String for multiple directions
final NodeProfile? profile;
final OperatorProfile? operatorProfile;
final UploadMode uploadMode; // Capture upload destination when queued
final UploadOperation operation; // Type of operation: create, modify, or delete
@@ -21,7 +21,7 @@ class PendingUpload {
PendingUpload({
required this.coord,
required this.direction,
required this.profile,
this.profile,
this.operatorProfile,
required this.uploadMode,
required this.operation,
@@ -34,6 +34,10 @@ class PendingUpload {
(operation == UploadOperation.create && originalNodeId == null) ||
(operation != UploadOperation.create && originalNodeId != null),
'originalNodeId must be null for create operations and non-null for modify/delete operations'
),
assert(
(operation == UploadOperation.delete) || (profile != null),
'profile is required for create and modify operations'
);
// True if this is an edit of an existing node, false if it's a new node
@@ -56,7 +60,12 @@ class PendingUpload {
// Get combined tags from node profile and operator profile
Map<String, String> getCombinedTags() {
final tags = Map<String, String>.from(profile.tags);
// Deletions don't need tags
if (operation == UploadOperation.delete || profile == null) {
return {};
}
final tags = Map<String, String>.from(profile!.tags);
// Add operator profile tags (they override node profile tags if there are conflicts)
if (operatorProfile != null) {
@@ -64,8 +73,14 @@ class PendingUpload {
}
// Add direction if required
if (profile.requiresDirection) {
tags['direction'] = direction.toStringAsFixed(0);
if (profile!.requiresDirection) {
if (direction is String) {
tags['direction'] = direction;
} else if (direction is double) {
tags['direction'] = direction.toStringAsFixed(0);
} else {
tags['direction'] = '0';
}
}
return tags;
@@ -75,7 +90,7 @@ class PendingUpload {
'lat': coord.latitude,
'lon': coord.longitude,
'dir': direction,
'profile': profile.toJson(),
'profile': profile?.toJson(),
'operatorProfile': operatorProfile?.toJson(),
'uploadMode': uploadMode.index,
'operation': operation.index,
@@ -91,7 +106,7 @@ class PendingUpload {
direction: j['dir'],
profile: j['profile'] is Map<String, dynamic>
? NodeProfile.fromJson(j['profile'])
: NodeProfile.genericAlpr(),
: null, // Profile is optional for deletions
operatorProfile: j['operatorProfile'] != null
? OperatorProfile.fromJson(j['operatorProfile'])
: null,

View File

@@ -0,0 +1,189 @@
import 'dart:convert';
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
class SuspectedLocation {
final String ticketNo;
final LatLng centroid;
final List<LatLng> bounds;
final Map<String, dynamic>? geoJson;
final Map<String, dynamic> allFields; // All CSV fields except location and ticket_no
SuspectedLocation({
required this.ticketNo,
required this.centroid,
required this.bounds,
this.geoJson,
required this.allFields,
});
/// Create from CSV row data
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
final locationString = row['location'] as String?;
final ticketNo = row['ticket_no']?.toString() ?? '';
LatLng centroid = const LatLng(0, 0);
List<LatLng> bounds = [];
Map<String, dynamic>? geoJson;
// Parse GeoJSON if available
if (locationString != null && locationString.isNotEmpty) {
try {
geoJson = jsonDecode(locationString) as Map<String, dynamic>;
final coordinates = _extractCoordinatesFromGeoJson(geoJson);
centroid = coordinates.centroid;
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');
}
}
// Store all fields except location and ticket_no
final allFields = Map<String, dynamic>.from(row);
allFields.remove('location');
allFields.remove('ticket_no');
return SuspectedLocation(
ticketNo: ticketNo,
centroid: centroid,
bounds: bounds,
geoJson: geoJson,
allFields: allFields,
);
}
/// Extract coordinates from GeoJSON
static ({LatLng centroid, List<LatLng> bounds}) _extractCoordinatesFromGeoJson(Map<String, dynamic> geoJson) {
try {
// 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');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
final List<LatLng> points = [];
// Handle different geometry types
final type = geoJson['type'] as String?;
switch (type) {
case 'Point':
if (coordinates.length >= 2) {
final point = LatLng(
(coordinates[1] as num).toDouble(),
(coordinates[0] as num).toDouble(),
);
points.add(point);
}
break;
case 'Polygon':
// Polygon coordinates are [[[lng, lat], ...]]
if (coordinates.isNotEmpty) {
final ring = coordinates[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
break;
case 'MultiPolygon':
// MultiPolygon coordinates are [[[[lng, lat], ...], ...], ...]
for (final polygon in coordinates) {
if (polygon is List && polygon.isNotEmpty) {
final ring = polygon[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
}
break;
default:
print('Unsupported geometry type: $type');
}
if (points.isEmpty) {
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
// Calculate centroid
double sumLat = 0;
double sumLng = 0;
for (final point in points) {
sumLat += point.latitude;
sumLng += point.longitude;
}
final centroid = LatLng(sumLat / points.length, sumLng / points.length);
return (centroid: centroid, bounds: points);
} catch (e) {
print('Error extracting coordinates from GeoJSON: $e');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
}
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'ticket_no': ticketNo,
'geo_json': geoJson,
'centroid_lat': centroid.latitude,
'centroid_lng': centroid.longitude,
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
'all_fields': allFields,
};
/// Create from stored JSON
factory SuspectedLocation.fromJson(Map<String, dynamic> json) {
final boundsData = json['bounds'] as List?;
final bounds = boundsData?.map((b) => LatLng(
(b[0] as num).toDouble(),
(b[1] as num).toDouble(),
)).toList() ?? <LatLng>[];
return SuspectedLocation(
ticketNo: json['ticket_no'] ?? '',
geoJson: json['geo_json'],
centroid: LatLng(
(json['centroid_lat'] as num).toDouble(),
(json['centroid_lng'] as num).toDouble(),
),
bounds: bounds,
allFields: Map<String, dynamic>.from(json['all_fields'] ?? {}),
);
}
/// Get a formatted display address
String get displayAddress {
final parts = <String>[];
final addr = allFields['addr']?.toString();
final street = allFields['street']?.toString();
final city = allFields['city']?.toString();
final state = allFields['state']?.toString();
if (addr?.isNotEmpty == true) parts.add(addr!);
if (street?.isNotEmpty == true) parts.add(street!);
if (city?.isNotEmpty == true) parts.add(city!);
if (state?.isNotEmpty == true) parts.add(state!);
return parts.isNotEmpty ? parts.join(', ') : 'No address available';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SuspectedLocation &&
runtimeType == other.runtimeType &&
ticketNo == other.ticketNo;
@override
int get hashCode => ticketNo.hashCode;
}

View File

@@ -1,9 +1,25 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/localization_service.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
Future<void> _launchUrl(String url, BuildContext context) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: $url'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
@@ -15,30 +31,39 @@ class AboutScreen extends StatelessWidget {
title: Text(locService.t('settings.aboutThisApp')),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
locService.t('about.title'),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.description'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.features'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.initiative'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 24),
Text(
@@ -48,10 +73,121 @@ class AboutScreen extends StatelessWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Release Notes button
Center(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/settings/release-notes');
},
icon: const Icon(Icons.article_outlined),
label: const Text('View Release Notes'),
),
),
const SizedBox(height: 24),
_buildHelpLinks(context),
],
),
),
),
);
}
Widget _buildHelpLinks(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildLinkText(context, 'About DeFlock', 'https://deflock.me/about'),
const SizedBox(height: 8),
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
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'),
const SizedBox(height: 8),
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
const SizedBox(height: 24),
// Divider for account management section
Divider(
color: Theme.of(context).dividerColor.withOpacity(0.3),
),
const SizedBox(height: 16),
// Account deletion link (less prominent)
_buildAccountDeletionLink(context),
],
);
}
Widget _buildAccountDeletionLink(BuildContext context) {
final locService = LocalizationService.instance;
return GestureDetector(
onTap: () => _showDeleteAccountDialog(context, locService),
child: Text(
locService.t('auth.deleteAccount'),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
}
void _showDeleteAccountDialog(BuildContext context, LocalizationService locService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('auth.deleteAccount')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('auth.deleteAccountExplanation')),
const SizedBox(height: 12),
Text(
locService.t('auth.deleteAccountWarning'),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('https://www.openstreetmap.org/account/deletion', context);
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text(locService.t('auth.goToOSM')),
),
],
),
);
}
Widget _buildLinkText(BuildContext context, String text, String url) {
return GestureDetector(
onTap: () => _launchUrl(url, context),
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
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 '../services/localization_service.dart';
@@ -19,14 +20,21 @@ class AdvancedSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.advancedSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
MaxNodesSection(),
Divider(),
ProximityAlertsSection(),
Divider(),
NetworkStatusSection(),
SuspectedLocationsSection(),
Divider(),
// NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled
// Divider(),
TileProviderSection(),
],
),

View File

@@ -18,8 +18,13 @@ 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';
import '../widgets/changelog_dialog.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
import '../services/changelog_service.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -46,6 +51,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Track selected node for highlighting
int? _selectedNodeId;
// Track popup display to avoid showing multiple times
bool _hasCheckedForPopup = false;
@override
void initState() {
@@ -64,8 +72,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
switch (mode) {
case FollowMeMode.off:
return locService.t('followMe.off');
case FollowMeMode.northUp:
return locService.t('followMe.northUp');
case FollowMeMode.follow:
return locService.t('followMe.follow');
case FollowMeMode.rotating:
return locService.t('followMe.rotating');
}
@@ -75,7 +83,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
switch (mode) {
case FollowMeMode.off:
return Icons.gps_off;
case FollowMeMode.northUp:
case FollowMeMode.follow:
return Icons.gps_fixed;
case FollowMeMode.rotating:
return Icons.navigation;
@@ -85,8 +93,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
switch (mode) {
case FollowMeMode.off:
return FollowMeMode.northUp;
case FollowMeMode.northUp:
return FollowMeMode.follow;
case FollowMeMode.follow:
return FollowMeMode.rotating;
case FollowMeMode.rotating:
return FollowMeMode.off;
@@ -122,6 +130,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
setState(() {
_addSheetHeight = 0.0;
});
// Handle dismissal by canceling session if still active
final appState = context.read<AppState>();
if (appState.session != null) {
debugPrint('[HomeScreen] AddNodeSheet dismissed - canceling session');
appState.cancelSession();
}
});
}
@@ -130,6 +145,25 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Disable follow-me when editing a camera so the map doesn't jump around
appState.setFollowMeMode(FollowMeMode.off);
final session = appState.editSession!; // should be non-null when this is called
// Center map on the node being edited (same animation as openNodeTagSheet)
try {
_mapController.animateTo(
dest: session.originalNode.coord,
zoom: _mapController.mapController.camera.zoom,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} catch (_) {
// Map controller not ready, fallback to immediate move
try {
_mapController.mapController.move(session.originalNode.coord, _mapController.mapController.camera.zoom);
} catch (_) {
// Controller really not ready, skip centering
}
}
// Set transition flag to prevent map bounce
_transitioningToEdit = true;
@@ -137,8 +171,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (_tagSheetHeight > 0) {
Navigator.of(context).pop();
}
final session = appState.editSession!; // should be non-null when this is called
// Small delay to let tag sheet close smoothly
Future.delayed(const Duration(milliseconds: 150), () {
@@ -172,6 +204,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetHeight = 0.0;
_transitioningToEdit = false;
});
// Handle dismissal by canceling edit session if still active
final appState = context.read<AppState>();
if (appState.editSession != null) {
debugPrint('[HomeScreen] EditNodeSheet dismissed - canceling edit session');
appState.cancelEditSession();
}
});
});
}
@@ -217,6 +256,65 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
// Check for and display welcome/changelog popup
Future<void> _checkForPopup() async {
if (!mounted) return;
try {
final appState = context.read<AppState>();
// Run any needed migrations first
final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration();
for (final version in versionsNeedingMigration) {
await ChangelogService().runMigration(version, appState);
}
// Determine what popup to show
final popupType = await ChangelogService().getPopupType();
if (!mounted) return; // Check again after async operation
switch (popupType) {
case PopupType.welcome:
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const WelcomeDialog(),
);
break;
case PopupType.changelog:
final changelogContent = await ChangelogService().getChangelogContentForDisplay();
if (changelogContent != null) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ChangelogDialog(changelogContent: changelogContent),
);
}
break;
case PopupType.none:
// No popup needed
break;
}
// Complete the version change workflow (updates last seen version)
await ChangelogService().completeVersionChange();
} catch (e) {
// Silently handle errors to avoid breaking the app launch
debugPrint('[HomeScreen] Error checking for popup: $e');
// Still complete version change to avoid getting stuck
try {
await ChangelogService().completeVersionChange();
} catch (e2) {
debugPrint('[HomeScreen] Error completing version change: $e2');
}
}
}
void _onStartRoute() {
final appState = context.read<AppState>();
@@ -228,7 +326,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
userLocation = _mapViewKey.currentState?.getUserLocation();
if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) {
debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start');
appState.setFollowMeMode(FollowMeMode.northUp);
appState.setFollowMeMode(FollowMeMode.follow);
enableFollowMe = true;
}
} catch (e) {
@@ -455,6 +553,52 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
void openSuspectedLocationSheet(SuspectedLocation location) {
final appState = context.read<AppState>();
appState.selectSuspectedLocation(location);
// Start smooth centering animation simultaneously with sheet opening
try {
_mapController.animateTo(
dest: location.centroid,
zoom: _mapController.mapController.camera.zoom,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} catch (_) {
// Map controller not ready, fallback to immediate move
try {
_mapController.mapController.move(location.centroid, _mapController.mapController.camera.zoom);
} catch (_) {
// Controller really not ready, skip centering
}
}
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: SuspectedLocationSheet(location: location),
),
),
);
// Reset height and clear selection when sheet is dismissed
controller.closed.then((_) {
setState(() {
_tagSheetHeight = 0.0;
});
appState.clearSuspectedLocationSelection();
});
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@@ -478,6 +622,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
}
// Check for welcome/changelog popup after app is fully initialized
if (appState.isInitialized && !_hasCheckedForPopup) {
_hasCheckedForPopup = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _checkForPopup());
}
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight
@@ -536,6 +686,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
onSearchPressed: _onNavigationButtonPressed,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
@@ -557,71 +708,76 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
left: 8,
right: 8,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
child: Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Padding(
padding: EdgeInsets.only(
bottom: safeArea.bottom + kBottomButtonBarOffset,
left: leftPositionWithSafeArea(8, safeArea),
right: rightPositionWithSafeArea(8, safeArea),
),
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),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withOpacity(0.3),
blurRadius: 10,
offset: Offset(0, -2),
)
],
),
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
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: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
},
),
),
],

View File

@@ -15,9 +15,14 @@ class LanguageSettingsScreen extends StatelessWidget {
appBar: AppBar(
title: Text(locService.t('settings.language')),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: LanguageSection(),
body: Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: const LanguageSection(),
),
),
);

View File

@@ -17,7 +17,12 @@ class NavigationSettingsScreen extends StatelessWidget {
title: Text(locService.t('navigation.navigationSettings')),
),
body: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -17,7 +17,12 @@ class OfflineSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.offlineSettings')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
OfflineModeSection(),
Divider(),

View File

@@ -56,7 +56,12 @@ class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextField(
controller: _nameCtrl,

View File

@@ -67,7 +67,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextField(
controller: _nameCtrl,

View File

@@ -17,7 +17,12 @@ class ProfilesSettingsScreen extends StatelessWidget {
title: Text(locService.t('settings.profiles')),
),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: const [
NodeProfilesSection(),
Divider(),

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import '../services/changelog_service.dart';
import '../services/version_service.dart';
class ReleaseNotesScreen extends StatefulWidget {
const ReleaseNotesScreen({super.key});
@override
State<ReleaseNotesScreen> createState() => _ReleaseNotesScreenState();
}
class _ReleaseNotesScreenState extends State<ReleaseNotesScreen> {
Map<String, String>? _changelogs;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadChangelogs();
}
Future<void> _loadChangelogs() async {
try {
// Ensure changelog service is initialized
if (!ChangelogService().isInitialized) {
await ChangelogService().init();
}
final changelogs = ChangelogService().getAllChangelogs();
if (mounted) {
setState(() {
_changelogs = changelogs;
_isLoading = false;
});
}
} catch (e) {
debugPrint('[ReleaseNotesScreen] Error loading changelogs: $e');
if (mounted) {
setState(() {
_changelogs = {};
_isLoading = false;
});
}
}
}
List<String> _sortVersions(List<String> versions) {
// Simple version sorting - splits by '.' and compares numerically
versions.sort((a, b) {
final aParts = a.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
final bParts = b.split('.').map(int.tryParse).where((v) => v != null).cast<int>().toList();
// Compare version parts (reverse order for newest first)
for (int i = 0; i < aParts.length && i < bParts.length; i++) {
final comparison = bParts[i].compareTo(aParts[i]); // Reverse for desc order
if (comparison != 0) return comparison;
}
// If one version has more parts, the longer one is newer
return bParts.length.compareTo(aParts.length);
});
return versions;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Release Notes'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _changelogs == null || _changelogs!.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text(
'No release notes available.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
)
: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Current version indicator
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Current Version: ${VersionService().version}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
// Changelog entries
..._buildChangelogEntries(),
],
),
);
}
List<Widget> _buildChangelogEntries() {
if (_changelogs == null || _changelogs!.isEmpty) return [];
final sortedVersions = _sortVersions(_changelogs!.keys.toList());
final currentVersion = VersionService().version;
return sortedVersions.map((version) {
final content = _changelogs![version]!;
final isCurrentVersion = version == currentVersion;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
border: Border.all(
color: isCurrentVersion
? Theme.of(context).colorScheme.primary.withOpacity(0.3)
: Theme.of(context).dividerColor.withOpacity(0.3),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: Row(
children: [
Text(
'Version $version',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isCurrentVersion
? Theme.of(context).colorScheme.primary
: null,
),
),
if (isCurrentVersion) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
],
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
child: Text(
content,
style: const TextStyle(height: 1.4),
),
),
],
),
);
}).toList();
}
}

View File

@@ -70,7 +70,8 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
width: 80,
child: TextFormField(
controller: _controller,
keyboardType: TextInputType.number,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),

View File

@@ -77,6 +77,27 @@ 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),
),
),
],
);
},

View File

@@ -181,7 +181,8 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
width: 80,
child: TextField(
controller: _distanceController,
keyboardType: TextInputType.number,
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],

View File

@@ -119,7 +119,11 @@ class QueueSection extends StatelessWidget {
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.round().toString()]) + '\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')}" : "")
),

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
class SuspectedLocationsSection extends StatelessWidget {
const SuspectedLocationsSection({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final isEnabled = appState.suspectedLocationsEnabled;
final isLoading = appState.suspectedLocationsLoading;
final lastFetch = appState.suspectedLocationsLastFetch;
String getLastFetchText() {
if (lastFetch == null) {
return locService.t('suspectedLocations.neverFetched');
} else {
final now = DateTime.now();
final diff = now.difference(lastFetch);
if (diff.inDays > 0) {
return locService.t('suspectedLocations.daysAgo', params: [diff.inDays.toString()]);
} else if (diff.inHours > 0) {
return locService.t('suspectedLocations.hoursAgo', params: [diff.inHours.toString()]);
} else if (diff.inMinutes > 0) {
return locService.t('suspectedLocations.minutesAgo', params: [diff.inMinutes.toString()]);
} else {
return locService.t('suspectedLocations.justNow');
}
}
}
Future<void> handleRefresh() async {
if (!context.mounted) return;
// Use the inline loading indicator by calling refreshSuspectedLocations
// The loading state will be managed by suspected location state
final success = await appState.refreshSuspectedLocations();
// Show result snackbar
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? locService.t('suspectedLocations.updateSuccess')
: locService.t('suspectedLocations.updateFailed')),
),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocations.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Enable/disable switch
ListTile(
leading: const Icon(Icons.help_outline),
title: Text(locService.t('suspectedLocations.showSuspectedLocations')),
subtitle: Text(locService.t('suspectedLocations.showSuspectedLocationsSubtitle')),
trailing: Switch(
value: isEnabled,
onChanged: (enabled) {
appState.setSuspectedLocationsEnabled(enabled);
},
),
),
if (isEnabled) ...[
const SizedBox(height: 8),
// Last update time
ListTile(
leading: const Icon(Icons.schedule),
title: Text(locService.t('suspectedLocations.lastUpdated')),
subtitle: Text(getLastFetchText()),
trailing: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.refresh),
onPressed: handleRefresh,
tooltip: locService.t('suspectedLocations.refreshNow'),
),
),
// Data info with credit
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(locService.t('suspectedLocations.dataSource')),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('suspectedLocations.dataSourceDescription')),
const SizedBox(height: 4),
Text(
locService.t('suspectedLocations.dataSourceCredit'),
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
// Minimum distance setting
ListTile(
leading: const Icon(Icons.social_distance),
title: Text(locService.t('suspectedLocations.minimumDistance')),
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
trailing: SizedBox(
width: 80,
child: TextFormField(
initialValue: appState.suspectedLocationMinDistance.toString(),
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
suffixText: 'm',
),
onFieldSubmitted: (value) {
final distance = int.tryParse(value) ?? 100;
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
},
),
),
),
],
],
);
},
);
}
}

View File

@@ -18,7 +18,12 @@ class SettingsScreen extends StatelessWidget {
builder: (context, child) => Scaffold(
appBar: AppBar(title: Text(locService.t('settings.title'))),
body: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[

View File

@@ -64,7 +64,12 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
TextFormField(
controller: _nameController,

View File

@@ -0,0 +1,318 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'version_service.dart';
import '../app_state.dart';
/// Service for managing changelog data and first launch detection
class ChangelogService {
static final ChangelogService _instance = ChangelogService._internal();
factory ChangelogService() => _instance;
ChangelogService._internal();
static const String _lastSeenVersionKey = 'last_seen_version';
static const String _hasSeenWelcomeKey = 'has_seen_welcome';
Map<String, dynamic>? _changelogData;
bool _initialized = false;
/// Parse changelog content from either string or array format
String? _parseChangelogContent(dynamic content) {
if (content == null) return null;
if (content is String) {
// Legacy format: single string with \n
return content.isEmpty ? null : content;
} else if (content is List) {
// New format: array of strings
final lines = content.whereType<String>().where((line) => line.isNotEmpty).toList();
return lines.isEmpty ? null : lines.join('\n');
}
return null;
}
/// Initialize the service by loading changelog data
Future<void> init() async {
if (_initialized) return;
try {
final String jsonString = await rootBundle.loadString('assets/changelog.json');
_changelogData = json.decode(jsonString);
_initialized = true;
debugPrint('[ChangelogService] Loaded changelog with ${_changelogData?.keys.length ?? 0} versions');
} catch (e) {
debugPrint('[ChangelogService] Failed to load changelog: $e');
_changelogData = {};
_initialized = true; // Mark as initialized even on failure to prevent repeated attempts
}
}
/// Check if this is the first app launch ever
Future<bool> isFirstLaunch() async {
final prefs = await SharedPreferences.getInstance();
return !prefs.containsKey(_lastSeenVersionKey);
}
/// Check if user has seen the welcome popup (separate from version tracking)
Future<bool> hasSeenWelcome() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_hasSeenWelcomeKey) ?? false;
}
/// Mark that user has seen the welcome popup
Future<void> markWelcomeSeen() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_hasSeenWelcomeKey, true);
}
/// Check if app version has changed since last launch
Future<bool> hasVersionChanged() async {
final prefs = await SharedPreferences.getInstance();
final lastSeenVersion = prefs.getString(_lastSeenVersionKey);
final currentVersion = VersionService().version;
return lastSeenVersion != currentVersion;
}
/// Update the stored version to current version
Future<void> updateLastSeenVersion() async {
final prefs = await SharedPreferences.getInstance();
final currentVersion = VersionService().version;
await prefs.setString(_lastSeenVersionKey, currentVersion);
debugPrint('[ChangelogService] Updated last seen version to: $currentVersion');
}
/// Get the last seen version (for migration purposes)
Future<String?> getLastSeenVersion() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_lastSeenVersionKey);
}
/// Get changelog content for the current version
String? getChangelogForCurrentVersion() {
if (!_initialized || _changelogData == null) {
debugPrint('[ChangelogService] Not initialized or no changelog data');
return null;
}
final currentVersion = VersionService().version;
final versionData = _changelogData![currentVersion] as Map<String, dynamic>?;
if (versionData == null) {
debugPrint('[ChangelogService] No changelog entry found for version: $currentVersion');
return null;
}
return _parseChangelogContent(versionData['content']);
}
/// Get the changelog content that should be displayed (may be combined from multiple versions)
/// This is the method home_screen should use to get content for the changelog popup
Future<String?> getChangelogContentForDisplay() async {
return await getCombinedChangelogContent();
}
/// Complete the version change workflow - call this after showing popups
/// This updates the last seen version so migrations don't run again
Future<void> completeVersionChange() async {
await updateLastSeenVersion();
}
/// Get changelog content for a specific version
String? getChangelogForVersion(String version) {
if (!_initialized || _changelogData == null) return null;
final versionData = _changelogData![version] as Map<String, dynamic>?;
if (versionData == null) return null;
return _parseChangelogContent(versionData['content']);
}
/// Get all changelog entries (for settings page)
Map<String, String> getAllChangelogs() {
if (!_initialized || _changelogData == null) return {};
final Map<String, String> result = {};
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = _parseChangelogContent(versionData?['content']);
// Only include versions with non-empty content
if (content != null && content.isNotEmpty) {
result[version] = content;
}
}
return result;
}
/// Determine what popup (if any) should be shown
Future<PopupType> getPopupType() async {
// Ensure services are initialized
if (!_initialized) await init();
final isFirstLaunch = await this.isFirstLaunch();
final hasSeenWelcome = await this.hasSeenWelcome();
final hasVersionChanged = await this.hasVersionChanged();
// First launch and haven't seen welcome
if (isFirstLaunch || !hasSeenWelcome) {
return PopupType.welcome;
}
// Version changed and there's changelog content
if (hasVersionChanged) {
final changelogContent = await getCombinedChangelogContent();
if (changelogContent != null) {
return PopupType.changelog;
}
}
return PopupType.none;
}
/// Check if version-change migrations need to be run
/// Returns list of version strings that need migrations
Future<List<String>> getVersionsNeedingMigration() async {
final lastSeenVersion = await getLastSeenVersion();
final currentVersion = VersionService().version;
if (lastSeenVersion == null) return []; // First launch, no migrations needed
final versionsNeedingMigration = <String>[];
// Check each version that could need migration
if (needsMigration(lastSeenVersion, currentVersion, '1.3.1')) {
versionsNeedingMigration.add('1.3.1');
}
// Future versions can be added here
// if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) {
// versionsNeedingMigration.add('2.0.0');
// }
return versionsNeedingMigration;
}
/// Get combined changelog content for all versions between last seen and current
/// Returns null if no changelog content exists for any intermediate version
Future<String?> getCombinedChangelogContent() async {
if (!_initialized || _changelogData == null) return null;
final lastSeenVersion = await getLastSeenVersion();
final currentVersion = VersionService().version;
if (lastSeenVersion == null) {
// First launch - just return current version changelog
return getChangelogForCurrentVersion();
}
final intermediateVersions = <String>[];
// Collect all relevant versions between lastSeen and current (exclusive of lastSeen, inclusive of current)
for (final entry in _changelogData!.entries) {
final version = entry.key;
final versionData = entry.value as Map<String, dynamic>?;
final content = _parseChangelogContent(versionData?['content']);
// Skip versions with empty content
if (content == null || content.isEmpty) continue;
// Include versions where: lastSeenVersion < version <= currentVersion
if (needsMigration(lastSeenVersion, currentVersion, version)) {
intermediateVersions.add(version);
}
}
// Sort versions in descending order (newest first)
intermediateVersions.sort((a, b) => compareVersions(b, a));
// Build changelog content
final intermediateChangelogs = intermediateVersions.map((version) {
final versionData = _changelogData![version] as Map<String, dynamic>;
final content = _parseChangelogContent(versionData['content'])!; // Safe to use ! here since we filtered empty content above
return '**Version $version:**\n$content';
}).toList();
return intermediateChangelogs.isNotEmpty ? intermediateChangelogs.join('\n\n---\n\n') : null;
}
/// Check if the service is properly initialized
bool get isInitialized => _initialized;
/// Run a specific migration by version number
Future<void> runMigration(String version, AppState appState) async {
debugPrint('[ChangelogService] Running $version migration');
switch (version) {
case '1.3.1':
// Enable network status indicator for all existing users
await appState.setNetworkStatusIndicatorEnabled(true);
debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator');
break;
// Future migrations can be added here
// case '2.0.0':
// await appState.doSomethingNew();
// debugPrint('[ChangelogService] 2.0.0 migration completed');
// break;
default:
debugPrint('[ChangelogService] Unknown migration version: $version');
}
}
/// Check if a migration should run
/// Migration runs if: lastSeenVersion < migrationVersion <= currentVersion
bool needsMigration(String lastSeenVersion, String currentVersion, String migrationVersion) {
final lastVsMigration = compareVersions(lastSeenVersion, migrationVersion);
final migrationVsCurrent = compareVersions(migrationVersion, currentVersion);
return lastVsMigration < 0 && migrationVsCurrent <= 0;
}
/// Compare two version strings
/// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
/// Versions are expected in format "major.minor.patch" (e.g., "1.3.1")
int compareVersions(String v1, String v2) {
try {
final v1Parts = v1.split('.').map(int.parse).toList();
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);
// Compare major version first
if (v1Parts[0] < v2Parts[0]) return -1;
if (v1Parts[0] > v2Parts[0]) return 1;
// Major versions equal, compare minor version
if (v1Parts[1] < v2Parts[1]) return -1;
if (v1Parts[1] > v2Parts[1]) return 1;
// Major and minor equal, compare patch version
if (v1Parts[2] < v2Parts[2]) return -1;
if (v1Parts[2] > v2Parts[2]) return 1;
// All parts equal
return 0;
} catch (e) {
debugPrint('[ChangelogService] Error comparing versions "$v1" vs "$v2": $e');
// Safe fallback: assume they're different so we run migrations
return v1 == v2 ? 0 : -1;
}
}
}
/// Types of popups that can be shown
enum PopupType {
none,
welcome,
changelog,
}

View File

@@ -11,6 +11,7 @@ import 'map_data_submodules/tiles_from_remote.dart';
import 'map_data_submodules/nodes_from_local.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'network_status.dart';
import 'prefetch_area_service.dart';
enum MapSource { local, remote, auto } // For future use
@@ -41,7 +42,6 @@ class MapDataProvider {
UploadMode uploadMode = UploadMode.production,
MapSource source = MapSource.auto,
}) async {
try {
final offline = AppState.instance.offlineMode;
// Explicit remote request: error if offline, else always remote
@@ -89,52 +89,40 @@ class MapDataProvider {
maxResults: AppState.instance.maxCameras,
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
final List<Future<List<OsmNode>>> futures = [];
// Production mode: use pre-fetch service for efficient area loading
final preFetchService = PrefetchAreaService();
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
// Always get local nodes first (fast, from cache)
final localNodes = await fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
));
);
// Always try to get remote nodes (slower, fresh data)
futures.add(_fetchRemoteNodes(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
return <OsmNode>[]; // Return empty list on remote failure
}));
// Check if we need to trigger a new pre-fetch (spatial or temporal)
final needsFetch = !preFetchService.isWithinPreFetchedArea(bounds, profiles, uploadMode) ||
preFetchService.isDataStale();
// Wait for both, then merge with deduplication by node ID
final results = await Future.wait(futures);
final localNodes = results[0];
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
final Map<int, OsmNode> mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
mergedNodes[node.id] = node;
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');
}
// Add remote nodes, overwriting any local duplicates
for (final node in remoteNodes) {
mergedNodes[node.id] = node;
// Apply rendering limit and warn if nodes are being excluded
final maxNodes = AppState.instance.maxCameras;
if (localNodes.length > maxNodes) {
NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes);
}
// Apply maxCameras limit to the merged result
final finalNodes = mergedNodes.values.take(AppState.instance.maxCameras).toList();
return finalNodes;
}
} finally {
// Always report node completion, regardless of success or failure
NetworkStatus.instance.reportNodeComplete();
return localNodes.take(maxNodes).toList();
}
}
@@ -212,6 +200,11 @@ class MapDataProvider {
void clearTileQueue() {
clearRemoteTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearTileQueueSelective(LatLngBounds currentBounds) {
clearRemoteTileQueueSelective(currentBounds);
}
/// Fetch remote nodes with Overpass first, OSM API fallback
Future<List<OsmNode>> _fetchRemoteNodes({

View File

@@ -47,44 +47,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = <OsmNode>[];
// Find all node elements
for (final nodeElement in document.findAllElements('node')) {
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
final latStr = nodeElement.getAttribute('lat');
final lonStr = nodeElement.getAttribute('lon');
if (id == null || latStr == null || lonStr == null) continue;
final lat = double.tryParse(latStr);
final lon = double.tryParse(lonStr);
if (lat == null || lon == null) continue;
// Parse tags
final tags = <String, String>{};
for (final tagElement in nodeElement.findElements('tag')) {
final key = tagElement.getAttribute('k');
final value = tagElement.getAttribute('v');
if (key != null && value != null) {
tags[key] = value;
}
}
// Check if this node matches any of our profiles
if (_nodeMatchesProfiles(tags, profiles)) {
nodes.add(OsmNode(
id: id,
coord: LatLng(lat, lon),
tags: tags,
));
}
// Respect maxResults limit if set
if (maxResults > 0 && nodes.length >= maxResults) {
break;
}
}
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
@@ -107,6 +70,93 @@ Future<List<OsmNode>> fetchOsmApiNodes({
}
}
/// Parse OSM API XML response to create OsmNode objects with constraint information.
List<OsmNode> _parseOsmApiResponseWithConstraints(XmlDocument document, List<NodeProfile> profiles, int maxResults) {
final surveillanceNodes = <int, Map<String, dynamic>>{}; // nodeId -> node data
final constrainedNodeIds = <int>{};
// First pass: collect surveillance nodes
for (final nodeElement in document.findAllElements('node')) {
final id = int.tryParse(nodeElement.getAttribute('id') ?? '');
final latStr = nodeElement.getAttribute('lat');
final lonStr = nodeElement.getAttribute('lon');
if (id == null || latStr == null || lonStr == null) continue;
final lat = double.tryParse(latStr);
final lon = double.tryParse(lonStr);
if (lat == null || lon == null) continue;
// Parse tags
final tags = <String, String>{};
for (final tagElement in nodeElement.findElements('tag')) {
final key = tagElement.getAttribute('k');
final value = tagElement.getAttribute('v');
if (key != null && value != null) {
tags[key] = value;
}
}
// Check if this node matches any of our profiles
if (_nodeMatchesProfiles(tags, profiles)) {
surveillanceNodes[id] = {
'id': id,
'lat': lat,
'lon': lon,
'tags': tags,
};
}
}
// Second pass: identify constrained nodes from ways
for (final wayElement in document.findAllElements('way')) {
for (final ndElement in wayElement.findElements('nd')) {
final ref = int.tryParse(ndElement.getAttribute('ref') ?? '');
if (ref != null && surveillanceNodes.containsKey(ref)) {
constrainedNodeIds.add(ref);
}
}
}
// Third pass: identify constrained nodes from relations
for (final relationElement in document.findAllElements('relation')) {
for (final memberElement in relationElement.findElements('member')) {
if (memberElement.getAttribute('type') == 'node') {
final ref = int.tryParse(memberElement.getAttribute('ref') ?? '');
if (ref != null && surveillanceNodes.containsKey(ref)) {
constrainedNodeIds.add(ref);
}
}
}
}
// Create OsmNode objects with constraint information
final nodes = <OsmNode>[];
for (final nodeData in surveillanceNodes.values) {
final nodeId = nodeData['id'] as int;
final isConstrained = constrainedNodeIds.contains(nodeId);
nodes.add(OsmNode(
id: nodeId,
coord: LatLng(nodeData['lat'], nodeData['lon']),
tags: nodeData['tags'] as Map<String, String>,
isConstrained: isConstrained,
));
// Respect maxResults limit if set
if (maxResults > 0 && nodes.length >= maxResults) {
break;
}
}
final constrainedCount = nodes.where((n) => n.isConstrained).length;
if (constrainedCount > 0) {
debugPrint('[fetchOsmApiNodes] Found $constrainedCount constrained nodes out of ${nodes.length} total');
}
return nodes;
}
/// Check if a node's tags match any of the given profiles
bool _nodeMatchesProfiles(Map<String, String> nodeTags, List<NodeProfile> profiles) {
for (final profile in profiles) {

View File

@@ -8,17 +8,101 @@ 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;
return _fetchOverpassNodesWithSplitting(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
splitDepth: 0,
wasUserInitiated: wasUserInitiated,
);
}
/// 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 wasUserInitiated,
}) 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,
);
} on OverpassRateLimitException catch (e) {
// Rate limits should NOT be split - just fail with extended backoff
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
// Report error if user was waiting
if (wasUserInitiated) {
NetworkStatus.instance.setNetworkError();
}
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
return []; // Return empty rather than rethrowing
} 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');
// Report timeout if this was user-initiated (can't split further)
if (wasUserInitiated) {
NetworkStatus.instance.setTimeoutError();
}
return [];
}
// 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,
wasUserInitiated: wasUserInitiated,
);
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,
}) async {
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
// Build the Overpass query
@@ -34,27 +118,49 @@ Future<List<OsmNode>> fetchOverpassNodes({
);
if (response.statusCode != 200) {
debugPrint('[fetchOverpassNodes] Overpass API error: ${response.body}');
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);
}
NetworkStatus.instance.reportOverpassIssue();
return [];
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
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} surveillance nodes');
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
}
NetworkStatus.instance.reportOverpassSuccess();
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
return OsmNode(
id: element['id'],
coord: LatLng(element['lat'], element['lon']),
tags: Map<String, String>.from(element['tags'] ?? {}),
);
}).toList();
// 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);
@@ -62,6 +168,9 @@ Future<List<OsmNode>> fetchOverpassNodes({
return nodes;
} catch (e) {
// Re-throw OverpassNodeLimitException so splitting logic can catch it
if (e is OverpassNodeLimitException) rethrow;
debugPrint('[fetchOverpassNodes] Exception: $e');
// Report network issues for connection errors
@@ -76,6 +185,7 @@ Future<List<OsmNode>> fetchOverpassNodes({
}
/// 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) {
// Build node clauses for each profile
final nodeClauses = profiles.map((profile) {
@@ -86,20 +196,101 @@ String _buildOverpassQuery(LatLngBounds bounds, List<NodeProfile> profiles, int
// 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 ');
}).join('\n ');
// Use unlimited output if maxResults is 0
final outputClause = maxResults > 0 ? 'out body $maxResults;' : 'out body;';
return '''
[out:json][timeout:25];
(
$nodeClauses
);
$outputClause
out body ${maxResults > 0 ? maxResults : ''};
(
way(bn);
rel(bn);
);
out meta;
''';
}
/// 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 {

View File

@@ -3,6 +3,8 @@ 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';
import '../network_status.dart';
@@ -18,6 +20,77 @@ void clearRemoteTileQueue() {
}
}
/// 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: initialDelay * (multiplier ^ (attempt - 1))
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 in-memory retries/backoff, and global concurrency limit.
@@ -31,16 +104,10 @@ Future<List<int>> fetchRemoteTile({
const int maxAttempts = kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final delays = [
kTileFetchInitialDelayMs + random.nextInt(kTileFetchJitter1Ms),
kTileFetchSecondDelayMs + random.nextInt(kTileFetchJitter2Ms),
kTileFetchThirdDelayMs + random.nextInt(kTileFetchJitter3Ms),
];
final hostInfo = Uri.parse(url).host; // For logging
while (true) {
await _tileFetchSemaphore.acquire();
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
try {
// Only log on first attempt or errors
if (attempt == 1) {
@@ -71,7 +138,7 @@ Future<List<int>> fetchRemoteTile({
rethrow;
}
final delay = delays[attempt - 1].clamp(0, 60000);
final delay = _calculateRetryDelay(attempt, random);
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
}
@@ -97,28 +164,42 @@ Future<List<int>> fetchOSMTile({
);
}
/// Simple counting semaphore, suitable for single-thread Flutter concurrency
/// 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
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<VoidCallback> _queue = [];
final List<_TileRequest> _queue = [];
_SimpleSemaphore(this._max);
Future<void> acquire() async {
Future<void> acquire({int? z, int? x, int? y}) async {
if (_current < _max) {
_current++;
return;
} else {
final c = Completer<void>();
_queue.add(() => c.complete());
final request = _TileRequest(
z: z ?? -1,
x: x ?? -1,
y: y ?? -1,
callback: () => c.complete(),
);
_queue.add(request);
await c.future;
}
}
void release() {
if (_queue.isNotEmpty) {
final callback = _queue.removeAt(0);
callback();
final request = _queue.removeAt(0);
request.callback();
} else {
_current--;
}
@@ -130,4 +211,17 @@ class _SimpleSemaphore {
_queue.clear();
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;
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
final clearedCount = initialCount - _queue.length;
if (clearedCount > 0) {
debugPrint('[SimpleSemaphore] Cleared $clearedCount stale tile requests, kept ${_queue.length}');
}
return clearedCount;
}
}

View File

@@ -4,10 +4,9 @@ import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
/// Simple loading state for dual-source async operations (brutalist approach)
enum LoadingState { ready, waiting, success, timeout }
class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
@@ -25,13 +24,8 @@ class NetworkStatus extends ChangeNotifier {
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
// New dual-source loading state (brutalist approach)
LoadingState _tileLoadingState = LoadingState.ready;
LoadingState _nodeLoadingState = LoadingState.ready;
Timer? _tileTimeoutTimer;
Timer? _nodeTimeoutTimer;
Timer? _successDisplayTimer;
bool _nodeLimitReached = false;
Timer? _nodeLimitResetTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
@@ -41,28 +35,16 @@ class NetworkStatus extends ChangeNotifier {
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
// New dual-source getters (brutalist approach)
LoadingState get tileLoadingState => _tileLoadingState;
LoadingState get nodeLoadingState => _nodeLoadingState;
/// Derive overall loading status from dual sources
bool get isDualSourceLoading => _tileLoadingState == LoadingState.waiting || _nodeLoadingState == LoadingState.waiting;
bool get isDualSourceTimeout => _tileLoadingState == LoadingState.timeout || _nodeLoadingState == LoadingState.timeout;
bool get isDualSourceSuccess => _tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success;
bool get nodeLimitReached => _nodeLimitReached;
NetworkStatusType get currentStatus {
// Check new dual-source states first
if (isDualSourceTimeout) return NetworkStatusType.timedOut;
if (isDualSourceLoading) return NetworkStatusType.waiting;
if (isDualSourceSuccess) return NetworkStatusType.success;
// Fall back to legacy states for compatibility
// 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;
if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached;
return NetworkStatusType.ready;
}
@@ -194,18 +176,72 @@ class NetworkStatus extends ChangeNotifier {
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_nodeLimitReached = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
notifyListeners();
}
}
/// Set timeout error state
void setTimeoutError() {
_isWaitingForData = false;
_isTimedOut = true;
_hasNoData = false;
_hasSuccess = false;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
notifyListeners();
debugPrint('[NetworkStatus] Request timed out');
// Auto-clear timeout after 5 seconds
Timer(const Duration(seconds: 5), () {
if (_isTimedOut) {
_isTimedOut = false;
notifyListeners();
}
});
}
/// 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();
debugPrint('[NetworkStatus] Network error occurred');
}
/// Show notification that node display limit was reached
void reportNodeLimitReached(int totalNodes, int maxNodes) {
_nodeLimitReached = true;
notifyListeners();
debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes');
// Auto-clear after 8 seconds
_nodeLimitResetTimer?.cancel();
_nodeLimitResetTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLimitReached) {
_nodeLimitReached = false;
notifyListeners();
}
});
}
@@ -231,81 +267,7 @@ class NetworkStatus extends ChangeNotifier {
});
}
// New dual-source loading methods (brutalist approach)
/// Start waiting for both tiles and nodes
void setDualSourceWaiting() {
_tileLoadingState = LoadingState.waiting;
_nodeLoadingState = LoadingState.waiting;
// Set timeout timers for both
_tileTimeoutTimer?.cancel();
_tileTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Tile loading timed out');
notifyListeners();
}
});
_nodeTimeoutTimer?.cancel();
_nodeTimeoutTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.timeout;
debugPrint('[NetworkStatus] Node loading timed out');
notifyListeners();
}
});
notifyListeners();
}
/// Report tile loading completion
void reportTileComplete() {
if (_tileLoadingState == LoadingState.waiting) {
_tileLoadingState = LoadingState.success;
_tileTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Report node loading completion
void reportNodeComplete() {
if (_nodeLoadingState == LoadingState.waiting) {
_nodeLoadingState = LoadingState.success;
_nodeTimeoutTimer?.cancel();
_checkDualSourceComplete();
}
}
/// Check if both sources are complete and show success briefly
void _checkDualSourceComplete() {
if (_tileLoadingState == LoadingState.success && _nodeLoadingState == LoadingState.success) {
debugPrint('[NetworkStatus] Both tiles and nodes loaded successfully');
notifyListeners();
// Auto-reset to ready after showing success briefly
_successDisplayTimer?.cancel();
_successDisplayTimer = Timer(const Duration(seconds: 2), () {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
notifyListeners();
});
} else {
// Just notify if one completed but not both yet
notifyListeners();
}
}
/// Reset dual-source state to ready
void resetDualSourceState() {
_tileLoadingState = LoadingState.ready;
_nodeLoadingState = LoadingState.ready;
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
notifyListeners();
}
@override
void dispose() {
@@ -313,9 +275,8 @@ class NetworkStatus extends ChangeNotifier {
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_tileTimeoutTimer?.cancel();
_nodeTimeoutTimer?.cancel();
_successDisplayTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -11,6 +11,7 @@ class NodeCache {
final Map<int, OsmNode> _nodes = {};
/// Add or update a batch of nodes in the cache.
/// TODO: Consider moving to compute() if cache operations cause ANR
void addOrUpdate(List<OsmNode> nodes) {
for (var node in nodes) {
final existing = _nodes[node.id];
@@ -26,6 +27,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: mergedTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
} else {
_nodes[node.id] = node;
@@ -57,6 +59,7 @@ class NodeCache {
id: node.id,
coord: node.coord,
tags: cleanTags,
isConstrained: node.isConstrained, // Preserve constraint information
);
}
}

View File

@@ -0,0 +1,23 @@
/// 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';
}

View File

@@ -0,0 +1,197 @@
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/camera_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();
// Report completion to network status (only if user was waiting)
NetworkStatus.instance.setSuccess();
// Notify UI that cache has been updated with fresh data
CameraProviderWithCache.instance.refreshDisplay();
} catch (e) {
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
// Report failure to network status (only if user was waiting)
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
// 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();
}
}

View File

@@ -20,7 +20,6 @@ class ProfileService {
// MUST convert to List before jsonEncode; the previous MappedIterable
// caused "Converting object to an encodable object failed".
final encodable = profiles
.where((p) => !p.builtin)
.map((p) => p.toJson())
.toList(); // <- crucial

View File

@@ -1,5 +1,7 @@
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 '../app_state.dart';
import 'map_data_provider.dart';
@@ -84,7 +86,10 @@ class SimpleTileHttpClient extends http.BaseClient {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
NetworkStatus.instance.reportTileComplete();
// Only report tile completion if we were in loading state (user-initiated)
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
NetworkStatus.instance.setSuccess();
}
}
}
}
@@ -93,6 +98,11 @@ class SimpleTileHttpClient extends http.BaseClient {
void clearTileQueue() {
_mapDataProvider.clearTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearStaleRequests(LatLngBounds currentBounds) {
_mapDataProvider.clearTileQueueSelective(currentBounds);
}
/// Format date for HTTP headers (RFC 7231)
String _httpDateFormat(DateTime date) {

View File

@@ -0,0 +1,227 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import '../models/suspected_location.dart';
import 'suspected_location_service.dart';
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
class SuspectedLocationEntry {
final Map<String, dynamic> rawData;
final LatLng centroid;
SuspectedLocationEntry({required this.rawData, required this.centroid});
Map<String, dynamic> toJson() => {
'rawData': rawData,
'centroid': [centroid.latitude, centroid.longitude],
};
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
final centroidList = json['centroid'] as List;
return SuspectedLocationEntry(
rawData: Map<String, dynamic>.from(json['rawData']),
centroid: LatLng(
(centroidList[0] as num).toDouble(),
(centroidList[1] as num).toDouble(),
),
);
}
}
class SuspectedLocationCache extends ChangeNotifier {
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
factory SuspectedLocationCache() => _instance;
SuspectedLocationCache._();
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
List<SuspectedLocationEntry> _processedEntries = [];
DateTime? _lastFetchTime;
final Map<String, List<SuspectedLocation>> _boundsCache = {};
/// Get suspected locations within specific bounds (cached)
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
if (!SuspectedLocationService().isEnabled) {
debugPrint('[SuspectedLocationCache] Service not enabled');
return [];
}
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
// Check cache first
if (_boundsCache.containsKey(boundsKey)) {
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
return _boundsCache[boundsKey]!;
}
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
final locations = <SuspectedLocation>[];
int inBoundsCount = 0;
for (final entry in _processedEntries) {
// Quick bounds check using pre-calculated centroid
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat <= bounds.north && lat >= bounds.south &&
lng <= bounds.east && lng >= bounds.west) {
try {
// Only create SuspectedLocation object if it's in bounds
final location = SuspectedLocation.fromCsvRow(entry.rawData);
locations.add(location);
inBoundsCount++;
} catch (e) {
// Skip invalid entries
continue;
}
}
}
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
// Cache the result
_boundsCache[boundsKey] = locations;
// Limit cache size to prevent memory issues
if (_boundsCache.length > 100) {
final oldestKey = _boundsCache.keys.first;
_boundsCache.remove(oldestKey);
}
return locations;
}
/// Load processed data from storage
Future<void> loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load last fetch time
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
if (lastFetchMs != null) {
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
}
// Load processed data
final processedDataString = prefs.getString(_prefsKeyProcessedData);
if (processedDataString != null) {
final List<dynamic> processedDataList = jsonDecode(processedDataString);
_processedEntries = processedDataList
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
}
} catch (e) {
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
_processedEntries.clear();
_lastFetchTime = null;
}
}
/// Process raw CSV data and save to storage (calculates centroids once)
Future<void> processAndSave(
List<Map<String, dynamic>> rawData,
DateTime fetchTime,
) async {
try {
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
final processedEntries = <SuspectedLocationEntry>[];
int validCount = 0;
int errorCount = 0;
int zeroCoordCount = 0;
for (int i = 0; i < rawData.length; i++) {
final rowData = rawData[i];
// Log progress every 1000 entries for debugging
if (i % 1000 == 0) {
debugPrint('[SuspectedLocationCache] Processed ${i + 1}/${rawData.length} entries...');
}
try {
// Create a temporary SuspectedLocation to extract the centroid
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
// Only save if we have a valid centroid (not at 0,0)
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
processedEntries.add(SuspectedLocationEntry(
rawData: rowData,
centroid: tempLocation.centroid,
));
validCount++;
} else {
zeroCoordCount++;
}
} catch (e) {
errorCount++;
continue;
}
}
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
_processedEntries = processedEntries;
_lastFetchTime = fetchTime;
// Clear bounds cache since data changed
_boundsCache.clear();
final prefs = await SharedPreferences.getInstance();
// Save processed data
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
await prefs.setString(_prefsKeyProcessedData, processedDataString);
// Save last fetch time
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
// Log coordinate ranges for debugging
if (processedEntries.isNotEmpty) {
double minLat = processedEntries.first.centroid.latitude;
double maxLat = minLat;
double minLng = processedEntries.first.centroid.longitude;
double maxLng = minLng;
for (final entry in processedEntries) {
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
}
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
notifyListeners();
} catch (e) {
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
}
}
/// Clear all cached data
void clear() {
_processedEntries.clear();
_boundsCache.clear();
_lastFetchTime = null;
notifyListeners();
}
/// Get last fetch time
DateTime? get lastFetchTime => _lastFetchTime;
/// Get total count of processed entries
int get totalCount => _processedEntries.length;
/// Check if we have data
bool get hasData => _processedEntries.isNotEmpty;
}

View File

@@ -0,0 +1,215 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:csv/csv.dart';
import '../dev_config.dart';
import '../models/suspected_location.dart';
import 'suspected_location_cache.dart';
class SuspectedLocationService {
static final SuspectedLocationService _instance = SuspectedLocationService._();
factory SuspectedLocationService() => _instance;
SuspectedLocationService._();
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
static const Duration _maxAge = Duration(days: 7);
static const Duration _timeout = Duration(seconds: 30);
final SuspectedLocationCache _cache = SuspectedLocationCache();
bool _isEnabled = false;
/// Get last fetch time
DateTime? get lastFetchTime => _cache.lastFetchTime;
/// Check if suspected locations are enabled
bool get isEnabled => _isEnabled;
/// Initialize the service - load from storage and check if refresh needed
Future<void> init({bool offlineMode = false}) async {
await _loadFromStorage();
// Load cache data
await _cache.loadFromStorage();
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
if (_isEnabled && _shouldRefresh() && !offlineMode) {
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
await _fetchData();
} else if (_isEnabled && _shouldRefresh() && offlineMode) {
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${_cache.lastFetchTime != null ? 'outdated' : 'missing'}');
}
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyEnabled, enabled);
// If disabling, clear the cache
if (!enabled) {
_cache.clear();
}
// Note: If enabling and no data, the state layer will call fetchDataIfNeeded()
}
/// Check if cache has any data
bool get hasData => _cache.hasData;
/// Get last fetch time
DateTime? get lastFetch => _cache.lastFetchTime;
/// Fetch data if needed (for enabling suspected locations when no data exists)
Future<bool> fetchDataIfNeeded() async {
if (!_shouldRefresh()) {
debugPrint('[SuspectedLocationService] Data is fresh, skipping fetch');
return true; // Already have fresh data
}
return await _fetchData();
}
/// Force refresh the data (for manual refresh button)
Future<bool> forceRefresh() async {
return await _fetchData();
}
/// Check if data should be refreshed
bool _shouldRefresh() {
if (!_cache.hasData) return true;
if (_cache.lastFetchTime == null) return true;
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
}
/// Load settings from shared preferences
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load enabled state
_isEnabled = prefs.getBool(_prefsKeyEnabled) ?? false;
debugPrint('[SuspectedLocationService] Loaded settings - enabled: $_isEnabled');
} catch (e) {
debugPrint('[SuspectedLocationService] Error loading from storage: $e');
}
}
/// Fetch data from the CSV URL
Future<bool> _fetchData() async {
try {
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
final response = await http.get(
Uri.parse(kSuspectedLocationsCsvUrl),
headers: {
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
},
).timeout(_timeout);
if (response.statusCode != 200) {
debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}');
return false;
}
// Parse CSV with proper field separator and quote handling
final csvData = await compute(_parseCSV, response.body);
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
if (csvData.isEmpty) {
debugPrint('[SuspectedLocationService] Empty CSV data');
return false;
}
// First row should be headers
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
debugPrint('[SuspectedLocationService] Headers: $headers');
final dataRows = csvData.skip(1);
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
// Find required column indices - we only need ticket_no and location
final ticketNoIndex = headers.indexOf('ticket_no');
final locationIndex = headers.indexOf('location');
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
if (ticketNoIndex == -1 || locationIndex == -1) {
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
return false;
}
// Parse rows and store all data dynamically
final List<Map<String, dynamic>> rawDataList = [];
int rowIndex = 0;
int validRows = 0;
for (final row in dataRows) {
rowIndex++;
try {
final Map<String, dynamic> rowData = {};
// Store all columns dynamically
for (int i = 0; i < headers.length && i < row.length; i++) {
final headerName = headers[i];
final cellValue = row[i];
if (cellValue != null) {
rowData[headerName] = cellValue;
}
}
// Basic validation - must have ticket_no and location
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
rowData['location']?.toString().isNotEmpty == true) {
rawDataList.add(rowData);
validRows++;
}
} catch (e, stackTrace) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
continue;
}
}
debugPrint('[SuspectedLocationService] Parsed $validRows valid rows from ${dataRows.length} total rows');
final fetchTime = DateTime.now();
// Process raw data and save (calculates centroids once)
await _cache.processAndSave(rawDataList, fetchTime);
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
return true;
} catch (e, stackTrace) {
debugPrint('[SuspectedLocationService] Error fetching data: $e');
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
return false;
}
}
/// Get suspected locations within a bounding box
List<SuspectedLocation> getLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _cache.getLocationsForBounds(LatLngBounds(
LatLng(north, west),
LatLng(south, east),
));
}
}
/// Simple CSV parser for compute() - must be top-level function
List<List<dynamic>> _parseCSV(String csvBody) {
return const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(csvBody);
}

View File

@@ -17,6 +17,12 @@ class Uploader {
try {
print('Uploader: Starting upload for node at ${p.coord.latitude}, ${p.coord.longitude}');
// Safety check: create and modify operations MUST have profiles
if ((p.operation == UploadOperation.create || p.operation == UploadOperation.modify) && p.profile == null) {
print('Uploader: ERROR - ${p.operation.name} operation attempted without profile data');
return false;
}
// 1. open changeset
String action;
switch (p.operation) {
@@ -30,11 +36,13 @@ class Uploader {
action = 'Delete';
break;
}
// Generate appropriate comment based on operation type
final profileName = p.profile?.name ?? 'surveillance';
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
<tag k="comment" v="$action $profileName surveillance node"/>
</changeset>
</osm>''';
print('Uploader: Creating changeset...');

View File

@@ -8,8 +8,14 @@ class OperatorProfileState extends ChangeNotifier {
List<OperatorProfile> get profiles => List.unmodifiable(_profiles);
Future<void> init() async {
Future<void> init({bool addDefaults = false}) async {
_profiles.addAll(await OperatorProfileService().load());
// Add default operator profiles if this is first launch
if (addDefaults) {
_profiles.addAll(OperatorProfile.getDefaults());
await OperatorProfileService().save(_profiles);
}
}
void addOrUpdateProfile(OperatorProfile p) {

View File

@@ -17,19 +17,16 @@ class ProfileState extends ChangeNotifier {
_profiles.where(isEnabled).toList(growable: false);
// Initialize profiles from built-in and custom sources
Future<void> init() async {
// Initialize profiles: built-in + custom
_profiles.add(NodeProfile.genericAlpr());
_profiles.add(NodeProfile.flock());
_profiles.add(NodeProfile.motorola());
_profiles.add(NodeProfile.genetec());
_profiles.add(NodeProfile.leonardo());
_profiles.add(NodeProfile.neology());
_profiles.add(NodeProfile.genericGunshotDetector());
_profiles.add(NodeProfile.shotspotter());
_profiles.add(NodeProfile.flockRaven());
Future<void> init({bool addDefaults = false}) async {
// Load custom profiles from storage
_profiles.addAll(await ProfileService().load());
// Add built-in profiles if this is first launch
if (addDefaults) {
_profiles.addAll(NodeProfile.getDefaults());
await ProfileService().save(_profiles);
}
// Load enabled profile IDs from prefs
final prefs = await SharedPreferences.getInstance();
final enabledIds = prefs.getStringList(_enabledPrefsKey);

View File

@@ -7,27 +7,45 @@ import '../models/osm_node.dart';
// ------------------ AddNodeSession ------------------
class AddNodeSession {
AddNodeSession({required this.profile, this.directionDegrees = 0});
NodeProfile profile;
NodeProfile? profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng? target;
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
AddNodeSession({
this.profile,
double initialDirection = 0,
this.operatorProfile,
this.target,
}) : directions = [initialDirection],
currentDirectionIndex = 0;
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
}
// ------------------ EditNodeSession ------------------
class EditNodeSession {
final OsmNode originalNode; // The original node being edited
NodeProfile? profile;
OperatorProfile? operatorProfile;
LatLng target; // Current position (can be dragged)
List<double> directions; // All directions [90, 180, 270]
int currentDirectionIndex; // Which direction we're editing (e.g. 1 = editing the 180°)
EditNodeSession({
required this.originalNode,
required this.profile,
required this.directionDegrees,
this.profile,
required double initialDirection,
required this.target,
});
}) : directions = [initialDirection],
currentDirectionIndex = 0;
final OsmNode originalNode; // The original node being edited
NodeProfile profile;
OperatorProfile? operatorProfile;
double directionDegrees;
LatLng target; // Current position (can be dragged)
// Slider always shows the current direction being edited
double get directionDegrees => directions[currentDirectionIndex];
set directionDegrees(double value) => directions[currentDirectionIndex] = value;
}
class SessionState extends ChangeNotifier {
@@ -39,11 +57,8 @@ class SessionState extends ChangeNotifier {
EditNodeSession? get editSession => _editSession;
void startAddSession(List<NodeProfile> enabledProfiles) {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
final defaultProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first; // Fallback to any enabled profile
_session = AddNodeSession(profile: defaultProfile);
// Start with no profile selected - force user to choose
_session = AddNodeSession();
_editSession = null; // Clear any edit session
notifyListeners();
}
@@ -52,11 +67,9 @@ class SessionState extends ChangeNotifier {
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
// Try to find a matching profile based on the node's tags
NodeProfile matchingProfile = submittableProfiles.isNotEmpty
? submittableProfiles.first
: enabledProfiles.first;
NodeProfile? matchingProfile;
// Attempt to find a better match by comparing tags
// Attempt to find a match by comparing tags
for (final profile in submittableProfiles) {
if (_profileMatchesTags(profile, node.tags)) {
matchingProfile = profile;
@@ -64,12 +77,20 @@ class SessionState extends ChangeNotifier {
}
}
// Start with no profile selected if no match found - force user to choose
// Initialize edit session with all existing directions
final existingDirections = node.directionDeg.isNotEmpty ? node.directionDeg : [0.0];
_editSession = EditNodeSession(
originalNode: node,
profile: matchingProfile,
directionDegrees: node.directionDeg ?? 0,
initialDirection: existingDirections.first,
target: node.coord,
);
// Replace the default single direction with all existing directions
_editSession!.directions = List<double>.from(existingDirections);
_editSession!.currentDirectionIndex = 0; // Start editing the first direction
_session = null; // Clear any add session
notifyListeners();
}
@@ -140,6 +161,49 @@ class SessionState extends ChangeNotifier {
if (dirty) notifyListeners();
}
// Add new direction at 0° and switch to editing it
void addDirection() {
if (_session != null) {
_session!.directions.add(0.0);
_session!.currentDirectionIndex = _session!.directions.length - 1;
notifyListeners();
} else if (_editSession != null) {
_editSession!.directions.add(0.0);
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
notifyListeners();
}
}
// Remove currently selected direction
void removeDirection() {
if (_session != null && _session!.directions.length > 1) {
_session!.directions.removeAt(_session!.currentDirectionIndex);
if (_session!.currentDirectionIndex >= _session!.directions.length) {
_session!.currentDirectionIndex = _session!.directions.length - 1;
}
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
_editSession!.directions.removeAt(_editSession!.currentDirectionIndex);
if (_editSession!.currentDirectionIndex >= _editSession!.directions.length) {
_editSession!.currentDirectionIndex = _editSession!.directions.length - 1;
}
notifyListeners();
}
}
// Cycle to next direction
void cycleDirection() {
if (_session != null && _session!.directions.length > 1) {
_session!.currentDirectionIndex = (_session!.currentDirectionIndex + 1) % _session!.directions.length;
notifyListeners();
} else if (_editSession != null && _editSession!.directions.length > 1) {
_editSession!.currentDirectionIndex = (_editSession!.currentDirectionIndex + 1) % _editSession!.directions.length;
notifyListeners();
}
}
void cancelSession() {
_session = null;
notifyListeners();
@@ -151,7 +215,7 @@ class SessionState extends ChangeNotifier {
}
AddNodeSession? commitSession() {
if (_session?.target == null) return null;
if (_session?.target == null || _session?.profile == null) return null;
final session = _session!;
_session = null;
@@ -160,7 +224,7 @@ class SessionState extends ChangeNotifier {
}
EditNodeSession? commitEditSession() {
if (_editSession == null) return null;
if (_editSession?.profile == null) return null;
final session = _editSession!;
_editSession = null;

View File

@@ -12,8 +12,8 @@ enum UploadMode { production, sandbox, simulate }
// Enum for follow-me mode (moved from HomeScreen to centralized state)
enum FollowMeMode {
off, // No following
northUp, // Follow position, keep north up
rotating, // Follow position and rotation
follow, // Follow position, preserve current rotation
rotating, // Follow position and rotation based on heading
}
class SettingsState extends ChangeNotifier {
@@ -27,25 +27,31 @@ class SettingsState extends ChangeNotifier {
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
bool _offlineMode = false;
bool _pauseQueueProcessing = false;
int _maxCameras = 250;
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
FollowMeMode _followMeMode = FollowMeMode.northUp;
FollowMeMode _followMeMode = FollowMeMode.follow;
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = false;
bool _networkStatusIndicatorEnabled = true;
int _suspectedLocationMinDistance = 100; // meters
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
// Getters
bool get offlineMode => _offlineMode;
bool get pauseQueueProcessing => _pauseQueueProcessing;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
FollowMeMode get followMeMode => _followMeMode;
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
int get proximityAlertDistance => _proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
@@ -89,6 +95,9 @@ class SettingsState extends ChangeNotifier {
// Load offline mode
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
// Load queue processing setting
_pauseQueueProcessing = prefs.getBool(_pauseQueueProcessingPrefsKey) ?? false;
// Load max cameras
if (prefs.containsKey(_maxCamerasPrefsKey)) {
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
@@ -99,7 +108,10 @@ class SettingsState extends ChangeNotifier {
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
// Load network status indicator setting
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
// Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
@@ -206,6 +218,13 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
Future<void> setPauseQueueProcessing(bool enabled) async {
_pauseQueueProcessing = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_pauseQueueProcessingPrefsKey, enabled);
notifyListeners();
}
set maxCameras(int n) {
if (n < 10) n = 10; // minimum
_maxCameras = n;
@@ -285,12 +304,13 @@ class SettingsState extends ChangeNotifier {
Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) {
_followMeMode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_followMeModePrefsKey, mode.index);
notifyListeners();
}
}
/// Set proximity alerts enabled/disabled
Future<void> setProximityAlertsEnabled(bool enabled) async {
if (_proximityAlertsEnabled != enabled) {
@@ -323,4 +343,14 @@ class SettingsState extends ChangeNotifier {
}
}
/// Set suspected location minimum distance from real nodes
Future<void> setSuspectedLocationMinDistance(int distance) async {
if (_suspectedLocationMinDistance != distance) {
_suspectedLocationMinDistance = distance;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_suspectedLocationMinDistancePrefsKey, distance);
notifyListeners();
}
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import '../models/suspected_location.dart';
import '../services/suspected_location_service.dart';
class SuspectedLocationState extends ChangeNotifier {
final SuspectedLocationService _service = SuspectedLocationService();
SuspectedLocation? _selectedLocation;
bool _isLoading = false;
/// Currently selected suspected location (for detail view)
SuspectedLocation? get selectedLocation => _selectedLocation;
/// Get suspected locations in bounds (this should be called by the map view)
List<SuspectedLocation> getLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _service.getLocationsInBounds(
north: north,
south: south,
east: east,
west: west,
);
}
/// Whether suspected locations are enabled
bool get isEnabled => _service.isEnabled;
/// Whether currently loading data
bool get isLoading => _isLoading;
/// Last time data was fetched
DateTime? get lastFetchTime => _service.lastFetchTime;
/// Initialize the state
Future<void> init({bool offlineMode = false}) async {
await _service.init(offlineMode: offlineMode);
notifyListeners();
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled);
// If enabling and no data exists, fetch it now
if (enabled && !_service.hasData) {
await _fetchData();
}
notifyListeners();
}
/// Manually refresh the data (force refresh)
Future<bool> refreshData() async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.forceRefresh();
return success;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Internal method to fetch data if needed with loading state management
Future<bool> _fetchData() async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.fetchDataIfNeeded();
return success;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Select a suspected location for detail view
void selectLocation(SuspectedLocation location) {
_selectedLocation = location;
notifyListeners();
}
/// Clear the selected location
void clearSelection() {
_selectedLocation = null;
notifyListeners();
}
}

View File

@@ -29,8 +29,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromSession(AddNodeSession session, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: session.target!,
direction: session.directionDegrees,
profile: session.profile,
direction: _formatDirectionsAsString(session.directions),
profile: session.profile!, // Safe to use ! because commitSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.create,
@@ -61,10 +61,15 @@ class UploadQueueState extends ChangeNotifier {
// Add a completed edit session to the upload queue
void addFromEditSession(EditNodeSession session, {required UploadMode uploadMode}) {
// For constrained nodes, always use original position regardless of session.target
final coordToUse = session.originalNode.isConstrained
? session.originalNode.coord
: session.target;
final upload = PendingUpload(
coord: session.target,
direction: session.directionDegrees,
profile: session.profile,
coord: coordToUse,
direction: _formatDirectionsAsString(session.directions),
profile: session.profile!, // Safe to use ! because commitEditSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
@@ -109,8 +114,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
final upload = PendingUpload(
coord: node.coord,
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
profile: NodeProfile.genericAlpr(), // Dummy profile - not used for deletions
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID
uploadMode: uploadMode,
operation: UploadOperation.delete,
originalNodeId: node.id,
@@ -158,16 +163,17 @@ class UploadQueueState extends ChangeNotifier {
// Start the upload processing loop
void startUploader({
required bool offlineMode,
required bool pauseQueueProcessing,
required UploadMode uploadMode,
required Future<String?> Function() getAccessToken,
}) {
_uploadTimer?.cancel();
// No uploads without queue, or if offline mode is enabled.
if (_queue.isEmpty || offlineMode) return;
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
if (_queue.isEmpty || offlineMode) {
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) {
_uploadTimer?.cancel();
return;
}
@@ -293,6 +299,13 @@ class UploadQueueState extends ChangeNotifier {
}
}
// Helper method to format multiple directions as a string or number
dynamic _formatDirectionsAsString(List<double> directions) {
if (directions.isEmpty) return 0.0;
if (directions.length == 1) return directions.first;
return directions.map((d) => d.round().toString()).join(';');
}
// ---------- Queue persistence ----------
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
@@ -12,6 +13,126 @@ class AddNodeSheet extends StatelessWidget {
final AddNodeSession session;
Widget _buildDirectionControls(BuildContext context, AppState appState, AddNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
if (i == session.currentDirectionIndex) {
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
} else {
directionsWithBold.add(dirStr);
}
}
directionsText = directionsWithBold.join(', ');
}
return Column(
children: [
ListTile(
title: requiresDirection
? RichText(
text: TextSpan(
style: Theme.of(context).textTheme.titleMedium,
children: [
const TextSpan(text: 'Directions: '),
if (directionsText.isNotEmpty)
...directionsText.split('**').asMap().entries.map((entry) {
final isEven = entry.key % 2 == 0;
return TextSpan(
text: entry.value,
style: TextStyle(
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
),
);
}),
],
),
)
: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Row(
children: [
// Slider takes most of the space
Expanded(
child: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
const SizedBox(width: 8),
// Remove button
IconButton(
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection ? () => appState.addDirection() : null,
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
),
),
// Show info text when profile doesn't require direction
if (!requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -34,7 +155,10 @@ class AddNodeSheet extends StatelessWidget {
}
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
final allowSubmit = appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
@@ -66,44 +190,18 @@ class AddNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('addNode.profile')),
trailing: DropdownButton<NodeProfile>(
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('addNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateSession(profile: p ?? session.profile),
onChanged: (p) => appState.updateSession(profile: p),
),
),
ListTile(
title: Text(locService.t('addNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
// Direction controls
_buildDirectionControls(context, appState, session, locService),
if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
@@ -136,7 +234,23 @@ class AddNodeSheet extends StatelessWidget {
],
),
)
else if (!session.profile.isSubmittable)
else if (session.profile == null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.profileRequired'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!session.profile!.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -158,7 +272,7 @@ class AddNodeSheet extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('addNode.refineTagsWithProfile', params: [session.operatorProfile!.name])

View File

@@ -0,0 +1,238 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/osm_node.dart';
import '../services/localization_service.dart';
/// Information about an OSM editor app
class EditorInfo {
final String name;
final String subtitle;
final IconData icon;
final String? urlScheme; // null means no custom scheme - go straight to store
final String? androidStoreUrl;
final String? iosStoreUrl;
final bool availableOnAndroid;
final bool availableOnIOS;
const EditorInfo({
required this.name,
required this.subtitle,
required this.icon,
this.urlScheme, // Made optional
this.androidStoreUrl,
this.iosStoreUrl,
required this.availableOnAndroid,
required this.availableOnIOS,
});
}
class AdvancedEditOptionsSheet extends StatelessWidget {
final OsmNode node;
const AdvancedEditOptionsSheet({super.key, required this.node});
/// Mobile editor apps with their platform availability and store URLs
List<EditorInfo> get _mobileEditors => [
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.vespucci'),
subtitle: LocalizationService.instance.t('advancedEdit.vespucciSubtitle'),
icon: Icons.android,
urlScheme: 'josm:/load_and_zoom?select=node${node.id}', // Has documented deep link support
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.blau.android',
availableOnAndroid: true,
availableOnIOS: false,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.streetComplete'),
subtitle: LocalizationService.instance.t('advancedEdit.streetCompleteSubtitle'),
icon: Icons.place,
urlScheme: null, // No documented deep link support - go straight to store
androidStoreUrl: 'https://play.google.com/store/apps/details?id=de.westnordost.streetcomplete',
availableOnAndroid: true,
availableOnIOS: false,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.everyDoor'),
subtitle: LocalizationService.instance.t('advancedEdit.everyDoorSubtitle'),
icon: Icons.map,
urlScheme: null, // No documented deep link support - go straight to store
androidStoreUrl: 'https://play.google.com/store/apps/details?id=info.zverev.ilya.every_door',
iosStoreUrl: 'https://apps.apple.com/app/every-door/id1621945342',
availableOnAndroid: true,
availableOnIOS: true,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.goMap'),
subtitle: LocalizationService.instance.t('advancedEdit.goMapSubtitle'),
icon: Icons.phone_iphone,
urlScheme: null, // No documented deep link support - go straight to store
iosStoreUrl: 'https://apps.apple.com/app/go-map/id592990211',
availableOnAndroid: false,
availableOnIOS: true,
),
];
/// Web editor apps (always available on all platforms)
List<EditorInfo> get _webEditors => [
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.iDEditor'),
subtitle: LocalizationService.instance.t('advancedEdit.iDEditorSubtitle'),
icon: Icons.public,
urlScheme: 'https://www.openstreetmap.org/edit?editor=id&node=${node.id}',
availableOnAndroid: true,
availableOnIOS: true,
),
EditorInfo(
name: LocalizationService.instance.t('advancedEdit.rapidEditor'),
subtitle: LocalizationService.instance.t('advancedEdit.rapidEditorSubtitle'),
icon: Icons.speed,
urlScheme: 'https://rapideditor.org/edit#map=19/0/0&nodes=${node.id}',
availableOnAndroid: true,
availableOnIOS: true,
),
];
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
// Filter mobile editors based on current platform
final availableMobileEditors = _mobileEditors.where((editor) {
if (Platform.isAndroid) return editor.availableOnAndroid;
if (Platform.isIOS) return editor.availableOnIOS;
return false; // Other platforms don't have mobile editors
}).toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('advancedEdit.title'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
locService.t('advancedEdit.subtitle'),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 16),
// Web Editors Section
Text(
locService.t('advancedEdit.webEditors'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._webEditors.map((editor) => _buildEditorTile(context, editor)),
// Mobile Editors Section (only show if there are available editors)
if (availableMobileEditors.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
locService.t('advancedEdit.mobileEditors'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...availableMobileEditors.map((editor) => _buildEditorTile(context, editor)),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
}
Widget _buildEditorTile(BuildContext context, EditorInfo editor) {
return ListTile(
leading: Icon(editor.icon, size: 24),
title: Text(editor.name),
subtitle: Text(editor.subtitle),
trailing: const Icon(Icons.launch, size: 18),
onTap: () => _launchEditor(context, editor),
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
);
}
void _launchEditor(BuildContext context, EditorInfo editor) async {
Navigator.pop(context); // Close the sheet first
// If app has a custom URL scheme, try to open it
if (editor.urlScheme != null) {
try {
final uri = Uri.parse(editor.urlScheme!);
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (launched) return; // Success - app opened
} catch (e) {
// App launch failed - continue to app store
}
}
// No custom scheme or app launch failed - redirect to app store
await _redirectToAppStore(context, editor);
}
Future<void> _redirectToAppStore(BuildContext context, EditorInfo editor) async {
final locService = LocalizationService.instance;
try {
if (Platform.isAndroid && editor.androidStoreUrl != null) {
// Try native Play Store first, then web fallback
final packageName = _extractAndroidPackageName(editor.androidStoreUrl!);
if (packageName != null) {
final marketUri = Uri.parse('market://details?id=$packageName');
try {
final launched = await launchUrl(marketUri, mode: LaunchMode.externalApplication);
if (launched) return;
} catch (e) {
// Fall back to web Play Store
}
}
// Web Play Store fallback
final webStoreUri = Uri.parse(editor.androidStoreUrl!);
await launchUrl(webStoreUri, mode: LaunchMode.externalApplication);
return;
} else if (Platform.isIOS && editor.iosStoreUrl != null) {
// iOS App Store
final iosStoreUri = Uri.parse(editor.iosStoreUrl!);
await launchUrl(iosStoreUri, mode: LaunchMode.externalApplication);
return;
}
} catch (e) {
// Fall through to show error message
}
// Could not open app or store - show error message
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenEditor'))),
);
}
}
/// Extract Android package name from Play Store URL for market:// scheme
String? _extractAndroidPackageName(String playStoreUrl) {
final uri = Uri.tryParse(playStoreUrl);
if (uri == null) return null;
// Extract from "id=" parameter in Play Store URLs
return uri.queryParameters['id'];
}
}

View File

@@ -40,10 +40,10 @@ class CameraIcon extends StatelessWidget {
height: kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(kNodeDotOpacity),
color: _ringColor.withOpacity(kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: kNodeRingThickness,
width: getNodeRingThickness(context),
),
),
);

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../services/changelog_service.dart';
import '../services/version_service.dart';
class ChangelogDialog extends StatelessWidget {
final String changelogContent;
const ChangelogDialog({
super.key,
required this.changelogContent,
});
void _onClose(BuildContext context) async {
// Note: Version tracking is updated by completeVersionChange() after all dialogs
if (context.mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('What\'s New in v${VersionService().version}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
changelogContent,
style: const TextStyle(fontSize: 14, height: 1.4),
),
const SizedBox(height: 16),
Text(
'Thank you for keeping DeFlock up to date!',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => _onClose(context),
child: const Text('Continue'),
),
],
);
}
}

View File

@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
/// A compass indicator widget that shows the current map rotation and allows tapping to animate to north.
/// The compass appears in the top-right corner of the map and is disabled (non-interactive) when in follow+rotate mode.
class CompassIndicator extends StatefulWidget {
final AnimatedMapController mapController;
final EdgeInsets safeArea;
const CompassIndicator({
super.key,
required this.mapController,
required this.safeArea,
});
@override
State<CompassIndicator> createState() => _CompassIndicatorState();
}
class _CompassIndicatorState extends State<CompassIndicator> {
Timer? _animationTimer;
@override
void dispose() {
_animationTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
// Get current map rotation in degrees
double rotationDegrees = 0.0;
try {
rotationDegrees = widget.mapController.mapController.camera.rotation;
} catch (_) {
// Map controller not ready yet
}
// Convert degrees to radians for Transform.rotate (flutter_map uses degrees)
final rotationRadians = rotationDegrees * (pi / 180);
// Check if we're in follow+rotate mode (compass should be disabled)
final isDisabled = appState.followMeMode == FollowMeMode.rotating;
final baseTop = (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18;
// Add extra spacing when search bar is visible
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60 : 0;
return Positioned(
top: baseTop + widget.safeArea.top + searchBarOffset,
right: 16 + widget.safeArea.right,
child: GestureDetector(
onTap: isDisabled ? null : () {
// Animate to north-up orientation
try {
// Cancel any existing animation timer
_animationTimer?.cancel();
// Start animation
widget.mapController.animateTo(
dest: widget.mapController.mapController.camera.center,
zoom: widget.mapController.mapController.camera.zoom,
rotation: 0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
// Start timer to force compass updates during animation
// Update every 16ms (~60fps) for smooth visual rotation
_animationTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
if (mounted) {
setState(() {});
}
});
// Stop the timer after animation completes (with small buffer)
Timer(const Duration(milliseconds: 550), () {
_animationTimer?.cancel();
_animationTimer = null;
if (mounted) {
setState(() {}); // Final update to ensure correct end state
}
});
} catch (_) {
// Controller not ready, ignore
}
},
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: isDisabled
? Colors.grey.withOpacity(0.8)
: Colors.white.withOpacity(0.95),
shape: BoxShape.circle,
border: Border.all(
color: isDisabled
? Colors.grey.shade400
: Colors.grey.shade300,
width: 2.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Stack(
children: [
// Compass face with cardinal directions
Center(
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDisabled
? Colors.grey.shade200
: Colors.grey.shade50,
),
),
),
// North indicator that rotates with map rotation
Transform.rotate(
angle: rotationRadians, // Rotate same direction as map rotation to counter-act it
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// North arrow (red triangle pointing up)
Container(
margin: const EdgeInsets.only(top: 6),
child: Icon(
Icons.keyboard_arrow_up,
size: 20,
color: isDisabled
? Colors.grey.shade600
: Colors.red.shade600,
),
),
// Small 'N' label
Text(
'N',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isDisabled
? Colors.grey.shade600
: Colors.red.shade600,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -2,17 +2,139 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../dev_config.dart';
import '../models/node_profile.dart';
import '../models/operator_profile.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
import 'refine_tags_sheet.dart';
import 'advanced_edit_options_sheet.dart';
class EditNodeSheet extends StatelessWidget {
const EditNodeSheet({super.key, required this.session});
final EditNodeSession session;
Widget _buildDirectionControls(BuildContext context, AppState appState, EditNodeSession session, LocalizationService locService) {
final requiresDirection = session.profile != null && session.profile!.requiresDirection;
// Format direction display text with bold for current direction
String directionsText = '';
if (requiresDirection) {
final directionsWithBold = <String>[];
for (int i = 0; i < session.directions.length; i++) {
final dirStr = session.directions[i].round().toString();
if (i == session.currentDirectionIndex) {
directionsWithBold.add('**$dirStr**'); // Mark for bold formatting
} else {
directionsWithBold.add(dirStr);
}
}
directionsText = directionsWithBold.join(', ');
}
return Column(
children: [
ListTile(
title: requiresDirection
? RichText(
text: TextSpan(
style: Theme.of(context).textTheme.titleMedium,
children: [
const TextSpan(text: 'Directions: '),
if (directionsText.isNotEmpty)
...directionsText.split('**').asMap().entries.map((entry) {
final isEven = entry.key % 2 == 0;
return TextSpan(
text: entry.value,
style: TextStyle(
fontWeight: isEven ? FontWeight.normal : FontWeight.bold,
),
);
}),
],
),
)
: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Row(
children: [
// Slider takes most of the space
Expanded(
child: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: requiresDirection ? (v) => appState.updateEditSession(directionDeg: v) : null,
),
),
// Direction control buttons - always show but grey out when direction not required
const SizedBox(width: 8),
// Remove button
IconButton(
icon: Icon(
Icons.remove,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.removeDirection()
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Add button
IconButton(
icon: Icon(
Icons.add,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection ? () => appState.addDirection() : null,
tooltip: requiresDirection ? 'Add new direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
icon: Icon(
Icons.repeat,
size: 20,
color: requiresDirection ? null : Theme.of(context).disabledColor,
),
onPressed: requiresDirection && session.directions.length > 1
? () => appState.cycleDirection()
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
),
],
),
),
// Show info text when profile doesn't require direction
if (!requiresDirection)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
'This profile does not require a direction.',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -36,7 +158,11 @@ class EditNodeSheet extends StatelessWidget {
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = appState.isLoggedIn && submittableProfiles.isNotEmpty && session.profile.isSubmittable;
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable;
void _openRefineTags() async {
final result = await Navigator.push<OperatorProfile?>(
@@ -73,45 +199,71 @@ class EditNodeSheet extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(locService.t('editNode.profile')),
trailing: DropdownButton<NodeProfile>(
trailing: DropdownButton<NodeProfile?>(
value: session.profile,
hint: Text(locService.t('editNode.selectProfile')),
items: submittableProfiles
.map((p) => DropdownMenuItem(value: p, child: Text(p.name)))
.toList(),
onChanged: (p) =>
appState.updateEditSession(profile: p ?? session.profile),
onChanged: (p) => appState.updateEditSession(profile: p),
),
),
ListTile(
title: Text(locService.t('editNode.direction', params: [session.directionDegrees.round().toString()])),
subtitle: Slider(
min: 0,
max: 359,
divisions: 359,
value: session.directionDegrees,
label: session.directionDegrees.round().toString(),
onChanged: session.profile.requiresDirection
? (v) => appState.updateEditSession(directionDeg: v)
: null, // Disables slider when requiresDirection is false
),
),
if (!session.profile.requiresDirection)
// Direction controls
_buildDirectionControls(context, appState, session, locService),
// Constraint message for nodes that cannot be moved
if (session.originalNode.isConstrained)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
child: Column(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
locService.t('editNode.cannotMoveConstrainedNode'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () => _openAdvancedEdit(context),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.useAdvancedEditor')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
),
),
],
),
],
),
),
if (!appState.isLoggedIn)
if (!kEnableNodeEdits)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.construction, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.temporarilyDisabled'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -143,7 +295,23 @@ class EditNodeSheet extends StatelessWidget {
],
),
)
else if (!session.profile.isSubmittable)
else if (session.profile == null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileRequired'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
)
else if (!session.profile!.isSubmittable)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -165,7 +333,7 @@ class EditNodeSheet extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _openRefineTags,
onPressed: session.profile != null ? _openRefineTags : null, // Disabled when no profile selected
icon: const Icon(Icons.tune),
label: Text(session.operatorProfile != null
? locService.t('editNode.refineTagsWithProfile', params: [session.operatorProfile!.name])
@@ -200,4 +368,12 @@ class EditNodeSheet extends StatelessWidget {
},
);
}
void _openAdvancedEdit(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: session.originalNode),
);
}
}

View File

@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + 1);
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@override
@@ -95,6 +95,7 @@ class CameraMarkersBuilder {
LatLng? userLocation,
int? selectedNodeId,
void Function(OsmNode)? onNodeTap,
bool shouldDim = false,
}) {
final markers = <Marker>[
// Camera markers
@@ -103,14 +104,14 @@ class CameraMarkersBuilder {
.map((n) {
// Check if this node should be highlighted (selected) or dimmed
final isSelected = selectedNodeId == n.id;
final shouldDim = selectedNodeId != null && !isSelected;
final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected);
return Marker(
point: n.coord,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
opacity: shouldDimNode ? 0.5 : 1.0,
child: CameraMapMarker(
node: n,
mapController: mapController,

View File

@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import '../../models/node_profile.dart';
import '../../app_state.dart' show UploadMode;
import '../../services/prefetch_area_service.dart';
import '../camera_provider_with_cache.dart';
import '../../dev_config.dart';
@@ -43,6 +44,8 @@ class CameraRefreshController {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear camera cache to ensure fresh data for new profile combination
_cameraProvider.clearCache();
// Clear pre-fetch area since profiles changed
PrefetchAreaService().clearPreFetchedArea();
// Force display refresh first (for immediate UI update)
_cameraProvider.refreshDisplay();
// Notify that profiles changed (triggers camera refresh)

View File

@@ -14,47 +14,85 @@ class DirectionConesBuilder {
required double zoom,
AddNodeSession? session,
EditNodeSession? editSession,
required BuildContext context,
}) {
final overlays = <Polygon>[];
// Add session cone if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile.requiresDirection) {
// Add session cones if in add-camera mode and profile requires direction
if (session != null && session.target != null && session.profile?.requiresDirection == true) {
// Add current working direction (full opacity)
overlays.add(_buildCone(
session.target!,
session.directionDegrees,
zoom,
context: context,
isSession: true,
isActiveDirection: true,
));
// Add other directions (reduced opacity)
for (int i = 0; i < session.directions.length; i++) {
if (i != session.currentDirectionIndex) {
overlays.add(_buildCone(
session.target!,
session.directions[i],
zoom,
context: context,
isSession: true,
isActiveDirection: false,
));
}
}
}
// Add edit session cone if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile.requiresDirection) {
// Add edit session cones if in edit-camera mode and profile requires direction
if (editSession != null && editSession.profile?.requiresDirection == true) {
// Add current working direction (full opacity)
overlays.add(_buildCone(
editSession.target,
editSession.directionDegrees,
zoom,
context: context,
isSession: true,
isActiveDirection: true,
));
// Add other directions (reduced opacity)
for (int i = 0; i < editSession.directions.length; i++) {
if (i != editSession.currentDirectionIndex) {
overlays.add(_buildCone(
editSession.target,
editSession.directions[i],
zoom,
context: context,
isSession: true,
isActiveDirection: false,
));
}
}
}
// Add cones for cameras with direction (but exclude camera being edited)
overlays.addAll(
cameras
.where((n) => _isValidCameraWithDirection(n) &&
(editSession == null || n.id != editSession.originalNode.id))
.map((n) => _buildCone(
n.coord,
n.directionDeg!,
zoom,
))
);
for (final node in cameras) {
if (_isValidCameraWithDirection(node) &&
(editSession == null || node.id != editSession.originalNode.id)) {
// Build a cone for each direction
for (final direction in node.directionDeg) {
overlays.add(_buildCone(
node.coord,
direction,
zoom,
context: context,
));
}
}
}
return overlays;
}
static bool _isValidCameraWithDirection(OsmNode node) {
return node.hasDirection &&
node.directionDeg != null &&
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
@@ -69,40 +107,59 @@ class DirectionConesBuilder {
LatLng origin,
double bearingDeg,
double zoom, {
required BuildContext context,
bool isPending = false,
bool isSession = false,
bool isActiveDirection = true,
}) {
final halfAngle = kDirectionConeHalfAngle;
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
// Number of points to create the arc (more = smoother curve)
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Number of points for the outer arc (within our directional range)
const int arcPoints = 12;
LatLng project(double deg) {
LatLng project(double deg, double distance) {
final rad = deg * math.pi / 180;
final dLat = length * math.cos(rad);
final dLat = distance * math.cos(rad);
final dLon =
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Build pizza slice with curved edge
final points = <LatLng>[origin];
// Build outer arc points only within our directional sector
final points = <LatLng>[];
// Add arc points from left to right
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
for (int i = 0; i <= arcPoints; i++) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
points.add(project(angle));
points.add(project(angle, outerRadius));
}
// Close the shape back to origin
points.add(origin);
// Add inner arc points from right to left (to close the donut shape)
for (int i = arcPoints; i >= 0; i--) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
points.add(project(angle, innerRadius));
}
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;
if (isSession && !isActiveDirection) {
opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(0.25),
color: kDirectionConeColor.withOpacity(opacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: 1,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}
}

View File

@@ -46,6 +46,7 @@ class GpsController {
required FollowMeMode newMode,
required FollowMeMode oldMode,
required AnimatedMapController controller,
VoidCallback? onMapMovedProgrammatically,
}) {
debugPrint('[GpsController] Follow-me mode changed: $oldMode$newMode');
@@ -55,13 +56,14 @@ class GpsController {
_currentLatLng != null) {
try {
if (newMode == FollowMeMode.northUp) {
if (newMode == FollowMeMode.follow) {
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
} else if (newMode == FollowMeMode.rotating) {
// When switching to rotating mode, reset to north-up first
controller.animateTo(
@@ -71,6 +73,7 @@ class GpsController {
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for follow-me change: $e');
@@ -89,6 +92,9 @@ class GpsController {
int proximityAlertDistance = 200,
List<OsmNode> nearbyNodes = const [],
List<NodeProfile> enabledProfiles = const [],
// Optional callback when map is moved programmatically
VoidCallback? onMapMovedProgrammatically,
}) {
final latLng = LatLng(position.latitude, position.longitude);
_currentLatLng = latLng;
@@ -111,14 +117,18 @@ class GpsController {
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
if (followMeMode == FollowMeMode.northUp) {
if (followMeMode == FollowMeMode.follow) {
// Follow position only, keep current rotation
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: controller.mapController.camera.rotation,
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
} else if (followMeMode == FollowMeMode.rotating) {
// Follow position and rotation based on heading
final heading = position.heading;
@@ -135,6 +145,9 @@ class GpsController {
duration: kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
// Notify that we moved the map programmatically (for node refresh)
onMapMovedProgrammatically?.call();
}
} catch (e) {
debugPrint('[GpsController] MapController not ready for position animation: $e');
@@ -153,6 +166,8 @@ class GpsController {
required int Function() getProximityAlertDistance,
required List<OsmNode> Function() getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
VoidCallback? onMapMovedProgrammatically,
}) async {
final perm = await Geolocator.requestPermission();
if (perm == LocationPermission.denied ||
@@ -168,7 +183,6 @@ class GpsController {
final proximityAlertDistance = getProximityAlertDistance();
final nearbyNodes = getNearbyNodes();
final enabledProfiles = getEnabledProfiles();
processPositionUpdate(
position: position,
followMeMode: currentFollowMeMode,
@@ -178,6 +192,7 @@ class GpsController {
proximityAlertDistance: proximityAlertDistance,
nearbyNodes: nearbyNodes,
enabledProfiles: enabledProfiles,
onMapMovedProgrammatically: onMapMovedProgrammatically,
);
});
}

View File

@@ -1,22 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
import '../../services/localization_service.dart';
import '../camera_icon.dart';
import '../compass_indicator.dart';
import 'layer_selector_button.dart';
/// Widget that renders all map overlay UI elements
class MapOverlays extends StatelessWidget {
final MapController mapController;
final AnimatedMapController mapController;
final UploadMode uploadMode;
final AddNodeSession? session;
final EditNodeSession? editSession;
final String? attribution; // Attribution for current tile provider
final VoidCallback? onSearchPressed; // Callback for search button
const MapOverlays({
super.key,
required this.mapController,
@@ -50,13 +51,15 @@ class MapOverlays extends StatelessWidget {
@override
Widget build(BuildContext context) {
final safeArea = MediaQuery.of(context).padding;
return Stack(
children: [
// MODE INDICATOR badge (top-right)
if (uploadMode == UploadMode.sandbox || uploadMode == UploadMode.simulate)
Positioned(
top: 18,
right: 14,
top: topPositionWithSafeArea(18, safeArea),
right: rightPositionWithSafeArea(14, safeArea),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
@@ -82,10 +85,16 @@ class MapOverlays extends StatelessWidget {
),
),
// Zoom indicator, positioned relative to button bar
// Compass indicator (top-right, below mode indicator)
CompassIndicator(
mapController: mapController,
safeArea: safeArea,
),
// Zoom indicator, positioned relative to button bar with left safe area
Positioned(
left: 10,
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
@@ -96,7 +105,7 @@ class MapOverlays extends StatelessWidget {
builder: (context) {
double zoom = 15.0; // fallback
try {
zoom = mapController.camera.zoom;
zoom = mapController.mapController.camera.zoom;
} catch (_) {
// Map controller not ready yet
}
@@ -113,11 +122,11 @@ class MapOverlays extends StatelessWidget {
),
),
// Attribution overlay, positioned relative to button bar
// Attribution overlay, positioned relative to button bar with left safe area
if (attribution != null)
Positioned(
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
left: 10,
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
left: leftPositionWithSafeArea(10, safeArea),
child: GestureDetector(
onTap: () => _showAttributionDialog(context, attribution!),
child: Container(
@@ -140,10 +149,10 @@ class MapOverlays extends StatelessWidget {
),
),
// Zoom and layer controls (bottom-right), positioned relative to button bar
// Zoom and layer controls (bottom-right), positioned relative to button bar with right safe area
Positioned(
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom),
right: 16,
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
right: rightPositionWithSafeArea(16, safeArea),
child: Consumer<AppState>(
builder: (context, appState, child) {
return Column(
@@ -173,8 +182,8 @@ class MapOverlays extends StatelessWidget {
heroTag: "zoom_in",
onPressed: () {
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom + 1);
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom + 1);
} catch (_) {
// Map controller not ready yet
}
@@ -188,8 +197,8 @@ class MapOverlays extends StatelessWidget {
heroTag: "zoom_out",
onPressed: () {
try {
final zoom = mapController.camera.zoom;
mapController.move(mapController.camera.center, zoom - 1);
final zoom = mapController.mapController.camera.zoom;
mapController.mapController.move(mapController.mapController.camera.center, zoom - 1);
} catch (_) {
// Map controller not ready yet
}

View File

@@ -0,0 +1,111 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../dev_config.dart';
import '../../models/suspected_location.dart';
import '../suspected_location_sheet.dart';
import '../suspected_location_icon.dart';
/// Smart marker widget for suspected location with single/double tap distinction
class SuspectedLocationMapMarker extends StatefulWidget {
final SuspectedLocation location;
final MapController mapController;
final void Function(SuspectedLocation)? onLocationTap;
const SuspectedLocationMapMarker({
required this.location,
required this.mapController,
this.onLocationTap,
Key? key,
}) : super(key: key);
@override
State<SuspectedLocationMapMarker> createState() => _SuspectedLocationMapMarkerState();
}
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
// Use callback if provided, otherwise fallback to direct modal
if (widget.onLocationTap != null) {
widget.onLocationTap!(widget.location);
} else {
showModalBottomSheet(
context: context,
builder: (_) => SuspectedLocationSheet(location: widget.location),
showDragHandle: true,
);
}
});
}
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
onDoubleTap: _onDoubleTap,
child: const SuspectedLocationIcon(),
);
}
}
/// Helper class to build marker layers for suspected locations
class SuspectedLocationMarkersBuilder {
static List<Marker> buildSuspectedLocationMarkers({
required List<SuspectedLocation> locations,
required MapController mapController,
String? selectedLocationId,
void Function(SuspectedLocation)? onLocationTap,
}) {
final markers = <Marker>[];
for (final location in locations) {
if (!_isValidCoordinate(location.centroid)) continue;
// Check if this location should be highlighted (selected) or dimmed
final isSelected = selectedLocationId == location.ticketNo;
final shouldDim = selectedLocationId != null && !isSelected;
markers.add(
Marker(
point: location.centroid,
width: 20,
height: 20,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
child: SuspectedLocationMapMarker(
location: location,
mapController: mapController,
onLocationTap: onLocationTap,
),
),
),
);
}
return markers;
}
static bool _isValidCoordinate(LatLng coord) {
return (coord.latitude != 0 || coord.longitude != 0) &&
coord.latitude.abs() <= 90 &&
coord.longitude.abs() <= 180;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/simple_tile_service.dart';
@@ -64,6 +65,11 @@ class TileLayerManager {
void clearTileQueueImmediate() {
_tileHttpClient.clearTileQueue();
}
/// Clear only tiles that are no longer visible in the current bounds
void clearStaleRequests({required LatLngBounds currentBounds}) {
_tileHttpClient.clearStaleRequests(currentBounds);
}
/// Build tile layer widget with current provider and type.
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.

View File

@@ -7,9 +7,12 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../services/prefetch_area_service.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
import '../models/tile_provider.dart';
import '../state/session_state.dart';
import 'debouncer.dart';
import 'camera_provider_with_cache.dart';
import 'camera_icon.dart';
@@ -20,6 +23,7 @@ import 'map/map_position_manager.dart';
import 'map/tile_layer_manager.dart';
import 'map/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'map/suspected_location_markers.dart';
import 'network_status_indicator.dart';
import 'provisional_pin.dart';
import 'proximity_alert_banner.dart';
@@ -38,6 +42,7 @@ class MapView extends StatefulWidget {
this.sheetHeight = 0.0,
this.selectedNodeId,
this.onNodeTap,
this.onSuspectedLocationTap,
this.onSearchPressed,
});
@@ -46,6 +51,7 @@ class MapView extends StatefulWidget {
final double sheetHeight;
final int? selectedNodeId;
final void Function(OsmNode)? onNodeTap;
final void Function(SuspectedLocation)? onSuspectedLocationTap;
final VoidCallback? onSearchPressed;
@override
@@ -57,6 +63,7 @@ class MapViewState extends State<MapView> {
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
late final MapPositionManager _positionManager;
late final TileLayerManager _tileManager;
@@ -66,8 +73,15 @@ class MapViewState extends State<MapView> {
// Track zoom to clear queue on zoom changes
double? _lastZoom;
// Track map center to clear queue on significant panning
LatLng? _lastCenter;
// State for proximity alert banner
bool _showProximityBanner = false;
@override
void initState() {
@@ -78,7 +92,7 @@ class MapViewState extends State<MapView> {
_tileManager = TileLayerManager();
_tileManager.initialize();
_cameraController = CameraRefreshController();
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
_gpsController = GpsController();
// Initialize proximity alert service
@@ -170,11 +184,16 @@ class MapViewState extends State<MapView> {
}
return [];
},
onMapMovedProgrammatically: () {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
},
);
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
_refreshNodesFromProvider();
});
}
@@ -190,12 +209,13 @@ class MapViewState extends State<MapView> {
_cameraController.dispose();
_tileManager.dispose();
_gpsController.dispose();
PrefetchAreaService().dispose();
super.dispose();
}
void _onCamerasUpdated() {
void _onNodesUpdated() {
if (mounted) setState(() {});
}
@@ -213,8 +233,8 @@ class MapViewState extends State<MapView> {
static Future<void> clearStoredMapPosition() =>
MapPositionManager.clearStoredMapPosition();
/// Get minimum zoom level for camera fetching based on upload mode
int _getMinZoomForCameras(BuildContext context) {
/// Get minimum zoom level for node fetching based on upload mode
int _getMinZoomForNodes(BuildContext context) {
final appState = context.read<AppState>();
final uploadMode = appState.uploadMode;
@@ -226,6 +246,53 @@ class MapViewState extends State<MapView> {
}
}
/// Check if the map has moved significantly enough to cancel stale tile requests.
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
bool _mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
if (newCenter == null || oldCenter == null) return false;
// Calculate approximate distance in meters (rough calculation for performance)
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
// This prevents excessive cancellations on small movements while catching real pans
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
}
/// Get interaction options for the map based on whether we're editing a constrained node.
/// Allows zoom and rotation but disables all forms of panning for constrained nodes.
InteractionOptions _getInteractionOptions(EditNodeSession? editSession) {
// Check if we're editing a constrained node
if (editSession?.originalNode.isConstrained == true) {
// Constrained node: only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.doubleTapDragZoom |
InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchZoom |
InteractiveFlag.rotate |
InteractiveFlag.scrollWheelZoom,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
/// Show zoom warning if user is below minimum zoom level
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
// Only show warning once per zoom level to avoid spam
@@ -250,8 +317,7 @@ class MapViewState extends State<MapView> {
}
void _refreshCamerasFromProvider() {
void _refreshNodesFromProvider() {
final appState = context.read<AppState>();
_cameraController.refreshCamerasFromProvider(
controller: _controller,
@@ -262,9 +328,6 @@ class MapViewState extends State<MapView> {
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -274,18 +337,14 @@ class MapViewState extends State<MapView> {
newMode: widget.followMeMode,
oldMode: oldWidget.followMeMode,
controller: _controller,
onMapMovedProgrammatically: () {
_refreshNodesFromProvider();
},
);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@@ -295,7 +354,7 @@ class MapViewState extends State<MapView> {
// Check if enabled profiles changed and refresh cameras if needed
_cameraController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles,
onProfilesChanged: _refreshCamerasFromProvider,
onProfilesChanged: _refreshNodesFromProvider,
);
// Check if tile type OR offline mode changed and clear cache if needed
@@ -326,26 +385,74 @@ class MapViewState extends State<MapView> {
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
Widget cameraLayers = Consumer<CameraProviderWithCache>(
builder: (context, cameraProvider, child) {
// Get current zoom level and map bounds (shared by all logic)
double currentZoom = 15.0; // fallback
LatLngBounds? mapBounds;
try {
currentZoom = _controller.mapController.camera.zoom;
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
// Controller not ready yet, use fallback values
mapBounds = null;
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
final minZoom = _getMinZoomForNodes(context);
List<OsmNode> nodes;
if (currentZoom >= minZoom) {
// Above minimum zoom - get cached nodes
nodes = (mapBounds != null)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
} else {
// Below minimum zoom - don't render any nodes
nodes = <OsmNode>[];
}
// Determine if we should dim node markers (when suspected location is selected)
final shouldDimNodes = appState.selectedSuspectedLocation != null;
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: cameras,
cameras: nodes,
mapController: _controller.mapController,
userLocation: _gpsController.currentLocation,
selectedNodeId: widget.selectedNodeId,
onNodeTap: widget.onNodeTap,
shouldDim: shouldDimNodes,
);
// Get current zoom level for direction cones
double currentZoom = 15.0; // fallback
// Build suspected location markers (respect same zoom and count limits as nodes)
final suspectedLocationMarkers = <Marker>[];
if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= minZoom) {
final suspectedLocations = appState.getSuspectedLocationsInBounds(
north: mapBounds.north,
south: mapBounds.south,
east: mapBounds.east,
west: mapBounds.west,
);
// Apply same node count limit as surveillance nodes
final maxNodes = appState.maxCameras;
final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList();
// Filter out suspected locations that are too close to real nodes
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
suspectedLocations: limitedSuspectedLocations,
realNodes: nodes,
minDistance: appState.suspectedLocationMinDistance,
);
suspectedLocationMarkers.addAll(
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
locations: filteredSuspectedLocations,
mapController: _controller.mapController,
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
onLocationTap: widget.onSuspectedLocationTap,
),
);
}
// Get current zoom level for direction cones (already have currentZoom)
try {
currentZoom = _controller.mapController.camera.zoom;
} catch (_) {
@@ -353,14 +460,30 @@ class MapViewState extends State<MapView> {
}
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: cameras,
cameras: nodes,
zoom: currentZoom,
session: session,
editSession: editSession,
context: context,
);
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
// Add suspected location bounds if one is selected
if (appState.selectedSuspectedLocation != null) {
final selectedLocation = appState.selectedSuspectedLocation!;
if (selectedLocation.bounds.isNotEmpty) {
overlays.add(
Polygon(
points: selectedLocation.bounds,
color: Colors.orange.withOpacity(0.3),
borderColor: Colors.orange,
borderStrokeWidth: 2.0,
),
);
}
}
// Build edit lines connecting original nodes to their edited positions
final editLines = _buildEditLines(nodes);
// Build center marker for add/edit sessions
final centerMarkers = <Marker>[];
@@ -436,7 +559,7 @@ class MapViewState extends State<MapView> {
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
MarkerLayer(markers: [...markers, ...centerMarkers]),
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
],
);
}
@@ -453,15 +576,46 @@ class MapViewState extends State<MapView> {
initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194),
initialZoom: _positionManager.initialZoom ?? 15,
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
interactionOptions: _getInteractionOptions(editSession),
onPositionChanged: (pos, gesture) {
setState(() {}); // Instant UI update for zoom, etc.
if (gesture) widget.onUserGesture();
if (gesture) {
widget.onUserGesture();
}
if (session != null) {
appState.updateSession(target: pos.center);
}
if (editSession != null) {
appState.updateEditSession(target: pos.center);
// For constrained nodes, always snap back to original position
if (editSession.originalNode.isConstrained) {
final originalPos = editSession.originalNode.coord;
// Always keep session target as original position
appState.updateEditSession(target: originalPos);
// Only snap back if position actually drifted, and debounce to wait for gesture completion
if (pos.center.latitude != originalPos.latitude || pos.center.longitude != originalPos.longitude) {
_constrainedNodeSnapBack(() {
// Only animate if we're still in a constrained edit session and still drifted
final currentEditSession = appState.editSession;
if (currentEditSession?.originalNode.isConstrained == true) {
final currentPos = _controller.mapController.camera.center;
if (currentPos.latitude != originalPos.latitude || currentPos.longitude != originalPos.longitude) {
_controller.animateTo(
dest: originalPos,
zoom: _controller.mapController.camera.zoom,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 250),
);
}
}
});
}
} else {
// Normal unconstrained node - allow position updates
appState.updateEditSession(target: pos.center);
}
}
// Update provisional pin location during navigation search/routing
@@ -469,34 +623,41 @@ class MapViewState extends State<MapView> {
appState.updateProvisionalPinLocation(pos.center);
}
// Start dual-source waiting when map moves (user is expecting new tiles AND nodes)
NetworkStatus.instance.setDualSourceWaiting();
// Only clear tile queue on significant ZOOM changes (not panning)
// Clear tile queue on tile level changes OR significant panning
final currentZoom = pos.zoom;
final zoomChanged = _lastZoom != null && (currentZoom - _lastZoom!).abs() > 0.5;
final currentCenter = pos.center;
final currentTileLevel = currentZoom.round();
final lastTileLevel = _lastZoom?.round();
final tileLevelChanged = lastTileLevel != null && currentTileLevel != lastTileLevel;
final centerMoved = _mapMovedSignificantly(currentCenter, _lastCenter);
if (zoomChanged) {
if (tileLevelChanged || centerMoved) {
_tileDebounce(() {
// Clear stale tile requests on zoom change (quietly)
_tileManager.clearTileQueueImmediate();
// Use selective clearing to only cancel tiles that are no longer visible
try {
final currentBounds = _controller.mapController.camera.visibleBounds;
_tileManager.clearStaleRequests(currentBounds: currentBounds);
} catch (e) {
// Fallback to clearing all if bounds calculation fails
debugPrint('[MapView] Could not get current bounds for selective clearing: $e');
_tileManager.clearTileQueueImmediate();
}
});
}
_lastZoom = currentZoom;
_lastCenter = currentCenter;
// Save map position (debounced to avoid excessive writes)
_mapPositionDebounce(() {
_positionManager.saveMapPosition(pos.center, pos.zoom);
});
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
final minZoom = _getMinZoomForCameras(context);
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
final minZoom = _getMinZoomForNodes(context);
if (pos.zoom >= minZoom) {
_cameraDebounce(_refreshCamerasFromProvider);
_cameraDebounce(_refreshNodesFromProvider);
} else {
// Skip nodes at low zoom - report immediate completion (brutalist approach)
NetworkStatus.instance.reportNodeComplete();
// Skip nodes at low zoom - no loading state needed
// Show zoom warning if needed
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
}
@@ -508,17 +669,22 @@ class MapViewState extends State<MapView> {
selectedTileType: appState.selectedTileType,
),
cameraLayers,
// Built-in scale bar from flutter_map, positioned relative to button bar
Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: 8,
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
// Built-in scale bar from flutter_map, positioned relative to button bar with safe area
Builder(
builder: (context) {
final safeArea = MediaQuery.of(context).padding;
return Scalebar(
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
strokeWidth: 3,
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
);
},
),
],
),
@@ -526,7 +692,7 @@ class MapViewState extends State<MapView> {
// All map overlays (mode indicator, zoom, attribution, add pin)
MapOverlays(
mapController: _controller.mapController,
mapController: _controller,
uploadMode: appState.uploadMode,
session: session,
editSession: editSession,
@@ -552,27 +718,27 @@ class MapViewState extends State<MapView> {
}
/// Build polylines connecting original cameras to their edited positions
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
List<Polyline> _buildEditLines(List<OsmNode> nodes) {
final lines = <Polyline>[];
// Create a lookup map of original node IDs to their coordinates
final originalNodes = <int, LatLng>{};
for (final camera in cameras) {
if (camera.tags['_pending_edit'] == 'true') {
originalNodes[camera.id] = camera.coord;
for (final node in nodes) {
if (node.tags['_pending_edit'] == 'true') {
originalNodes[node.id] = node.coord;
}
}
// Find edited cameras and draw lines to their originals
for (final camera in cameras) {
final originalIdStr = camera.tags['_original_node_id'];
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
// Find edited nodes and draw lines to their originals
for (final node in nodes) {
final originalIdStr = node.tags['_original_node_id'];
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
final originalId = int.tryParse(originalIdStr);
final originalCoord = originalId != null ? originalNodes[originalId] : null;
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, camera.coord],
points: [originalCoord, node.coord],
color: kNodeRingColorPending,
strokeWidth: 3.0,
));
@@ -582,5 +748,40 @@ class MapViewState extends State<MapView> {
return lines;
}
/// Filter suspected locations that are too close to real nodes
List<SuspectedLocation> _filterSuspectedLocationsByProximity({
required List<SuspectedLocation> suspectedLocations,
required List<OsmNode> realNodes,
required int minDistance, // in meters
}) {
if (minDistance <= 0) return suspectedLocations;
const distance = Distance();
final filteredLocations = <SuspectedLocation>[];
for (final suspected in suspectedLocations) {
bool tooClose = false;
for (final realNode in realNodes) {
final distanceMeters = distance.as(
LengthUnit.Meter,
suspected.centroid,
realNode.coord,
);
if (distanceMeters < minDistance) {
tooClose = true;
break;
}
}
if (!tooClose) {
filteredLocations.add(suspected);
}
}
return filteredLocations;
}
}

View File

@@ -1,103 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/network_status.dart';
import '../services/localization_service.dart';
class NetworkStatusIndicator extends StatelessWidget {
const NetworkStatusIndicator({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: NetworkStatus.instance,
child: Consumer<NetworkStatus>(
builder: (context, networkStatus, child) {
String message;
IconData icon;
Color color;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ChangeNotifierProvider.value(
value: NetworkStatus.instance,
child: Consumer<NetworkStatus>(
builder: (context, networkStatus, child) {
final locService = LocalizationService.instance;
String message;
IconData icon;
Color color;
switch (networkStatus.currentStatus) {
case NetworkStatusType.waiting:
message = 'Loading...';
icon = Icons.hourglass_empty;
color = Colors.blue;
break;
case NetworkStatusType.timedOut:
message = 'Timed out';
icon = Icons.hourglass_disabled;
color = Colors.orange;
break;
case NetworkStatusType.noData:
message = 'No tiles here';
icon = Icons.cloud_off;
color = Colors.grey;
break;
switch (networkStatus.currentStatus) {
case NetworkStatusType.waiting:
message = locService.t('networkStatus.loading');
icon = Icons.hourglass_empty;
color = Colors.blue;
break;
case NetworkStatusType.timedOut:
message = locService.t('networkStatus.timedOut');
icon = Icons.hourglass_disabled;
color = Colors.orange;
break;
case NetworkStatusType.noData:
message = locService.t('networkStatus.noData');
icon = Icons.cloud_off;
color = Colors.grey;
break;
case NetworkStatusType.success:
message = 'Done';
icon = Icons.check_circle;
color = Colors.green;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = 'Tile provider slow';
icon = Icons.map_outlined;
color = Colors.orange;
break;
case NetworkIssueType.overpassApi:
message = 'Camera data slow';
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkIssueType.both:
message = 'Network issues';
icon = Icons.cloud_off_outlined;
color = Colors.red;
break;
default:
return const SizedBox.shrink();
}
break;
case NetworkStatusType.ready:
return const SizedBox.shrink();
}
case NetworkStatusType.success:
message = locService.t('networkStatus.success');
icon = Icons.check_circle;
color = Colors.green;
break;
case NetworkStatusType.nodeLimitReached:
message = locService.t('networkStatus.nodeLimitReached');
icon = Icons.visibility_off;
color = Colors.amber;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = locService.t('networkStatus.tileProviderSlow');
icon = Icons.map_outlined;
color = Colors.orange;
break;
case NetworkIssueType.overpassApi:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkIssueType.both:
message = locService.t('networkStatus.networkIssues');
icon = Icons.cloud_off_outlined;
color = Colors.red;
break;
default:
return const SizedBox.shrink();
}
break;
case NetworkStatusType.ready:
return const SizedBox.shrink();
}
return Positioned(
top: 8, // Position relative to the map area (not the screen)
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
message,
style: TextStyle(
return Positioned(
top: 8, // Position relative to the map area (not the screen)
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
const SizedBox(width: 4),
Text(
message,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
},
);
},
),
),
);
}

View File

@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
import 'advanced_edit_options_sheet.dart';
class NodeTagSheet extends StatelessWidget {
final OsmNode node;
@@ -67,82 +71,166 @@ class NodeTagSheet extends StatelessWidget {
}
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
void _viewOnOSM() async {
final url = 'https://www.openstreetmap.org/node/${node.id}';
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
}
void _openAdvancedEdit() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AdvancedEditOptionsSheet(node: node),
);
}
return LayoutBuilder(
builder: (context, constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('node.title').replaceAll('{}', node.id.toString()),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// Tag list with flexible height constraint
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
),
const SizedBox(height: 12),
...node.tags.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...node.tags.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Linkify(
onOpen: (link) async {
final uri = Uri.parse(link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${LocalizationService.instance.t('advancedEdit.couldNotOpenURL')}: ${link.url}')),
);
}
},
text: e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(humanize: false),
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(height: 16),
// First row: View and Advanced buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => _viewOnOSM(),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(locService.t('actions.viewOnOSM')),
),
const SizedBox(width: 8),
if (isEditable) ...[
OutlinedButton.icon(
onPressed: _openAdvancedEdit,
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(locService.t('actions.advanced')),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
],
),
const SizedBox(height: 8),
// Second row: Edit, Delete, and Close buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: Colors.red,
),
),
const SizedBox(width: 12),
],
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
},
);
},
);
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class SuspectedLocationIcon extends StatelessWidget {
const SuspectedLocationIcon({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.orange,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.help_outline,
color: Colors.white,
size: 12,
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
class SuspectedLocationProgressDialog extends StatelessWidget {
final String title;
final String message;
final double? progress; // 0.0 to 1.0, null for indeterminate
final VoidCallback? onCancel;
const SuspectedLocationProgressDialog({
super.key,
required this.title,
required this.message,
this.progress,
this.onCancel,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.help_outline, color: Colors.orange),
const SizedBox(width: 8),
Expanded(child: Text(title)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
const SizedBox(height: 16),
if (progress != null)
LinearProgressIndicator(value: progress)
else
const LinearProgressIndicator(),
const SizedBox(height: 8),
if (progress != null)
Text(
'${(progress! * 100).toInt()}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
if (onCancel != null)
TextButton(
onPressed: onCancel,
child: Text(LocalizationService.instance.cancel),
),
],
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/suspected_location.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
class SuspectedLocationSheet extends StatelessWidget {
final SuspectedLocation location;
const SuspectedLocationSheet({super.key, required this.location});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final appState = context.watch<AppState>();
final locService = LocalizationService.instance;
// Get all fields except location and ticket_no
final displayData = <String, String>{};
for (final entry in location.allFields.entries) {
final value = entry.value?.toString();
if (value != null && value.isNotEmpty) {
displayData[entry.key] = value;
}
}
return LayoutBuilder(
builder: (context, constraints) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocation.title', params: [location.ticketNo]),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// Field list with flexible height constraint
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * getTagListHeightRatio(context),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Display all fields
...displayData.entries.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.key,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: e.key.toLowerCase().contains('url') && e.value.isNotEmpty
? GestureDetector(
onTap: () async {
final uri = Uri.parse(e.value);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: ${e.value}'),
),
);
}
}
},
child: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
softWrap: true,
),
)
: Text(
e.value,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Coordinates info
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocation.coordinates'),
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
softWrap: true,
),
),
],
),
),
const SizedBox(height: 16),
// Close button
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.t('actions.close')),
),
],
),
],
),
),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/changelog_service.dart';
import '../services/localization_service.dart';
class WelcomeDialog extends StatefulWidget {
const WelcomeDialog({super.key});
@override
State<WelcomeDialog> createState() => _WelcomeDialogState();
}
class _WelcomeDialogState extends State<WelcomeDialog> {
bool _dontShowAgain = false;
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
void _onClose() async {
if (_dontShowAgain) {
await ChangelogService().markWelcomeSeen();
}
// Note: Version tracking is updated by completeVersionChange() after all dialogs
if (mounted) {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => AlertDialog(
title: Text(locService.t('welcome.title')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Scrollable content
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('welcome.description'),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.mission'),
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Text(
locService.t('welcome.firsthandKnowledge'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.deepOrange),
),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.privacy'),
style: const TextStyle(fontSize: 13, fontStyle: FontStyle.italic),
),
const SizedBox(height: 12),
Text(
locService.t('welcome.tileNote'),
style: const TextStyle(fontSize: 13, color: Colors.orange),
),
const SizedBox(height: 16),
Text(
locService.t('welcome.moreInfo'),
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
// Quick links row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildLinkButton('Website', 'https://deflock.me'),
_buildLinkButton('GitHub', 'https://github.com/FoggedLens/deflock-app'),
_buildLinkButton('Discord', 'https://discord.gg/aV7v4R3sKT'),
],
),
],
),
),
),
const SizedBox(height: 16),
// Always visible checkbox at the bottom
Row(
children: [
Checkbox(
value: _dontShowAgain,
onChanged: (value) {
setState(() {
_dontShowAgain = value ?? false;
});
},
),
Expanded(
child: Text(
locService.t('welcome.dontShowAgain'),
style: const TextStyle(fontSize: 13),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: _onClose,
child: Text(locService.t('welcome.getStarted')),
),
],
),
);
}
Widget _buildLinkButton(String text, String url) {
return Flexible(
child: GestureDetector(
onTap: () => _launchUrl(url),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
),
);
}
}

View File

@@ -38,5 +38,13 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
# Ensure minimum deployment target is 10.13 or higher for all pods
target.build_configurations.each do |config|
deployment_target = config.build_settings['MACOSX_DEPLOYMENT_TARGET']
if deployment_target && deployment_target.to_f < 10.13
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.13'
end
end
end
end

View File

@@ -572,7 +572,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (

View File

@@ -2,17 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
</array>
</dict>
</plist>

View File

@@ -89,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
csv:
dependency: "direct main"
description:
name: csv
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
url: "https://pub.dev"
source: hosted
version: "6.0.0"
dart_earcut:
dependency: transitive
description:
@@ -158,6 +166,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_linkify:
dependency: "direct main"
description:
name: flutter_linkify
sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
@@ -387,6 +403,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.1"
linkify:
dependency: transitive
description:
name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lists:
dependency: transitive
description:
@@ -705,7 +729,7 @@ packages:
source: hosted
version: "2.2.2"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.1.0+2 # The thing after the + is the google versionCode
version: 1.4.0+13 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
@@ -20,6 +20,8 @@ dependencies:
flutter_svg: ^2.0.10
xml: ^6.4.2
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
flutter_linkify: ^6.0.0
# Auth, storage, prefs
oauth2_client: ^4.2.0
@@ -30,6 +32,7 @@ dependencies:
shared_preferences: ^2.2.2
uuid: ^4.0.0
package_info_plus: ^8.0.0
csv: ^6.0.0
dev_dependencies:
flutter_launcher_icons: ^0.14.4
@@ -43,6 +46,7 @@ flutter:
- assets/android_app_icon.png
- assets/transparent_1x1.png
- assets/deflock-logo.svg
- assets/changelog.json
- lib/localizations/
flutter_launcher_icons:

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env dart
import 'dart:convert';
import 'dart:io';
const String localizationsDir = 'lib/localizations';
const String referenceFile = 'en.json';
void main() async {
print('🌍 Validating localization files...\n');
try {
final result = await validateLocalizations();
if (result) {
print('✅ All localization files are valid!');
exit(0);
} else {
print('❌ Localization validation failed!');
exit(1);
}
} catch (e) {
print('💥 Error during validation: $e');
exit(1);
}
}
Future<bool> validateLocalizations() async {
// Get all JSON files in localizations directory
final locDir = Directory(localizationsDir);
if (!locDir.existsSync()) {
print('❌ Localizations directory not found: $localizationsDir');
return false;
}
final jsonFiles = locDir
.listSync()
.where((file) => file.path.endsWith('.json'))
.map((file) => file.path.split('/').last)
.toList();
if (jsonFiles.isEmpty) {
print('❌ No JSON localization files found');
return false;
}
print('📁 Found ${jsonFiles.length} localization files:');
for (final file in jsonFiles) {
print('$file');
}
print('');
// Load reference file (English)
final refFile = File('$localizationsDir/$referenceFile');
if (!refFile.existsSync()) {
print('❌ Reference file not found: $referenceFile');
return false;
}
Map<String, dynamic> referenceData;
try {
final refContent = await refFile.readAsString();
referenceData = json.decode(refContent) as Map<String, dynamic>;
} catch (e) {
print('❌ Failed to parse reference file $referenceFile: $e');
return false;
}
final referenceKeys = _extractAllKeys(referenceData);
print('🔑 Reference file ($referenceFile) has ${referenceKeys.length} keys');
bool allValid = true;
// Validate each localization file
for (final fileName in jsonFiles) {
if (fileName == referenceFile) continue; // Skip reference file
print('\n🔍 Validating $fileName...');
final file = File('$localizationsDir/$fileName');
Map<String, dynamic> fileData;
try {
final content = await file.readAsString();
fileData = json.decode(content) as Map<String, dynamic>;
} catch (e) {
print(' ❌ Failed to parse $fileName: $e');
allValid = false;
continue;
}
final fileKeys = _extractAllKeys(fileData);
final validation = _validateKeys(referenceKeys, fileKeys, fileName);
if (validation.isValid) {
print(' ✅ Structure matches reference (${fileKeys.length} keys)');
} else {
print(' ❌ Structure validation failed:');
for (final error in validation.errors) {
print('$error');
}
allValid = false;
}
}
return allValid;
}
/// Extract all nested keys from a JSON object using dot notation
/// Example: {"user": {"name": "John"}} -> ["user.name"]
Set<String> _extractAllKeys(Map<String, dynamic> data, {String prefix = ''}) {
final keys = <String>{};
for (final entry in data.entries) {
final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
if (entry.value is Map<String, dynamic>) {
// Recurse into nested objects
keys.addAll(_extractAllKeys(entry.value as Map<String, dynamic>, prefix: key));
} else {
// Add leaf key
keys.add(key);
}
}
return keys;
}
class ValidationResult {
final bool isValid;
final List<String> errors;
ValidationResult({required this.isValid, required this.errors});
}
ValidationResult _validateKeys(Set<String> referenceKeys, Set<String> fileKeys, String fileName) {
final errors = <String>[];
// Find missing keys
final missingKeys = referenceKeys.difference(fileKeys);
if (missingKeys.isNotEmpty) {
errors.add('Missing ${missingKeys.length} keys: ${missingKeys.take(5).join(', ')}${missingKeys.length > 5 ? '...' : ''}');
}
// Find extra keys
final extraKeys = fileKeys.difference(referenceKeys);
if (extraKeys.isNotEmpty) {
errors.add('Extra ${extraKeys.length} keys not in reference: ${extraKeys.take(5).join(', ')}${extraKeys.length > 5 ? '...' : ''}');
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}

View File

@@ -1,83 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/models/pending_upload.dart';
import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/state/settings_state.dart';
void main() {
group('PendingUpload', () {
test('should serialize and deserialize upload mode correctly', () {
// Test each upload mode
final testModes = [
UploadMode.production,
UploadMode.sandbox,
UploadMode.simulate,
];
for (final mode in testModes) {
final original = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: NodeProfile.flock(),
uploadMode: mode,
);
// Serialize to JSON
final json = original.toJson();
// Deserialize from JSON
final restored = PendingUpload.fromJson(json);
// Verify upload mode is preserved
expect(restored.uploadMode, equals(mode));
expect(restored.uploadModeDisplayName, equals(original.uploadModeDisplayName));
// Verify other fields too
expect(restored.coord.latitude, equals(original.coord.latitude));
expect(restored.coord.longitude, equals(original.coord.longitude));
expect(restored.direction, equals(original.direction));
expect(restored.profile.id, equals(original.profile.id));
}
});
test('should handle legacy JSON without uploadMode', () {
// Simulate old JSON format without uploadMode field
final legacyJson = {
'lat': 37.7749,
'lon': -122.4194,
'dir': 90.0,
'profile': NodeProfile.flock().toJson(),
'originalNodeId': null,
'attempts': 0,
'error': false,
// Note: no 'uploadMode' field
};
final upload = PendingUpload.fromJson(legacyJson);
// Should default to production mode for legacy entries
expect(upload.uploadMode, equals(UploadMode.production));
expect(upload.uploadModeDisplayName, equals('Production'));
});
test('should correctly identify edits vs new cameras', () {
final newCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: NodeProfile.flock(),
uploadMode: UploadMode.production,
);
final editCamera = PendingUpload(
coord: LatLng(37.7749, -122.4194),
direction: 90.0,
profile: CameraProfile.flock(),
uploadMode: UploadMode.production,
originalNodeId: 12345,
);
expect(newCamera.isEdit, isFalse);
expect(editCamera.isEdit, isTrue);
});
});
}