Compare commits

..

99 Commits

Author SHA1 Message Date
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
stopflock
08238eaad2 stray period 2025-10-06 19:01:34 -05:00
stopflock
3fbcd8f092 add google versioncode 2025-10-06 18:53:29 -05:00
stopflock
aeea503060 Name asset files in release correctly, remove finished todos 2025-10-06 14:48:13 -05:00
stopflock
69084be7bd add permission for attaching assets to release 2025-10-06 12:42:27 -05:00
stopflock
14b52f8018 Bump version to 1.1 2025-10-06 12:03:26 -05:00
stopflock
5301810c0e run actions on tags on master, attach to release 2025-10-06 11:59:05 -05:00
stopflock
23713acb99 Merge pull request #20 from FoggedLens/vector-tiles
Everything but Vector tiles:

Splash screens and icons working right on both platforms
Search function available through a button
Stop adjusting UX depending on keyboard
Stop overlapping OS nav controls bar on android
Improve attribution (truncation, tap to view)
Add configurable max zoom to tile types
Remove tile providers whose terms we were possibly breaking
Navigation/routing UX, hidden behind dev mode - not in release yet
2025-10-06 11:11:46 -05:00
79 changed files with 5376 additions and 754 deletions

View File

@@ -1,26 +1,40 @@
name: Build Release
on: workflow_dispatch
name: Build and Release
on:
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
@@ -44,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
@@ -61,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
@@ -93,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
@@ -110,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
@@ -135,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
@@ -154,3 +252,88 @@ jobs:
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
path: Runner.ipa
if-no-files-found: 'error'
attach-to-release:
name: Attach Assets to Release
needs: [get-version, build-android-apk, build-android-aab, build-ios]
runs-on: ubuntu-latest
steps:
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.apk
- name: Download AAB artifact
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.aab
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
- name: Rename files for release
run: |
mv app-release.apk deflock_v${{ needs.get-version.outputs.version }}.apk
mv app-release.aab deflock_v${{ needs.get-version.outputs.version }}.aab
mv Runner.ipa deflock_v${{ needs.get-version.outputs.version }}.ipa
- name: Attach assets to release
uses: softprops/action-gh-release@v2
with:
files: |
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,42 +91,35 @@ 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
- 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
- Move download button?
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Clean up dev_config
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
- Add Rekor profile
### 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

26
assets/changelog.json Normal file
View File

@@ -0,0 +1,26 @@
{
"1.3.2": {
"content": "• HOTFIX: Temporarily disabled node editing to prevent OSM database issues while a bug is resolved\n• TECH: Added kEnableNodeEdits feature flag for controlling edit functionality"
},
"1.3.1": {
"content": "• UX: Network status indicator always enabled\n• UX: Direction slider wider on small screens\n• UX: Fixed iOS keyboard missing 'Done' in settings\n• UX: Fixed multi-direction nodes in upload queue\n• 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.\n• NEW: Note in welcome message about not submitting data you cannot vouch for personally (no street view etc)\n• NEW: Added default operator profiles for the most common private operators nationwide (Lowe's, Home Depot, et al)\n• 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\n• Smart area caching: Loads 3x larger areas and refreshes data every 60 seconds for much faster browsing\n• Enhanced tile loading: Increased retry attempts with faster delays - tiles load much more reliably\n• Better network status: Simplified loading indicator logic\n• Instant node display: Surveillance devices now appear immediately when data finishes loading\n• 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\n• Automatic changelog display when app updates (like this one!)\n• Added Release Notes viewer in Settings > About\n• Enhanced user onboarding and transparency about data handling\n• Improved documentation for contributors"
},
"1.2.3": {
"content": "• Enhanced map performance and stability\n• Improved offline sync reliability\n• Added better error handling for uploads\n• Various bug fixes and improvements"
},
"1.2.2": {
"content": "• New surveillance device profiles added\n• Improved tile loading performance\n• Fixed issue with GPS accuracy\n• Updated translations"
},
"1.2.0": {
"content": "• Major UI improvements\n• Added proximity alerts\n• Enhanced offline capabilities\n• 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();
@@ -124,9 +133,11 @@ class AppState extends ChangeNotifier {
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 +150,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 +165,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 +285,20 @@ class AppState extends ChangeNotifier {
);
}
void addDirection() {
_sessionState.addDirection();
}
void removeDirection() {
_sessionState.removeDirection();
}
void cycleDirection() {
_sessionState.cycleDirection();
}
void cancelSession() {
_sessionState.cancelSession();
}
@@ -392,7 +445,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 +461,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 +480,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() {
@@ -449,6 +538,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)
@@ -37,12 +40,18 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
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 = false; // 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,6 +67,14 @@ 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
@@ -68,14 +85,18 @@ 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.005; // Mouse scroll wheel zoom speed (default 0.005)
const double kPinchZoomThreshold = 0.5; // How much pinch required to start zoom (default 0.5)
const double kPinchMoveThreshold = 40.0; // How much drag required for two-finger pan (default 40.0)
// 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 +107,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

@@ -19,8 +19,8 @@
"clear": "Löschen"
},
"followMe": {
"off": "Verfolgung aktivieren (Norden oben)",
"northUp": "Verfolgung aktivieren (Rotation)",
"off": "Verfolgung aktivieren",
"follow": "Verfolgung aktivieren (Rotation)",
"rotating": "Verfolgung deaktivieren"
},
"settings": {
@@ -72,6 +72,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,8 +85,11 @@
"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.",
@@ -124,7 +129,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: {}",
@@ -306,6 +316,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 +362,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",
@@ -26,8 +37,8 @@
"clear": "Clear"
},
"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": {
@@ -79,6 +90,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,8 +103,11 @@
"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.",
@@ -131,7 +147,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: {}",
@@ -341,5 +362,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",
@@ -26,8 +37,8 @@
"clear": "Limpiar"
},
"followMe": {
"off": "Activar seguimiento (norte arriba)",
"northUp": "Activar seguimiento (rotación)",
"off": "Activar seguimiento",
"follow": "Activar seguimiento (rotación)",
"rotating": "Desactivar seguimiento"
},
"settings": {
@@ -79,6 +90,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,8 +103,11 @@
"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.",
@@ -131,7 +147,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: {}",
@@ -341,5 +362,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",
@@ -26,8 +37,8 @@
"clear": "Effacer"
},
"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": {
@@ -79,6 +90,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,8 +103,11 @@
"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.",
@@ -131,7 +147,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: {}",
@@ -341,5 +362,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",
@@ -26,8 +37,8 @@
"clear": "Pulisci"
},
"followMe": {
"off": "Attiva seguimi (nord in alto)",
"northUp": "Attiva seguimi (rotazione)",
"off": "Attiva seguimi",
"follow": "Attiva seguimi (rotazione)",
"rotating": "Disattiva seguimi"
},
"settings": {
@@ -79,6 +90,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,8 +103,11 @@
"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.",
@@ -131,7 +147,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: {}",
@@ -341,5 +362,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",
@@ -26,8 +37,8 @@
"clear": "Limpar"
},
"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": {
@@ -79,6 +90,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,8 +103,11 @@
"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.",
@@ -131,7 +147,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: {}",
@@ -341,5 +362,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": "下载",
@@ -26,8 +37,8 @@
"clear": "清空"
},
"followMe": {
"off": "启用跟随模式(北向上)",
"northUp": "启用跟随模式(旋转)",
"off": "启用跟随模式",
"follow": "启用跟随模式(旋转)",
"rotating": "禁用跟随模式"
},
"settings": {
@@ -79,6 +90,8 @@
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
@@ -90,8 +103,11 @@
"editNode": {
"title": "编辑节点 #{}",
"profile": "配置文件",
"selectProfile": "选择配置文件...",
"profileRequired": "请选择配置文件以继续。",
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
@@ -131,7 +147,12 @@
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录"
"connectionFailed": "连接失败 - 请重新登录",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
"deleteAccountWarning": "警告:此操作无法撤销,将永久删除您的 OSM 账户。",
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"pendingUploads": "待上传:{}",
@@ -341,5 +362,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,149 @@ 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-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

@@ -32,23 +32,47 @@ class OsmNode {
);
}
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;
@@ -17,28 +33,32 @@ class AboutScreen extends StatelessWidget {
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
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 +68,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';
@@ -25,8 +26,10 @@ class AdvancedSettingsScreen extends StatelessWidget {
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();
}
});
}
@@ -172,6 +187,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 +239,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 +309,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 +536,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 +605,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 +669,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
onSearchPressed: _onNavigationButtonPressed,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {

View File

@@ -0,0 +1,191 @@
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: const EdgeInsets.all(16),
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

@@ -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

@@ -0,0 +1,304 @@
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;
/// 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;
}
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : 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;
final content = versionData['content'] as String?;
return (content?.isEmpty == true) ? null : 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 = versionData?['content'] as String?;
// 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 = versionData?['content'] as String?;
// 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 = versionData['content'] as String;
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

@@ -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,12 +118,39 @@ 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) {
@@ -62,6 +173,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
@@ -100,6 +214,35 @@ $outputClause
''';
}
/// 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),
),
];
}
/// 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];

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,14 +27,16 @@ 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';
bool _offlineMode = 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 = '';
@@ -46,6 +48,7 @@ class SettingsState extends ChangeNotifier {
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;
@@ -99,7 +102,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)) {
@@ -285,12 +291,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 +330,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,
@@ -63,8 +63,8 @@ class UploadQueueState extends ChangeNotifier {
void addFromEditSession(EditNodeSession 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 commitEditSession() checks for null
operatorProfile: session.operatorProfile,
uploadMode: uploadMode,
operation: UploadOperation.modify,
@@ -109,8 +109,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,
@@ -293,6 +293,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

@@ -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,55 @@
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 {
// Update version tracking when closing changelog dialog
await ChangelogService().updateLastSeenVersion();
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,164 @@
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;
const CompassIndicator({
super.key,
required this.mapController,
});
@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;
return Positioned(
top: (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) ? 60 : 18,
right: 16,
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,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';
@@ -13,6 +14,126 @@ class EditNodeSheet extends StatelessWidget {
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 +157,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 +198,35 @@ 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),
if (!kEnableNodeEdits)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.grey, size: 16),
const Icon(Icons.construction, color: Colors.orange, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.profileNoDirectionInfo'),
style: const TextStyle(color: Colors.grey, fontSize: 12),
locService.t('editNode.temporarilyDisabled'),
style: const TextStyle(color: Colors.orange, fontSize: 13),
),
),
],
),
),
if (!appState.isLoggedIn)
)
else if (!appState.isLoggedIn)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
@@ -143,7 +258,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 +296,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])

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,
@@ -82,6 +83,11 @@ class MapOverlays extends StatelessWidget {
),
),
// Compass indicator (top-right, below mode indicator)
CompassIndicator(
mapController: mapController,
),
// Zoom indicator, positioned relative to button bar
Positioned(
left: 10,
@@ -96,7 +102,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
}
@@ -173,8 +179,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 +194,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,8 +7,10 @@ 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 'debouncer.dart';
import 'camera_provider_with_cache.dart';
@@ -20,6 +22,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 +41,7 @@ class MapView extends StatefulWidget {
this.sheetHeight = 0.0,
this.selectedNodeId,
this.onNodeTap,
this.onSuspectedLocationTap,
this.onSearchPressed,
});
@@ -46,6 +50,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
@@ -66,8 +71,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 +90,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 +182,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 +207,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 +231,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 +244,22 @@ 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;
}
/// 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 +284,7 @@ class MapViewState extends State<MapView> {
}
void _refreshCamerasFromProvider() {
void _refreshNodesFromProvider() {
final appState = context.read<AppState>();
_cameraController.refreshCamerasFromProvider(
controller: _controller,
@@ -262,9 +295,6 @@ class MapViewState extends State<MapView> {
}
@override
void didUpdateWidget(covariant MapView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -274,18 +304,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 +321,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 +352,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 +427,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 +526,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,9 +543,16 @@ 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: const InteractionOptions(
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
),
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);
@@ -469,34 +566,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);
}
@@ -526,7 +630,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 +656,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 +686,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

@@ -40,6 +40,12 @@ class NetworkStatusIndicator extends StatelessWidget {
color = Colors.green;
break;
case NetworkStatusType.nodeLimitReached:
message = 'Showing limit - increase in settings';
icon = Icons.visibility_off;
color = Colors.amber;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:

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,150 @@
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';
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 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('suspectedLocation.title', params: [location.ticketNo]),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
// 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,163 @@
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();
}
// Always update version tracking when closing welcome dialog
await ChangelogService().updateLastSeenVersion();
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:
@@ -705,7 +713,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.0.10
version: 1.3.2+10 # 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,7 @@ dependencies:
flutter_svg: ^2.0.10
xml: ^6.4.2
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
# Auth, storage, prefs
oauth2_client: ^4.2.0
@@ -30,6 +31,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 +45,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);
});
});
}