Compare commits

...

88 Commits

Author SHA1 Message Date
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
stopflock
f5aeba473b UX tweaks to attribution, download button, allow search in release builds 2025-10-06 10:07:54 -05:00
stopflock
f285a18563 Clean up dev_config, rename more camera -> node 2025-10-05 22:27:18 -05:00
stopflock
ae220fc3f5 Update Readme roadmap / todos 2025-10-05 16:50:57 -05:00
stopflock
111bdc4254 Fix opentopomap x/y, allow searching in release when not offline 2025-10-05 16:48:25 -05:00
stopflock
731cdc4a4b Remove USGS tile types that only to go zoom 8 2025-10-05 15:25:14 -05:00
stopflock
a08d61fb98 Add other providers, max zoom per tile type 2025-10-05 15:08:27 -05:00
stopflock
5976ab4bab max zoom per tile type 2025-10-05 13:04:48 -05:00
stopflock
5568173c6e update README, remove esri map provider 2025-10-05 12:44:46 -05:00
stopflock
c4ec144f20 Remove completed items from readme TODOs 2025-10-05 01:23:16 -05:00
stopflock
b636ab4d26 Remove google tile provider 2025-10-05 01:01:02 -05:00
stopflock
5e426c342d bump version, disable dev mode 2025-10-05 00:29:19 -05:00
stopflock
bbfeda8280 Stop respecting keyboard for UI layout 2025-10-05 00:24:18 -05:00
stopflock
079448eeeb Fix UI overlap with OS nav bar/buttons 2025-10-05 00:07:31 -05:00
stopflock
9ef06cdec2 Globe emoji on Language section, disable dev mode 2025-10-04 16:49:20 -05:00
stopflock
bdde689ee7 Fix icons and splash screens, build in gh actions workflow, remove resources from git 2025-10-04 16:22:37 -05:00
stopflock
2842481d98 update icon splash generator script to use new command structure 2025-10-04 13:25:38 -05:00
Will Freeman
e73a885544 Merge pull request #19 from FoggedLens/main 2025-10-04 10:44:17 -06:00
stopflock
d8b48c8fdb Merge branch 'vector-tiles' into main 2025-10-04 11:29:21 -05:00
stopflock
3e1fb58162 Merge pull request #18 from BowlesCR/local-about
Localize about screen
2025-10-03 12:10:08 -05:00
stopflock
dbe667ee8b if/else for android signing keys 2025-10-03 11:34:15 -05:00
stopflock
0bc420efca Remove old offset var from dev_config, fix android builds 2025-10-03 11:06:46 -05:00
stopflock
02c66b3785 App version from pubspec only. Bump version. 2025-10-03 10:44:33 -05:00
stopflock
fd47813bdf Optional navigation features 2025-10-03 00:09:25 -05:00
stopflock
8b4b9722c4 Navigation settings 2025-10-02 22:47:15 -05:00
stopflock
dfb8eceaad Basic routing. Still some bugs. 2025-10-02 20:08:21 -05:00
stopflock
c6db4396e4 follow-me, sheet dismissal, zoom/centering on start 2025-10-02 19:19:36 -05:00
stopflock
40c78ab3b7 Edge cases - UX bugs 2025-10-02 18:51:52 -05:00
stopflock
19de232484 Better pins, bugfixes 2025-10-02 18:29:17 -05:00
stopflock
bac033528c UX actually close 2025-10-02 17:39:29 -05:00
stopflock
763fa31266 Moving the right direction I think 2025-10-02 17:23:17 -05:00
stopflock
408b52cdb0 UX bones 2025-10-02 16:50:07 -05:00
Chris Bowles
7d18656ec6 Localize about screen 2025-10-02 16:56:31 -04:00
stopflock
a7186ab2c5 change android target sdk ver 2025-10-02 04:25:41 -05:00
stopflock
b02099e3fe Fix android signing - actually do it 2025-10-02 04:06:35 -05:00
stopflock
80c6d0a82d fire me 2025-10-02 03:53:40 -05:00
stopflock
3e6a27cc15 code signing for android 2025-10-02 03:34:40 -05:00
stopflock
4e072a34c0 Search address / POI 2025-10-02 03:30:36 -05:00
stopflock
9ad7e82e93 Bump version 2025-10-02 01:57:40 -05:00
stopflock
2fabc90be7 Merge pull request #17 from BowlesCR/main
Add build workflows
2025-10-02 01:44:24 -05:00
Chris Bowles
e41ea0488d Add build workflows 2025-10-02 02:39:51 -04:00
stopflock
acd010bcfa Update readme, remove flockmapapp OLD references 2025-10-02 00:56:34 -05:00
stopflock
6c0981abdd Optional network indicator 2025-10-01 23:34:18 -05:00
stopflock
1007a88dd2 about/info translations, version in settings 2025-10-01 21:15:42 -05:00
stopflock
6569ea9f57 update readme 2025-10-01 14:22:31 -05:00
117 changed files with 5194 additions and 478 deletions

304
.github/workflows/workflow.yml vendored Normal file
View File

@@ -0,0 +1,304 @@
name: Build Release
on:
push:
tags:
- '*'
workflow_dispatch:
permissions:
contents: write
jobs:
get-version:
name: Get Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.set-version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Get version from lib/dev_config.dart
id: set-version
run: |
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
build-android-apk:
name: Build Android APK
needs: get-version
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Create key.properties
run: |
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ vars.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=keystore.jks" >> android/key.properties
- name: Build Android .apk
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
with:
name: deflock_v${{ needs.get-version.outputs.version }}.apk
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: 'error'
build-android-aab:
name: Build Android AAB
needs: get-version
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks
- name: Create key.properties
run: |
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ vars.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=keystore.jks" >> android/key.properties
- name: Build Android 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
with:
name: deflock_v${{ needs.get-version.outputs.version }}.aab
path: build/app/outputs/bundle/release/app-release.aab
if-no-files-found: 'error'
build-ios:
name: Build iOS
needs: get-version
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Generate icons and splash screens
run: |
dart run flutter_launcher_icons
dart run flutter_native_splash:create
- 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: |
# 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
if: always()
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
with:
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
path: Runner.ipa
if-no-files-found: 'error'
- name: Upload to App Store Connect
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
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
mkdir -p ~/private_keys
# Decode and save the API key
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
# Debug: Check if file was created and has content
echo "=== API Key File Debug ==="
ls -la ~/private_keys/
file ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
echo "First few lines of key file:"
head -3 ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
echo "=========================="
# Upload using xcrun altool
xcrun altool --upload-app \
--type ios \
--file Runner.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
# Clean up
rm -rf ~/private_keys
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

18
.gitignore vendored
View File

@@ -25,6 +25,11 @@ android/app/profile/
android/app/release/
*.iml
# Generated icons and splash screens (exclude manually maintained files)
android/app/src/main/res/drawable*/
android/app/src/main/res/mipmap*/
!android/app/src/main/res/values*/
# ───────────────────────────────
# iOS / macOS
# ───────────────────────────────
@@ -37,10 +42,18 @@ ios/Runner.xcworkspace/
macos/Pods/
macos/.generated/
macos/Flutter/ephemeral/
# CocoaPods  commit Podfile.lock if you need reproducible iOS builds
# CocoaPods commit Podfile.lock if you need reproducible iOS builds
Podfile.lock
Pods/
# Generated icons and splash screens
ios/Runner/Assets.xcassets/AppIcon.appiconset/*
ios/Runner/Assets.xcassets/LaunchImage.imageset/*
ios/Runner/Assets.xcassets/LaunchBackground.imageset/*
!ios/Runner/Assets.xcassets/AppIcon.appiconset/.gitkeep
!ios/Runner/Assets.xcassets/LaunchImage.imageset/.gitkeep
!ios/Runner/Assets.xcassets/LaunchBackground.imageset/.gitkeep
# Xcode user data & build artifacts
*.xcworkspace
*.xcuserstate
@@ -80,6 +93,9 @@ Thumbs.db
*.keystore
.env
# Local OSM client ID configuration (contains secrets)
build_keys.conf
# ───────────────────────────────
# For now - not targeting these
# ───────────────────────────────

View File

@@ -13,7 +13,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
- **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details
- **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes)
- **Work completely offline** with downloadable map areas and device data, plus upload queue
- **Multiple map types** including satellite imagery from Google, Esri, Mapbox, and OpenStreetMap, plus custom map tile provider support
- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support
- **Editing Ability** to update existing device locations and properties
- **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets
@@ -22,7 +22,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with
## Key Features
### Map & Navigation
- **Multi-source tiles**: Switch between OpenStreetMap, Google Satellite, Esri imagery, Mapbox, and any custom providers
- **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
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
@@ -78,17 +78,22 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Are offline areas preferred for fast loading even when online? Check working.
- Ease up on overpass by pre-caching a larger area. Maybe we could grab the full latest database just like for suspected locations?
- Stop failing to fetch tiles; keep retrying after 3. Remove kTileFetchInitialDelayMs, kTileFetchJitter1Ms, etc from dev_config. Fix network indicator - only done when fetch queue is empty!
### Current Development
- Clean cache when nodes have disappeared / been deletd by others
- Clean up dev_config
- Swap in alprwatch.org/directions avoidance routing API
- Help button with links to email, discord, and website
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
- Improve offline area node refresh live display
- Add default operator profiles (Lowes etc)
### Future Features & Wishlist
- Update offline area nodes while browsing?
- Suspected locations toggle (alprwatch.com/flock/utilities)
- Jump to location by coordinates, address, or POI name
- Route planning that avoids surveillance devices (alprwatch.com/directions)
- Offline navigation
### Maybes
- Yellow ring for devices missing specific tag details?
@@ -97,6 +102,14 @@ cp lib/keys.dart.example lib/keys.dart
- 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
---

View File

@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,11 +8,17 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "me.deflock.deflockapp"
// Matches current stable Flutter (compileSdk 34 as of July 2025)
compileSdk = 35
compileSdk = 36
// NDK only needed if you build native plugins; keep your pinned version
ndkVersion = "27.0.12077973"
@@ -31,17 +40,32 @@ android {
// oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23
// ────────────────────────────────────────────────────────────
minSdk = 23
targetSdk = 34
targetSdk = 36
// Flutter tool injects these during `flutter build`
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Using debug signing so `flutter run --release` works outofbox.
signingConfig = signingConfigs.getByName("debug")
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
} else {
// Fall back to debug signing for development builds
signingConfig = signingConfigs.getByName("debug")
}
}
}
}

View File

@@ -23,7 +23,7 @@
android:hardwareAccelerated="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustNothing">
<!-- The theme behind the splash while Flutter initializes -->
<meta-data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -2,6 +2,9 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>

View File

@@ -2,6 +2,9 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_background">#202020</color>
<color name="launch_background">#152131</color>
</resources>

BIN
assets/android_app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,73 +0,0 @@
🇺🇸 ENGLISH
DeFlock - Surveillance Transparency
DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.
• Offline-capable mapping with downloadable areas
• Upload directly to OpenStreetMap with OAuth2
• Built-in profiles for major manufacturers
• Privacy-respecting - no user data collected
• Multiple map tile providers (OSM, satellite imagery)
Part of the broader DeFlock initiative to promote surveillance transparency.
Visit: deflock.me
Built with Flutter • Open Source
---
🇪🇸 ESPAÑOL
DeFlock - Transparencia en Vigilancia
DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.
• Mapeo con capacidad offline con áreas descargables
• Subida directa a OpenStreetMap con OAuth2
• Perfiles integrados para fabricantes principales
• Respeta la privacidad - no se recopilan datos del usuario
• Múltiples proveedores de mapas (OSM, imágenes satelitales)
Parte de la iniciativa más amplia DeFlock para promover la transparencia en vigilancia.
Visita: deflock.me
Construido con Flutter • Código Abierto
---
🇫🇷 FRANÇAIS
DeFlock - Transparence de la Surveillance
DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.
• Cartographie hors ligne avec zones téléchargeables
• Upload direct vers OpenStreetMap avec OAuth2
• Profils intégrés pour les principaux fabricants
• Respectueux de la confidentialité - aucune donnée utilisateur collectée
• Multiples fournisseurs de cartes (OSM, imagerie satellite)
Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.
Visitez : deflock.me
Construit avec Flutter • Source Ouverte
---
🇩🇪 DEUTSCH
DeFlock - Überwachungs-Transparenz
DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.
• Offline-fähige Kartierung mit herunterladbaren Bereichen
• Direkter Upload zu OpenStreetMap mit OAuth2
• Integrierte Profile für große Hersteller
• Datenschutzfreundlich - keine Nutzerdaten gesammelt
• Multiple Kartenanbieter (OSM, Satellitenbilder)
Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.
Besuchen Sie: deflock.me
Gebaut mit Flutter • Open Source

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,47 @@ 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 "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | 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"
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 +99,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

@@ -1,8 +1,8 @@
#!/bin/bash
echo "Generate splash screens..."
flutter pub run flutter_native_splash:create
echo
echo
echo "Generate icons..."
flutter pub run flutter_launcher_icons:main
dart run flutter_launcher_icons
echo
echo
echo "Generate splash screens..."
dart run flutter_native_splash:create

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";
@@ -528,7 +532,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -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,15 +682,19 @@
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 = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flockMapApp;
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

@@ -1 +0,0 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -6,19 +6,25 @@ 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 'widgets/camera_provider_with_cache.dart';
import 'state/auth_state.dart';
import 'state/navigation_state.dart';
import 'state/operator_profile_state.dart';
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
export 'state/navigation_state.dart' show AppNavigationMode;
export 'state/settings_state.dart' show UploadMode, FollowMeMode;
export 'state/session_state.dart' show AddNodeSession, EditNodeSession;
@@ -28,10 +34,13 @@ class AppState extends ChangeNotifier {
// State modules
late final AuthState _authState;
late final NavigationState _navigationState;
late final OperatorProfileState _operatorProfileState;
late final ProfileState _profileState;
late final SearchState _searchState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
late final SuspectedLocationState _suspectedLocationState;
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
@@ -39,18 +48,24 @@ class AppState extends ChangeNotifier {
AppState() {
instance = this;
_authState = AuthState();
_navigationState = NavigationState();
_operatorProfileState = OperatorProfileState();
_profileState = ProfileState();
_searchState = SearchState();
_sessionState = SessionState();
_settingsState = SettingsState();
_suspectedLocationState = SuspectedLocationState();
_uploadQueueState = UploadQueueState();
// Set up state change listeners
_authState.addListener(_onStateChanged);
_navigationState.addListener(_onStateChanged);
_operatorProfileState.addListener(_onStateChanged);
_profileState.addListener(_onStateChanged);
_searchState.addListener(_onStateChanged);
_sessionState.addListener(_onStateChanged);
_settingsState.addListener(_onStateChanged);
_suspectedLocationState.addListener(_onStateChanged);
_uploadQueueState.addListener(_onStateChanged);
_init();
@@ -63,6 +78,35 @@ class AppState extends ChangeNotifier {
bool get isLoggedIn => _authState.isLoggedIn;
String get username => _authState.username;
// Navigation state - simplified
AppNavigationMode get navigationMode => _navigationState.mode;
LatLng? get provisionalPinLocation => _navigationState.provisionalPinLocation;
String? get provisionalPinAddress => _navigationState.provisionalPinAddress;
bool get showProvisionalPin => _navigationState.showProvisionalPin;
bool get isInSearchMode => _navigationState.isInSearchMode;
bool get isInRouteMode => _navigationState.isInRouteMode;
bool get hasActiveRoute => _navigationState.hasActiveRoute;
bool get showSearchButton => _navigationState.showSearchButton;
bool get showRouteButton => _navigationState.showRouteButton;
List<LatLng>? get routePath => _navigationState.routePath;
// Route state
LatLng? get routeStart => _navigationState.routeStart;
LatLng? get routeEnd => _navigationState.routeEnd;
String? get routeStartAddress => _navigationState.routeStartAddress;
String? get routeEndAddress => _navigationState.routeEndAddress;
double? get routeDistance => _navigationState.routeDistance;
bool get settingRouteStart => _navigationState.settingRouteStart;
bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint;
bool get isCalculating => _navigationState.isCalculating;
bool get showingOverview => _navigationState.showingOverview;
String? get routingError => _navigationState.routingError;
bool get hasRoutingError => _navigationState.hasRoutingError;
// Navigation search state
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
// Profile state
List<NodeProfile> get profiles => _profileState.profiles;
List<NodeProfile> get enabledProfiles => _profileState.enabledProfiles;
@@ -71,6 +115,11 @@ class AppState extends ChangeNotifier {
// Operator profile state
List<OperatorProfile> get operatorProfiles => _operatorProfileState.profiles;
// Search state
bool get isSearchLoading => _searchState.isLoading;
List<SearchResult> get searchResults => _searchState.results;
String get lastSearchQuery => _searchState.lastQuery;
// Session state
AddNodeSession? get session => _sessionState.session;
EditNodeSession? get editSession => _sessionState.editSession;
@@ -82,6 +131,8 @@ class AppState extends ChangeNotifier {
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;
@@ -94,6 +145,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();
}
@@ -108,6 +165,7 @@ class AppState extends ChangeNotifier {
await _operatorProfileState.init();
await _profileState.init();
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -230,6 +288,77 @@ class AppState extends ChangeNotifier {
_startUploader();
}
// ---------- Search Methods ----------
Future<void> search(String query) async {
await _searchState.search(query);
}
void clearSearchResults() {
_searchState.clearResults();
}
// ---------- Navigation Methods - Simplified ----------
void enterSearchMode(LatLng mapCenter) {
_navigationState.enterSearchMode(mapCenter);
}
void cancelNavigation() {
_navigationState.cancel();
}
void updateProvisionalPinLocation(LatLng newLocation) {
_navigationState.updateProvisionalPinLocation(newLocation);
}
void selectSearchResult(SearchResult result) {
_navigationState.selectSearchResult(result);
}
void startRoutePlanning({required bool thisLocationIsStart}) {
_navigationState.startRoutePlanning(thisLocationIsStart: thisLocationIsStart);
}
void selectSecondRoutePoint() {
_navigationState.selectSecondRoutePoint();
}
void startRoute() {
_navigationState.startRoute();
// Auto-enable follow-me if user is near the start point
// We need to get user location from the GPS controller
// This will be handled in HomeScreen where we have access to MapView
}
bool shouldAutoEnableFollowMe(LatLng? userLocation) {
return _navigationState.shouldAutoEnableFollowMe(userLocation);
}
void showRouteOverview() {
_navigationState.showRouteOverview();
}
void hideRouteOverview() {
_navigationState.hideRouteOverview();
}
void cancelRoute() {
_navigationState.cancelRoute();
}
// Navigation search methods
Future<void> searchNavigation(String query) async {
await _navigationState.search(query);
}
void clearNavigationSearchResults() {
_navigationState.clearSearchResults();
}
void retryRouteCalculation() {
_navigationState.retryRouteCalculation();
}
// ---------- Settings Methods ----------
Future<void> setOfflineMode(bool enabled) async {
await _settingsState.setOfflineMode(enabled);
@@ -287,6 +416,16 @@ class AppState extends ChangeNotifier {
await _settingsState.setProximityAlertDistance(distance);
}
/// Set network status indicator enabled/disabled
Future<void> setNetworkStatusIndicatorEnabled(bool enabled) async {
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();
@@ -301,6 +440,39 @@ 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({
void Function(String message, double? progress)? onProgress,
}) async {
return await _suspectedLocationState.refreshData(onProgress: onProgress);
}
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() {
@@ -322,10 +494,13 @@ class AppState extends ChangeNotifier {
@override
void dispose() {
_authState.removeListener(_onStateChanged);
_navigationState.removeListener(_onStateChanged);
_operatorProfileState.removeListener(_onStateChanged);
_profileState.removeListener(_onStateChanged);
_searchState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);
_suspectedLocationState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
_uploadQueueState.dispose();

View File

@@ -12,9 +12,12 @@ const int kPreviewTileY = 101300;
const int kPreviewTileX = 41904;
// Direction cone for map view
const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
const double kDirectionConeHalfAngle = 35.0; // degrees
const double kDirectionConeBaseLength = 5; // multiplier
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
// Base values for thickness - use helper functions below for pixel-ratio scaling
const double _kDirectionConeBorderWidthBase = 1.6;
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
@@ -31,18 +34,32 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
}
// Add Camera icon vertical offset (no offset needed since circle is centered)
const double kAddPinYOffset = 0.0;
// Client name and version for OSM uploads ("created_by" tag)
// Client name for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
const String kClientVersion = '1.0.0';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/pub/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
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
return false; // Release builds: never allow navigation
} else {
return !offlineMode; // Dev builds: only when online
}
}
// Marker/node interaction
const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -57,11 +74,6 @@ const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Last map location and settings storage
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
const String kLastMapZoomKey = 'last_map_zoom';
// Tile/OSM fetch retry parameters (for tunable backoff)
const int kTileFetchMaxAttempts = 3;
const int kTileFetchInitialDelayMs = 4000;
@@ -77,15 +89,26 @@ const int kMaxUserDownloadZoomSpan = 7;
// Download area limits and constants
const int kMaxReasonableTileCount = 20000;
const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 19;
const int kAbsoluteMaxZoom = 23;
// Camera icon configuration
const double kCameraIconDiameter = 20.0;
const double kCameraRingThickness = 4.0;
const double kCameraDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kCameraRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
const Color kCameraRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
const Color kCameraRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kCameraRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kCameraRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
const Color kCameraRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
// Node icon configuration
const double kNodeIconDiameter = 18.0;
const double _kNodeRingThicknessBase = 2.5;
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
const 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
// 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

@@ -18,6 +18,11 @@ Want to add support for your language? It's simple:
"app": {
"title": "DeFlock" ← Keep this as-is
},
"about": {
"title": "Your Translation Here",
"description": "Your Translation Here",
...
},
"actions": {
"tagNode": "Your Translation Here",
"download": "Your Translation Here",
@@ -26,9 +31,7 @@ Want to add support for your language? It's simple:
}
```
3. **Add your language to the About screen**: Edit `assets/info.txt` and add your language section at the bottom (copy the English section and translate it)
4. **Submit a PR** with your JSON file and the updated about.txt. Done!
3. **Submit a PR** with your JSON file. Done!
The new language will automatically appear in Settings → Language.
@@ -45,12 +48,14 @@ The new language will automatically appear in Settings → Language.
- `es.json` - Español
- `fr.json` - Français
- `de.json` - Deutsch
- `it.json` - Italiano
- `pt.json` - Português
- `zh.json` - 中文
## Files to Update
For a complete translation, you'll need to touch:
1. **`lib/localizations/xx.json`** - Main UI translations (buttons, menus, etc.)
2. **`assets/info.txt`** - About screen content (add your language section)
For a complete translation, you only need to update:
1. **`lib/localizations/xx.json`** - All UI translations including about content
## That's It!

View File

@@ -45,7 +45,8 @@
"offlineSettingsSubtitle": "Offline-Modus und heruntergeladene Bereiche verwalten",
"advancedSettings": "Erweiterte Einstellungen",
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
"proximityAlerts": "Näherungswarnungen"
"proximityAlerts": "Näherungswarnungen",
"networkStatusIndicator": "Netzwerkstatus-Anzeige"
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
@@ -183,6 +184,11 @@
"attribution": "Zuschreibung",
"attributionHint": "© Karten-Anbieter",
"attributionRequired": "Zuschreibung ist erforderlich",
"maxZoom": "Max Zoom-Stufe",
"maxZoomHint": "Maximale Zoom-Stufe (1-23)",
"maxZoomRequired": "Max Zoom ist erforderlich",
"maxZoomInvalid": "Max Zoom muss eine Zahl sein",
"maxZoomRange": "Max Zoom muss zwischen {} und {} liegen",
"fetchPreview": "Vorschau Laden",
"previewTileLoaded": "Vorschau-Kachel erfolgreich geladen",
"previewTileFailed": "Vorschau laden fehlgeschlagen: {}",
@@ -200,7 +206,8 @@
},
"mapTiles": {
"title": "Karten-Kacheln",
"manageProviders": "Anbieter Verwalten"
"manageProviders": "Anbieter Verwalten",
"attribution": "Karten-Zuschreibung"
},
"profileEditor": {
"viewProfile": "Profil Anzeigen",
@@ -287,5 +294,87 @@
"cannotChangeTileTypes": "Kachel-Typen können während des Herunterladens von Offline-Bereichen nicht geändert werden",
"selectMapLayer": "Kartenschicht Auswählen",
"noTileProvidersAvailable": "Keine Kachel-Anbieter verfügbar"
},
"networkStatus": {
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen"
},
"about": {
"title": "DeFlock - Überwachungs-Transparenz",
"description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.",
"features": "• Offline-fähige Kartierung mit herunterladbaren Bereichen\n• Direkter Upload zu OpenStreetMap mit OAuth2\n• Integrierte Profile für große Hersteller\n• Datenschutzfreundlich - keine Nutzerdaten gesammelt\n• Multiple Kartenanbieter (OSM, Satellitenbilder)",
"initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.",
"footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source"
},
"navigation": {
"searchLocation": "Ort suchen",
"searchPlaceholder": "Orte oder Koordinaten suchen...",
"routeTo": "Route zu",
"routeFrom": "Route von",
"selectLocation": "Ort auswählen",
"calculatingRoute": "Route wird berechnet...",
"routeCalculationFailed": "Routenberechnung fehlgeschlagen",
"start": "Start",
"resume": "Fortsetzen",
"endRoute": "Route beenden",
"routeOverview": "Routenübersicht",
"retry": "Wiederholen",
"cancelSearch": "Suche abbrechen",
"noResultsFound": "Keine Ergebnisse gefunden",
"searching": "Suche...",
"location": "Standort",
"startPoint": "Start",
"endPoint": "Ende",
"startSelect": "Start (auswählen)",
"endSelect": "Ende (auswählen)",
"distance": "Entfernung: {} km",
"routeActive": "Route aktiv",
"navigationSettings": "Navigation",
"navigationSettingsSubtitle": "Routenplanung und Vermeidungseinstellungen",
"avoidanceDistance": "Vermeidungsabstand",
"avoidanceDistanceSubtitle": "Mindestabstand zu Überwachungsgeräten",
"searchHistory": "Max. Suchverlauf",
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken",
"units": "Einheiten",
"unitsSubtitle": "Anzeigeeinheiten für Entfernungen und Messungen",
"metric": "Metrisch (km, m)",
"imperial": "Britisch (mi, ft)",
"meters": "Meter",
"feet": "Fuß"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - Surveillance Transparency",
"description": "DeFlock is a privacy-focused mobile app for mapping public surveillance infrastructure using OpenStreetMap. Document cameras, ALPRs, gunshot detectors, and other surveillance devices in your community to make this infrastructure visible and searchable.",
"features": "• Offline-capable mapping with downloadable areas\n• Upload directly to OpenStreetMap with OAuth2\n• Built-in profiles for major manufacturers\n• Privacy-respecting - no user data collected\n• Multiple map tile providers (OSM, satellite imagery)",
"initiative": "Part of the broader DeFlock initiative to promote surveillance transparency.",
"footer": "Visit: deflock.me\nBuilt with Flutter • Open Source"
},
"actions": {
"tagNode": "New Node",
"download": "Download",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "Manage offline mode and downloaded areas",
"advancedSettings": "Advanced Settings",
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
"proximityAlerts": "Proximity Alerts"
"proximityAlerts": "Proximity Alerts",
"networkStatusIndicator": "Network Status Indicator"
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
@@ -183,6 +191,11 @@
"attribution": "Attribution",
"attributionHint": "© Map Provider",
"attributionRequired": "Attribution is required",
"maxZoom": "Max Zoom Level",
"maxZoomHint": "Maximum zoom level (1-23)",
"maxZoomRequired": "Max zoom is required",
"maxZoomInvalid": "Max zoom must be a number",
"maxZoomRange": "Max zoom must be between {} and {}",
"fetchPreview": "Fetch Preview",
"previewTileLoaded": "Preview tile loaded successfully",
"previewTileFailed": "Failed to fetch preview: {}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "Map Tiles",
"manageProviders": "Manage Providers"
"manageProviders": "Manage Providers",
"attribution": "Map Attribution"
},
"profileEditor": {
"viewProfile": "View Profile",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "Cannot change tile types while downloading offline areas",
"selectMapLayer": "Select Map Layer",
"noTileProvidersAvailable": "No tile providers available"
},
"networkStatus": {
"showIndicator": "Show network status indicator",
"showIndicatorSubtitle": "Display network loading and error status on the map"
},
"navigation": {
"searchLocation": "Search Location",
"searchPlaceholder": "Search places or coordinates...",
"routeTo": "Route To",
"routeFrom": "Route From",
"selectLocation": "Select Location",
"calculatingRoute": "Calculating route...",
"routeCalculationFailed": "Route calculation failed",
"start": "Start",
"resume": "Resume",
"endRoute": "End Route",
"routeOverview": "Route Overview",
"retry": "Retry",
"cancelSearch": "Cancel search",
"noResultsFound": "No results found",
"searching": "Searching...",
"location": "Location",
"startPoint": "Start",
"endPoint": "End",
"startSelect": "Start (select)",
"endSelect": "End (select)",
"distance": "Distance: {} km",
"routeActive": "Route active",
"navigationSettings": "Navigation",
"navigationSettingsSubtitle": "Route planning and avoidance settings",
"avoidanceDistance": "Avoidance Distance",
"avoidanceDistanceSubtitle": "Minimum distance to stay away from surveillance devices",
"searchHistory": "Max Search History",
"searchHistorySubtitle": "Maximum number of recent searches to remember",
"units": "Units",
"unitsSubtitle": "Display units for distances and measurements",
"metric": "Metric (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "meters",
"feet": "feet"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - Transparencia en Vigilancia",
"description": "DeFlock es una aplicación móvil enfocada en la privacidad para mapear infraestructura de vigilancia pública usando OpenStreetMap. Documenta cámaras, ALPRs, detectores de disparos y otros dispositivos de vigilancia en tu comunidad para hacer visible y consultable esta infraestructura.",
"features": "• Mapeo con capacidad offline con áreas descargables\n• Subida directa a OpenStreetMap con OAuth2\n• Perfiles integrados para fabricantes principales\n• Respeta la privacidad - no se recopilan datos del usuario\n• Múltiples proveedores de mapas (OSM, imágenes satelitales)",
"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"
},
"actions": {
"tagNode": "Nuevo Nodo",
"download": "Descargar",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "Gestionar modo sin conexión y áreas descargadas",
"advancedSettings": "Configuración Avanzada",
"advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas",
"proximityAlerts": "Alertas de Proximidad"
"proximityAlerts": "Alertas de Proximidad",
"networkStatusIndicator": "Indicador de Estado de Red"
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
@@ -183,6 +191,11 @@
"attribution": "Atribución",
"attributionHint": "© Proveedor de Mapas",
"attributionRequired": "La atribución es requerida",
"maxZoom": "Nivel de Zoom Máximo",
"maxZoomHint": "Nivel de zoom máximo (1-23)",
"maxZoomRequired": "El zoom máximo es requerido",
"maxZoomInvalid": "El zoom máximo debe ser un número",
"maxZoomRange": "El zoom máximo debe estar entre {} y {}",
"fetchPreview": "Obtener Vista Previa",
"previewTileLoaded": "Tile de vista previa cargado exitosamente",
"previewTileFailed": "Falló al obtener vista previa: {}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "Tiles de Mapa",
"manageProviders": "Gestionar Proveedores"
"manageProviders": "Gestionar Proveedores",
"attribution": "Atribución del Mapa"
},
"profileEditor": {
"viewProfile": "Ver Perfil",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "No se pueden cambiar los tipos de teselas mientras se descargan áreas sin conexión",
"selectMapLayer": "Seleccionar Capa del Mapa",
"noTileProvidersAvailable": "No hay proveedores de teselas disponibles"
},
"networkStatus": {
"showIndicator": "Mostrar indicador de estado de red",
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa"
},
"navigation": {
"searchLocation": "Buscar ubicación",
"searchPlaceholder": "Buscar lugares o coordenadas...",
"routeTo": "Ruta a",
"routeFrom": "Ruta desde",
"selectLocation": "Seleccionar ubicación",
"calculatingRoute": "Calculando ruta...",
"routeCalculationFailed": "Falló el cálculo de ruta",
"start": "Iniciar",
"resume": "Continuar",
"endRoute": "Finalizar ruta",
"routeOverview": "Vista de ruta",
"retry": "Reintentar",
"cancelSearch": "Cancelar búsqueda",
"noResultsFound": "No se encontraron resultados",
"searching": "Buscando...",
"location": "Ubicación",
"startPoint": "Inicio",
"endPoint": "Fin",
"startSelect": "Inicio (seleccionar)",
"endSelect": "Fin (seleccionar)",
"distance": "Distancia: {} km",
"routeActive": "Ruta activa",
"navigationSettings": "Navegación",
"navigationSettingsSubtitle": "Configuración de planificación de rutas y evitación",
"avoidanceDistance": "Distancia de evitación",
"avoidanceDistanceSubtitle": "Distancia mínima para mantenerse alejado de dispositivos de vigilancia",
"searchHistory": "Historial máximo de búsqueda",
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar",
"units": "Unidades",
"unitsSubtitle": "Unidades de visualización para distancias y medidas",
"metric": "Métrico (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pies"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - Transparence de la Surveillance",
"description": "DeFlock est une application mobile axée sur la confidentialité pour cartographier l'infrastructure de surveillance publique en utilisant OpenStreetMap. Documentez les caméras, ALPRs, détecteurs de coups de feu et autres dispositifs de surveillance dans votre communauté pour rendre cette infrastructure visible et consultable.",
"features": "• Cartographie hors ligne avec zones téléchargeables\n• Upload direct vers OpenStreetMap avec OAuth2\n• Profils intégrés pour les principaux fabricants\n• Respectueux de la confidentialité - aucune donnée utilisateur collectée\n• Multiples fournisseurs de cartes (OSM, imagerie satellite)",
"initiative": "Partie de l'initiative plus large DeFlock pour promouvoir la transparence de la surveillance.",
"footer": "Visitez : deflock.me\nConstruit avec Flutter • Source Ouverte"
},
"actions": {
"tagNode": "Nouveau Nœud",
"download": "Télécharger",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "Gérer le mode hors ligne et les zones téléchargées",
"advancedSettings": "Paramètres Avancés",
"advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles",
"proximityAlerts": "Alertes de Proximité"
"proximityAlerts": "Alertes de Proximité",
"networkStatusIndicator": "Indicateur de Statut Réseau"
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
@@ -183,6 +191,11 @@
"attribution": "Attribution",
"attributionHint": "© Fournisseur de Cartes",
"attributionRequired": "L'attribution est requise",
"maxZoom": "Niveau de Zoom Maximum",
"maxZoomHint": "Niveau de zoom maximum (1-23)",
"maxZoomRequired": "Le zoom maximum est requis",
"maxZoomInvalid": "Le zoom maximum doit être un nombre",
"maxZoomRange": "Le zoom maximum doit être entre {} et {}",
"fetchPreview": "Récupérer Aperçu",
"previewTileLoaded": "Tuile d'aperçu chargée avec succès",
"previewTileFailed": "Échec de récupération de l'aperçu: {}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "Tuiles de Carte",
"manageProviders": "Gérer Fournisseurs"
"manageProviders": "Gérer Fournisseurs",
"attribution": "Attribution de Carte"
},
"profileEditor": {
"viewProfile": "Voir Profil",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "Impossible de changer les types de tuiles pendant le téléchargement des zones hors ligne",
"selectMapLayer": "Sélectionner la Couche de Carte",
"noTileProvidersAvailable": "Aucun fournisseur de tuiles disponible"
},
"networkStatus": {
"showIndicator": "Afficher l'indicateur de statut réseau",
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte"
},
"navigation": {
"searchLocation": "Rechercher lieu",
"searchPlaceholder": "Rechercher lieux ou coordonnées...",
"routeTo": "Itinéraire vers",
"routeFrom": "Itinéraire depuis",
"selectLocation": "Sélectionner lieu",
"calculatingRoute": "Calcul de l'itinéraire...",
"routeCalculationFailed": "Échec du calcul d'itinéraire",
"start": "Démarrer",
"resume": "Reprendre",
"endRoute": "Terminer l'itinéraire",
"routeOverview": "Vue d'ensemble",
"retry": "Réessayer",
"cancelSearch": "Annuler recherche",
"noResultsFound": "Aucun résultat trouvé",
"searching": "Recherche...",
"location": "Lieu",
"startPoint": "Début",
"endPoint": "Fin",
"startSelect": "Début (sélectionner)",
"endSelect": "Fin (sélectionner)",
"distance": "Distance: {} km",
"routeActive": "Itinéraire actif",
"navigationSettings": "Navigation",
"navigationSettingsSubtitle": "Paramètres de planification d'itinéraire et d'évitement",
"avoidanceDistance": "Distance d'évitement",
"avoidanceDistanceSubtitle": "Distance minimale pour éviter les dispositifs de surveillance",
"searchHistory": "Historique de recherche max",
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir",
"units": "Unités",
"unitsSubtitle": "Unités d'affichage pour distances et mesures",
"metric": "Métrique (km, m)",
"imperial": "Impérial (mi, ft)",
"meters": "mètres",
"feet": "pieds"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - Trasparenza della Sorveglianza",
"description": "DeFlock è un'app mobile orientata alla privacy per mappare l'infrastruttura di sorveglianza pubblica utilizzando OpenStreetMap. Documenta telecamere, ALPR, rilevatori di spari e altri dispositivi di sorveglianza nella tua comunità per rendere questa infrastruttura visibile e ricercabile.",
"features": "• Mappatura con capacità offline con aree scaricabili\n• Upload diretto su OpenStreetMap con OAuth2\n• Profili integrati per i principali produttori\n• Rispettoso della privacy - nessun dato utente raccolto\n• Multipli fornitori di mappe (OSM, immagini satellitari)",
"initiative": "Parte della più ampia iniziativa DeFlock per promuovere la trasparenza della sorveglianza.",
"footer": "Visita: deflock.me\nCostruito con Flutter • Open Source"
},
"actions": {
"tagNode": "Nuovo Nodo",
"download": "Scarica",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "Gestisci modalità offline e aree scaricate",
"advancedSettings": "Impostazioni Avanzate",
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
"proximityAlerts": "Avvisi di Prossimità"
"proximityAlerts": "Avvisi di Prossimità",
"networkStatusIndicator": "Indicatore di Stato di Rete"
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
@@ -183,6 +191,11 @@
"attribution": "Attribuzione",
"attributionHint": "© Fornitore Mappe",
"attributionRequired": "L'attribuzione è obbligatoria",
"maxZoom": "Livello Zoom Massimo",
"maxZoomHint": "Livello di zoom massimo (1-23)",
"maxZoomRequired": "Il zoom massimo è obbligatorio",
"maxZoomInvalid": "Il zoom massimo deve essere un numero",
"maxZoomRange": "Il zoom massimo deve essere tra {} e {}",
"fetchPreview": "Ottieni Anteprima",
"previewTileLoaded": "Tile di anteprima caricato con successo",
"previewTileFailed": "Impossibile ottenere l'anteprima: {}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "Tile Mappa",
"manageProviders": "Gestisci Fornitori"
"manageProviders": "Gestisci Fornitori",
"attribution": "Attribuzione Mappa"
},
"profileEditor": {
"viewProfile": "Visualizza Profilo",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "Impossibile cambiare tipi di tile durante il download di aree offline",
"selectMapLayer": "Seleziona Livello Mappa",
"noTileProvidersAvailable": "Nessun fornitore di tile disponibile"
},
"networkStatus": {
"showIndicator": "Mostra indicatore di stato di rete",
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa"
},
"navigation": {
"searchLocation": "Cerca posizione",
"searchPlaceholder": "Cerca luoghi o coordinate...",
"routeTo": "Percorso verso",
"routeFrom": "Percorso da",
"selectLocation": "Seleziona posizione",
"calculatingRoute": "Calcolo percorso...",
"routeCalculationFailed": "Calcolo percorso fallito",
"start": "Inizia",
"resume": "Riprendi",
"endRoute": "Termina percorso",
"routeOverview": "Panoramica percorso",
"retry": "Riprova",
"cancelSearch": "Annulla ricerca",
"noResultsFound": "Nessun risultato trovato",
"searching": "Ricerca in corso...",
"location": "Posizione",
"startPoint": "Inizio",
"endPoint": "Fine",
"startSelect": "Inizio (seleziona)",
"endSelect": "Fine (seleziona)",
"distance": "Distanza: {} km",
"routeActive": "Percorso attivo",
"navigationSettings": "Navigazione",
"navigationSettingsSubtitle": "Impostazioni pianificazione percorso ed evitamento",
"avoidanceDistance": "Distanza di evitamento",
"avoidanceDistanceSubtitle": "Distanza minima da mantenere dai dispositivi di sorveglianza",
"searchHistory": "Cronologia ricerca max",
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare",
"units": "Unità",
"unitsSubtitle": "Unità di visualizzazione per distanze e misure",
"metric": "Metrico (km, m)",
"imperial": "Imperiale (mi, ft)",
"meters": "metri",
"feet": "piedi"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - Transparência da Vigilância",
"description": "DeFlock é um aplicativo móvel focado na privacidade para mapear infraestrutura de vigilância pública usando OpenStreetMap. Documente câmeras, ALPRs, detectores de tiros e outros dispositivos de vigilância em sua comunidade para tornar essa infraestrutura visível e pesquisável.",
"features": "• Mapeamento com capacidade offline com áreas para download\n• Upload direto para OpenStreetMap com OAuth2\n• Perfis integrados para principais fabricantes\n• Respeitoso à privacidade - nenhum dado do usuário coletado\n• Múltiplos provedores de mapas (OSM, imagens de satélite)",
"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"
},
"actions": {
"tagNode": "Novo Nó",
"download": "Baixar",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "Gerenciar modo offline e áreas baixadas",
"advancedSettings": "Configurações Avançadas",
"advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas",
"proximityAlerts": "Alertas de Proximidade"
"proximityAlerts": "Alertas de Proximidade",
"networkStatusIndicator": "Indicador de Status de Rede"
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
@@ -183,6 +191,11 @@
"attribution": "Atribuição",
"attributionHint": "© Provedor de Mapas",
"attributionRequired": "Atribuição é obrigatória",
"maxZoom": "Nível de Zoom Máximo",
"maxZoomHint": "Nível de zoom máximo (1-23)",
"maxZoomRequired": "Zoom máximo é obrigatório",
"maxZoomInvalid": "Zoom máximo deve ser um número",
"maxZoomRange": "Zoom máximo deve estar entre {} e {}",
"fetchPreview": "Buscar Preview",
"previewTileLoaded": "Tile de preview carregado com sucesso",
"previewTileFailed": "Falha ao buscar preview: {}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "Tiles do Mapa",
"manageProviders": "Gerenciar Provedores"
"manageProviders": "Gerenciar Provedores",
"attribution": "Atribuição do Mapa"
},
"profileEditor": {
"viewProfile": "Ver Perfil",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "Não é possível alterar tipos de tiles durante o download de áreas offline",
"selectMapLayer": "Selecionar Camada do Mapa",
"noTileProvidersAvailable": "Nenhum provedor de tiles disponível"
},
"networkStatus": {
"showIndicator": "Exibir indicador de status de rede",
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa"
},
"navigation": {
"searchLocation": "Buscar localização",
"searchPlaceholder": "Buscar locais ou coordenadas...",
"routeTo": "Rota para",
"routeFrom": "Rota de",
"selectLocation": "Selecionar localização",
"calculatingRoute": "Calculando rota...",
"routeCalculationFailed": "Falha no cálculo da rota",
"start": "Iniciar",
"resume": "Continuar",
"endRoute": "Terminar rota",
"routeOverview": "Visão geral da rota",
"retry": "Tentar novamente",
"cancelSearch": "Cancelar busca",
"noResultsFound": "Nenhum resultado encontrado",
"searching": "Buscando...",
"location": "Localização",
"startPoint": "Início",
"endPoint": "Fim",
"startSelect": "Início (selecionar)",
"endSelect": "Fim (selecionar)",
"distance": "Distância: {} km",
"routeActive": "Rota ativa",
"navigationSettings": "Navegação",
"navigationSettingsSubtitle": "Configurações de planejamento de rota e evasão",
"avoidanceDistance": "Distância de evasão",
"avoidanceDistanceSubtitle": "Distância mínima para ficar longe de dispositivos de vigilância",
"searchHistory": "Histórico máximo de busca",
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar",
"units": "Unidades",
"unitsSubtitle": "Unidades de exibição para distâncias e medidas",
"metric": "Métrico (km, m)",
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pés"
},
"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

@@ -5,6 +5,13 @@
"app": {
"title": "DeFlock"
},
"about": {
"title": "DeFlock - 监控透明化",
"description": "DeFlock 是一款注重隐私的移动应用,使用 OpenStreetMap 绘制公共监控基础设施。记录您社区中的摄像头、车牌识别系统、枪击探测器和其他监控设备,使这些基础设施可见且可搜索。",
"features": "• 具有可下载区域的离线映射功能\n• 使用 OAuth2 直接上传到 OpenStreetMap\n• 主要制造商的内置配置文件\n• 尊重隐私 - 不收集用户数据\n• 多个地图提供商OSM、卫星图像",
"initiative": "DeFlock 更广泛倡议的一部分,旨在促进监控透明化。",
"footer": "访问deflock.me\n使用 Flutter 构建 • 开源"
},
"actions": {
"tagNode": "新建节点",
"download": "下载",
@@ -45,7 +52,8 @@
"offlineSettingsSubtitle": "管理离线模式和已下载区域",
"advancedSettings": "高级设置",
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
"proximityAlerts": "邻近警报"
"proximityAlerts": "邻近警报",
"networkStatusIndicator": "网络状态指示器"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
@@ -183,6 +191,11 @@
"attribution": "归属",
"attributionHint": "© 地图提供商",
"attributionRequired": "归属为必填项",
"maxZoom": "最大缩放级别",
"maxZoomHint": "最大缩放级别 (1-23)",
"maxZoomRequired": "最大缩放为必填项",
"maxZoomInvalid": "最大缩放必须为数字",
"maxZoomRange": "最大缩放必须在 {} 和 {} 之间",
"fetchPreview": "获取预览",
"previewTileLoaded": "预览瓦片加载成功",
"previewTileFailed": "获取预览失败:{}",
@@ -200,7 +213,8 @@
},
"mapTiles": {
"title": "地图瓦片",
"manageProviders": "管理提供商"
"manageProviders": "管理提供商",
"attribution": "地图归属"
},
"profileEditor": {
"viewProfile": "查看配置文件",
@@ -287,5 +301,80 @@
"cannotChangeTileTypes": "在下载离线区域时无法更改瓦片类型",
"selectMapLayer": "选择地图图层",
"noTileProvidersAvailable": "无可用瓦片提供商"
},
"networkStatus": {
"showIndicator": "显示网络状态指示器",
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态"
},
"navigation": {
"searchLocation": "搜索位置",
"searchPlaceholder": "搜索地点或坐标...",
"routeTo": "路线至",
"routeFrom": "路线从",
"selectLocation": "选择位置",
"calculatingRoute": "计算路线中...",
"routeCalculationFailed": "路线计算失败",
"start": "开始",
"resume": "继续",
"endRoute": "结束路线",
"routeOverview": "路线概览",
"retry": "重试",
"cancelSearch": "取消搜索",
"noResultsFound": "未找到结果",
"searching": "搜索中...",
"location": "位置",
"startPoint": "起点",
"endPoint": "终点",
"startSelect": "起点(选择)",
"endSelect": "终点(选择)",
"distance": "距离:{} 公里",
"routeActive": "路线活跃",
"navigationSettings": "导航",
"navigationSettingsSubtitle": "路线规划和回避设置",
"avoidanceDistance": "回避距离",
"avoidanceDistanceSubtitle": "与监控设备保持的最小距离",
"searchHistory": "最大搜索历史",
"searchHistorySubtitle": "要记住的最近搜索次数",
"units": "单位",
"unitsSubtitle": "距离和测量的显示单位",
"metric": "公制(公里,米)",
"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

@@ -5,17 +5,22 @@ import 'app_state.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/profiles_settings_screen.dart';
import 'screens/navigation_settings_screen.dart';
import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize version service
await VersionService().init();
// Initialize localization service
await LocalizationService.instance.init();
@@ -64,6 +69,7 @@ class DeFlockApp extends StatelessWidget {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/settings/navigation': (context) => const NavigationSettingsScreen(),
'/settings/offline': (context) => const OfflineSettingsScreen(),
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),

View File

@@ -0,0 +1,47 @@
import 'package:latlong2/latlong.dart';
/// Represents a search result from a geocoding service
class SearchResult {
final String displayName;
final LatLng coordinates;
final String? category;
final String? type;
const SearchResult({
required this.displayName,
required this.coordinates,
this.category,
this.type,
});
/// Create SearchResult from Nominatim JSON response
factory SearchResult.fromNominatim(Map<String, dynamic> json) {
final lat = double.parse(json['lat'] as String);
final lon = double.parse(json['lon'] as String);
return SearchResult(
displayName: json['display_name'] as String,
coordinates: LatLng(lat, lon),
category: json['category'] as String?,
type: json['type'] as String?,
);
}
@override
String toString() {
return 'SearchResult(displayName: $displayName, coordinates: $coordinates)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SearchResult &&
other.displayName == displayName &&
other.coordinates == coordinates;
}
@override
int get hashCode {
return displayName.hashCode ^ coordinates.hashCode;
}
}

View File

@@ -0,0 +1,212 @@
import 'dart:convert';
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
class SuspectedLocation {
final String ticketNo;
final String? urlFull;
final String? addr;
final String? street;
final String? city;
final String? state;
final String? digSiteIntersectingStreet;
final String? digWorkDoneFor;
final String? digSiteRemarks;
final Map<String, dynamic>? geoJson;
final LatLng centroid;
final List<LatLng> bounds;
SuspectedLocation({
required this.ticketNo,
this.urlFull,
this.addr,
this.street,
this.city,
this.state,
this.digSiteIntersectingStreet,
this.digWorkDoneFor,
this.digSiteRemarks,
this.geoJson,
required this.centroid,
required this.bounds,
});
/// Create from CSV row data
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
final locationString = row['location'] as String?;
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 ${row['ticket_no']}: $e');
print('[SuspectedLocation] Location string: $locationString');
}
}
return SuspectedLocation(
ticketNo: row['ticket_no']?.toString() ?? '',
urlFull: row['url_full']?.toString(),
addr: row['addr']?.toString(),
street: row['street']?.toString(),
city: row['city']?.toString(),
state: row['state']?.toString(),
digSiteIntersectingStreet: row['dig_site_intersecting_street']?.toString(),
digWorkDoneFor: row['dig_work_done_for']?.toString(),
digSiteRemarks: row['dig_site_remarks']?.toString(),
geoJson: geoJson,
centroid: centroid,
bounds: bounds,
);
}
/// 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,
'url_full': urlFull,
'addr': addr,
'street': street,
'city': city,
'state': state,
'dig_site_intersecting_street': digSiteIntersectingStreet,
'dig_work_done_for': digWorkDoneFor,
'dig_site_remarks': digSiteRemarks,
'geo_json': geoJson,
'centroid_lat': centroid.latitude,
'centroid_lng': centroid.longitude,
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
};
/// 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'] ?? '',
urlFull: json['url_full'],
addr: json['addr'],
street: json['street'],
city: json['city'],
state: json['state'],
digSiteIntersectingStreet: json['dig_site_intersecting_street'],
digWorkDoneFor: json['dig_work_done_for'],
digSiteRemarks: json['dig_site_remarks'],
geoJson: json['geo_json'],
centroid: LatLng(
(json['centroid_lat'] as num).toDouble(),
(json['centroid_lng'] as num).toDouble(),
),
bounds: bounds,
);
}
/// Get a formatted display address
String get displayAddress {
final parts = <String>[];
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

@@ -8,6 +8,7 @@ class TileType {
final String urlTemplate;
final String attribution;
final Uint8List? previewTile; // Single tile image data for preview
final int maxZoom; // Maximum zoom level for this tile type
const TileType({
required this.id,
@@ -15,6 +16,7 @@ class TileType {
required this.urlTemplate,
required this.attribution,
this.previewTile,
this.maxZoom = 18, // Default max zoom level
});
/// Create URL for a specific tile, replacing template variables
@@ -40,6 +42,7 @@ class TileType {
'urlTemplate': urlTemplate,
'attribution': attribution,
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
'maxZoom': maxZoom,
};
static TileType fromJson(Map<String, dynamic> json) => TileType(
@@ -50,6 +53,7 @@ class TileType {
previewTile: json['previewTile'] != null
? base64Decode(json['previewTile'])
: null,
maxZoom: json['maxZoom'] ?? 18, // Default to 18 if not specified
);
TileType copyWith({
@@ -58,12 +62,14 @@ class TileType {
String? urlTemplate,
String? attribution,
Uint8List? previewTile,
int? maxZoom,
}) => TileType(
id: id ?? this.id,
name: name ?? this.name,
urlTemplate: urlTemplate ?? this.urlTemplate,
attribution: attribution ?? this.attribution,
previewTile: previewTile ?? this.previewTile,
maxZoom: maxZoom ?? this.maxZoom,
);
@override
@@ -151,42 +157,7 @@ class DefaultTileProviders {
name: 'Street Map',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
),
],
),
TileProvider(
id: 'google',
name: 'Google',
tileTypes: [
TileType(
id: 'google_hybrid',
name: 'Satellite + Roads',
urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_satellite',
name: 'Satellite Only',
urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '© Google',
),
TileType(
id: 'google_roadmap',
name: 'Road Map',
urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
attribution: '© Google',
),
],
),
TileProvider(
id: 'esri',
name: 'Esri',
tileTypes: [
TileType(
id: 'esri_satellite',
name: 'Satellite Imagery',
urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png',
attribution: '© Esri © Maxar',
maxZoom: 19,
),
],
),
@@ -208,6 +179,19 @@ class DefaultTileProviders {
),
],
),
TileProvider(
id: 'opentopomap_memomaps',
name: 'OpenTopoMap/Memomaps',
tileTypes: [
TileType(
id: 'opentopomap_topo',
name: 'Topographic',
urlTemplate: 'https://tile.memomaps.de/tilegen/{z}/{x}/{y}.png',
attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © OpenTopoMap (CC-BY-SA)',
maxZoom: 18,
),
],
),
];
}
}

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;
@@ -14,38 +30,84 @@ class AboutScreen extends StatelessWidget {
appBar: AppBar(
title: Text(locService.t('settings.aboutThisApp')),
),
body: FutureBuilder<String>(
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Error loading info: ${snapshot.error}',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
locService.t('about.title'),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
snapshot.data ?? 'No info available.',
style: Theme.of(context).textTheme.bodyLarge,
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(
locService.t('about.footer'),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
_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: 8),
_buildLinkText(context, 'Donate', 'https://deflock.me/donate'),
],
);
}
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,7 +1,9 @@
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';
class AdvancedSettingsScreen extends StatelessWidget {
@@ -24,6 +26,10 @@ class AdvancedSettingsScreen extends StatelessWidget {
Divider(),
ProximityAlertsSection(),
Divider(),
SuspectedLocationsSection(),
Divider(),
NetworkStatusSection(),
Divider(),
TileProviderSection(),
],
),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
@@ -15,7 +16,12 @@ import '../widgets/node_tag_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -29,11 +35,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
bool _editSheetShown = false;
bool _navigationSheetShown = false;
// Track sheet heights for map positioning
double _addSheetHeight = 0.0;
double _editSheetHeight = 0.0;
double _tagSheetHeight = 0.0;
double _navigationSheetHeight = 0.0;
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
@@ -96,13 +104,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final session = appState.session!; // guaranteed nonnull now
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_addSheetHeight = height;
});
},
child: AddNodeSheet(session: session),
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: AddNodeSheet(session: session),
),
),
);
@@ -134,19 +147,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (!mounted) return;
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
(ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
),
child: MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_editSheetHeight = height + MediaQuery.of(context).padding.bottom;
// Clear transition flag and reset tag sheet height once edit sheet starts sizing
if (height > 0 && _transitioningToEdit) {
_transitioningToEdit = false;
_tagSheetHeight = 0.0; // Now safe to reset
_selectedNodeId = null; // Clear selection when moving to edit
}
});
},
child: EditNodeSheet(session: session),
),
),
);
@@ -160,6 +178,227 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
});
}
void _openNavigationSheet() {
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(() {
_navigationSheetHeight = height + MediaQuery.of(context).padding.bottom;
});
},
child: NavigationSheet(
onStartRoute: _onStartRoute,
onResumeRoute: _onResumeRoute,
),
),
),
);
// Reset height when sheet is dismissed
controller.closed.then((_) {
setState(() {
_navigationSheetHeight = 0.0;
});
// Handle different dismissal scenarios
final appState = context.read<AppState>();
if (appState.isSettingSecondPoint) {
// If user dismisses sheet while setting second point, cancel everything
debugPrint('[HomeScreen] Sheet dismissed during second point selection - canceling navigation');
appState.cancelNavigation();
} else if (appState.isInRouteMode && appState.showingOverview) {
// If we're in route active mode and showing overview, just hide the overview
debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview');
appState.hideRouteOverview();
}
});
}
void _onStartRoute() {
final appState = context.read<AppState>();
// Get user location and check if we should auto-enable follow-me
LatLng? userLocation;
bool enableFollowMe = false;
try {
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);
enableFollowMe = true;
}
} catch (e) {
debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e');
}
// Start the route
appState.startRoute();
// Zoom to level 14 and center appropriately
_zoomAndCenterForRoute(enableFollowMe, userLocation, appState.routeStart);
}
void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) {
try {
LatLng centerLocation;
if (followMeEnabled && userLocation != null) {
// Center on user if follow-me is enabled
centerLocation = userLocation;
debugPrint('[HomeScreen] Centering on user location for route start');
} else if (routeStart != null) {
// Center on start pin if user is far away or no GPS
centerLocation = routeStart;
debugPrint('[HomeScreen] Centering on route start pin');
} else {
debugPrint('[HomeScreen] No valid location to center on');
return;
}
// Animate to zoom 14 and center location
_mapController.animateTo(
dest: centerLocation,
zoom: 14.0,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
);
} catch (e) {
debugPrint('[HomeScreen] Could not zoom/center for route: $e');
}
}
void _onResumeRoute() {
final appState = context.read<AppState>();
// Hide the overview
appState.hideRouteOverview();
// Zoom and center for resumed route
// For resume, we always center on user if GPS is available, otherwise start pin
LatLng? userLocation;
try {
userLocation = _mapViewKey.currentState?.getUserLocation();
} catch (e) {
debugPrint('[HomeScreen] Could not get user location for route resume: $e');
}
_zoomAndCenterForRoute(
appState.followMeMode != FollowMeMode.off, // Use current follow-me state
userLocation,
appState.routeStart
);
}
void _zoomToShowFullRoute(AppState appState) {
if (appState.routeStart == null || appState.routeEnd == null) return;
try {
// Calculate the bounds of the route
final start = appState.routeStart!;
final end = appState.routeEnd!;
// Find the center point between start and end
final centerLat = (start.latitude + end.latitude) / 2;
final centerLng = (start.longitude + end.longitude) / 2;
final center = LatLng(centerLat, centerLng);
// Calculate distance between points to determine appropriate zoom
final distance = const Distance().as(LengthUnit.Meter, start, end);
double zoom;
if (distance < 500) {
zoom = 16.0;
} else if (distance < 2000) {
zoom = 14.0;
} else if (distance < 10000) {
zoom = 12.0;
} else {
zoom = 10.0;
}
debugPrint('[HomeScreen] Zooming to show full route - distance: ${distance.toStringAsFixed(0)}m, zoom: $zoom');
_mapController.animateTo(
dest: center,
zoom: zoom,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOut,
);
} catch (e) {
debugPrint('[HomeScreen] Could not zoom to show full route: $e');
}
}
void _onNavigationButtonPressed() {
final appState = context.read<AppState>();
debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}');
if (appState.showRouteButton) {
// Route button - show route overview and zoom to show route
debugPrint('[HomeScreen] Showing route overview');
appState.showRouteOverview();
// Zoom out a bit to show the full route when viewing overview
_zoomToShowFullRoute(appState);
} else {
// Search button
if (appState.offlineMode) {
// Show offline snackbar instead of entering search mode
debugPrint('[HomeScreen] Search disabled - offline mode');
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Search not available while offline'),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
} else {
// Enter search mode normally
debugPrint('[HomeScreen] Entering search mode');
try {
final mapCenter = _mapController.mapController.camera.center;
debugPrint('[HomeScreen] Map center: $mapCenter');
appState.enterSearchMode(mapCenter);
} catch (e) {
// Controller not ready, use fallback location
debugPrint('[HomeScreen] Map controller not ready: $e, using fallback');
appState.enterSearchMode(LatLng(37.7749, -122.4194));
}
}
}
}
void _onSearchResultSelected(SearchResult result) {
final appState = context.read<AppState>();
// Update navigation state with selected result
appState.selectSearchResult(result);
// Jump to the search result location
try {
_mapController.animateTo(
dest: result.coordinates,
zoom: 16.0, // Good zoom level for viewing the area
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
} catch (_) {
// Map controller not ready, fallback to immediate move
try {
_mapController.mapController.move(result.coordinates, 16.0);
} catch (_) {
debugPrint('[HomeScreen] Could not move to search result: ${result.coordinates}');
}
}
}
void openNodeTagSheet(OsmNode node) {
setState(() {
_selectedNodeId = node.id; // Track selected node for highlighting
@@ -184,19 +423,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => MeasuredSheet(
onHeightChanged: (height) {
setState(() {
_tagSheetHeight = height;
});
},
child: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
(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: NodeTagSheet(
node: node,
onEditPressed: () {
final appState = context.read<AppState>();
appState.startEditSession(node);
// This will trigger _openEditNodeSheet via the existing auto-show logic
},
),
),
),
);
@@ -213,6 +457,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>();
@@ -225,20 +515,36 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_editSheetShown = false;
}
// Auto-open navigation sheet when needed - simplified logic (only in dev mode)
if (kEnableNavigationFeatures) {
final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview;
if (shouldShowNavSheet && !_navigationSheetShown) {
_navigationSheetShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet());
} else if (!shouldShowNavSheet) {
_navigationSheetShown = false;
}
}
// Pass the active sheet height directly to the map
final activeSheetHeight = _addSheetHeight > 0
? _addSheetHeight
: (_editSheetHeight > 0
? _editSheetHeight
: _tagSheetHeight);
: (_navigationSheetHeight > 0
? _navigationSheetHeight
: _tagSheetHeight));
return MultiProvider(
providers: [
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: Scaffold(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
automaticallyImplyLeading: false, // Disable automatic back button
title: SvgPicture.asset(
'assets/deflock-logo.svg',
height: 28,
@@ -278,12 +584,26 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
onSearchPressed: _onNavigationButtonPressed,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
}
},
),
// Search bar (slides in when in search mode) - only online since search doesn't work offline
if (!appState.offlineMode && appState.isInSearchMode)
Positioned(
top: 0,
left: 0,
right: 0,
child: LocationSearchBar(
onResultSelected: _onSearchResultSelected,
onCancel: () => appState.cancelNavigation(),
),
),
// Bottom button bar (restored to original)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
@@ -311,6 +631,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: Row(
children: [
Expanded(
flex: 7, // 70% for primary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
@@ -326,18 +647,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
),
SizedBox(width: 12),
Expanded(
flex: 3, // 30% for secondary action
child: AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
builder: (context, child) => FittedBox(
fit: BoxFit.scaleDown,
child: ElevatedButton.icon(
icon: Icon(Icons.download_for_offline),
label: Text(LocalizationService.instance.download),
onPressed: () => showDialog(
context: context,
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
),
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
),
),
),
),
@@ -351,6 +676,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
import '../app_state.dart';
import 'package:provider/provider.dart';
class NavigationSettingsScreen extends StatelessWidget {
const NavigationSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) => Scaffold(
appBar: AppBar(
title: Text(locService.t('navigation.navigationSettings')),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Coming soon message
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Colors.blue),
const SizedBox(width: 8),
Text(
'Navigation Features',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Navigation and routing settings will be available here. Coming soon:\n\n'
'• Surveillance avoidance distance\n'
'• Route planning preferences\n'
'• Search history management\n'
'• Distance units (metric/imperial)',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 24),
// Placeholder settings (disabled for now)
_buildDisabledSetting(
context,
icon: Icons.warning_outlined,
title: locService.t('navigation.avoidanceDistance'),
subtitle: locService.t('navigation.avoidanceDistanceSubtitle'),
value: '100 ${locService.t('navigation.meters')}',
),
const Divider(),
_buildDisabledSetting(
context,
icon: Icons.history,
title: locService.t('navigation.searchHistory'),
subtitle: locService.t('navigation.searchHistorySubtitle'),
value: '10 searches',
),
const Divider(),
_buildDisabledSetting(
context,
icon: Icons.straighten,
title: locService.t('navigation.units'),
subtitle: locService.t('navigation.unitsSubtitle'),
value: locService.t('navigation.metric'),
),
],
),
),
),
);
}
Widget _buildDisabledSetting(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required String value,
}) {
return Opacity(
opacity: 0.5,
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right, size: 16),
],
),
enabled: false,
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
/// Settings section for network status indicator configuration
/// Follows brutalist principles: simple, explicit UI that matches existing patterns
class NetworkStatusSection extends StatelessWidget {
const NetworkStatusSection({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AppState>(
builder: (context, appState, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('settings.networkStatusIndicator'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Enable/disable toggle
SwitchListTile(
title: Text(locService.t('networkStatus.showIndicator')),
subtitle: Text(locService.t('networkStatus.showIndicatorSubtitle')),
value: appState.networkStatusIndicatorEnabled,
onChanged: (enabled) {
appState.setNetworkStatusIndicatorEnabled(enabled);
},
contentPadding: EdgeInsets.zero,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/suspected_location_progress_dialog.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;
// Show simple progress dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (progressContext) => SuspectedLocationProgressDialog(
title: locService.t('suspectedLocations.updating'),
message: locService.t('suspectedLocations.downloadingAndProcessing'),
),
);
// Start the refresh
final success = await appState.refreshSuspectedLocations();
// Close progress dialog
if (context.mounted) {
Navigator.of(context).pop();
// Show result snackbar
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: TextInputType.number,
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

@@ -3,6 +3,7 @@ import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.dart';
import '../services/localization_service.dart';
import '../services/version_service.dart';
import '../dev_config.dart';
class SettingsScreen extends StatelessWidget {
@@ -39,6 +40,18 @@ class SettingsScreen extends StatelessWidget {
),
const Divider(),
// Only show navigation settings in development builds
if (kEnableNavigationFeatures) ...[
_buildNavigationTile(
context,
icon: Icons.navigation,
title: locService.t('navigation.navigationSettings'),
subtitle: locService.t('navigation.navigationSettingsSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/navigation'),
),
const Divider(),
],
_buildNavigationTile(
context,
icon: Icons.cloud_off,
@@ -73,6 +86,19 @@ class SettingsScreen extends StatelessWidget {
subtitle: locService.t('settings.aboutSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/about'),
),
const Divider(),
// Version display
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Version: ${VersionService().version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
),
],
),
),

View File

@@ -256,6 +256,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
late final TextEditingController _nameController;
late final TextEditingController _urlController;
late final TextEditingController _attributionController;
late final TextEditingController _maxZoomController;
Uint8List? _previewTile;
bool _isLoadingPreview = false;
@@ -266,6 +267,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
_nameController = TextEditingController(text: tileType?.name ?? '');
_urlController = TextEditingController(text: tileType?.urlTemplate ?? '');
_attributionController = TextEditingController(text: tileType?.attribution ?? '');
_maxZoomController = TextEditingController(text: (tileType?.maxZoom ?? 18).toString());
_previewTile = tileType?.previewTile;
}
@@ -274,6 +276,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
_nameController.dispose();
_urlController.dispose();
_attributionController.dispose();
_maxZoomController.dispose();
super.dispose();
}
@@ -326,6 +329,22 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.attributionRequired') : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _maxZoomController,
decoration: InputDecoration(
labelText: locService.t('tileTypeEditor.maxZoom'),
hintText: locService.t('tileTypeEditor.maxZoomHint'),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.maxZoomRequired');
final zoom = int.tryParse(value!);
if (zoom == null) return locService.t('tileTypeEditor.maxZoomInvalid');
if (zoom < 1 || zoom > kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
return null;
},
),
const SizedBox(height: 16),
Row(
children: [
TextButton.icon(
@@ -425,6 +444,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
urlTemplate: _urlController.text.trim(),
attribution: _attributionController.text.trim(),
previewTile: _previewTile,
maxZoom: int.parse(_maxZoomController.text.trim()),
);
widget.onSave(tileType);

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
class RouteResult {
final List<LatLng> waypoints;
final double distanceMeters;
final double durationSeconds;
const RouteResult({
required this.waypoints,
required this.distanceMeters,
required this.durationSeconds,
});
@override
String toString() {
return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)';
}
}
class RoutingService {
static const String _baseUrl = 'https://router.project-osrm.org';
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
static const Duration _timeout = Duration(seconds: 15);
/// Calculate route between two points using OSRM
Future<RouteResult> calculateRoute({
required LatLng start,
required LatLng end,
String profile = 'driving', // driving, walking, cycling
}) async {
debugPrint('[RoutingService] Calculating route from $start to $end');
// OSRM uses lng,lat order (opposite of LatLng)
final startCoord = '${start.longitude},${start.latitude}';
final endCoord = '${end.longitude},${end.latitude}';
final uri = Uri.parse('$_baseUrl/route/v1/$profile/$startCoord;$endCoord')
.replace(queryParameters: {
'overview': 'full', // Get full geometry
'geometries': 'polyline', // Use polyline encoding (more compact)
'steps': 'false', // Don't need turn-by-turn for now
});
debugPrint('[RoutingService] OSRM request: $uri');
try {
final response = await http.get(
uri,
headers: {
'User-Agent': _userAgent,
},
).timeout(_timeout);
if (response.statusCode != 200) {
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}
final data = json.decode(response.body) as Map<String, dynamic>;
// Check OSRM response status
final code = data['code'] as String?;
if (code != 'Ok') {
final message = data['message'] as String? ?? 'Unknown routing error';
throw RoutingException('OSRM error ($code): $message');
}
final routes = data['routes'] as List<dynamic>?;
if (routes == null || routes.isEmpty) {
throw RoutingException('No route found between these points');
}
final route = routes[0] as Map<String, dynamic>;
final geometry = route['geometry'] as String?;
final distance = (route['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (route['duration'] as num?)?.toDouble() ?? 0.0;
if (geometry == null) {
throw RoutingException('Route geometry missing from response');
}
// Decode polyline geometry to waypoints
final waypoints = _decodePolyline(geometry);
if (waypoints.isEmpty) {
throw RoutingException('Failed to decode route geometry');
}
final result = RouteResult(
waypoints: waypoints,
distanceMeters: distance,
durationSeconds: duration,
);
debugPrint('[RoutingService] Route calculated: $result');
return result;
} catch (e) {
debugPrint('[RoutingService] Route calculation failed: $e');
if (e is RoutingException) {
rethrow;
} else {
throw RoutingException('Network error: $e');
}
}
}
/// Decode OSRM polyline geometry to LatLng waypoints
List<LatLng> _decodePolyline(String encoded) {
try {
final List<LatLng> points = [];
int index = 0;
int lat = 0;
int lng = 0;
while (index < encoded.length) {
int b;
int shift = 0;
int result = 0;
// Decode latitude
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
final deltaLat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
lat += deltaLat;
shift = 0;
result = 0;
// Decode longitude
do {
b = encoded.codeUnitAt(index++) - 63;
result |= (b & 0x1f) << shift;
shift += 5;
} while (b >= 0x20);
final deltaLng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
lng += deltaLng;
points.add(LatLng(lat / 1E5, lng / 1E5));
}
return points;
} catch (e) {
debugPrint('[RoutingService] Manual polyline decoding failed: $e');
return [];
}
}
}
class RoutingException implements Exception {
final String message;
const RoutingException(this.message);
@override
String toString() => 'RoutingException: $message';
}

View File

@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
class SearchService {
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
static const int _maxResults = 5;
static const Duration _timeout = Duration(seconds: 10);
/// Search for places using Nominatim geocoding service
Future<List<SearchResult>> search(String query) async {
if (query.trim().isEmpty) {
return [];
}
// Check if query looks like coordinates first
final coordResult = _tryParseCoordinates(query.trim());
if (coordResult != null) {
return [coordResult];
}
// Otherwise, use Nominatim API
return await _searchNominatim(query.trim());
}
/// Try to parse various coordinate formats
SearchResult? _tryParseCoordinates(String query) {
// Remove common separators and normalize
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
final parts = normalized.split(RegExp(r'\s+'));
if (parts.length != 2) return null;
final lat = double.tryParse(parts[0]);
final lon = double.tryParse(parts[1]);
if (lat == null || lon == null) return null;
// Basic validation for Earth coordinates
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return SearchResult(
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
coordinates: LatLng(lat, lon),
category: 'coordinates',
type: 'point',
);
}
/// Search using Nominatim API
Future<List<SearchResult>> _searchNominatim(String query) async {
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: {
'q': query,
'format': 'json',
'limit': _maxResults.toString(),
'addressdetails': '1',
'extratags': '1',
});
debugPrint('[SearchService] Searching Nominatim: $uri');
try {
final response = await http.get(
uri,
headers: {
'User-Agent': _userAgent,
},
).timeout(_timeout);
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}
final List<dynamic> jsonResults = json.decode(response.body);
final results = jsonResults
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
.toList();
debugPrint('[SearchService] Found ${results.length} results');
return results;
} catch (e) {
debugPrint('[SearchService] Search failed: $e');
throw Exception('Search failed: $e');
}
}
}

View File

@@ -0,0 +1,229 @@
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, {
void Function(String message, double? progress)? onProgress,
}) 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];
// Report progress every 1000 entries
if (i % 1000 == 0) {
final progress = i / rawData.length;
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
}
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,242 @@
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;
bool _isLoading = false;
/// Get last fetch time
DateTime? get lastFetchTime => _cache.lastFetchTime;
/// Check if suspected locations are enabled
bool get isEnabled => _isEnabled;
/// Check if currently loading
bool get isLoading => _isLoading;
/// 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 enabling for the first time and no data, fetch it
if (enabled && !_cache.hasData) {
await _fetchData();
}
// If disabling, clear the cache
if (!enabled) {
_cache.clear();
}
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
return await _fetchData(onProgress: onProgress);
}
/// 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({
void Function(String message, double? progress)? onProgress,
}) async {
if (_isLoading) return false;
_isLoading = true;
try {
onProgress?.call('Downloading CSV data...', null);
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;
}
onProgress?.call('Parsing CSV data...', 0.2);
// Parse CSV with proper field separator and quote handling
final csvData = const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(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
final ticketNoIndex = headers.indexOf('ticket_no');
final urlFullIndex = headers.indexOf('url_full');
final addrIndex = headers.indexOf('addr');
final streetIndex = headers.indexOf('street');
final cityIndex = headers.indexOf('city');
final stateIndex = headers.indexOf('state');
final digSiteIntersectingStreetIndex = headers.indexOf('dig_site_intersecting_street');
final digWorkDoneForIndex = headers.indexOf('dig_work_done_for');
final digSiteRemarksIndex = headers.indexOf('dig_site_remarks');
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 as raw data (don't process GeoJSON yet)
final List<Map<String, dynamic>> rawDataList = [];
int rowIndex = 0;
int validRows = 0;
for (final row in dataRows) {
rowIndex++;
try {
final Map<String, dynamic> rowData = {};
if (ticketNoIndex < row.length) rowData['ticket_no'] = row[ticketNoIndex];
if (urlFullIndex != -1 && urlFullIndex < row.length) rowData['url_full'] = row[urlFullIndex];
if (addrIndex != -1 && addrIndex < row.length) rowData['addr'] = row[addrIndex];
if (streetIndex != -1 && streetIndex < row.length) rowData['street'] = row[streetIndex];
if (cityIndex != -1 && cityIndex < row.length) rowData['city'] = row[cityIndex];
if (stateIndex != -1 && stateIndex < row.length) rowData['state'] = row[stateIndex];
if (digSiteIntersectingStreetIndex != -1 && digSiteIntersectingStreetIndex < row.length) {
rowData['dig_site_intersecting_street'] = row[digSiteIntersectingStreetIndex];
}
if (digWorkDoneForIndex != -1 && digWorkDoneForIndex < row.length) {
rowData['dig_work_done_for'] = row[digWorkDoneForIndex];
}
if (digSiteRemarksIndex != -1 && digSiteRemarksIndex < row.length) {
rowData['dig_site_remarks'] = row[digSiteRemarksIndex];
}
if (locationIndex < row.length) rowData['location'] = row[locationIndex];
// Basic validation - must have ticket_no and location
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
rowData['location']?.toString().isNotEmpty == true) {
rawDataList.add(rowData);
validRows++;
}
// Report progress every 1000 rows
if (rowIndex % 1000 == 0) {
final progress = 0.4 + (rowIndex / dataRows.length) * 0.4; // 40% to 80% of total
onProgress?.call('Processing row $rowIndex...', progress);
}
} catch (e, stackTrace) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
continue;
}
}
onProgress?.call('Calculating coordinates...', 0.8);
final fetchTime = DateTime.now();
// Process raw data and save (calculates centroids once)
await _cache.processAndSave(rawDataList, fetchTime, onProgress: (message, progress) {
// Map cache progress to final 20% (0.8 to 1.0)
final finalProgress = 0.8 + (progress ?? 0) * 0.2;
onProgress?.call(message, finalProgress);
});
onProgress?.call('Complete!', 1.0);
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;
} finally {
_isLoading = 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),
));
}
}

View File

@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import '../models/pending_upload.dart';
import '../dev_config.dart';
import 'version_service.dart';
import '../app_state.dart';
class Uploader {
@@ -32,7 +33,7 @@ class Uploader {
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName $kClientVersion"/>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
</changeset>
</osm>''';

View File

@@ -0,0 +1,68 @@
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
/// Service for getting app version information from pubspec.yaml.
/// This ensures we have a single source of truth for version info.
class VersionService {
static final VersionService _instance = VersionService._internal();
factory VersionService() => _instance;
VersionService._internal();
PackageInfo? _packageInfo;
bool _initialized = false;
/// Initialize the service by loading package info
Future<void> init() async {
if (_initialized) return;
try {
_packageInfo = await PackageInfo.fromPlatform();
_initialized = true;
debugPrint('[VersionService] Loaded version: ${_packageInfo!.version}');
} catch (e) {
debugPrint('[VersionService] Failed to load package info: $e');
_initialized = false;
}
}
/// Get the app version (e.g., "1.0.2")
String get version {
if (!_initialized || _packageInfo == null) {
debugPrint('[VersionService] Warning: Service not initialized, returning fallback version');
return 'unknown'; // Fallback for development/testing
}
return _packageInfo!.version;
}
/// Get the app name
String get appName {
if (!_initialized || _packageInfo == null) {
return 'DeFlock'; // Fallback
}
return _packageInfo!.appName;
}
/// Get the package name/bundle ID
String get packageName {
if (!_initialized || _packageInfo == null) {
return 'me.deflock.deflockapp'; // Fallback
}
return _packageInfo!.packageName;
}
/// Get the build number
String get buildNumber {
if (!_initialized || _packageInfo == null) {
return '1'; // Fallback
}
return _packageInfo!.buildNumber;
}
/// Get full version string with build number (e.g., "1.0.2+1")
String get fullVersion {
return '$version+$buildNumber';
}
/// Check if the service is properly initialized
bool get isInitialized => _initialized && _packageInfo != null;
}

View File

@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
import '../services/search_service.dart';
import '../services/routing_service.dart';
/// Simplified navigation modes - brutalist approach
enum AppNavigationMode {
normal, // Regular map view
search, // Search/routing UI active
routeActive, // Following a route
}
/// Simplified navigation state - fewer modes, clearer logic
class NavigationState extends ChangeNotifier {
final SearchService _searchService = SearchService();
final RoutingService _routingService = RoutingService();
// Core state - just 3 modes
AppNavigationMode _mode = AppNavigationMode.normal;
// Simple flags instead of complex sub-states
bool _isSettingSecondPoint = false;
bool _isCalculating = false;
bool _showingOverview = false;
String? _routingError;
// Search state
bool _isSearchLoading = false;
List<SearchResult> _searchResults = [];
String _lastQuery = '';
// Location state
LatLng? _provisionalPinLocation;
String? _provisionalPinAddress;
// Route state
LatLng? _routeStart;
LatLng? _routeEnd;
String? _routeStartAddress;
String? _routeEndAddress;
List<LatLng>? _routePath;
double? _routeDistance;
bool _nextPointIsStart = false; // What we're setting next
// Getters
AppNavigationMode get mode => _mode;
bool get isSettingSecondPoint => _isSettingSecondPoint;
bool get isCalculating => _isCalculating;
bool get showingOverview => _showingOverview;
String? get routingError => _routingError;
bool get hasRoutingError => _routingError != null;
bool get isSearchLoading => _isSearchLoading;
List<SearchResult> get searchResults => List.unmodifiable(_searchResults);
String get lastQuery => _lastQuery;
LatLng? get provisionalPinLocation => _provisionalPinLocation;
String? get provisionalPinAddress => _provisionalPinAddress;
LatLng? get routeStart => _routeStart;
LatLng? get routeEnd => _routeEnd;
String? get routeStartAddress => _routeStartAddress;
String? get routeEndAddress => _routeEndAddress;
List<LatLng>? get routePath => _routePath != null ? List.unmodifiable(_routePath!) : null;
double? get routeDistance => _routeDistance;
bool get settingRouteStart => _nextPointIsStart; // For sheet display compatibility
// Simplified convenience getters
bool get isInSearchMode => _mode == AppNavigationMode.search;
bool get isInRouteMode => _mode == AppNavigationMode.routeActive;
bool get hasActiveRoute => _routePath != null && _mode == AppNavigationMode.routeActive;
bool get showProvisionalPin => _provisionalPinLocation != null && (_mode == AppNavigationMode.search);
bool get showSearchButton => _mode == AppNavigationMode.normal;
bool get showRouteButton => _mode == AppNavigationMode.routeActive;
/// BRUTALIST: Single entry point to search mode
void enterSearchMode(LatLng mapCenter) {
debugPrint('[NavigationState] enterSearchMode - current mode: $_mode');
if (_mode != AppNavigationMode.normal) {
debugPrint('[NavigationState] Cannot enter search mode - not in normal mode');
return;
}
_mode = AppNavigationMode.search;
_provisionalPinLocation = mapCenter;
_provisionalPinAddress = null;
_clearSearchResults();
debugPrint('[NavigationState] Entered search mode');
notifyListeners();
}
/// BRUTALIST: Single cancellation method - cleans up EVERYTHING
void cancel() {
debugPrint('[NavigationState] cancel() - cleaning up all state');
_mode = AppNavigationMode.normal;
// Clear ALL provisional data
_provisionalPinLocation = null;
_provisionalPinAddress = null;
// Clear ALL route data (except active route)
if (_mode != AppNavigationMode.routeActive) {
_routeStart = null;
_routeEnd = null;
_routeStartAddress = null;
_routeEndAddress = null;
_routePath = null;
_routeDistance = null;
}
// Reset ALL flags
_isSettingSecondPoint = false;
_isCalculating = false;
_showingOverview = false;
_nextPointIsStart = false;
_routingError = null;
// Clear search
_clearSearchResults();
debugPrint('[NavigationState] Everything cleaned up');
notifyListeners();
}
/// Update provisional pin when map moves
void updateProvisionalPinLocation(LatLng newLocation) {
if (!showProvisionalPin) return;
_provisionalPinLocation = newLocation;
_provisionalPinAddress = null; // Clear address when location changes
notifyListeners();
}
/// Jump to search result
void selectSearchResult(SearchResult result) {
if (_mode != AppNavigationMode.search) return;
_provisionalPinLocation = result.coordinates;
_provisionalPinAddress = result.displayName;
_clearSearchResults();
debugPrint('[NavigationState] Selected search result: ${result.displayName}');
notifyListeners();
}
/// Start route planning - simplified logic
void startRoutePlanning({required bool thisLocationIsStart}) {
if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return;
debugPrint('[NavigationState] Starting route planning - thisLocationIsStart: $thisLocationIsStart');
// Clear any previous route data
_routeStart = null;
_routeEnd = null;
_routeStartAddress = null;
_routeEndAddress = null;
_routePath = null;
_routeDistance = null;
// Set the current location as start or end
if (thisLocationIsStart) {
_routeStart = _provisionalPinLocation;
_routeStartAddress = _provisionalPinAddress;
_nextPointIsStart = false; // Next we'll set the END
debugPrint('[NavigationState] Set route start, next setting END');
} else {
_routeEnd = _provisionalPinLocation;
_routeEndAddress = _provisionalPinAddress;
_nextPointIsStart = true; // Next we'll set the START
debugPrint('[NavigationState] Set route end, next setting START');
}
// Enter second point selection mode
_isSettingSecondPoint = true;
notifyListeners();
}
/// Select the second route point
void selectSecondRoutePoint() {
if (!_isSettingSecondPoint || _provisionalPinLocation == null) return;
debugPrint('[NavigationState] Selecting second route point - nextPointIsStart: $_nextPointIsStart');
// Set the second point
if (_nextPointIsStart) {
_routeStart = _provisionalPinLocation;
_routeStartAddress = _provisionalPinAddress;
} else {
_routeEnd = _provisionalPinLocation;
_routeEndAddress = _provisionalPinAddress;
}
_isSettingSecondPoint = false;
_routingError = null; // Clear any previous errors
_calculateRoute();
}
/// Retry route calculation (for error recovery)
void retryRouteCalculation() {
if (_routeStart == null || _routeEnd == null) return;
debugPrint('[NavigationState] Retrying route calculation');
_routingError = null;
_calculateRoute();
}
/// Calculate route using OSRM
void _calculateRoute() {
if (_routeStart == null || _routeEnd == null) return;
debugPrint('[NavigationState] Calculating route with OSRM...');
_isCalculating = true;
_routingError = null;
notifyListeners();
_routingService.calculateRoute(
start: _routeStart!,
end: _routeEnd!,
profile: 'driving', // Could make this configurable later
).then((routeResult) {
if (!_isCalculating) return; // Canceled while calculating
_routePath = routeResult.waypoints;
_routeDistance = routeResult.distanceMeters;
_isCalculating = false;
_showingOverview = true;
_provisionalPinLocation = null; // Hide provisional pin
debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}');
notifyListeners();
}).catchError((error) {
if (!_isCalculating) return; // Canceled while calculating
debugPrint('[NavigationState] Route calculation failed: $error');
_isCalculating = false;
_routingError = error.toString().replaceAll('RoutingException: ', '');
// Don't show overview on error, stay in current state
notifyListeners();
});
}
/// Start following the route
void startRoute() {
if (_routePath == null) return;
_mode = AppNavigationMode.routeActive;
_showingOverview = false;
debugPrint('[NavigationState] Started following route');
notifyListeners();
}
/// Check if user should auto-enable follow-me (called from outside with user location)
bool shouldAutoEnableFollowMe(LatLng? userLocation) {
if (userLocation == null || _routeStart == null) return false;
final distanceToStart = const Distance().as(LengthUnit.Meter, userLocation, _routeStart!);
final shouldEnable = distanceToStart <= 1000; // Within 1km
debugPrint('[NavigationState] Distance to start: ${distanceToStart.toStringAsFixed(0)}m, auto follow-me: $shouldEnable');
return shouldEnable;
}
/// Show route overview (from route button during active navigation)
void showRouteOverview() {
if (_mode != AppNavigationMode.routeActive) return;
_showingOverview = true;
debugPrint('[NavigationState] Showing route overview');
notifyListeners();
}
/// Hide route overview (back to active navigation)
void hideRouteOverview() {
if (_mode != AppNavigationMode.routeActive) return;
_showingOverview = false;
debugPrint('[NavigationState] Hiding route overview');
notifyListeners();
}
/// Cancel active route and return to normal
void cancelRoute() {
if (_mode != AppNavigationMode.routeActive) return;
debugPrint('[NavigationState] Canceling active route');
cancel(); // Use the brutalist single cleanup method
}
/// Search functionality
Future<void> search(String query) async {
if (query.trim().isEmpty) {
_clearSearchResults();
return;
}
if (query.trim() == _lastQuery.trim()) return;
_setSearchLoading(true);
_lastQuery = query.trim();
try {
final results = await _searchService.search(query.trim());
_searchResults = results;
debugPrint('[NavigationState] Found ${results.length} results');
} catch (e) {
debugPrint('[NavigationState] Search failed: $e');
_searchResults = [];
}
_setSearchLoading(false);
}
void clearSearchResults() {
_clearSearchResults();
}
void _clearSearchResults() {
if (_searchResults.isNotEmpty || _lastQuery.isNotEmpty) {
_searchResults = [];
_lastQuery = '';
notifyListeners();
}
}
void _setSearchLoading(bool loading) {
if (_isSearchLoading != loading) {
_isSearchLoading = loading;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/search_result.dart';
import '../services/search_service.dart';
class SearchState extends ChangeNotifier {
final SearchService _searchService = SearchService();
bool _isLoading = false;
List<SearchResult> _results = [];
String _lastQuery = '';
// Getters
bool get isLoading => _isLoading;
List<SearchResult> get results => List.unmodifiable(_results);
String get lastQuery => _lastQuery;
/// Search for places by query string
Future<void> search(String query) async {
if (query.trim().isEmpty) {
_clearResults();
return;
}
// Don't search if query hasn't changed
if (query.trim() == _lastQuery.trim()) {
return;
}
_setLoading(true);
_lastQuery = query.trim();
try {
final results = await _searchService.search(query.trim());
_results = results;
debugPrint('[SearchState] Found ${results.length} results for "$query"');
} catch (e) {
debugPrint('[SearchState] Search failed: $e');
_results = [];
}
_setLoading(false);
}
/// Clear search results
void clearResults() {
_clearResults();
}
void _clearResults() {
if (_results.isNotEmpty || _lastQuery.isNotEmpty) {
_results = [];
_lastQuery = '';
notifyListeners();
}
}
void _setLoading(bool loading) {
if (_isLoading != loading) {
_isLoading = loading;
notifyListeners();
}
}
}

View File

@@ -26,6 +26,8 @@ class SettingsState extends ChangeNotifier {
static const String _followMeModePrefsKey = 'follow_me_mode';
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;
@@ -33,6 +35,8 @@ class SettingsState extends ChangeNotifier {
FollowMeMode _followMeMode = FollowMeMode.northUp;
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = false;
int _suspectedLocationMinDistance = 100; // meters
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
@@ -43,6 +47,8 @@ class SettingsState extends ChangeNotifier {
FollowMeMode get followMeMode => _followMeMode;
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
int get proximityAlertDistance => _proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
@@ -95,6 +101,12 @@ class SettingsState extends ChangeNotifier {
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
// Load network status indicator setting
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
// Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
@@ -147,6 +159,9 @@ class SettingsState extends ChangeNotifier {
_tileProviders = providersList
.map((json) => TileProvider.fromJson(json))
.toList();
// Migration: Add any missing built-in providers
await _addMissingBuiltinProviders(prefs);
}
} catch (e) {
debugPrint('Error loading tile providers: $e');
@@ -160,6 +175,25 @@ class SettingsState extends ChangeNotifier {
}
}
/// Add any built-in providers that are missing from user's configuration
Future<void> _addMissingBuiltinProviders(SharedPreferences prefs) async {
final defaultProviders = DefaultTileProviders.createDefaults();
final existingProviderIds = _tileProviders.map((p) => p.id).toSet();
bool hasUpdates = false;
for (final defaultProvider in defaultProviders) {
if (!existingProviderIds.contains(defaultProvider.id)) {
_tileProviders.add(defaultProvider);
hasUpdates = true;
debugPrint('SettingsState: Added missing built-in provider: ${defaultProvider.name}');
}
}
if (hasUpdates) {
await _saveTileProviders(prefs);
}
}
Future<void> _saveTileProviders(SharedPreferences prefs) async {
try {
final providersJson = jsonEncode(
@@ -285,4 +319,24 @@ class SettingsState extends ChangeNotifier {
}
}
/// Set network status indicator enabled/disabled
Future<void> setNetworkStatusIndicatorEnabled(bool enabled) async {
if (_networkStatusIndicatorEnabled != enabled) {
_networkStatusIndicatorEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_networkStatusIndicatorEnabledPrefsKey, enabled);
notifyListeners();
}
}
/// 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,80 @@
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 || _service.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);
notifyListeners();
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.refreshData(onProgress: onProgress);
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

@@ -51,10 +51,7 @@ class AddNodeSheet extends StatelessWidget {
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -192,7 +189,6 @@ class AddNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
);
},
);

View File

@@ -19,31 +19,31 @@ class CameraIcon extends StatelessWidget {
Color get _ringColor {
switch (type) {
case CameraIconType.real:
return kCameraRingColorReal;
return kNodeRingColorReal;
case CameraIconType.mock:
return kCameraRingColorMock;
return kNodeRingColorMock;
case CameraIconType.pending:
return kCameraRingColorPending;
return kNodeRingColorPending;
case CameraIconType.editing:
return kCameraRingColorEditing;
return kNodeRingColorEditing;
case CameraIconType.pendingEdit:
return kCameraRingColorPendingEdit;
return kNodeRingColorPendingEdit;
case CameraIconType.pendingDeletion:
return kCameraRingColorPendingDeletion;
return kNodeRingColorPendingDeletion;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: kCameraIconDiameter,
height: kCameraIconDiameter,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(kCameraDotOpacity),
color: _ringColor.withOpacity(kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: kCameraRingThickness,
width: getNodeRingThickness(context),
),
),
);

View File

@@ -53,10 +53,7 @@ class EditNodeSheet extends StatelessWidget {
}
}
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
@@ -199,7 +196,6 @@ class EditNodeSheet extends StatelessWidget {
),
const SizedBox(height: 20),
],
),
);
},
);

View File

@@ -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: kCameraIconDiameter,
height: kCameraIconDiameter,
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

@@ -69,12 +69,12 @@ class CameraRefreshController {
}
final zoom = controller.mapController.camera.zoom;
if (zoom < kCameraMinZoomLevel) {
if (zoom < kNodeMinZoomLevel) {
// Show a snackbar-style bubble warning
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cameras not drawn below zoom level $kCameraMinZoomLevel'),
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);

View File

@@ -14,6 +14,7 @@ class DirectionConesBuilder {
required double zoom,
AddNodeSession? session,
EditNodeSession? editSession,
required BuildContext context,
}) {
final overlays = <Polygon>[];
@@ -23,6 +24,7 @@ class DirectionConesBuilder {
session.target!,
session.directionDegrees,
zoom,
context: context,
isSession: true,
));
}
@@ -33,6 +35,7 @@ class DirectionConesBuilder {
editSession.target,
editSession.directionDegrees,
zoom,
context: context,
isSession: true,
));
}
@@ -46,6 +49,7 @@ class DirectionConesBuilder {
n.coord,
n.directionDeg!,
zoom,
context: context,
))
);
@@ -69,40 +73,52 @@ class DirectionConesBuilder {
LatLng origin,
double bearingDeg,
double zoom, {
required BuildContext context,
bool isPending = false,
bool isSession = false,
}) {
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));
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(0.25),
color: kDirectionConeColor.withOpacity(kDirectionConeOpacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: 1,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}
}

Some files were not shown because too many files have changed in this diff Show More