Compare commits
88 Commits
v1.0.0-rel
...
v1.2.2-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0f298c0e | ||
|
|
0ac158eb4a | ||
|
|
7eb680c677 | ||
|
|
a30dace404 | ||
|
|
50d2c6cbf6 | ||
|
|
925804e546 | ||
|
|
4076d9657a | ||
|
|
789930049a | ||
|
|
09019915e7 | ||
|
|
16e1927ff1 | ||
|
|
02e43f78c3 | ||
|
|
8a109029ca | ||
|
|
cd5315b919 | ||
|
|
03f3419f72 | ||
|
|
7ace123b4b | ||
|
|
08f017fb0f | ||
|
|
7a199a3258 | ||
|
|
8c999c04cd | ||
|
|
dc8dc9f11b | ||
|
|
93f0d9edae | ||
|
|
793e735452 | ||
|
|
6a2c1230d2 | ||
|
|
b8834cd256 | ||
|
|
b8b9d4b797 | ||
|
|
4b1111a0a3 | ||
|
|
f80f125599 | ||
|
|
afa0ff94b2 | ||
|
|
02f3cb0077 | ||
|
|
c671f29930 | ||
|
|
68068214bb | ||
|
|
b00db130d7 | ||
|
|
5c28057fa1 | ||
|
|
106277faf4 | ||
|
|
f9351ba272 | ||
|
|
4a44ab96d6 | ||
|
|
904af42cbf | ||
|
|
cc0386ee97 | ||
|
|
08238eaad2 | ||
|
|
3fbcd8f092 | ||
|
|
aeea503060 | ||
|
|
69084be7bd | ||
|
|
14b52f8018 | ||
|
|
5301810c0e | ||
|
|
23713acb99 | ||
|
|
f5aeba473b | ||
|
|
f285a18563 | ||
|
|
ae220fc3f5 | ||
|
|
111bdc4254 | ||
|
|
731cdc4a4b | ||
|
|
a08d61fb98 | ||
|
|
5976ab4bab | ||
|
|
5568173c6e | ||
|
|
c4ec144f20 | ||
|
|
b636ab4d26 | ||
|
|
5e426c342d | ||
|
|
bbfeda8280 | ||
|
|
079448eeeb | ||
|
|
9ef06cdec2 | ||
|
|
bdde689ee7 | ||
|
|
2842481d98 | ||
|
|
e73a885544 | ||
|
|
d8b48c8fdb | ||
|
|
3e1fb58162 | ||
|
|
dbe667ee8b | ||
|
|
0bc420efca | ||
|
|
02c66b3785 | ||
|
|
fd47813bdf | ||
|
|
8b4b9722c4 | ||
|
|
dfb8eceaad | ||
|
|
c6db4396e4 | ||
|
|
40c78ab3b7 | ||
|
|
19de232484 | ||
|
|
bac033528c | ||
|
|
763fa31266 | ||
|
|
408b52cdb0 | ||
|
|
7d18656ec6 | ||
|
|
a7186ab2c5 | ||
|
|
b02099e3fe | ||
|
|
80c6d0a82d | ||
|
|
3e6a27cc15 | ||
|
|
4e072a34c0 | ||
|
|
9ad7e82e93 | ||
|
|
2fabc90be7 | ||
|
|
e41ea0488d | ||
|
|
acd010bcfa | ||
|
|
6c0981abdd | ||
|
|
1007a88dd2 | ||
|
|
6569ea9f57 |
304
.github/workflows/workflow.yml
vendored
Normal 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
@@ -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
|
||||
# ───────────────────────────────
|
||||
|
||||
25
README.md
@@ -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 (Lowe’s 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 out‑of‑box.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Fall back to debug signing for development builds
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 56 KiB |
@@ -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
@@ -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
|
||||
63
do_builds.sh
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"}}
|
||||
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 875 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
0
ios/Runner/Assets.xcassets/LaunchBackground.imageset/.gitkeep
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 69 B |
0
ios/Runner/Assets.xcassets/LaunchImage.imageset/.gitkeep
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 57 KiB |
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "无可用地址"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
47
lib/models/search_result.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
212
lib/models/suspected_location.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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 non‑null 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 {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
124
lib/screens/navigation_settings_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/screens/settings/sections/network_status_section.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/screens/settings/sections/suspected_locations_section.dart
Normal 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));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
164
lib/services/routing_service.dart
Normal 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';
|
||||
}
|
||||
91
lib/services/search_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
229
lib/services/suspected_location_cache.dart
Normal 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;
|
||||
}
|
||||
242
lib/services/suspected_location_service.dart
Normal 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),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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>''';
|
||||
|
||||
68
lib/services/version_service.dart
Normal 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;
|
||||
}
|
||||
339
lib/state/navigation_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
65
lib/state/search_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
80
lib/state/suspected_location_state.dart
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||