Compare commits
246 Commits
v0.8-beta
...
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 | ||
|
|
ec63aed459 | ||
|
|
b440629ad6 | ||
|
|
322b9fae62 | ||
|
|
792f94065d | ||
|
|
1aeae18ebc | ||
|
|
96b82ef416 | ||
|
|
adec4b175f | ||
|
|
583499ccd1 | ||
|
|
7ff9273f47 | ||
|
|
03654f354e | ||
|
|
9e97b69b85 | ||
|
|
fe554230b6 | ||
|
|
41ee9cab10 | ||
|
|
ab567b1da4 | ||
|
|
e79790c30d | ||
|
|
d397610121 | ||
|
|
3a985d2f8f | ||
|
|
b3a87fc56a | ||
|
|
82501a3131 | ||
|
|
e71adab87e | ||
|
|
2d29c93145 | ||
|
|
caa20140b4 | ||
|
|
2b26bf9188 | ||
|
|
1140e6300a | ||
|
|
7a1b1befb4 | ||
|
|
71fa212d71 | ||
|
|
6b5f05d036 | ||
|
|
87256e2c74 | ||
|
|
4a7a99502c | ||
|
|
5c80fdc169 | ||
|
|
5c525900f1 | ||
|
|
28828fbac0 | ||
|
|
9bf46721f0 | ||
|
|
363439f712 | ||
|
|
38f15a1f8b | ||
|
|
a05abd8bd8 | ||
|
|
c8a8d4c81f | ||
|
|
63e8934490 | ||
|
|
4053c9b39b | ||
|
|
4ad33d17e0 | ||
|
|
c9f1ecf7d0 | ||
|
|
7c49b38230 | ||
|
|
25f0e358a3 | ||
|
|
0cbcec7017 | ||
|
|
68289135bd | ||
|
|
23b7586e25 | ||
|
|
a2b842fb67 | ||
|
|
175bc8831a | ||
|
|
99ce659064 | ||
|
|
2b1b98ae0f | ||
|
|
ec063e9c27 | ||
|
|
bdddbb5d8e | ||
|
|
f05a31f40b | ||
|
|
3150297bb0 | ||
|
|
988516c040 | ||
|
|
fa6b6ffcda | ||
|
|
8381388ffa | ||
|
|
fa16e3c299 | ||
|
|
a17c50188e | ||
|
|
5c2bfbc76e | ||
|
|
a8ac237317 | ||
|
|
eeedbd7da7 | ||
|
|
3ddebd2664 | ||
|
|
b5c210d009 | ||
|
|
208b3486f3 | ||
|
|
04a6d129b7 | ||
|
|
944df59d7c | ||
|
|
29031b1372 | ||
|
|
6bcfef0caa | ||
|
|
d2a3e96a86 | ||
|
|
395ef77fe3 | ||
|
|
57acff8ae7 | ||
|
|
a437d9bf60 | ||
|
|
c4c1505253 | ||
|
|
42c03eca7d | ||
|
|
bcc4461621 | ||
|
|
3cb875b67a | ||
|
|
d03ef6b50d | ||
|
|
6db691dbeb | ||
|
|
5ccf215f4e | ||
|
|
deb9a4272b | ||
|
|
1b3c3e620c | ||
|
|
c42d3afd0b | ||
|
|
f4ae861bc6 | ||
|
|
07d18ae33c | ||
|
|
92255eb03e | ||
|
|
3026b88230 | ||
|
|
728cef22af | ||
|
|
d7fbfaaaeb | ||
|
|
9c05f1d7a9 | ||
|
|
2c275ec528 | ||
|
|
f8726880d7 | ||
|
|
497b9e52be | ||
|
|
d9f6c8c8e0 | ||
|
|
45bf73aeee | ||
|
|
7ff945e262 | ||
|
|
26d8eca312 | ||
|
|
efbb8765de | ||
|
|
fae1cac6e4 | ||
|
|
aee0dcf8b8 | ||
|
|
2db4f597dc | ||
|
|
376fa27736 | ||
|
|
24b20e8a57 | ||
|
|
2d0dc7fd66 | ||
|
|
b735283f27 | ||
|
|
ebf7f93dd5 | ||
|
|
d56a6e8e7c | ||
|
|
84e057c986 | ||
|
|
c1e25ec5b1 | ||
|
|
a3edcfc2de | ||
|
|
17c9ee0c5c | ||
|
|
9e620ef9e4 | ||
|
|
bedfdcca6e | ||
|
|
f1c73a5e55 | ||
|
|
4ee783793f | ||
|
|
aada97295b | ||
|
|
813f4f69ea | ||
|
|
2d615128aa | ||
|
|
024d3f09c3 | ||
|
|
e65b9f58a6 | ||
|
|
7bd6f68a99 | ||
|
|
f11bd6e238 | ||
|
|
f45279ecfe | ||
|
|
d6625ccc23 | ||
|
|
722e640a72 | ||
|
|
a21e807d88 | ||
|
|
a2bc3309c0 | ||
|
|
f6adffc84e | ||
|
|
01f73322c7 | ||
|
|
257aefb2fc | ||
|
|
63ebc2b682 | ||
|
|
1f3849cd84 | ||
|
|
e35266c160 | ||
|
|
05de16b2e2 | ||
|
|
32507e1646 | ||
|
|
1272eb9409 | ||
|
|
4cc8929378 | ||
|
|
44707bf064 | ||
|
|
ff9a052d3f | ||
|
|
df5e26f78d | ||
|
|
865f91ea55 | ||
|
|
268c9ebb3a | ||
|
|
7875fd0d58 | ||
|
|
4bb57580cd | ||
|
|
5521da28c4 | ||
|
|
e5d00803f7 | ||
|
|
a73605cc53 | ||
|
|
7aa0c9dff4 | ||
|
|
e2830a189b | ||
|
|
d9beeb9d83 | ||
|
|
446b70eaff | ||
|
|
2829730705 | ||
|
|
a131fb61e0 | ||
|
|
6aaddb4fe2 | ||
|
|
e2adbf85ad | ||
|
|
6d079b3c34 | ||
|
|
e293727ec8 | ||
|
|
9375f48a07 |
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
|
||||
25
.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,3 +93,13 @@ Thumbs.db
|
||||
*.keystore
|
||||
.env
|
||||
|
||||
# Local OSM client ID configuration (contains secrets)
|
||||
build_keys.conf
|
||||
|
||||
# ───────────────────────────────
|
||||
# For now - not targeting these
|
||||
# ───────────────────────────────
|
||||
linux/
|
||||
macos/
|
||||
web/
|
||||
windows/
|
||||
425
DEVELOPER.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Developer Documentation
|
||||
|
||||
This document provides detailed technical information about the DeFlock app architecture, key design decisions, and development guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Philosophy: Brutalist Code
|
||||
|
||||
Our development approach prioritizes **simplicity over cleverness**:
|
||||
|
||||
- **Explicit over implicit**: Clear, readable code that states its intent
|
||||
- **Few edge cases by design**: Avoid complex branching and special cases
|
||||
- **Maintainable over efficient**: Choose the approach that's easier to understand and modify
|
||||
- **Delete before adding**: Remove complexity when possible rather than adding features
|
||||
|
||||
**Hierarchy of preferred code:**
|
||||
1. **Code we don't write** (through thoughtful design and removing edge cases)
|
||||
2. **Code we can remove** (by seeing problems from a new angle)
|
||||
3. **Code that sadly must exist** (simple, explicit, maintainable)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### State Management
|
||||
|
||||
The app uses **Provider pattern** with modular state classes:
|
||||
|
||||
```
|
||||
AppState (main coordinator)
|
||||
├── AuthState (OAuth2 login/logout)
|
||||
├── OperatorProfileState (operator tag sets)
|
||||
├── ProfileState (node profiles & toggles)
|
||||
├── SessionState (add/edit sessions)
|
||||
├── SettingsState (preferences & tile providers)
|
||||
└── UploadQueueState (pending operations)
|
||||
```
|
||||
|
||||
**Why this approach:**
|
||||
- **Separation of concerns**: Each state handles one domain
|
||||
- **Testability**: Individual state classes can be unit tested
|
||||
- **Brutalist**: No complex state orchestration, just simple delegation
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
```
|
||||
UI Layer (Widgets)
|
||||
↕️
|
||||
AppState (Coordinator)
|
||||
↕️
|
||||
State Modules (AuthState, ProfileState, etc.)
|
||||
↕️
|
||||
Services (MapDataProvider, NodeCache, Uploader)
|
||||
↕️
|
||||
External APIs (OSM, Overpass, Tile providers)
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- **Unidirectional data flow**: UI → AppState → Services → APIs
|
||||
- **No direct service access from UI**: Everything goes through AppState
|
||||
- **Clean boundaries**: Each layer has a clear responsibility
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. MapDataProvider
|
||||
|
||||
**Purpose**: Unified interface for fetching map tiles and surveillance nodes
|
||||
|
||||
**Design decisions:**
|
||||
- **Pluggable sources**: Local (cached) vs Remote (live API)
|
||||
- **Offline-first**: Always try local first, graceful degradation
|
||||
- **Mode-aware**: Different behavior for production vs sandbox
|
||||
- **Failure handling**: Never crash the UI, always provide fallbacks
|
||||
|
||||
**Key methods:**
|
||||
- `getNodes()`: Smart fetching with local/remote merging
|
||||
- `getTile()`: Tile fetching with caching
|
||||
- `_fetchRemoteNodes()`: Handles Overpass → OSM API fallback
|
||||
|
||||
**Why unified interface:**
|
||||
The app needs to seamlessly switch between multiple data sources (local cache, Overpass API, OSM API, offline areas) based on network status, upload mode, and zoom level. A single interface prevents the UI from needing to know about these complexities.
|
||||
|
||||
### 2. Node Operations (Create/Edit/Delete)
|
||||
|
||||
**Upload Operations Enum:**
|
||||
```dart
|
||||
enum UploadOperation { create, modify, delete }
|
||||
```
|
||||
|
||||
**Why explicit enum vs boolean flags:**
|
||||
- **Brutalist**: Three explicit states instead of nullable booleans
|
||||
- **Extensible**: Easy to add new operations (like bulk operations)
|
||||
- **Clear intent**: `operation == UploadOperation.delete` is unambiguous
|
||||
|
||||
**Session Pattern:**
|
||||
- `AddNodeSession`: For creating new nodes
|
||||
- `EditNodeSession`: For modifying existing nodes
|
||||
- No "DeleteSession": Deletions are immediate (simpler)
|
||||
|
||||
**Why no delete session:**
|
||||
Deletions don't need position dragging or tag editing - they just need confirmation and queuing. A session would add complexity without benefit.
|
||||
|
||||
### 3. Upload Queue System
|
||||
|
||||
**Design principles:**
|
||||
- **Operation-agnostic**: Same queue handles create/modify/delete
|
||||
- **Offline-capable**: Queue persists between app sessions
|
||||
- **Visual feedback**: Each operation type has distinct UI state
|
||||
- **Error recovery**: Retry mechanism with exponential backoff
|
||||
|
||||
**Queue workflow:**
|
||||
1. User action (add/edit/delete) → `PendingUpload` created
|
||||
2. Immediate visual feedback (cache updated with temp markers)
|
||||
3. Background uploader processes queue when online
|
||||
4. Success → cache updated with real data, temp markers removed
|
||||
5. Failure → error state, retry available
|
||||
|
||||
**Why immediate visual feedback:**
|
||||
Users expect instant response to their actions. By immediately updating the cache with temporary markers (e.g., `_pending_deletion`), the UI stays responsive while the actual API calls happen in background.
|
||||
|
||||
### 4. Cache & Visual States
|
||||
|
||||
**Node visual states:**
|
||||
- **Blue ring**: Real nodes from OSM
|
||||
- **Purple ring**: Pending uploads (new nodes)
|
||||
- **Grey ring**: Original nodes with pending edits
|
||||
- **Orange ring**: Node currently being edited
|
||||
- **Red ring**: Nodes pending deletion
|
||||
|
||||
**Cache tags for state tracking:**
|
||||
```dart
|
||||
'_pending_upload' // New node waiting to upload
|
||||
'_pending_edit' // Original node has pending edits
|
||||
'_pending_deletion' // Node queued for deletion
|
||||
'_original_node_id' // For drawing connection lines
|
||||
```
|
||||
|
||||
**Why underscore prefix:**
|
||||
These are internal app tags, not OSM tags. The underscore prefix makes this explicit and prevents accidental upload to OSM.
|
||||
|
||||
### 5. Multi-API Data Sources
|
||||
|
||||
**Production mode:** Overpass API → OSM API fallback
|
||||
**Sandbox mode:** OSM API only (Overpass doesn't have sandbox data)
|
||||
|
||||
**Zoom level restrictions:**
|
||||
- **Production (Overpass)**: Zoom ≥ 10 (established limit)
|
||||
- **Sandbox (OSM API)**: Zoom ≥ 13 (stricter due to bbox limits)
|
||||
|
||||
**Why different zoom limits:**
|
||||
The OSM API returns ALL data types (nodes, ways, relations) in a bounding box and has stricter size limits. Overpass is more efficient for large areas. The zoom restrictions prevent API errors and excessive data transfer.
|
||||
|
||||
### 6. Offline vs Online Mode Behavior
|
||||
|
||||
**Mode combinations:**
|
||||
```
|
||||
Production + Online → Local cache + Overpass API
|
||||
Production + Offline → Local cache only
|
||||
Sandbox + Online → OSM API only (no cache mixing)
|
||||
Sandbox + Offline → No nodes (cache is production data)
|
||||
```
|
||||
|
||||
**Why sandbox + offline = no nodes:**
|
||||
Local cache contains production data. Showing production nodes in sandbox mode would be confusing and could lead to users trying to edit production nodes with sandbox credentials.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions & Rationales
|
||||
|
||||
### 1. Why Provider Pattern?
|
||||
|
||||
**Alternatives considered:**
|
||||
- BLoC: Too verbose for our needs
|
||||
- Riverpod: Added complexity without clear benefit
|
||||
- setState: Doesn't scale beyond single widgets
|
||||
|
||||
**Why Provider won:**
|
||||
- **Familiar**: Most Flutter developers know Provider
|
||||
- **Simple**: Minimal boilerplate
|
||||
- **Flexible**: Easy to compose multiple providers
|
||||
- **Battle-tested**: Mature, stable library
|
||||
|
||||
### 2. Why Separate State Classes?
|
||||
|
||||
**Alternative**: Single monolithic AppState
|
||||
|
||||
**Why modular state:**
|
||||
- **Single responsibility**: Each state class has one concern
|
||||
- **Testability**: Easier to unit test individual features
|
||||
- **Maintainability**: Changes to auth don't affect profile logic
|
||||
- **Team development**: Different developers can work on different states
|
||||
|
||||
### 3. Why Upload Queue vs Direct API Calls?
|
||||
|
||||
**Alternative**: Direct API calls from UI actions
|
||||
|
||||
**Why queue approach:**
|
||||
- **Offline capability**: Actions work without internet
|
||||
- **User experience**: Instant feedback, no waiting for API calls
|
||||
- **Error recovery**: Failed uploads can be retried
|
||||
- **Batch processing**: Could optimize multiple operations
|
||||
- **Visual feedback**: Users can see pending operations
|
||||
|
||||
### 4. Why Overpass + OSM API vs Just One?
|
||||
|
||||
**Why not just Overpass:**
|
||||
- Overpass doesn't have sandbox data
|
||||
- Overpass can be unreliable/slow
|
||||
- OSM API is canonical source
|
||||
|
||||
**Why not just OSM API:**
|
||||
- OSM API has strict bbox size limits
|
||||
- OSM API returns all data types (inefficient)
|
||||
- Overpass is optimized for surveillance device queries
|
||||
|
||||
**Result**: Use the best tool for each situation
|
||||
|
||||
### 5. Why Zoom Level Restrictions?
|
||||
|
||||
**Alternative**: Always fetch, handle errors gracefully
|
||||
|
||||
**Why restrictions:**
|
||||
- **Prevents API abuse**: Large bbox queries can overload servers
|
||||
- **User experience**: Fetching 10,000 nodes causes UI lag
|
||||
- **Battery life**: Excessive network requests drain battery
|
||||
- **Clear feedback**: Users understand why nodes aren't showing
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### 1. Adding New Features
|
||||
|
||||
**Before writing code:**
|
||||
1. Can we solve this by removing existing code?
|
||||
2. Can we simplify the problem to avoid edge cases?
|
||||
3. Does this fit the existing patterns?
|
||||
|
||||
**When adding new upload operations:**
|
||||
1. Add to `UploadOperation` enum
|
||||
2. Update `PendingUpload` serialization
|
||||
3. Add visual state (color, icon)
|
||||
4. Update uploader logic
|
||||
5. Add cache cleanup handling
|
||||
|
||||
### 2. Testing Philosophy
|
||||
|
||||
**Priority order:**
|
||||
1. **Integration tests**: Test complete user workflows
|
||||
2. **Widget tests**: Test UI components with mock data
|
||||
3. **Unit tests**: Test individual state classes
|
||||
|
||||
**Why integration tests first:**
|
||||
The most important thing is that user workflows work end-to-end. Unit tests can pass while the app is broken from a user perspective.
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
**Principles:**
|
||||
- **Never crash the UI**: Always provide fallbacks
|
||||
- **Fail gracefully**: Empty list is better than exception
|
||||
- **User feedback**: Show meaningful error messages
|
||||
- **Logging**: Use debugPrint for troubleshooting
|
||||
|
||||
**Example pattern:**
|
||||
```dart
|
||||
try {
|
||||
final result = await riskyOperation();
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('Operation failed: $e');
|
||||
// Show user-friendly message
|
||||
showSnackBar('Unable to load data. Please try again.');
|
||||
return <EmptyResult>[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4. State Updates
|
||||
|
||||
**Always notify listeners:**
|
||||
```dart
|
||||
void updateSomething() {
|
||||
_something = newValue;
|
||||
notifyListeners(); // Don't forget this!
|
||||
}
|
||||
```
|
||||
|
||||
**Batch related updates:**
|
||||
```dart
|
||||
void updateMultipleThings() {
|
||||
_thing1 = value1;
|
||||
_thing2 = value2;
|
||||
_thing3 = value3;
|
||||
notifyListeners(); // Single notification for all changes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Flutter SDK**: Latest stable version
|
||||
- **Xcode**: For iOS builds (macOS only)
|
||||
- **Android Studio**: For Android builds
|
||||
- **Git**: For version control
|
||||
|
||||
### OAuth2 Setup
|
||||
|
||||
**Required registrations:**
|
||||
1. **Production OSM**: https://www.openstreetmap.org/oauth2/applications
|
||||
2. **Sandbox OSM**: https://master.apis.dev.openstreetmap.org/oauth2/applications
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Edit keys.dart with your OAuth2 client IDs
|
||||
```
|
||||
|
||||
### iOS Setup
|
||||
```bash
|
||||
cd ios && pod install
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
flutter test
|
||||
|
||||
# Run with coverage
|
||||
flutter test --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
lib/
|
||||
├── models/ # Data classes
|
||||
│ ├── osm_camera_node.dart
|
||||
│ ├── pending_upload.dart
|
||||
│ └── node_profile.dart
|
||||
├── services/ # Business logic
|
||||
│ ├── map_data_provider.dart
|
||||
│ ├── uploader.dart
|
||||
│ └── node_cache.dart
|
||||
├── state/ # State management
|
||||
│ ├── app_state.dart
|
||||
│ ├── auth_state.dart
|
||||
│ └── upload_queue_state.dart
|
||||
├── widgets/ # UI components
|
||||
│ ├── map_view.dart
|
||||
│ ├── edit_node_sheet.dart
|
||||
│ └── map/ # Map-specific widgets
|
||||
├── screens/ # Full screens
|
||||
│ ├── home_screen.dart
|
||||
│ └── settings_screen.dart
|
||||
└── localizations/ # i18n strings
|
||||
├── en.json
|
||||
├── de.json
|
||||
├── es.json
|
||||
└── fr.json
|
||||
```
|
||||
|
||||
**Principles:**
|
||||
- **Models**: Pure data, no business logic
|
||||
- **Services**: Stateless business logic
|
||||
- **State**: Stateful coordination
|
||||
- **Widgets**: UI only, delegate to AppState
|
||||
- **Screens**: Compose widgets, handle navigation
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Nodes not appearing:**
|
||||
- Check zoom level (≥10 production, ≥13 sandbox)
|
||||
- Check upload mode vs expected data source
|
||||
- Check network connectivity
|
||||
- Look for console errors
|
||||
|
||||
**Upload failures:**
|
||||
- Verify OAuth2 credentials
|
||||
- Check upload mode matches login (production vs sandbox)
|
||||
- Ensure node has required tags
|
||||
- Check network connectivity
|
||||
|
||||
**Cache issues:**
|
||||
- Clear app data to reset cache
|
||||
- Check if offline mode is affecting behavior
|
||||
- Verify upload mode switches clear cache
|
||||
|
||||
### Debug Logging
|
||||
|
||||
**Enable verbose logging:**
|
||||
```dart
|
||||
debugPrint('[ComponentName] Detailed message: $data');
|
||||
```
|
||||
|
||||
**Key areas to log:**
|
||||
- Network requests and responses
|
||||
- Cache operations
|
||||
- State transitions
|
||||
- User actions
|
||||
|
||||
### Performance
|
||||
|
||||
**Monitor:**
|
||||
- Memory usage during large node fetches
|
||||
- UI responsiveness during background uploads
|
||||
- Battery usage during GPS tracking
|
||||
|
||||
---
|
||||
|
||||
This documentation should be updated as the architecture evolves. When making significant changes, update both the relevant section here and add a brief note explaining the rationale for the change.
|
||||
212
README.md
@@ -1,145 +1,137 @@
|
||||
# Flock Map App
|
||||
# DeFlock
|
||||
|
||||
A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance nodes) for OpenStreetMap, with advanced offline support, robust camera profile management, and a pro-grade UX.
|
||||
A comprehensive Flutter app for mapping public surveillance infrastructure with OpenStreetMap. Includes offline capabilities, editing ability, and an intuitive interface.
|
||||
|
||||
**DeFlock** is a privacy-focused initiative to document the rapid expansion of ALPRs, AI surveillance cameras, and other public surveillance infrastructure. This app aims to be the go-to tool for contributors to map surveillance devices in their communities and upload the data to OpenStreetMap, making surveillance infrastructure visible and searchable.
|
||||
|
||||
**For complete documentation, tutorials, and community info, visit [deflock.me](https://deflock.me)**
|
||||
|
||||
---
|
||||
|
||||
## Code Organization (2025 Refactor)
|
||||
## What This App Does
|
||||
|
||||
- **Data providers:** All map tile and camera data fetching now routes through `lib/services/map_data_provider.dart`, which supports both OSM/Overpass and fully offline/local sources, with pluggable submodules:
|
||||
- Remote tile fetch: `map_data_submodules/tiles_from_osm.dart`
|
||||
- Remote cameras: `map_data_submodules/cameras_from_overpass.dart`
|
||||
- *Coming soon:* Local tile/camera modules for offline/area-aware access
|
||||
- **Settings UI:** Each settings section lives in its own widget under `lib/screens/settings_screen_sections/`, using clean, modular ListTile-based layouts.
|
||||
- **Offline areas:** Management, persistence, and download logic remain in `OfflineAreaService`, but all fetch/caching is routed through the new provider.
|
||||
- **Legacy OSM/Overpass tile and camera fetch code has been removed from old modules.**
|
||||
- **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 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
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Map Data & Provider Architecture
|
||||
- **All map tile and camera fetches** go through MapDataProvider, which selects local or remote sources as needed, automatically obeying the user's offline/online preference and settings.
|
||||
- **Offline Mode:** A global toggle in Settings disables all remote network fetches, forcing the app to use only locally downloaded map areas and cached camera data. (Instant feedback; no network calls when enabled.)
|
||||
- **MapSource Selection:** MapDataProvider lets calling code specify local-only, remote-only, or auto preference for tiles and camera points.
|
||||
### Map & Navigation
|
||||
- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers
|
||||
- **Offline-first design**: Download a region for complete offline operation
|
||||
- **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, and gesture-friendly interactions
|
||||
- **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red)
|
||||
|
||||
### Map View
|
||||
- **Seamless offline/online tile loading:** Tiles are fetched (in parallel, with global concurrency/throttle control and exponential backoff) from OSM *only as needed*, with robust error handling and UI updates as tiles arrive.
|
||||
- **Camera overlays** are fetched from Overpass or local cache, respecting both offline mode and user preference for which camera types to display.
|
||||
### Device Management
|
||||
- **Comprehensive profiles**: Built-in profiles for major manufacturers (Flock Safety, Motorola/Vigilant, Genetec, Leonardo/ELSAG, Neology) plus custom profile creation
|
||||
- **Full CRUD operations**: Create, edit, and delete surveillance devices
|
||||
- **Direction visualization**: Interactive field-of-view cones showing camera viewing angles
|
||||
- **Bulk operations**: Tag multiple devices efficiently with profile-based workflow
|
||||
|
||||
### Camera Profiles & Upload Queue
|
||||
- Unchanged: creation/editing/enabling; see prior documentation.
|
||||
### Professional Upload & Sync
|
||||
- **OpenStreetMap integration**: Direct upload with full OAuth2 authentication
|
||||
- **Upload modes**: Production OSM, testing sandbox, or simulate-only mode
|
||||
- **Queue management**: Review, edit, retry, or cancel pending uploads
|
||||
- **Changeset tracking**: Automatic grouping and commenting for organized contributions
|
||||
|
||||
### Offline Map Areas
|
||||
- **Download tiles/cameras for any bounding box**; areas cover any region/zoom, and are automatically de-duped and managed.
|
||||
- **Robust area downloads** use the same MapDataProvider for source-of-truth logic, so downloads are always consistent with runtime lookup.
|
||||
- **Permanent world base map** at low zoom always available for core map functionality, even on first-use/offline.
|
||||
### Offline Operations
|
||||
- **Smart area downloads**: Automatically calculate tile counts and storage requirements
|
||||
- **Device caching**: Offline areas include surveillance device data for complete functionality without network
|
||||
- **Global base map**: Permanent worldwide coverage at low zoom levels
|
||||
- **Robust downloads**: Exponential backoff, retry logic, and progress tracking for reliable area downloads
|
||||
|
||||
### Modular, Future-friendly Codebase
|
||||
- **No network fetch code outside the provider and submodules.**
|
||||
- **All legacy/duplicate OSM/Overpass downloaders have been removed or marked for deprecation.**
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install** the app on iOS or Android
|
||||
2. **Enable location** permissions
|
||||
3. **Log into OpenStreetMap**: Choose upload mode and get OAuth2 credentials
|
||||
4. **Add your first device**: Tap the "New Node" button, position the pin, set direction, select a profile, and tap submit
|
||||
5. **Edit or delete devices**: Tap any device marker to view details, then use Edit or Delete buttons
|
||||
|
||||
**New to OpenStreetMap?** Visit [deflock.me](https://deflock.me) for complete setup instructions and community guidelines.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
**Highlights:**
|
||||
- To add a new data source, just drop in a new submodule and route fetch via MapDataProvider.
|
||||
- Any section of the app that needs tiles or camera data calls MapDataProvider with the relevant bounds/zoom/profiles and source preference.
|
||||
- Offline Mode and all core settings are strictly respected at a single data/control point.
|
||||
**See [DEVELOPER.md](DEVELOPER.md)** for comprehensive technical documentation including:
|
||||
- Architecture overview and design decisions
|
||||
- Development setup and build instructions
|
||||
- Code organization and contribution guidelines
|
||||
- Debugging tips and troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (2025+)
|
||||
|
||||
- **COMPLETE:** Core provider logic, settings, robust downloading and modular prefetch/caching.
|
||||
- **IN PROGRESS:** Local/offline tile/camera fetch modules for runtime map viewing and offline area management.
|
||||
- **NEXT:** More map overlays, offline routing, and data visualization.
|
||||
- **SOON:** UX polish for download/error states, multi-layer base maps.
|
||||
|
||||
---
|
||||
|
||||
*See prior README version for detailed setup/build/dependency notes—they remain unchanged!*
|
||||
|
||||
|
||||
### Map View
|
||||
- **Explore the Map:** View OSM raster tiles, live camera overlays, and a visual scale bar and zoom indicator in the lower left.
|
||||
- **Tag Cameras:** Add a camera by dropping a pin, setting direction, and choosing a camera profile. Camera tap/double-tap is smart—double-tap always zooms, single-tap opens camera info.
|
||||
- **Location:** Blue GPS dot shows your current location, always on top of map icons.
|
||||
|
||||
### Camera Profiles
|
||||
- **Flexible, Private Profiles:** Enable/disable, create, edit, or delete camera types in Settings. At least one profile must be enabled at all times.
|
||||
- If the last enabled profile is disabled, the generic profile will be auto-enabled so the app always works.
|
||||
|
||||
### Upload Destinations/Queue
|
||||
- **Full OSM OAuth2 Integration:** Upload to live OSM, OSM Sandbox for testing, or keep your changes private in simulate mode.
|
||||
- **Queue Management:** Settings screen shows a queue of pending uploads—clear or retry them as you wish.
|
||||
|
||||
### Offline Map Areas
|
||||
- **Download Any Region, Any Zoom:** Save the current map area at any zoom for true offline viewing.
|
||||
- **Intelligent Tile Management:** World tiles at zooms 1–4 are permanently available (via a protected offline area). All downloads include accurate tile and storage estimates, and never request duplicate or unnecessary tiles.
|
||||
- **Robust Downloading:** All tile/download logic uses serial fetching and exponential backoff for network failures, minimizing risk of OSM rate-limits and always respecting API etiquette.
|
||||
- **No Duplicates:** Only one world area; can be re-downloaded (refreshed) but never deleted or renamed.
|
||||
- **Camera Cache:** Download areas keep camera points in sync for full offline visibility—except the global area, which never attempts to fetch all world cameras.
|
||||
- **Settings Management:** Cancel, refresh, or remove downloads as needed. Progress, tile count, storage consumption, and cached camera count always displayed.
|
||||
|
||||
### Polished UX & Settings Architecture
|
||||
- **Permanent global base map:** Coverage for the entire world at zooms 1–4, always present.
|
||||
- **Smooth map gestures:** Double-tap to zoom even on markers; pinch zoom; camera popups distinguished from zoom.
|
||||
- **Modular Settings:** All major settings/queue/offline/camera management UI sections are cleanly separated for extensibility and rapid development.
|
||||
- **Order-preserving overlays:** Your location is always drawn on top for easy visibility.
|
||||
- **No more dead ends:** Disabling all profiles is impossible; canceling downloads is clean and instant.
|
||||
|
||||
---
|
||||
|
||||
## OAuth & Build Setup
|
||||
|
||||
**Before uploading to OSM:**
|
||||
- Register OAuth2 applications on both [Production OSM](https://www.openstreetmap.org/oauth2/applications) and [Sandbox OSM](https://master.apis.dev.openstreetmap.org/oauth2/applications).
|
||||
- Copy generated client IDs to `lib/keys.dart` (see template `.example` file).
|
||||
|
||||
### Build Environment Notes
|
||||
- Requires Xcode, Android Studio, and standard Flutter dependencies. See notes at the end of this file for CLI setup details.
|
||||
**Quick setup:**
|
||||
```shell
|
||||
flutter pub get
|
||||
cp lib/keys.dart.example lib/keys.dart
|
||||
# Add OAuth2 client IDs, then: flutter run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **COMPLETE**:
|
||||
- Offline map area download/storage/camera overlay; cancel/retry; fast tile/camera/size estimates; exponential backoff and robust retry logic for network outages or rate-limiting.
|
||||
- Pro-grade map UX (zoom bar, marker tap/double-tap, robust FABs).
|
||||
- Modularized, maintainable codebase using small service/helper files and section-separated UI components.
|
||||
- **SOON**:
|
||||
- "Offline mode" setting: map never hits the network and always provides a fallback tile for every view (no blank maps; graceful offline-first UX).
|
||||
- Resumable/robust interrupted downloads.
|
||||
- Further polish for edge cases (queue, error states).
|
||||
- **LATER**:
|
||||
- Satellite base layers, north-up/satellite-mode.
|
||||
- Offline wayfinding or routing.
|
||||
- Fancier icons and overlays.
|
||||
### 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
|
||||
- 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)
|
||||
- Offline navigation
|
||||
|
||||
### Maybes
|
||||
- Yellow ring for devices missing specific tag details?
|
||||
- "Cache accumulating" offline area?
|
||||
- "Offline areas" as tile provider?
|
||||
- Optional custom icons for camera profiles?
|
||||
- Upgrade device marker design? (considering nullplate's svg)
|
||||
- Custom device providers and OSM/Overpass alternatives?
|
||||
- More map data providers:
|
||||
https://gis.sanramon.ca.gov/arcgis_js_api/sdk/jsapi/esri.basemaps-amd.html#osm
|
||||
https://www.icgc.cat/en/Geoinformation-and-Maps/Base-Map-Service
|
||||
https://github.com/CartoDB/basemap-styles
|
||||
https://forum.inductiveautomation.com/t/perspective-map-theming-internet-tile-server-options/40164
|
||||
https://github.com/roblabs/xyz-raster-sources
|
||||
https://github.com/geopandas/xyzservices/blob/main/provider_sources/xyzservices-providers.json
|
||||
https://medium.com/@go2garret/free-basemap-tiles-for-maplibre-18374fab60cb
|
||||
|
||||
---
|
||||
|
||||
## Build Environment Quick Setup
|
||||
## Contributing & Community
|
||||
|
||||
# Install from GUI:
|
||||
Xcode, Android Studio.
|
||||
Xcode cmdline tools
|
||||
Android cmdline tools + NDK
|
||||
This app is part of the larger **DeFlock** initiative. Join the community:
|
||||
|
||||
# Terminal
|
||||
brew install openjdk@17
|
||||
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk
|
||||
- **Documentation & Guides**: [deflock.me](https://deflock.me)
|
||||
- **Community Discussion**: [deflock.me](https://deflock.me)
|
||||
- **Issues & Feature Requests**: GitHub Issues
|
||||
- **Development**: See developer setup above
|
||||
|
||||
brew install ruby
|
||||
---
|
||||
|
||||
gem install cocoapods
|
||||
## Privacy & Ethics
|
||||
|
||||
sdkmanager --install "ndk;27.0.12077973"
|
||||
This project helps make existing public surveillance infrastructure transparent and searchable. We only document surveillance devices that are already installed and visible in public spaces.
|
||||
|
||||
export PATH="/Users/bob/.gem/ruby/3.4.0/bin:$PATH"
|
||||
export PATH=$HOME/development/flutter/bin:$PATH
|
||||
No user information is ever collected, and no data leaves your device except submissions to OSM and whatever data your tile provider can glean from your requests.
|
||||
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter run
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is open source. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -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 = "com.example.flock_map_app"
|
||||
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"
|
||||
@@ -17,6 +26,7 @@ android {
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
@@ -24,23 +34,38 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// Application ID (package name)
|
||||
applicationId = "com.example.flock_map_app"
|
||||
applicationId = "me.deflock.deflockapp"
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,3 +75,7 @@ flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
<!-- Location permissions for blue‑dot positioning -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<!-- Notification permission for proximity alerts -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:name="${applicationName}"
|
||||
android:label="flock_map_app"
|
||||
android:label="DeFlock"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<!-- Main Flutter activity -->
|
||||
@@ -17,11 +20,10 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
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
|
||||
@@ -44,8 +46,7 @@
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<!-- flockmap://auth -->
|
||||
<data android:scheme="flockmap" android:host="auth"/>
|
||||
<data android:scheme="deflockapp" android:host="auth"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.flock_map_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.deflock.deflockapp
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 39 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: 165 KiB |
|
Before Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 805 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/launch_background" />
|
||||
<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: 4.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 23 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 |
|
Before Width: | Height: | Size: 757 KiB After Width: | Height: | Size: 96 KiB |
27
assets/deflock-logo.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 1150 300" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="1150" height="300" style="fill:none;"/>
|
||||
<g id="Artboard11" serif:id="Artboard1">
|
||||
<g>
|
||||
<g transform="matrix(344.475,0,0,344.475,30.1181,267.042)">
|
||||
<path d="M0.377,-0.658L0.377,-0.655C0.421,-0.629 0.415,-0.593 0.415,-0.547L0.415,-0.415C0.373,-0.452 0.317,-0.473 0.261,-0.473C0.124,-0.473 0.024,-0.364 0.024,-0.229C0.024,-0.08 0.131,0.013 0.277,0.013C0.295,0.013 0.312,0.013 0.329,0.008L0.388,-0.082C0.361,-0.065 0.334,-0.053 0.302,-0.053C0.197,-0.053 0.125,-0.142 0.125,-0.243C0.125,-0.334 0.19,-0.407 0.27,-0.407C0.323,-0.407 0.374,-0.383 0.399,-0.335C0.418,-0.298 0.415,-0.254 0.415,-0.214L0.415,-0L0.544,-0L0.544,-0.003C0.5,-0.027 0.506,-0.064 0.506,-0.11L0.506,-0.674L0.503,-0.674C0.492,-0.658 0.468,-0.658 0.445,-0.658L0.377,-0.658Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,229.914,267.042)">
|
||||
<path d="M0.5,-0.246C0.504,-0.375 0.411,-0.473 0.275,-0.473C0.126,-0.473 0.025,-0.372 0.025,-0.233C0.025,-0.094 0.142,0.013 0.312,0.013C0.359,0.013 0.407,0.006 0.45,-0.012L0.5,-0.106L0.497,-0.106C0.451,-0.07 0.393,-0.053 0.333,-0.053C0.22,-0.053 0.135,-0.124 0.133,-0.246L0.5,-0.246ZM0.137,-0.304C0.149,-0.367 0.199,-0.407 0.266,-0.407C0.338,-0.407 0.384,-0.374 0.395,-0.304L0.137,-0.304Z" style="fill:rgb(0,128,188);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,409.04,267.042)">
|
||||
<path d="M0.023,-0.394L0.071,-0.394L0.071,-0.11C0.071,-0.064 0.077,-0.027 0.033,-0.003L0.033,-0L0.2,-0L0.2,-0.003C0.156,-0.028 0.162,-0.064 0.162,-0.11L0.162,-0.394L0.264,-0.394C0.276,-0.394 0.291,-0.391 0.295,-0.38L0.298,-0.38L0.298,-0.46L0.162,-0.46C0.162,-0.56 0.157,-0.608 0.249,-0.608C0.278,-0.608 0.308,-0.603 0.333,-0.59L0.333,-0.11C0.333,-0.064 0.339,-0.027 0.295,-0.003L0.295,-0L0.462,-0L0.462,-0.003C0.418,-0.027 0.424,-0.064 0.424,-0.11L0.424,-0.674L0.421,-0.674C0.411,-0.663 0.394,-0.656 0.378,-0.656C0.347,-0.656 0.319,-0.674 0.266,-0.674C0.206,-0.674 0.148,-0.654 0.107,-0.608C0.068,-0.564 0.071,-0.525 0.071,-0.46L0.023,-0.394Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,581.278,267.042)">
|
||||
<path d="M0.276,0.013C0.417,0.013 0.537,-0.091 0.537,-0.235C0.537,-0.303 0.506,-0.369 0.455,-0.414C0.407,-0.456 0.352,-0.473 0.288,-0.473C0.144,-0.473 0.023,-0.376 0.023,-0.226C0.023,-0.084 0.139,0.013 0.276,0.013ZM0.281,-0.053C0.179,-0.053 0.124,-0.152 0.124,-0.244C0.124,-0.334 0.184,-0.407 0.277,-0.407C0.384,-0.407 0.436,-0.311 0.436,-0.214C0.436,-0.124 0.373,-0.053 0.281,-0.053Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,774.184,267.042)">
|
||||
<path d="M0.415,-0.461C0.38,-0.469 0.343,-0.473 0.307,-0.473C0.156,-0.473 0.022,-0.39 0.022,-0.218C0.022,-0.088 0.142,0.013 0.296,0.013C0.34,0.013 0.386,0.009 0.428,-0.007L0.48,-0.102L0.477,-0.102C0.438,-0.073 0.382,-0.053 0.331,-0.053C0.22,-0.053 0.123,-0.129 0.123,-0.244C0.123,-0.339 0.193,-0.407 0.29,-0.407C0.335,-0.407 0.383,-0.391 0.412,-0.358L0.415,-0.358L0.415,-0.461Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(344.475,0,0,344.475,932.642,267.042)">
|
||||
<path d="M0.029,-0.658L0.029,-0.655C0.072,-0.63 0.066,-0.593 0.066,-0.547L0.066,-0.111C0.066,-0.065 0.072,-0.028 0.029,-0.003L0.029,-0L0.196,-0L0.196,-0.003C0.151,-0.028 0.157,-0.065 0.157,-0.111L0.157,-0.674L0.154,-0.674C0.141,-0.659 0.117,-0.658 0.095,-0.658L0.029,-0.658ZM0.324,-0.056C0.343,-0.029 0.368,-0 0.426,-0L0.504,-0C0.459,-0.028 0.429,-0.071 0.398,-0.112L0.276,-0.267L0.443,-0.46L0.291,-0.46L0.291,-0.457C0.301,-0.451 0.31,-0.442 0.31,-0.428C0.31,-0.403 0.274,-0.365 0.259,-0.348L0.176,-0.257L0.324,-0.056Z" style="fill:rgb(86,86,86);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,7 +0,0 @@
|
||||
Flock Map App
|
||||
|
||||
Built with Flutter.
|
||||
|
||||
Offline areas, privacy-respecting, designed for OpenStreetMap camera tagging.
|
||||
|
||||
This text is loaded from assets/info.txt.
|
||||
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
|
||||
110
do_builds.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Default options
|
||||
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
|
||||
--ios)
|
||||
BUILD_ANDROID=false
|
||||
;;
|
||||
--android)
|
||||
BUILD_IOS=false
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--ios | --android]"
|
||||
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
|
||||
|
||||
# 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 $DART_DEFINE_ARGS || exit 1
|
||||
|
||||
echo "Converting .app to .ipa..."
|
||||
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
|
||||
|
||||
echo "Moving iOS files..."
|
||||
mv Runner.ipa "../deflock_v${appver}.ipa" || exit 1
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$BUILD_ANDROID" = true ]; then
|
||||
echo "Building Android..."
|
||||
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
|
||||
echo
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
|
||||
@@ -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,15 +470,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";
|
||||
@@ -494,7 +498,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.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -512,7 +516,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.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -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,15 +656,19 @@
|
||||
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 = (
|
||||
"$(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_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: 805 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 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: 39 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 435 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.
|
||||
@@ -16,13 +16,19 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.125" green="0.125" blue="0.125" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
@@ -32,6 +38,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchImage" width="512" height="512"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Flock Map App</string>
|
||||
<string>DeFlock</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>flock_map_app</string>
|
||||
<string>deflockapp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -55,7 +55,7 @@
|
||||
<string>None</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>flockmap</string>
|
||||
<string>deflockapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
@@ -1,473 +1,509 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models/camera_profile.dart';
|
||||
import 'models/node_profile.dart';
|
||||
import 'models/operator_profile.dart';
|
||||
import 'models/osm_node.dart';
|
||||
import 'models/pending_upload.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/uploader.dart';
|
||||
import 'services/profile_service.dart';
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
// Enum for upload mode (Production, OSM Sandbox, Simulate)
|
||||
enum UploadMode { production, sandbox, simulate }
|
||||
|
||||
// ------------------ AddCameraSession ------------------
|
||||
class AddCameraSession {
|
||||
AddCameraSession({required this.profile, this.directionDegrees = 0});
|
||||
CameraProfile profile;
|
||||
double directionDegrees;
|
||||
LatLng? target;
|
||||
}
|
||||
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;
|
||||
|
||||
// ------------------ AppState ------------------
|
||||
class AppState extends ChangeNotifier {
|
||||
static late AppState instance;
|
||||
|
||||
// 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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ------------------- Offline Mode -------------------
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
bool _offlineMode = false;
|
||||
bool get offlineMode => _offlineMode;
|
||||
Future<void> setOfflineMode(bool enabled) async {
|
||||
final wasOffline = _offlineMode;
|
||||
_offlineMode = enabled;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_offlineModePrefsKey, enabled);
|
||||
if (wasOffline && !enabled) {
|
||||
// Transitioning from offline to online: clear tile cache!
|
||||
TileProviderWithCache.clearCache();
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _auth = AuthService();
|
||||
String? _username;
|
||||
|
||||
bool _isInitialized = false;
|
||||
// Getters that delegate to individual state modules
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
// Auth state
|
||||
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;
|
||||
bool isEnabled(NodeProfile p) => _profileState.isEnabled(p);
|
||||
|
||||
// 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;
|
||||
|
||||
// Settings state
|
||||
bool get offlineMode => _settingsState.offlineMode;
|
||||
int get maxCameras => _settingsState.maxCameras;
|
||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
||||
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
|
||||
|
||||
// Tile provider state
|
||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||
TileType? get selectedTileType => _settingsState.selectedTileType;
|
||||
TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider;
|
||||
|
||||
|
||||
final List<CameraProfile> _profiles = [];
|
||||
final Set<CameraProfile> _enabled = {};
|
||||
static const String _enabledPrefsKey = 'enabled_profiles';
|
||||
static const String _maxCamerasPrefsKey = 'max_cameras';
|
||||
|
||||
// Upload queue state
|
||||
int get pendingCount => _uploadQueueState.pendingCount;
|
||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||
|
||||
// Maximum number of cameras fetched/drawn
|
||||
int _maxCameras = 250;
|
||||
int get maxCameras => _maxCameras;
|
||||
set maxCameras(int n) {
|
||||
if (n < 10) n = 10; // minimum
|
||||
_maxCameras = n;
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setInt(_maxCamerasPrefsKey, n);
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Upload mode: production, sandbox, or simulate (in-memory, no uploads)
|
||||
UploadMode _uploadMode = UploadMode.production;
|
||||
static const String _uploadModePrefsKey = 'upload_mode';
|
||||
UploadMode get uploadMode => _uploadMode;
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
_uploadMode = mode;
|
||||
// Update AuthService to match new mode
|
||||
_auth.setUploadMode(mode);
|
||||
// Refresh user display for active mode, validating token
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Switching mode, token exists; validating...');
|
||||
final isValid = await validateToken();
|
||||
if (isValid) {
|
||||
print("AppState: Switching mode; fetching username for $mode...");
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Switched mode, now logged in as $_username");
|
||||
} else {
|
||||
print('AppState: Switched mode but failed to retrieve username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: Switching mode, token invalid—auto-logout.');
|
||||
await logout(); // This clears _username also.
|
||||
}
|
||||
} else {
|
||||
_username = null;
|
||||
print("AppState: Mode change: not logged in in $mode");
|
||||
}
|
||||
} catch (e) {
|
||||
_username = null;
|
||||
print("AppState: Mode change user restoration error: $e");
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_uploadModePrefsKey, mode.index);
|
||||
print("AppState: Upload mode set to $mode");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// For legacy bool test mode
|
||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||
|
||||
AddCameraSession? _session;
|
||||
AddCameraSession? get session => _session;
|
||||
final List<PendingUpload> _queue = [];
|
||||
Timer? _uploadTimer;
|
||||
|
||||
bool get isLoggedIn => _username != null;
|
||||
String get username => _username ?? '';
|
||||
|
||||
// ---------- Init ----------
|
||||
Future<void> _init() async {
|
||||
// Initialize profiles: built-in + custom
|
||||
_profiles.add(CameraProfile.alpr());
|
||||
_profiles.addAll(await ProfileService().load());
|
||||
|
||||
// Load enabled profile IDs and upload/test mode from prefs
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final enabledIds = prefs.getStringList(_enabledPrefsKey);
|
||||
if (enabledIds != null && enabledIds.isNotEmpty) {
|
||||
// Restore enabled profiles by id
|
||||
_enabled.addAll(_profiles.where((p) => enabledIds.contains(p.id)));
|
||||
} else {
|
||||
// By default, all are enabled
|
||||
_enabled.addAll(_profiles);
|
||||
}
|
||||
// Upload mode loading (including migration from old test_mode bool)
|
||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||
if (idx >= 0 && idx < UploadMode.values.length) {
|
||||
_uploadMode = UploadMode.values[idx];
|
||||
}
|
||||
} else if (prefs.containsKey(_legacyTestModePrefsKey)) {
|
||||
// migrate legacy test_mode (true->simulate, false->prod)
|
||||
final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false;
|
||||
_uploadMode = legacy ? UploadMode.simulate : UploadMode.production;
|
||||
await prefs.remove(_legacyTestModePrefsKey);
|
||||
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
|
||||
}
|
||||
// Max cameras
|
||||
if (prefs.containsKey(_maxCamerasPrefsKey)) {
|
||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||
}
|
||||
// Offline mode loading
|
||||
if (prefs.containsKey(_offlineModePrefsKey)) {
|
||||
_offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false;
|
||||
}
|
||||
// Ensure AuthService follows loaded mode
|
||||
_auth.setUploadMode(_uploadMode);
|
||||
print('AppState: AuthService mode now updated to $_uploadMode');
|
||||
|
||||
await _loadQueue();
|
||||
// Initialize all state modules
|
||||
await _settingsState.init();
|
||||
|
||||
// Check if we're already logged in and get username
|
||||
try {
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: User appears to be logged in, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Successfully retrieved username: $_username");
|
||||
} else {
|
||||
print('AppState: Failed to retrieve username despite being logged in');
|
||||
}
|
||||
} else {
|
||||
print('AppState: User is not logged in');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Error during auth initialization: $e");
|
||||
}
|
||||
// Attempt to fetch missing tile type preview tiles (fails silently)
|
||||
_fetchMissingTilePreviews();
|
||||
|
||||
await _operatorProfileState.init();
|
||||
await _profileState.init();
|
||||
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
|
||||
await _uploadQueueState.init();
|
||||
await _authState.init(_settingsState.uploadMode);
|
||||
|
||||
// Initialize OfflineAreaService to ensure offline areas are loaded
|
||||
await OfflineAreaService().ensureInitialized();
|
||||
|
||||
// Start uploader if conditions are met
|
||||
_startUploader();
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
// ---------- Auth Methods ----------
|
||||
Future<void> login() async {
|
||||
try {
|
||||
print('AppState: Starting login process...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Login successful for user: $_username");
|
||||
} else {
|
||||
print('AppState: Login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.login();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _auth.logout();
|
||||
_username = null;
|
||||
notifyListeners();
|
||||
await _authState.logout();
|
||||
}
|
||||
|
||||
// Add method to refresh auth state
|
||||
Future<void> refreshAuthState() async {
|
||||
try {
|
||||
print('AppState: Refreshing auth state...');
|
||||
if (await _auth.isLoggedIn()) {
|
||||
print('AppState: Token exists, fetching username...');
|
||||
_username = await _auth.login();
|
||||
if (_username != null) {
|
||||
print("AppState: Auth refresh successful: $_username");
|
||||
} else {
|
||||
print('AppState: Auth refresh failed - no username');
|
||||
}
|
||||
} else {
|
||||
print('AppState: No valid token found');
|
||||
_username = null;
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Auth refresh error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.refreshAuthState();
|
||||
}
|
||||
|
||||
// Force a completely fresh login (clears stored tokens)
|
||||
Future<void> forceLogin() async {
|
||||
try {
|
||||
print('AppState: Starting forced fresh login...');
|
||||
_username = await _auth.forceLogin();
|
||||
if (_username != null) {
|
||||
print("AppState: Forced login successful: $_username");
|
||||
} else {
|
||||
print('AppState: Forced login failed - no username returned');
|
||||
}
|
||||
} catch (e) {
|
||||
print("AppState: Forced login error: $e");
|
||||
_username = null;
|
||||
}
|
||||
notifyListeners();
|
||||
await _authState.forceLogin();
|
||||
}
|
||||
|
||||
// Validate current token/credentials
|
||||
Future<bool> validateToken() async {
|
||||
try {
|
||||
return await _auth.isLoggedIn();
|
||||
} catch (e) {
|
||||
print("AppState: Token validation error: $e");
|
||||
return false;
|
||||
}
|
||||
return await _authState.validateToken();
|
||||
}
|
||||
|
||||
// ---------- Profiles ----------
|
||||
List<CameraProfile> get profiles => List.unmodifiable(_profiles);
|
||||
bool isEnabled(CameraProfile p) => _enabled.contains(p);
|
||||
List<CameraProfile> get enabledProfiles =>
|
||||
_profiles.where(isEnabled).toList(growable: false);
|
||||
void toggleProfile(CameraProfile p, bool e) {
|
||||
if (e) {
|
||||
_enabled.add(p);
|
||||
} else {
|
||||
_enabled.remove(p);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
notifyListeners();
|
||||
// ---------- Profile Methods ----------
|
||||
void toggleProfile(NodeProfile p, bool e) {
|
||||
_profileState.toggleProfile(p, e);
|
||||
}
|
||||
|
||||
void addOrUpdateProfile(CameraProfile p) {
|
||||
final idx = _profiles.indexWhere((x) => x.id == p.id);
|
||||
if (idx >= 0) {
|
||||
_profiles[idx] = p;
|
||||
} else {
|
||||
_profiles.add(p);
|
||||
_enabled.add(p);
|
||||
_saveEnabledProfiles();
|
||||
}
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
void addOrUpdateProfile(NodeProfile p) {
|
||||
_profileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
void deleteProfile(CameraProfile p) {
|
||||
if (p.builtin) return;
|
||||
_enabled.remove(p);
|
||||
_profiles.removeWhere((x) => x.id == p.id);
|
||||
// Safety: Always have at least one enabled profile
|
||||
if (_enabled.isEmpty) {
|
||||
final builtIn = _profiles.firstWhere((profile) => profile.builtin, orElse: () => _profiles.first);
|
||||
_enabled.add(builtIn);
|
||||
}
|
||||
_saveEnabledProfiles();
|
||||
ProfileService().save(_profiles);
|
||||
notifyListeners();
|
||||
void deleteProfile(NodeProfile p) {
|
||||
_profileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// Save enabled profile IDs to disk
|
||||
Future<void> _saveEnabledProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_enabledPrefsKey,
|
||||
_enabled.map((p) => p.id).toList(),
|
||||
);
|
||||
// ---------- Operator Profile Methods ----------
|
||||
void addOrUpdateOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.addOrUpdateProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Add‑camera session ----------
|
||||
void deleteOperatorProfile(OperatorProfile p) {
|
||||
_operatorProfileState.deleteProfile(p);
|
||||
}
|
||||
|
||||
// ---------- Session Methods ----------
|
||||
void startAddSession() {
|
||||
_session = AddCameraSession(profile: enabledProfiles.first);
|
||||
notifyListeners();
|
||||
_sessionState.startAddSession(enabledProfiles);
|
||||
}
|
||||
|
||||
void startEditSession(OsmNode node) {
|
||||
_sessionState.startEditSession(node, enabledProfiles);
|
||||
}
|
||||
|
||||
void updateSession({
|
||||
double? directionDeg,
|
||||
CameraProfile? profile,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
if (_session == null) return;
|
||||
_sessionState.updateSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
bool dirty = false;
|
||||
if (directionDeg != null && directionDeg != _session!.directionDegrees) {
|
||||
_session!.directionDegrees = directionDeg;
|
||||
dirty = true;
|
||||
}
|
||||
if (profile != null && profile != _session!.profile) {
|
||||
_session!.profile = profile;
|
||||
dirty = true;
|
||||
}
|
||||
if (target != null) {
|
||||
_session!.target = target;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) notifyListeners(); // <-- slider & map update
|
||||
void updateEditSession({
|
||||
double? directionDeg,
|
||||
NodeProfile? profile,
|
||||
OperatorProfile? operatorProfile,
|
||||
LatLng? target,
|
||||
}) {
|
||||
_sessionState.updateEditSession(
|
||||
directionDeg: directionDeg,
|
||||
profile: profile,
|
||||
operatorProfile: operatorProfile,
|
||||
target: target,
|
||||
);
|
||||
}
|
||||
|
||||
void cancelSession() {
|
||||
_session = null;
|
||||
notifyListeners();
|
||||
_sessionState.cancelSession();
|
||||
}
|
||||
|
||||
void cancelEditSession() {
|
||||
_sessionState.cancelEditSession();
|
||||
}
|
||||
|
||||
void commitSession() {
|
||||
if (_session?.target == null) return;
|
||||
_queue.add(
|
||||
PendingUpload(
|
||||
coord: _session!.target!,
|
||||
direction: _session!.directionDegrees,
|
||||
profile: _session!.profile,
|
||||
),
|
||||
);
|
||||
_saveQueue();
|
||||
_session = null;
|
||||
|
||||
// Restart uploader when new items are added
|
||||
final session = _sessionState.commitSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
void commitEditSession() {
|
||||
final session = _sessionState.commitEditSession();
|
||||
if (session != null) {
|
||||
_uploadQueueState.addFromEditSession(session, uploadMode: uploadMode);
|
||||
_startUploader();
|
||||
}
|
||||
}
|
||||
|
||||
void deleteNode(OsmNode node) {
|
||||
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||
_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();
|
||||
|
||||
notifyListeners();
|
||||
// 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
|
||||
}
|
||||
|
||||
// ---------- Queue persistence ----------
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _queue.map((e) => e.toJson()).toList();
|
||||
await prefs.setString('queue', jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = prefs.getString('queue');
|
||||
if (jsonStr == null) return;
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map((e) => PendingUpload.fromJson(e)));
|
||||
}
|
||||
|
||||
// ---------- Uploader ----------
|
||||
void _startUploader() {
|
||||
_uploadTimer?.cancel();
|
||||
|
||||
// No uploads without auth or queue, or if offline mode is enabled.
|
||||
if (_queue.isEmpty || _offlineMode) return;
|
||||
|
||||
_uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async {
|
||||
if (_queue.isEmpty || _offlineMode) {
|
||||
_uploadTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first queue item that is NOT in error state and act on that
|
||||
final item = _queue.where((pu) => !pu.error).cast<PendingUpload?>().firstOrNull;
|
||||
if (item == null) return;
|
||||
|
||||
// Retrieve access after every tick (accounts for re-login)
|
||||
final access = await _auth.getAccessToken();
|
||||
if (access == null) return; // not logged in
|
||||
|
||||
bool ok;
|
||||
if (_uploadMode == UploadMode.simulate) {
|
||||
// Simulate successful upload without calling real API
|
||||
print("AppState: UploadMode.simulate - simulating upload for ${item.coord}");
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
|
||||
ok = true;
|
||||
print('AppState: Simulated upload successful');
|
||||
} else {
|
||||
// Real upload -- pass uploadMode so uploader can switch between prod and sandbox
|
||||
final up = Uploader(access, () {
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}, uploadMode: _uploadMode);
|
||||
ok = await up.upload(item);
|
||||
}
|
||||
|
||||
if (ok && _uploadMode == UploadMode.simulate) {
|
||||
// Remove manually for simulate mode
|
||||
_queue.remove(item);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
}
|
||||
if (!ok) {
|
||||
item.attempts++;
|
||||
if (item.attempts >= 3) {
|
||||
// Mark as error and stop the uploader. User can manually retry.
|
||||
item.error = true;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadTimer?.cancel();
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 20));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Exposed getters ----------
|
||||
int get pendingCount => _queue.length;
|
||||
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
|
||||
|
||||
// ---------- Queue management ----------
|
||||
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);
|
||||
if (!enabled) {
|
||||
_startUploader(); // Resume upload queue processing as we leave offline mode
|
||||
} else {
|
||||
_uploadQueueState.stopUploader(); // Stop uploader in offline mode
|
||||
// Cancel any active area downloads
|
||||
await OfflineAreaService().cancelActiveDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
set maxCameras(int n) {
|
||||
_settingsState.maxCameras = n;
|
||||
}
|
||||
|
||||
Future<void> setUploadMode(UploadMode mode) async {
|
||||
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
|
||||
NodeCache.instance.clear();
|
||||
CameraProviderWithCache.instance.notifyListeners();
|
||||
debugPrint('[AppState] Cleared node cache due to upload mode change');
|
||||
|
||||
await _settingsState.setUploadMode(mode);
|
||||
await _authState.onUploadModeChanged(mode);
|
||||
_startUploader(); // Restart uploader with new mode
|
||||
}
|
||||
|
||||
/// Select a tile type by ID
|
||||
Future<void> setSelectedTileType(String tileTypeId) async {
|
||||
await _settingsState.setSelectedTileType(tileTypeId);
|
||||
}
|
||||
|
||||
/// Add or update a tile provider
|
||||
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
|
||||
await _settingsState.addOrUpdateTileProvider(provider);
|
||||
}
|
||||
|
||||
/// Delete a tile provider
|
||||
Future<void> deleteTileProvider(String providerId) async {
|
||||
await _settingsState.deleteTileProvider(providerId);
|
||||
}
|
||||
|
||||
/// Set follow-me mode
|
||||
Future<void> setFollowMeMode(FollowMeMode mode) async {
|
||||
await _settingsState.setFollowMeMode(mode);
|
||||
}
|
||||
|
||||
/// Set proximity alerts enabled/disabled
|
||||
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||
await _settingsState.setProximityAlertsEnabled(enabled);
|
||||
}
|
||||
|
||||
/// Set proximity alert distance
|
||||
Future<void> setProximityAlertDistance(int distance) async {
|
||||
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() {
|
||||
print("AppState: Clearing upload queue (${_queue.length} items)");
|
||||
_queue.clear();
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.clearQueue();
|
||||
}
|
||||
|
||||
void removeFromQueue(PendingUpload upload) {
|
||||
print("AppState: Removing upload from queue: ${upload.coord}");
|
||||
_queue.remove(upload);
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.removeFromQueue(upload);
|
||||
}
|
||||
|
||||
// Retry a failed upload (clear error and attempts, then try uploading again)
|
||||
void retryUpload(PendingUpload upload) {
|
||||
upload.error = false;
|
||||
upload.attempts = 0;
|
||||
_saveQueue();
|
||||
notifyListeners();
|
||||
_uploadQueueState.retryUpload(upload);
|
||||
_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() {
|
||||
// Run asynchronously without awaiting to avoid blocking app startup
|
||||
TilePreviewService.fetchMissingPreviews(_settingsState).catchError((error) {
|
||||
// Silently ignore errors - this is best effort
|
||||
debugPrint('AppState: Tile preview fetching failed silently: $error');
|
||||
});
|
||||
}
|
||||
|
||||
void _startUploader() {
|
||||
_uploadQueueState.startUploader(
|
||||
offlineMode: offlineMode,
|
||||
uploadMode: uploadMode,
|
||||
getAccessToken: _authState.getAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@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();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,79 @@
|
||||
// lib/dev_config.dart
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
const int kWorldMinZoom = 1;
|
||||
const int kWorldMaxZoom = 5;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Example: Default tile storage estimate (KB per tile), for size estimates
|
||||
const double kTileEstimateKb = 25.0;
|
||||
/// Developer/build-time configuration for global/non-user-tunable constants.
|
||||
|
||||
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
|
||||
const double kFallbackTileEstimateKb = 25.0;
|
||||
|
||||
// Preview tile coordinates for tile provider previews and size estimates
|
||||
const int kPreviewTileZoom = 18;
|
||||
const int kPreviewTileY = 101300;
|
||||
const int kPreviewTileX = 41904;
|
||||
|
||||
// Direction cone for map view
|
||||
const double kDirectionConeHalfAngle = 20.0; // degrees
|
||||
const double kDirectionConeBaseLength = 0.0012; // multiplier
|
||||
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;
|
||||
|
||||
// Marker/camera interaction
|
||||
const int kCameraMinZoomLevel = 10; // Minimum zoom to show cameras or warning
|
||||
// Bottom button bar positioning
|
||||
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
|
||||
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
|
||||
|
||||
// Map overlay spacing relative to button bar top
|
||||
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
|
||||
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
|
||||
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
|
||||
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
|
||||
|
||||
// Helper to calculate bottom position relative to button bar
|
||||
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
|
||||
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Client name for OSM uploads ("created_by" tag)
|
||||
const String kClientName = 'DeFlock';
|
||||
// Note: Version is now dynamically retrieved from VersionService
|
||||
|
||||
// Suspected locations CSV URL
|
||||
const String kSuspectedLocationsCsvUrl = 'https://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 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);
|
||||
|
||||
// Follow-me mode smooth transitions
|
||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
||||
|
||||
// Proximity alerts configuration
|
||||
const int kProximityAlertDefaultDistance = 200; // meters
|
||||
const int kProximityAlertMinDistance = 50; // meters
|
||||
const int kProximityAlertMaxDistance = 1000; // meters
|
||||
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
|
||||
|
||||
// Tile/OSM fetch retry parameters (for tunable backoff)
|
||||
const int kTileFetchMaxAttempts = 3;
|
||||
const int kTileFetchInitialDelayMs = 4000;
|
||||
@@ -26,3 +85,30 @@ const int kTileFetchJitter3Ms = 5000;
|
||||
|
||||
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
|
||||
const int kMaxUserDownloadZoomSpan = 7;
|
||||
|
||||
// Download area limits and constants
|
||||
const int kMaxReasonableTileCount = 20000;
|
||||
const int kAbsoluteMaxTileCount = 50000;
|
||||
const int kAbsoluteMaxZoom = 23;
|
||||
|
||||
// Node icon configuration
|
||||
const double kNodeIconDiameter = 18.0;
|
||||
const double _kNodeRingThicknessBase = 2.5;
|
||||
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
|
||||
const 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,6 +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 = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; // example - replace with real
|
||||
const String kOsmSandboxClientId = 'x26twxRKTZwf1a4Ha1a-wkXncBzqnJv8JwtacJope9Q'; // 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';
|
||||
62
lib/localizations/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# DeFlock Localizations
|
||||
|
||||
This directory contains translation files for DeFlock. Each language is a simple JSON file.
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
Want to add support for your language? It's simple:
|
||||
|
||||
1. **Copy the English file**: `cp en.json your_language_code.json`
|
||||
- Use 2-letter language codes: `es` (Spanish), `fr` (French), `it` (Italian), etc.
|
||||
|
||||
2. **Edit your new file**:
|
||||
```json
|
||||
{
|
||||
"language": {
|
||||
"name": "Your Language Name" ← Change this to your language in your language
|
||||
},
|
||||
"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",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Submit a PR** with your JSON file. Done!
|
||||
|
||||
The new language will automatically appear in Settings → Language.
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **Only translate the values** (text after the `:`), never the keys
|
||||
- **Keep `{}` placeholders** if you see them - they get replaced with numbers/text
|
||||
- **Don't translate "DeFlock"** - it's the app name
|
||||
- **Use your language's name for itself** - "Français" not "French", "Español" not "Spanish"
|
||||
|
||||
## Current Languages
|
||||
|
||||
- `en.json` - English
|
||||
- `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 only need to update:
|
||||
1. **`lib/localizations/xx.json`** - All UI translations including about content
|
||||
|
||||
## That's It!
|
||||
|
||||
No configuration files, no build steps, no complex setup. Add your files and it works.
|
||||
380
lib/localizations/de.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Deutsch"
|
||||
},
|
||||
"app": {
|
||||
"title": "DeFlock"
|
||||
},
|
||||
"actions": {
|
||||
"tagNode": "Neuer Knoten",
|
||||
"download": "Herunterladen",
|
||||
"settings": "Einstellungen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen",
|
||||
"submit": "Senden",
|
||||
"saveEdit": "Bearbeitung Speichern",
|
||||
"clear": "Löschen"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Verfolgung aktivieren (Norden oben)",
|
||||
"northUp": "Verfolgung aktivieren (Rotation)",
|
||||
"rotating": "Verfolgung deaktivieren"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen (Standard: 250).",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
"offlineMode": "Offline-Modus",
|
||||
"offlineModeSubtitle": "Alle Netzwerkanfragen außer für lokale/Offline-Bereiche deaktivieren.",
|
||||
"offlineModeWarningTitle": "Aktive Downloads",
|
||||
"offlineModeWarningMessage": "Die Aktivierung des Offline-Modus bricht alle aktiven Bereichsdownloads ab. Möchten Sie fortfahren?",
|
||||
"enableOfflineMode": "Offline-Modus Aktivieren",
|
||||
"profiles": "Profile",
|
||||
"profilesSubtitle": "Knoten- und Betreiberprofile verwalten",
|
||||
"offlineSettings": "Offline-Einstellungen",
|
||||
"offlineSettingsSubtitle": "Offline-Modus und heruntergeladene Bereiche verwalten",
|
||||
"advancedSettings": "Erweiterte Einstellungen",
|
||||
"advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen",
|
||||
"proximityAlerts": "Näherungswarnungen",
|
||||
"networkStatusIndicator": "Netzwerkstatus-Anzeige"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
|
||||
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
|
||||
"notificationsEnabled": "✓ Benachrichtigungen aktiviert",
|
||||
"notificationsDisabled": "⚠ Benachrichtigungen deaktiviert",
|
||||
"permissionRequired": "Benachrichtigungsberechtigung erforderlich",
|
||||
"permissionExplanation": "Push-Benachrichtigungen sind deaktiviert. Sie sehen nur In-App-Warnungen und werden nicht benachrichtigt, wenn die App im Hintergrund läuft.",
|
||||
"enableNotifications": "Benachrichtigungen Aktivieren",
|
||||
"checkingPermissions": "Berechtigungen prüfen...",
|
||||
"alertDistance": "Warnentfernung: ",
|
||||
"meters": "Meter",
|
||||
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
"tagSheetTitle": "Gerät-Tags",
|
||||
"queuedForUpload": "Knoten zum Upload eingereiht",
|
||||
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
|
||||
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
|
||||
"confirmDeleteTitle": "Knoten löschen",
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Knoten #{} Bearbeiten",
|
||||
"profile": "Profil",
|
||||
"direction": "Richtung {}°",
|
||||
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
|
||||
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
|
||||
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
|
||||
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
|
||||
"refineTags": "Tags Verfeinern",
|
||||
"refineTagsWithProfile": "Tags Verfeinern ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Kartenbereich Herunterladen",
|
||||
"maxZoomLevel": "Max. Zoom-Level",
|
||||
"storageEstimate": "Speicher-Schätzung:",
|
||||
"tilesAndSize": "{} Kacheln, {} MB",
|
||||
"minZoom": "Min. Zoom:",
|
||||
"maxRecommendedZoom": "Max. empfohlenes Zoom: Z{}",
|
||||
"withinTileLimit": "Innerhalb {} Kachel-Limit",
|
||||
"exceedsTileLimit": "Aktuelle Auswahl überschreitet {} Kachel-Limit",
|
||||
"offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.",
|
||||
"downloadStarted": "Download gestartet! Lade Kacheln und Knoten...",
|
||||
"downloadFailed": "Download konnte nicht gestartet werden: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload-Ziel",
|
||||
"subtitle": "Wählen Sie, wohin Kameras hochgeladen werden",
|
||||
"production": "Produktion",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulieren",
|
||||
"productionDescription": "Hochladen in die Live-OSM-Datenbank (für alle Benutzer sichtbar)",
|
||||
"sandboxDescription": "Uploads gehen an die OSM Sandbox (sicher zum Testen, wird regelmäßig zurückgesetzt).",
|
||||
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Angemeldet als {}",
|
||||
"loginToOSM": "Bei OpenStreetMap anmelden",
|
||||
"tapToLogout": "Zum Abmelden antippen",
|
||||
"requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen",
|
||||
"loggedOut": "Abgemeldet",
|
||||
"testConnection": "Verbindung Testen",
|
||||
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
|
||||
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
|
||||
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Ausstehende Uploads: {}",
|
||||
"simulateModeEnabled": "Simulationsmodus aktiviert – Uploads simuliert",
|
||||
"sandboxMode": "Sandbox-Modus – Uploads gehen an OSM Sandbox",
|
||||
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",
|
||||
"clearUploadQueue": "Upload-Warteschlange Löschen",
|
||||
"removeAllPending": "Alle {} ausstehenden Uploads entfernen",
|
||||
"clearQueueTitle": "Warteschlange Löschen",
|
||||
"clearQueueConfirm": "Alle {} ausstehenden Uploads entfernen?",
|
||||
"queueCleared": "Warteschlange geleert",
|
||||
"uploadQueueTitle": "Upload-Warteschlange ({} Elemente)",
|
||||
"queueIsEmpty": "Warteschlange ist leer",
|
||||
"cameraWithIndex": "Kamera {}",
|
||||
"error": " (Fehler)",
|
||||
"completing": " (Wird abgeschlossen...)",
|
||||
"destination": "Ziel: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Richtung: {}°",
|
||||
"attempts": "Versuche: {}",
|
||||
"uploadFailedRetry": "Upload fehlgeschlagen. Zum Wiederholen antippen.",
|
||||
"retryUpload": "Upload wiederholen",
|
||||
"clearAll": "Alle Löschen"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Kachel-Anbieter",
|
||||
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
|
||||
"tileTypesCount": "{} Kachel-Typen",
|
||||
"apiKeyConfigured": "API-Schlüssel konfiguriert",
|
||||
"needsApiKey": "Benötigt API-Schlüssel",
|
||||
"editProvider": "Anbieter Bearbeiten",
|
||||
"addProvider": "Anbieter Hinzufügen",
|
||||
"deleteProvider": "Anbieter Löschen",
|
||||
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"providerName": "Anbieter-Name",
|
||||
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
|
||||
"providerNameRequired": "Anbieter-Name ist erforderlich",
|
||||
"apiKey": "API-Schlüssel (Optional)",
|
||||
"apiKeyHint": "API-Schlüssel eingeben, falls von Kachel-Typen benötigt",
|
||||
"tileTypes": "Kachel-Typen",
|
||||
"addType": "Typ Hinzufügen",
|
||||
"noTileTypesConfigured": "Keine Kachel-Typen konfiguriert",
|
||||
"atLeastOneTileTypeRequired": "Mindestens ein Kachel-Typ ist erforderlich",
|
||||
"manageTileProviders": "Anbieter Verwalten"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Kachel-Typ Bearbeiten",
|
||||
"addTileType": "Kachel-Typ Hinzufügen",
|
||||
"name": "Name",
|
||||
"nameHint": "z.B. Satellit",
|
||||
"nameRequired": "Name ist erforderlich",
|
||||
"urlTemplate": "URL-Vorlage",
|
||||
"urlTemplateHint": "https://beispiel.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL-Vorlage ist erforderlich",
|
||||
"urlTemplatePlaceholders": "URL muss {z}, {x} und {y} Platzhalter enthalten",
|
||||
"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: {}",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Knoten-Profile",
|
||||
"newProfile": "Neues Profil",
|
||||
"builtIn": "Eingebaut",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"view": "Anzeigen",
|
||||
"deleteProfile": "Profil Löschen",
|
||||
"deleteProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"profileDeleted": "Profil gelöscht"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Karten-Kacheln",
|
||||
"manageProviders": "Anbieter Verwalten",
|
||||
"attribution": "Karten-Zuschreibung"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Profil Anzeigen",
|
||||
"newProfile": "Neues Profil",
|
||||
"editProfile": "Profil Bearbeiten",
|
||||
"profileName": "Profil-Name",
|
||||
"profileNameHint": "z.B. Benutzerdefinierte ALPR-Kamera",
|
||||
"profileNameRequired": "Profil-Name ist erforderlich",
|
||||
"requiresDirection": "Benötigt Richtung",
|
||||
"requiresDirectionSubtitle": "Ob Kameras dieses Typs ein Richtungs-Tag benötigen",
|
||||
"submittable": "Übertragbar",
|
||||
"submittableSubtitle": "Ob dieses Profil für Kamera-Übertragungen verwendet werden kann",
|
||||
"osmTags": "OSM-Tags",
|
||||
"addTag": "Tag Hinzufügen",
|
||||
"saveProfile": "Profil Speichern",
|
||||
"keyHint": "Schlüssel",
|
||||
"valueHint": "Wert",
|
||||
"atLeastOneTagRequired": "Mindestens ein Tag ist erforderlich",
|
||||
"profileSaved": "Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Neues Betreiber-Profil",
|
||||
"editOperatorProfile": "Betreiber-Profil Bearbeiten",
|
||||
"operatorName": "Betreiber-Name",
|
||||
"operatorNameHint": "z.B. Polizei Austin",
|
||||
"operatorNameRequired": "Betreiber-Name ist erforderlich",
|
||||
"operatorProfileSaved": "Betreiber-Profil \"{}\" gespeichert"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Betreiber-Profile",
|
||||
"noProfilesMessage": "Keine Betreiber-Profile definiert. Erstellen Sie eines, um Betreiber-Tags auf Knoten-Übertragungen anzuwenden.",
|
||||
"tagsCount": "{} Tags",
|
||||
"deleteOperatorProfile": "Betreiber-Profil Löschen",
|
||||
"deleteOperatorProfileConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
|
||||
"operatorProfileDeleted": "Betreiber-Profil gelöscht"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline-Bereiche",
|
||||
"noAreasTitle": "Keine Offline-Bereiche",
|
||||
"noAreasSubtitle": "Laden Sie einen Kartenbereich für die Offline-Nutzung herunter.",
|
||||
"provider": "Anbieter",
|
||||
"maxZoom": "Max Zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Breite",
|
||||
"longitude": "Länge",
|
||||
"tiles": "Kacheln",
|
||||
"size": "Größe",
|
||||
"nodes": "Knoten",
|
||||
"areaIdFallback": "Bereich {}...",
|
||||
"renameArea": "Bereich umbenennen",
|
||||
"refreshWorldTiles": "Welt-Kacheln aktualisieren/neu herunterladen",
|
||||
"deleteOfflineArea": "Offline-Bereich löschen",
|
||||
"cancelDownload": "Download abbrechen",
|
||||
"renameAreaDialogTitle": "Offline-Bereich Umbenennen",
|
||||
"areaNameLabel": "Bereichsname",
|
||||
"renameButton": "Umbenennen",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Bereich aktualisieren",
|
||||
"refreshAreaDialogTitle": "Offline-Bereich aktualisieren",
|
||||
"refreshAreaDialogSubtitle": "Wählen Sie aus, was für diesen Bereich aktualisiert werden soll:",
|
||||
"refreshTiles": "Karten-Kacheln aktualisieren",
|
||||
"refreshTilesSubtitle": "Alle Kacheln für aktualisierte Bilder erneut herunterladen",
|
||||
"refreshNodes": "Knoten aktualisieren",
|
||||
"refreshNodesSubtitle": "Knotendaten für diesen Bereich erneut abrufen",
|
||||
"startRefresh": "Aktualisierung starten",
|
||||
"refreshStarted": "Aktualisierung gestartet!",
|
||||
"refreshFailed": "Aktualisierung fehlgeschlagen: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Tags Verfeinern",
|
||||
"operatorProfile": "Betreiber-Profil",
|
||||
"done": "Fertig",
|
||||
"none": "Keine",
|
||||
"noAdditionalOperatorTags": "Keine zusätzlichen Betreiber-Tags",
|
||||
"additionalTags": "zusätzliche Tags",
|
||||
"additionalTagsTitle": "Zusätzliche Tags",
|
||||
"noTagsDefinedForProfile": "Keine Tags für dieses Betreiber-Profil definiert.",
|
||||
"noOperatorProfiles": "Keine Betreiber-Profile definiert",
|
||||
"noOperatorProfilesMessage": "Erstellen Sie Betreiber-Profile in den Einstellungen, um zusätzliche Tags auf Ihre Knoten-Übertragungen anzuwenden."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/en.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "English"
|
||||
},
|
||||
"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",
|
||||
"settings": "Settings",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"saveEdit": "Save Edit",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Enable follow-me (north up)",
|
||||
"northUp": "Enable follow-me (rotating)",
|
||||
"rotating": "Disable follow-me"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map (default: 250).",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
"offlineMode": "Offline Mode",
|
||||
"offlineModeSubtitle": "Disable all network requests except for local/offline areas.",
|
||||
"offlineModeWarningTitle": "Active Downloads",
|
||||
"offlineModeWarningMessage": "Enabling offline mode will cancel any active area downloads. Do you want to continue?",
|
||||
"enableOfflineMode": "Enable Offline Mode",
|
||||
"profiles": "Profiles",
|
||||
"profilesSubtitle": "Manage node and operator profiles",
|
||||
"offlineSettings": "Offline Settings",
|
||||
"offlineSettingsSubtitle": "Manage offline mode and downloaded areas",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"advancedSettingsSubtitle": "Performance, alerts, and tile provider settings",
|
||||
"proximityAlerts": "Proximity Alerts",
|
||||
"networkStatusIndicator": "Network Status Indicator"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Get notified when approaching surveillance devices",
|
||||
"batteryUsage": "Uses extra battery for continuous location monitoring",
|
||||
"notificationsEnabled": "✓ Notifications enabled",
|
||||
"notificationsDisabled": "⚠ Notifications disabled",
|
||||
"permissionRequired": "Notification permission required",
|
||||
"permissionExplanation": "Push notifications are disabled. You'll only see in-app alerts and won't be notified when the app is in background.",
|
||||
"enableNotifications": "Enable Notifications",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"alertDistance": "Alert distance: ",
|
||||
"meters": "meters",
|
||||
"rangeInfo": "Range: {}-{} meters (default: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
"tagSheetTitle": "Surveillance Device Tags",
|
||||
"queuedForUpload": "Node queued for upload",
|
||||
"editQueuedForUpload": "Node edit queued for upload",
|
||||
"deleteQueuedForUpload": "Node deletion queued for upload",
|
||||
"confirmDeleteTitle": "Delete Node",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Edit Node #{}",
|
||||
"profile": "Profile",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "This profile does not require a direction.",
|
||||
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
|
||||
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
|
||||
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
|
||||
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
|
||||
"refineTags": "Refine Tags",
|
||||
"refineTagsWithProfile": "Refine Tags ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Download Map Area",
|
||||
"maxZoomLevel": "Max zoom level",
|
||||
"storageEstimate": "Storage estimate:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Min zoom:",
|
||||
"maxRecommendedZoom": "Max recommended zoom: Z{}",
|
||||
"withinTileLimit": "Within {} tile limit",
|
||||
"exceedsTileLimit": "Current selection exceeds {} tile limit",
|
||||
"offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.",
|
||||
"downloadStarted": "Download started! Fetching tiles and nodes...",
|
||||
"downloadFailed": "Failed to start download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Upload Destination",
|
||||
"subtitle": "Choose where cameras are uploaded",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simulate",
|
||||
"productionDescription": "Upload to the live OSM database (visible to all users)",
|
||||
"sandboxDescription": "Uploads go to the OSM Sandbox (safe for testing, resets regularly).",
|
||||
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Logged in as {}",
|
||||
"loginToOSM": "Log in to OpenStreetMap",
|
||||
"tapToLogout": "Tap to logout",
|
||||
"requiredToSubmit": "Required to submit camera data",
|
||||
"loggedOut": "Logged out",
|
||||
"testConnection": "Test Connection",
|
||||
"testConnectionSubtitle": "Verify OSM credentials are working",
|
||||
"connectionOK": "Connection OK - credentials are valid",
|
||||
"connectionFailed": "Connection failed - please re-login"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Pending uploads: {}",
|
||||
"simulateModeEnabled": "Simulate mode enabled – uploads simulated",
|
||||
"sandboxMode": "Sandbox mode – uploads go to OSM Sandbox",
|
||||
"tapToViewQueue": "Tap to view queue",
|
||||
"clearUploadQueue": "Clear Upload Queue",
|
||||
"removeAllPending": "Remove all {} pending uploads",
|
||||
"clearQueueTitle": "Clear Queue",
|
||||
"clearQueueConfirm": "Remove all {} pending uploads?",
|
||||
"queueCleared": "Queue cleared",
|
||||
"uploadQueueTitle": "Upload Queue ({} items)",
|
||||
"queueIsEmpty": "Queue is empty",
|
||||
"cameraWithIndex": "Camera {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completing...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Attempts: {}",
|
||||
"uploadFailedRetry": "Upload failed. Tap retry to try again.",
|
||||
"retryUpload": "Retry upload",
|
||||
"clearAll": "Clear All"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Tile Providers",
|
||||
"noProvidersConfigured": "No tile providers configured",
|
||||
"tileTypesCount": "{} tile types",
|
||||
"apiKeyConfigured": "API Key configured",
|
||||
"needsApiKey": "Needs API key",
|
||||
"editProvider": "Edit Provider",
|
||||
"addProvider": "Add Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"providerName": "Provider Name",
|
||||
"providerNameHint": "e.g., Custom Maps Inc.",
|
||||
"providerNameRequired": "Provider name is required",
|
||||
"apiKey": "API Key (Optional)",
|
||||
"apiKeyHint": "Enter API key if required by tile types",
|
||||
"tileTypes": "Tile Types",
|
||||
"addType": "Add Type",
|
||||
"noTileTypesConfigured": "No tile types configured",
|
||||
"atLeastOneTileTypeRequired": "At least one tile type is required",
|
||||
"manageTileProviders": "Manage Providers"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Edit Tile Type",
|
||||
"addTileType": "Add Tile Type",
|
||||
"name": "Name",
|
||||
"nameHint": "e.g., Satellite",
|
||||
"nameRequired": "Name is required",
|
||||
"urlTemplate": "URL Template",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL template is required",
|
||||
"urlTemplatePlaceholders": "URL must contain {z}, {x}, and {y} placeholders",
|
||||
"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: {}",
|
||||
"save": "Save"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Node Profiles",
|
||||
"newProfile": "New Profile",
|
||||
"builtIn": "Built-in",
|
||||
"custom": "Custom",
|
||||
"view": "View",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"profileDeleted": "Profile deleted"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Map Tiles",
|
||||
"manageProviders": "Manage Providers",
|
||||
"attribution": "Map Attribution"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "View Profile",
|
||||
"newProfile": "New Profile",
|
||||
"editProfile": "Edit Profile",
|
||||
"profileName": "Profile name",
|
||||
"profileNameHint": "e.g., Custom ALPR Camera",
|
||||
"profileNameRequired": "Profile name is required",
|
||||
"requiresDirection": "Requires Direction",
|
||||
"requiresDirectionSubtitle": "Whether cameras of this type need a direction tag",
|
||||
"submittable": "Submittable",
|
||||
"submittableSubtitle": "Whether this profile can be used for camera submissions",
|
||||
"osmTags": "OSM Tags",
|
||||
"addTag": "Add tag",
|
||||
"saveProfile": "Save Profile",
|
||||
"keyHint": "key",
|
||||
"valueHint": "value",
|
||||
"atLeastOneTagRequired": "At least one tag is required",
|
||||
"profileSaved": "Profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "New Operator Profile",
|
||||
"editOperatorProfile": "Edit Operator Profile",
|
||||
"operatorName": "Operator name",
|
||||
"operatorNameHint": "e.g., Austin Police Department",
|
||||
"operatorNameRequired": "Operator name is required",
|
||||
"operatorProfileSaved": "Operator profile \"{}\" saved"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Operator Profiles",
|
||||
"noProfilesMessage": "No operator profiles defined. Create one to apply operator tags to node submissions.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Delete Operator Profile",
|
||||
"deleteOperatorProfileConfirm": "Are you sure you want to delete \"{}\"?",
|
||||
"operatorProfileDeleted": "Operator profile deleted"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Offline Areas",
|
||||
"noAreasTitle": "No offline areas",
|
||||
"noAreasSubtitle": "Download a map area for offline use.",
|
||||
"provider": "Provider",
|
||||
"maxZoom": "Max zoom",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tiles",
|
||||
"size": "Size",
|
||||
"nodes": "Nodes",
|
||||
"areaIdFallback": "Area {}...",
|
||||
"renameArea": "Rename area",
|
||||
"refreshWorldTiles": "Refresh/re-download world tiles",
|
||||
"deleteOfflineArea": "Delete offline area",
|
||||
"cancelDownload": "Cancel download",
|
||||
"renameAreaDialogTitle": "Rename Offline Area",
|
||||
"areaNameLabel": "Area Name",
|
||||
"renameButton": "Rename",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Refresh area",
|
||||
"refreshAreaDialogTitle": "Refresh Offline Area",
|
||||
"refreshAreaDialogSubtitle": "Choose what to refresh for this area:",
|
||||
"refreshTiles": "Refresh Map Tiles",
|
||||
"refreshTilesSubtitle": "Re-download all tiles for updated imagery",
|
||||
"refreshNodes": "Refresh Nodes",
|
||||
"refreshNodesSubtitle": "Re-fetch node data for this area",
|
||||
"startRefresh": "Start Refresh",
|
||||
"refreshStarted": "Refresh started!",
|
||||
"refreshFailed": "Refresh failed: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refine Tags",
|
||||
"operatorProfile": "Operator Profile",
|
||||
"done": "Done",
|
||||
"none": "None",
|
||||
"noAdditionalOperatorTags": "No additional operator tags",
|
||||
"additionalTags": "additional tags",
|
||||
"additionalTagsTitle": "Additional Tags",
|
||||
"noTagsDefinedForProfile": "No tags defined for this operator profile.",
|
||||
"noOperatorProfiles": "No operator profiles defined",
|
||||
"noOperatorProfilesMessage": "Create operator profiles in Settings to apply additional tags to your node submissions."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/es.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Español"
|
||||
},
|
||||
"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",
|
||||
"settings": "Configuración",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Guardar Edición",
|
||||
"clear": "Limpiar"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activar seguimiento (norte arriba)",
|
||||
"northUp": "Activar seguimiento (rotación)",
|
||||
"rotating": "Desactivar seguimiento"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa (predeterminado: 250).",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
"offlineMode": "Modo Sin Conexión",
|
||||
"offlineModeSubtitle": "Deshabilitar todas las solicitudes de red excepto para áreas locales/sin conexión.",
|
||||
"offlineModeWarningTitle": "Descargas Activas",
|
||||
"offlineModeWarningMessage": "Habilitar el modo sin conexión cancelará cualquier descarga de área activa. ¿Desea continuar?",
|
||||
"enableOfflineMode": "Habilitar Modo Sin Conexión",
|
||||
"profiles": "Perfiles",
|
||||
"profilesSubtitle": "Gestionar perfiles de nodos y operadores",
|
||||
"offlineSettings": "Configuración Sin Conexión",
|
||||
"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",
|
||||
"networkStatusIndicator": "Indicador de Estado de Red"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
|
||||
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
|
||||
"notificationsEnabled": "✓ Notificaciones habilitadas",
|
||||
"notificationsDisabled": "⚠ Notificaciones deshabilitadas",
|
||||
"permissionRequired": "Permiso de notificación requerido",
|
||||
"permissionExplanation": "Las notificaciones push están deshabilitadas. Solo verás alertas dentro de la app y no serás notificado cuando la app esté en segundo plano.",
|
||||
"enableNotifications": "Habilitar Notificaciones",
|
||||
"checkingPermissions": "Verificando permisos...",
|
||||
"alertDistance": "Distancia de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Etiquetas del Dispositivo",
|
||||
"queuedForUpload": "Nodo en cola para subir",
|
||||
"editQueuedForUpload": "Edición de nodo en cola para subir",
|
||||
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
|
||||
"confirmDeleteTitle": "Eliminar Nodo",
|
||||
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nodo #{}",
|
||||
"profile": "Perfil",
|
||||
"direction": "Dirección {}°",
|
||||
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
|
||||
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
|
||||
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
|
||||
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
|
||||
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
|
||||
"refineTags": "Refinar Etiquetas",
|
||||
"refineTagsWithProfile": "Refinar Etiquetas ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Descargar Área del Mapa",
|
||||
"maxZoomLevel": "Nivel máx. de zoom",
|
||||
"storageEstimate": "Estimación de almacenamiento:",
|
||||
"tilesAndSize": "{} mosaicos, {} MB",
|
||||
"minZoom": "Zoom mín.:",
|
||||
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||
"withinTileLimit": "Dentro del límite de {} mosaicos",
|
||||
"exceedsTileLimit": "La selección actual excede el límite de {} mosaicos",
|
||||
"offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.",
|
||||
"downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...",
|
||||
"downloadFailed": "Error al iniciar la descarga: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino de Subida",
|
||||
"subtitle": "Elige dónde se suben las cámaras",
|
||||
"production": "Producción",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Subir a la base de datos OSM en vivo (visible para todos los usuarios)",
|
||||
"sandboxDescription": "Las subidas van al Sandbox de OSM (seguro para pruebas, se reinicia regularmente).",
|
||||
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Conectado como {}",
|
||||
"loginToOSM": "Iniciar sesión en OpenStreetMap",
|
||||
"tapToLogout": "Toque para cerrar sesión",
|
||||
"requiredToSubmit": "Requerido para enviar datos de cámaras",
|
||||
"loggedOut": "Sesión cerrada",
|
||||
"testConnection": "Probar Conexión",
|
||||
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
|
||||
"connectionOK": "Conexión OK - las credenciales son válidas",
|
||||
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Subidas pendientes: {}",
|
||||
"simulateModeEnabled": "Modo simulación activado – subidas simuladas",
|
||||
"sandboxMode": "Modo sandbox – subidas van al Sandbox OSM",
|
||||
"tapToViewQueue": "Toque para ver cola",
|
||||
"clearUploadQueue": "Limpiar Cola de Subida",
|
||||
"removeAllPending": "Eliminar todas las {} subidas pendientes",
|
||||
"clearQueueTitle": "Limpiar Cola",
|
||||
"clearQueueConfirm": "¿Eliminar todas las {} subidas pendientes?",
|
||||
"queueCleared": "Cola limpiada",
|
||||
"uploadQueueTitle": "Cola de Subida ({} elementos)",
|
||||
"queueIsEmpty": "La cola está vacía",
|
||||
"cameraWithIndex": "Cámara {}",
|
||||
"error": " (Error)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Dirección: {}°",
|
||||
"attempts": "Intentos: {}",
|
||||
"uploadFailedRetry": "Subida falló. Toque reintentar para intentar de nuevo.",
|
||||
"retryUpload": "Reintentar subida",
|
||||
"clearAll": "Limpiar Todo"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Proveedores de Tiles",
|
||||
"noProvidersConfigured": "No hay proveedores de tiles configurados",
|
||||
"tileTypesCount": "{} tipos de tiles",
|
||||
"apiKeyConfigured": "Clave API configurada",
|
||||
"needsApiKey": "Necesita clave API",
|
||||
"editProvider": "Editar Proveedor",
|
||||
"addProvider": "Agregar Proveedor",
|
||||
"deleteProvider": "Eliminar Proveedor",
|
||||
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"providerName": "Nombre del Proveedor",
|
||||
"providerNameHint": "ej., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "El nombre del proveedor es requerido",
|
||||
"apiKey": "Clave API (Opcional)",
|
||||
"apiKeyHint": "Ingrese la clave API si es requerida por los tipos de tiles",
|
||||
"tileTypes": "Tipos de Tiles",
|
||||
"addType": "Agregar Tipo",
|
||||
"noTileTypesConfigured": "No hay tipos de tiles configurados",
|
||||
"atLeastOneTileTypeRequired": "Se requiere al menos un tipo de tile",
|
||||
"manageTileProviders": "Gestionar Proveedores"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Editar Tipo de Tile",
|
||||
"addTileType": "Agregar Tipo de Tile",
|
||||
"name": "Nombre",
|
||||
"nameHint": "ej., Satélite",
|
||||
"nameRequired": "El nombre es requerido",
|
||||
"urlTemplate": "Plantilla de URL",
|
||||
"urlTemplateHint": "https://ejemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "La plantilla de URL es requerida",
|
||||
"urlTemplatePlaceholders": "La URL debe contener marcadores {z}, {x} y {y}",
|
||||
"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: {}",
|
||||
"save": "Guardar"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Perfiles de Nodos",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"builtIn": "Incorporado",
|
||||
"custom": "Personalizado",
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Eliminar Perfil",
|
||||
"deleteProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"profileDeleted": "Perfil eliminado"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles de Mapa",
|
||||
"manageProviders": "Gestionar Proveedores",
|
||||
"attribution": "Atribución del Mapa"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
"newProfile": "Nuevo Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"profileName": "Nombre del perfil",
|
||||
"profileNameHint": "ej., Cámara ALPR Personalizada",
|
||||
"profileNameRequired": "El nombre del perfil es requerido",
|
||||
"requiresDirection": "Requiere Dirección",
|
||||
"requiresDirectionSubtitle": "Si las cámaras de este tipo necesitan una etiqueta de dirección",
|
||||
"submittable": "Envíable",
|
||||
"submittableSubtitle": "Si este perfil puede usarse para envíos de cámaras",
|
||||
"osmTags": "Etiquetas OSM",
|
||||
"addTag": "Agregar Etiqueta",
|
||||
"saveProfile": "Guardar Perfil",
|
||||
"keyHint": "clave",
|
||||
"valueHint": "valor",
|
||||
"atLeastOneTagRequired": "Se requiere al menos una etiqueta",
|
||||
"profileSaved": "Perfil \"{}\" guardado"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuevo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nombre del operador",
|
||||
"operatorNameHint": "ej., Departamento de Policía de Austin",
|
||||
"operatorNameRequired": "El nombre del operador es requerido",
|
||||
"operatorProfileSaved": "Perfil de operador \"{}\" guardado"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Perfiles de Operador",
|
||||
"noProfilesMessage": "No hay perfiles de operador definidos. Cree uno para aplicar etiquetas de operador a los envíos de nodos.",
|
||||
"tagsCount": "{} etiquetas",
|
||||
"deleteOperatorProfile": "Eliminar Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador eliminado"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Áreas Sin Conexión",
|
||||
"noAreasTitle": "Sin áreas sin conexión",
|
||||
"noAreasSubtitle": "Descarga un área del mapa para uso sin conexión.",
|
||||
"provider": "Proveedor",
|
||||
"maxZoom": "Zoom máx",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Teselas",
|
||||
"size": "Tamaño",
|
||||
"nodes": "Nodos",
|
||||
"areaIdFallback": "Área {}...",
|
||||
"renameArea": "Renombrar área",
|
||||
"refreshWorldTiles": "Actualizar/re-descargar teselas mundiales",
|
||||
"deleteOfflineArea": "Eliminar área sin conexión",
|
||||
"cancelDownload": "Cancelar descarga",
|
||||
"renameAreaDialogTitle": "Renombrar Área Sin Conexión",
|
||||
"areaNameLabel": "Nombre del Área",
|
||||
"renameButton": "Renombrar",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Actualizar área",
|
||||
"refreshAreaDialogTitle": "Actualizar Área sin Conexión",
|
||||
"refreshAreaDialogSubtitle": "Elija qué actualizar para esta área:",
|
||||
"refreshTiles": "Actualizar Mosaicos del Mapa",
|
||||
"refreshTilesSubtitle": "Volver a descargar todos los mosaicos para imágenes actualizadas",
|
||||
"refreshNodes": "Actualizar Nodos",
|
||||
"refreshNodesSubtitle": "Volver a obtener datos de nodos para esta área",
|
||||
"startRefresh": "Iniciar Actualización",
|
||||
"refreshStarted": "¡Actualización iniciada!",
|
||||
"refreshFailed": "Actualización falló: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Etiquetas",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
"done": "Listo",
|
||||
"none": "Ninguno",
|
||||
"noAdditionalOperatorTags": "Sin etiquetas adicionales de operador",
|
||||
"additionalTags": "etiquetas adicionales",
|
||||
"additionalTagsTitle": "Etiquetas Adicionales",
|
||||
"noTagsDefinedForProfile": "No hay etiquetas definidas para este perfil de operador.",
|
||||
"noOperatorProfiles": "No hay perfiles de operador definidos",
|
||||
"noOperatorProfilesMessage": "Cree perfiles de operador en Configuración para aplicar etiquetas adicionales a sus envíos de nodos."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/fr.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Français"
|
||||
},
|
||||
"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",
|
||||
"settings": "Paramètres",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler",
|
||||
"ok": "OK",
|
||||
"close": "Fermer",
|
||||
"submit": "Soumettre",
|
||||
"saveEdit": "Sauvegarder Modification",
|
||||
"clear": "Effacer"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Activer le suivi (nord en haut)",
|
||||
"northUp": "Activer le suivi (rotation)",
|
||||
"rotating": "Désactiver le suivi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"systemDefault": "Par Défaut du Système",
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"maxNodes": "Max. nœuds dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte (par défaut: 250).",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
"offlineMode": "Mode Hors Ligne",
|
||||
"offlineModeSubtitle": "Désactiver toutes les requêtes réseau sauf pour les zones locales/hors ligne.",
|
||||
"offlineModeWarningTitle": "Téléchargements Actifs",
|
||||
"offlineModeWarningMessage": "L'activation du mode hors ligne annulera tous les téléchargements de zone actifs. Voulez-vous continuer?",
|
||||
"enableOfflineMode": "Activer le Mode Hors Ligne",
|
||||
"profiles": "Profils",
|
||||
"profilesSubtitle": "Gérer les profils de nœuds et d'opérateurs",
|
||||
"offlineSettings": "Paramètres Hors Ligne",
|
||||
"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é",
|
||||
"networkStatusIndicator": "Indicateur de Statut Réseau"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
|
||||
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
|
||||
"notificationsEnabled": "✓ Notifications activées",
|
||||
"notificationsDisabled": "⚠ Notifications désactivées",
|
||||
"permissionRequired": "Autorisation de notification requise",
|
||||
"permissionExplanation": "Les notifications push sont désactivées. Vous ne verrez que des alertes dans l'application et ne serez pas notifié lorsque l'application est en arrière-plan.",
|
||||
"enableNotifications": "Activer les Notifications",
|
||||
"checkingPermissions": "Vérification des autorisations...",
|
||||
"alertDistance": "Distance d'alerte : ",
|
||||
"meters": "mètres",
|
||||
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
"tagSheetTitle": "Balises du Dispositif",
|
||||
"queuedForUpload": "Nœud mis en file pour envoi",
|
||||
"editQueuedForUpload": "Modification de nœud mise en file pour envoi",
|
||||
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
|
||||
"confirmDeleteTitle": "Supprimer le Nœud",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifier Nœud #{}",
|
||||
"profile": "Profil",
|
||||
"direction": "Direction {}°",
|
||||
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
|
||||
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
|
||||
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
|
||||
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
|
||||
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
|
||||
"refineTags": "Affiner Balises",
|
||||
"refineTagsWithProfile": "Affiner Balises ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Télécharger Zone de Carte",
|
||||
"maxZoomLevel": "Niveau de zoom max.",
|
||||
"storageEstimate": "Estimation de stockage:",
|
||||
"tilesAndSize": "{} tuiles, {} MB",
|
||||
"minZoom": "Zoom min.:",
|
||||
"maxRecommendedZoom": "Zoom max. recommandé: Z{}",
|
||||
"withinTileLimit": "Dans la limite de {} tuiles",
|
||||
"exceedsTileLimit": "La sélection actuelle dépasse la limite de {} tuiles",
|
||||
"offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.",
|
||||
"downloadStarted": "Téléchargement démarré! Récupération des tuiles et nœuds...",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destination de Téléchargement",
|
||||
"subtitle": "Choisir où les caméras sont téléchargées",
|
||||
"production": "Production",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simuler",
|
||||
"productionDescription": "Télécharger vers la base de données OSM en direct (visible pour tous les utilisateurs)",
|
||||
"sandboxDescription": "Les téléchargements vont vers le Sandbox OSM (sûr pour les tests, réinitialisé régulièrement).",
|
||||
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Connecté en tant que {}",
|
||||
"loginToOSM": "Se connecter à OpenStreetMap",
|
||||
"tapToLogout": "Appuyer pour se déconnecter",
|
||||
"requiredToSubmit": "Requis pour soumettre des données de caméras",
|
||||
"loggedOut": "Déconnecté",
|
||||
"testConnection": "Tester Connexion",
|
||||
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
|
||||
"connectionOK": "Connexion OK - les identifiants sont valides",
|
||||
"connectionFailed": "Connexion échouée - veuillez vous reconnecter"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Téléchargements en attente: {}",
|
||||
"simulateModeEnabled": "Mode simulation activé – téléchargements simulés",
|
||||
"sandboxMode": "Mode sandbox – téléchargements vont vers OSM Sandbox",
|
||||
"tapToViewQueue": "Appuyer pour voir la file",
|
||||
"clearUploadQueue": "Vider File de Téléchargement",
|
||||
"removeAllPending": "Supprimer tous les {} téléchargements en attente",
|
||||
"clearQueueTitle": "Vider File",
|
||||
"clearQueueConfirm": "Supprimer tous les {} téléchargements en attente?",
|
||||
"queueCleared": "File vidée",
|
||||
"uploadQueueTitle": "File de Téléchargement ({} éléments)",
|
||||
"queueIsEmpty": "La file est vide",
|
||||
"cameraWithIndex": "Caméra {}",
|
||||
"error": " (Erreur)",
|
||||
"completing": " (Finalisation...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direction: {}°",
|
||||
"attempts": "Tentatives: {}",
|
||||
"uploadFailedRetry": "Téléchargement échoué. Appuyer pour réessayer.",
|
||||
"retryUpload": "Réessayer téléchargement",
|
||||
"clearAll": "Tout Vider"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fournisseurs de Tuiles",
|
||||
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
|
||||
"tileTypesCount": "{} types de tuiles",
|
||||
"apiKeyConfigured": "Clé API configurée",
|
||||
"needsApiKey": "Nécessite une clé API",
|
||||
"editProvider": "Modifier Fournisseur",
|
||||
"addProvider": "Ajouter Fournisseur",
|
||||
"deleteProvider": "Supprimer Fournisseur",
|
||||
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"providerName": "Nom du Fournisseur",
|
||||
"providerNameHint": "ex., Cartes Personnalisées Inc.",
|
||||
"providerNameRequired": "Le nom du fournisseur est requis",
|
||||
"apiKey": "Clé API (Optionnel)",
|
||||
"apiKeyHint": "Entrez la clé API si requise par les types de tuiles",
|
||||
"tileTypes": "Types de Tuiles",
|
||||
"addType": "Ajouter Type",
|
||||
"noTileTypesConfigured": "Aucun type de tuile configuré",
|
||||
"atLeastOneTileTypeRequired": "Au moins un type de tuile est requis",
|
||||
"manageTileProviders": "Gérer Fournisseurs"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Modifier Type de Tuile",
|
||||
"addTileType": "Ajouter Type de Tuile",
|
||||
"name": "Nom",
|
||||
"nameHint": "ex., Satellite",
|
||||
"nameRequired": "Le nom est requis",
|
||||
"urlTemplate": "Modèle d'URL",
|
||||
"urlTemplateHint": "https://exemple.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Le modèle d'URL est requis",
|
||||
"urlTemplatePlaceholders": "L'URL doit contenir les marqueurs {z}, {x} et {y}",
|
||||
"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: {}",
|
||||
"save": "Sauvegarder"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profils de Nœuds",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"builtIn": "Intégré",
|
||||
"custom": "Personnalisé",
|
||||
"view": "Voir",
|
||||
"deleteProfile": "Supprimer Profil",
|
||||
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"profileDeleted": "Profil supprimé"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tuiles de Carte",
|
||||
"manageProviders": "Gérer Fournisseurs",
|
||||
"attribution": "Attribution de Carte"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Voir Profil",
|
||||
"newProfile": "Nouveau Profil",
|
||||
"editProfile": "Modifier Profil",
|
||||
"profileName": "Nom du profil",
|
||||
"profileNameHint": "ex., Caméra ALPR Personnalisée",
|
||||
"profileNameRequired": "Le nom du profil est requis",
|
||||
"requiresDirection": "Nécessite Direction",
|
||||
"requiresDirectionSubtitle": "Si les caméras de ce type ont besoin d'une balise de direction",
|
||||
"submittable": "Soumissible",
|
||||
"submittableSubtitle": "Si ce profil peut être utilisé pour les soumissions de caméras",
|
||||
"osmTags": "Balises OSM",
|
||||
"addTag": "Ajouter Balise",
|
||||
"saveProfile": "Sauvegarder Profil",
|
||||
"keyHint": "clé",
|
||||
"valueHint": "valeur",
|
||||
"atLeastOneTagRequired": "Au moins une balise est requise",
|
||||
"profileSaved": "Profil \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nouveau Profil d'Opérateur",
|
||||
"editOperatorProfile": "Modifier Profil d'Opérateur",
|
||||
"operatorName": "Nom de l'opérateur",
|
||||
"operatorNameHint": "ex., Département de Police d'Austin",
|
||||
"operatorNameRequired": "Le nom de l'opérateur est requis",
|
||||
"operatorProfileSaved": "Profil d'opérateur \"{}\" sauvegardé"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profils d'Opérateur",
|
||||
"noProfilesMessage": "Aucun profil d'opérateur défini. Créez-en un pour appliquer des balises d'opérateur aux soumissions de nœuds.",
|
||||
"tagsCount": "{} balises",
|
||||
"deleteOperatorProfile": "Supprimer Profil d'Opérateur",
|
||||
"deleteOperatorProfileConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
|
||||
"operatorProfileDeleted": "Profil d'opérateur supprimé"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Zones Hors Ligne",
|
||||
"noAreasTitle": "Aucune zone hors ligne",
|
||||
"noAreasSubtitle": "Téléchargez une zone de carte pour utilisation hors ligne.",
|
||||
"provider": "Fournisseur",
|
||||
"maxZoom": "Zoom max",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tuiles",
|
||||
"size": "Taille",
|
||||
"nodes": "Nœuds",
|
||||
"areaIdFallback": "Zone {}...",
|
||||
"renameArea": "Renommer la zone",
|
||||
"refreshWorldTiles": "Actualiser/re-télécharger les tuiles mondiales",
|
||||
"deleteOfflineArea": "Supprimer la zone hors ligne",
|
||||
"cancelDownload": "Annuler le téléchargement",
|
||||
"renameAreaDialogTitle": "Renommer la Zone Hors Ligne",
|
||||
"areaNameLabel": "Nom de la Zone",
|
||||
"renameButton": "Renommer",
|
||||
"megabytes": "Mo",
|
||||
"kilobytes": "Ko",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Actualiser la zone",
|
||||
"refreshAreaDialogTitle": "Actualiser la Zone Hors Ligne",
|
||||
"refreshAreaDialogSubtitle": "Choisissez quoi actualiser pour cette zone :",
|
||||
"refreshTiles": "Actualiser les Tuiles de Carte",
|
||||
"refreshTilesSubtitle": "Télécharger à nouveau toutes les tuiles pour des images mises à jour",
|
||||
"refreshNodes": "Actualiser les Nœuds",
|
||||
"refreshNodesSubtitle": "Récupérer à nouveau les données de nœuds pour cette zone",
|
||||
"startRefresh": "Démarrer l'Actualisation",
|
||||
"refreshStarted": "Actualisation démarrée !",
|
||||
"refreshFailed": "Actualisation échouée : {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Affiner les Étiquettes",
|
||||
"operatorProfile": "Profil d'Opérateur",
|
||||
"done": "Terminé",
|
||||
"none": "Aucun",
|
||||
"noAdditionalOperatorTags": "Aucune étiquette d'opérateur supplémentaire",
|
||||
"additionalTags": "étiquettes supplémentaires",
|
||||
"additionalTagsTitle": "Étiquettes Supplémentaires",
|
||||
"noTagsDefinedForProfile": "Aucune étiquette définie pour ce profil d'opérateur.",
|
||||
"noOperatorProfiles": "Aucun profil d'opérateur défini",
|
||||
"noOperatorProfilesMessage": "Créez des profils d'opérateur dans les Paramètres pour appliquer des étiquettes supplémentaires à vos soumissions de nœuds."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/it.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Italiano"
|
||||
},
|
||||
"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",
|
||||
"settings": "Impostazioni",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"cancel": "Annulla",
|
||||
"ok": "OK",
|
||||
"close": "Chiudi",
|
||||
"submit": "Invia",
|
||||
"saveEdit": "Salva Modifica",
|
||||
"clear": "Pulisci"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Attiva seguimi (nord in alto)",
|
||||
"northUp": "Attiva seguimi (rotazione)",
|
||||
"rotating": "Disattiva seguimi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"language": "Lingua",
|
||||
"systemDefault": "Predefinito del Sistema",
|
||||
"aboutInfo": "Informazioni",
|
||||
"aboutThisApp": "Informazioni su questa App",
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa (predefinito: 250).",
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
"offlineMode": "Modalità Offline",
|
||||
"offlineModeSubtitle": "Disabilita tutte le richieste di rete tranne per aree locali/offline.",
|
||||
"offlineModeWarningTitle": "Download Attivi",
|
||||
"offlineModeWarningMessage": "L'attivazione della modalità offline cancellerà qualsiasi download di area attivo. Vuoi continuare?",
|
||||
"enableOfflineMode": "Attiva Modalità Offline",
|
||||
"profiles": "Profili",
|
||||
"profilesSubtitle": "Gestisci profili di nodi e operatori",
|
||||
"offlineSettings": "Impostazioni Offline",
|
||||
"offlineSettingsSubtitle": "Gestisci modalità offline e aree scaricate",
|
||||
"advancedSettings": "Impostazioni Avanzate",
|
||||
"advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere",
|
||||
"proximityAlerts": "Avvisi di Prossimità",
|
||||
"networkStatusIndicator": "Indicatore di Stato di Rete"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
|
||||
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
|
||||
"notificationsEnabled": "✓ Notifiche abilitate",
|
||||
"notificationsDisabled": "⚠ Notifiche disabilitate",
|
||||
"permissionRequired": "Autorizzazione notifica richiesta",
|
||||
"permissionExplanation": "Le notifiche push sono disabilitate. Vedrai solo avvisi nell'app e non sarai notificato quando l'app è in background.",
|
||||
"enableNotifications": "Abilita Notifiche",
|
||||
"checkingPermissions": "Controllo autorizzazioni...",
|
||||
"alertDistance": "Distanza di avviso: ",
|
||||
"meters": "metri",
|
||||
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
"tagSheetTitle": "Tag Dispositivo di Sorveglianza",
|
||||
"queuedForUpload": "Nodo in coda per il caricamento",
|
||||
"editQueuedForUpload": "Modifica nodo in coda per il caricamento",
|
||||
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
|
||||
"confirmDeleteTitle": "Elimina Nodo",
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Profilo",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Modifica Nodo #{}",
|
||||
"profile": "Profilo",
|
||||
"direction": "Direzione {}°",
|
||||
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
|
||||
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
|
||||
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
|
||||
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
|
||||
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
|
||||
"refineTags": "Affina Tag",
|
||||
"refineTagsWithProfile": "Affina Tag ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Scarica Area Mappa",
|
||||
"maxZoomLevel": "Livello zoom max",
|
||||
"storageEstimate": "Stima archiviazione:",
|
||||
"tilesAndSize": "{} tile, {} MB",
|
||||
"minZoom": "Zoom min:",
|
||||
"maxRecommendedZoom": "Zoom max raccomandato: Z{}",
|
||||
"withinTileLimit": "Entro il limite di {} tile",
|
||||
"exceedsTileLimit": "La selezione corrente supera il limite di {} tile",
|
||||
"offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.",
|
||||
"downloadStarted": "Download avviato! Recupero tile e nodi...",
|
||||
"downloadFailed": "Impossibile avviare il download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destinazione Upload",
|
||||
"subtitle": "Scegli dove vengono caricate le telecamere",
|
||||
"production": "Produzione",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simula",
|
||||
"productionDescription": "Carica nel database OSM dal vivo (visibile a tutti gli utenti)",
|
||||
"sandboxDescription": "Gli upload vanno alla Sandbox OSM (sicuro per i test, si resetta regolarmente).",
|
||||
"simulateDescription": "Simula upload (non contatta i server OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Loggato come {}",
|
||||
"loginToOSM": "Accedi a OpenStreetMap",
|
||||
"tapToLogout": "Tocca per disconnetterti",
|
||||
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
|
||||
"loggedOut": "Disconnesso",
|
||||
"testConnection": "Testa Connessione",
|
||||
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
|
||||
"connectionOK": "Connessione OK - le credenziali sono valide",
|
||||
"connectionFailed": "Connessione fallita - per favore accedi di nuovo"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Upload in sospeso: {}",
|
||||
"simulateModeEnabled": "Modalità simulazione abilitata – upload simulati",
|
||||
"sandboxMode": "Modalità sandbox – upload vanno alla Sandbox OSM",
|
||||
"tapToViewQueue": "Tocca per vedere la coda",
|
||||
"clearUploadQueue": "Pulisci Coda Upload",
|
||||
"removeAllPending": "Rimuovi tutti i {} upload in sospeso",
|
||||
"clearQueueTitle": "Pulisci Coda",
|
||||
"clearQueueConfirm": "Rimuovere tutti i {} upload in sospeso?",
|
||||
"queueCleared": "Coda pulita",
|
||||
"uploadQueueTitle": "Coda Upload ({} elementi)",
|
||||
"queueIsEmpty": "La coda è vuota",
|
||||
"cameraWithIndex": "Telecamera {}",
|
||||
"error": " (Errore)",
|
||||
"completing": " (Completamento...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direzione: {}°",
|
||||
"attempts": "Tentativi: {}",
|
||||
"uploadFailedRetry": "Upload fallito. Tocca riprova per tentare di nuovo.",
|
||||
"retryUpload": "Riprova upload",
|
||||
"clearAll": "Pulisci Tutto"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Fornitori di Tile",
|
||||
"noProvidersConfigured": "Nessun fornitore di tile configurato",
|
||||
"tileTypesCount": "{} tipi di tile",
|
||||
"apiKeyConfigured": "Chiave API configurata",
|
||||
"needsApiKey": "Richiede chiave API",
|
||||
"editProvider": "Modifica Fornitore",
|
||||
"addProvider": "Aggiungi Fornitore",
|
||||
"deleteProvider": "Elimina Fornitore",
|
||||
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"providerName": "Nome Fornitore",
|
||||
"providerNameHint": "es., Mappe Personalizzate Inc.",
|
||||
"providerNameRequired": "Il nome del fornitore è obbligatorio",
|
||||
"apiKey": "Chiave API (Opzionale)",
|
||||
"apiKeyHint": "Inserisci la chiave API se richiesta dai tipi di tile",
|
||||
"tileTypes": "Tipi di Tile",
|
||||
"addType": "Aggiungi Tipo",
|
||||
"noTileTypesConfigured": "Nessun tipo di tile configurato",
|
||||
"atLeastOneTileTypeRequired": "È richiesto almeno un tipo di tile",
|
||||
"manageTileProviders": "Gestisci Fornitori"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Modifica Tipo Tile",
|
||||
"addTileType": "Aggiungi Tipo Tile",
|
||||
"name": "Nome",
|
||||
"nameHint": "es., Satellite",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"urlTemplate": "Template URL",
|
||||
"urlTemplateHint": "https://esempio.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Il template URL è obbligatorio",
|
||||
"urlTemplatePlaceholders": "L'URL deve contenere i segnaposto {z}, {x} e {y}",
|
||||
"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: {}",
|
||||
"save": "Salva"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Profili Nodo",
|
||||
"newProfile": "Nuovo Profilo",
|
||||
"builtIn": "Integrato",
|
||||
"custom": "Personalizzato",
|
||||
"view": "Visualizza",
|
||||
"deleteProfile": "Elimina Profilo",
|
||||
"deleteProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"profileDeleted": "Profilo eliminato"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tile Mappa",
|
||||
"manageProviders": "Gestisci Fornitori",
|
||||
"attribution": "Attribuzione Mappa"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Visualizza Profilo",
|
||||
"newProfile": "Nuovo Profilo",
|
||||
"editProfile": "Modifica Profilo",
|
||||
"profileName": "Nome profilo",
|
||||
"profileNameHint": "es., Telecamera ALPR Personalizzata",
|
||||
"profileNameRequired": "Il nome del profilo è obbligatorio",
|
||||
"requiresDirection": "Richiede Direzione",
|
||||
"requiresDirectionSubtitle": "Se le telecamere di questo tipo necessitano di un tag direzione",
|
||||
"submittable": "Inviabile",
|
||||
"submittableSubtitle": "Se questo profilo può essere usato per invii di telecamere",
|
||||
"osmTags": "Tag OSM",
|
||||
"addTag": "Aggiungi Tag",
|
||||
"saveProfile": "Salva Profilo",
|
||||
"keyHint": "chiave",
|
||||
"valueHint": "valore",
|
||||
"atLeastOneTagRequired": "È richiesto almeno un tag",
|
||||
"profileSaved": "Profilo \"{}\" salvato"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Nuovo Profilo Operatore",
|
||||
"editOperatorProfile": "Modifica Profilo Operatore",
|
||||
"operatorName": "Nome operatore",
|
||||
"operatorNameHint": "es., Dipartimento di Polizia di Austin",
|
||||
"operatorNameRequired": "Il nome dell'operatore è obbligatorio",
|
||||
"operatorProfileSaved": "Profilo operatore \"{}\" salvato"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Profili Operatore",
|
||||
"noProfilesMessage": "Nessun profilo operatore definito. Creane uno per applicare tag operatore agli invii di nodi.",
|
||||
"tagsCount": "{} tag",
|
||||
"deleteOperatorProfile": "Elimina Profilo Operatore",
|
||||
"deleteOperatorProfileConfirm": "Sei sicuro di voler eliminare \"{}\"?",
|
||||
"operatorProfileDeleted": "Profilo operatore eliminato"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Aree Offline",
|
||||
"noAreasTitle": "Nessuna area offline",
|
||||
"noAreasSubtitle": "Scarica un'area mappa per l'uso offline.",
|
||||
"provider": "Fornitore",
|
||||
"maxZoom": "Zoom max",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tile",
|
||||
"size": "Dimensione",
|
||||
"nodes": "Nodi",
|
||||
"areaIdFallback": "Area {}...",
|
||||
"renameArea": "Rinomina area",
|
||||
"refreshWorldTiles": "Aggiorna/ri-scarica tile mondiali",
|
||||
"deleteOfflineArea": "Elimina area offline",
|
||||
"cancelDownload": "Annulla download",
|
||||
"renameAreaDialogTitle": "Rinomina Area Offline",
|
||||
"areaNameLabel": "Nome Area",
|
||||
"renameButton": "Rinomina",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Aggiorna area",
|
||||
"refreshAreaDialogTitle": "Aggiorna Area Offline",
|
||||
"refreshAreaDialogSubtitle": "Scegli cosa aggiornare per quest'area:",
|
||||
"refreshTiles": "Aggiorna Tile Mappa",
|
||||
"refreshTilesSubtitle": "Riscarica tutte le tile per immagini aggiornate",
|
||||
"refreshNodes": "Aggiorna Nodi",
|
||||
"refreshNodesSubtitle": "Ricarica i dati dei nodi per quest'area",
|
||||
"startRefresh": "Avvia Aggiornamento",
|
||||
"refreshStarted": "Aggiornamento avviato!",
|
||||
"refreshFailed": "Aggiornamento fallito: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Affina Tag",
|
||||
"operatorProfile": "Profilo Operatore",
|
||||
"done": "Fatto",
|
||||
"none": "Nessuno",
|
||||
"noAdditionalOperatorTags": "Nessun tag operatore aggiuntivo",
|
||||
"additionalTags": "tag aggiuntivi",
|
||||
"additionalTagsTitle": "Tag Aggiuntivi",
|
||||
"noTagsDefinedForProfile": "Nessun tag definito per questo profilo operatore.",
|
||||
"noOperatorProfiles": "Nessun profilo operatore definito",
|
||||
"noOperatorProfilesMessage": "Crea profili operatore nelle Impostazioni per applicare tag aggiuntivi ai tuoi invii di nodi."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/pt.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "Português"
|
||||
},
|
||||
"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",
|
||||
"settings": "Configurações",
|
||||
"edit": "Editar",
|
||||
"delete": "Excluir",
|
||||
"cancel": "Cancelar",
|
||||
"ok": "OK",
|
||||
"close": "Fechar",
|
||||
"submit": "Enviar",
|
||||
"saveEdit": "Salvar Edição",
|
||||
"clear": "Limpar"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "Ativar seguir-me (norte para cima)",
|
||||
"northUp": "Ativar seguir-me (rotação)",
|
||||
"rotating": "Desativar seguir-me"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
"language": "Idioma",
|
||||
"systemDefault": "Padrão do Sistema",
|
||||
"aboutInfo": "Sobre / Informações",
|
||||
"aboutThisApp": "Sobre este App",
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa (padrão: 250).",
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
"offlineMode": "Modo Offline",
|
||||
"offlineModeSubtitle": "Desabilitar todas as requisições de rede exceto para áreas locais/offline.",
|
||||
"offlineModeWarningTitle": "Downloads Ativos",
|
||||
"offlineModeWarningMessage": "Ativar o modo offline cancelará qualquer download de área ativo. Deseja continuar?",
|
||||
"enableOfflineMode": "Ativar Modo Offline",
|
||||
"profiles": "Perfis",
|
||||
"profilesSubtitle": "Gerenciar perfis de nós e operadores",
|
||||
"offlineSettings": "Configurações Offline",
|
||||
"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",
|
||||
"networkStatusIndicator": "Indicador de Status de Rede"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
|
||||
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
|
||||
"notificationsEnabled": "✓ Notificações habilitadas",
|
||||
"notificationsDisabled": "⚠ Notificações desabilitadas",
|
||||
"permissionRequired": "Permissão de notificação necessária",
|
||||
"permissionExplanation": "Notificações push estão desabilitadas. Você só verá alertas dentro do app e não será notificado quando o app estiver em segundo plano.",
|
||||
"enableNotifications": "Habilitar Notificações",
|
||||
"checkingPermissions": "Verificando permissões...",
|
||||
"alertDistance": "Distância de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nó #{}",
|
||||
"tagSheetTitle": "Tags do Dispositivo de Vigilância",
|
||||
"queuedForUpload": "Nó na fila para envio",
|
||||
"editQueuedForUpload": "Edição de nó na fila para envio",
|
||||
"deleteQueuedForUpload": "Exclusão de nó na fila para envio",
|
||||
"confirmDeleteTitle": "Excluir Nó",
|
||||
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita."
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "Perfil",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "Editar Nó #{}",
|
||||
"profile": "Perfil",
|
||||
"direction": "Direção {}°",
|
||||
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
|
||||
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
|
||||
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
|
||||
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
|
||||
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
|
||||
"refineTags": "Refinar Tags",
|
||||
"refineTagsWithProfile": "Refinar Tags ({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "Baixar Área do Mapa",
|
||||
"maxZoomLevel": "Nível máx. de zoom",
|
||||
"storageEstimate": "Estimativa de armazenamento:",
|
||||
"tilesAndSize": "{} tiles, {} MB",
|
||||
"minZoom": "Zoom mín.:",
|
||||
"maxRecommendedZoom": "Zoom máx. recomendado: Z{}",
|
||||
"withinTileLimit": "Dentro do limite de {} tiles",
|
||||
"exceedsTileLimit": "A seleção atual excede o limite de {} tiles",
|
||||
"offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.",
|
||||
"downloadStarted": "Download iniciado! Buscando tiles e nós...",
|
||||
"downloadFailed": "Falha ao iniciar o download: {}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "Destino do Upload",
|
||||
"subtitle": "Escolha onde as câmeras são enviadas",
|
||||
"production": "Produção",
|
||||
"sandbox": "Sandbox",
|
||||
"simulate": "Simular",
|
||||
"productionDescription": "Enviar para o banco de dados OSM ao vivo (visível para todos os usuários)",
|
||||
"sandboxDescription": "Uploads vão para o Sandbox OSM (seguro para testes, redefine regularmente).",
|
||||
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "Logado como {}",
|
||||
"loginToOSM": "Fazer login no OpenStreetMap",
|
||||
"tapToLogout": "Toque para sair",
|
||||
"requiredToSubmit": "Necessário para enviar dados de câmeras",
|
||||
"loggedOut": "Deslogado",
|
||||
"testConnection": "Testar Conexão",
|
||||
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
|
||||
"connectionOK": "Conexão OK - credenciais são válidas",
|
||||
"connectionFailed": "Conexão falhou - por favor, faça login novamente"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "Uploads pendentes: {}",
|
||||
"simulateModeEnabled": "Modo simulação ativado – uploads simulados",
|
||||
"sandboxMode": "Modo sandbox – uploads vão para o Sandbox OSM",
|
||||
"tapToViewQueue": "Toque para ver a fila",
|
||||
"clearUploadQueue": "Limpar Fila de Upload",
|
||||
"removeAllPending": "Remover todos os {} uploads pendentes",
|
||||
"clearQueueTitle": "Limpar Fila",
|
||||
"clearQueueConfirm": "Remover todos os {} uploads pendentes?",
|
||||
"queueCleared": "Fila limpa",
|
||||
"uploadQueueTitle": "Fila de Upload ({} itens)",
|
||||
"queueIsEmpty": "A fila está vazia",
|
||||
"cameraWithIndex": "Câmera {}",
|
||||
"error": " (Erro)",
|
||||
"completing": " (Completando...)",
|
||||
"destination": "Dest: {}",
|
||||
"latitude": "Lat: {}",
|
||||
"longitude": "Lon: {}",
|
||||
"direction": "Direção: {}°",
|
||||
"attempts": "Tentativas: {}",
|
||||
"uploadFailedRetry": "Upload falhou. Toque em tentar novamente para tentar novamente.",
|
||||
"retryUpload": "Tentar upload novamente",
|
||||
"clearAll": "Limpar Tudo"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "Provedores de Tiles",
|
||||
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
|
||||
"tileTypesCount": "{} tipos de tiles",
|
||||
"apiKeyConfigured": "Chave API configurada",
|
||||
"needsApiKey": "Precisa de chave API",
|
||||
"editProvider": "Editar Provedor",
|
||||
"addProvider": "Adicionar Provedor",
|
||||
"deleteProvider": "Excluir Provedor",
|
||||
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"providerName": "Nome do Provedor",
|
||||
"providerNameHint": "ex., Mapas Personalizados Inc.",
|
||||
"providerNameRequired": "Nome do provedor é obrigatório",
|
||||
"apiKey": "Chave API (Opcional)",
|
||||
"apiKeyHint": "Insira a chave API se necessária pelos tipos de tiles",
|
||||
"tileTypes": "Tipos de Tiles",
|
||||
"addType": "Adicionar Tipo",
|
||||
"noTileTypesConfigured": "Nenhum tipo de tile configurado",
|
||||
"atLeastOneTileTypeRequired": "Pelo menos um tipo de tile é obrigatório",
|
||||
"manageTileProviders": "Gerenciar Provedores"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "Editar Tipo de Tile",
|
||||
"addTileType": "Adicionar Tipo de Tile",
|
||||
"name": "Nome",
|
||||
"nameHint": "ex., Satélite",
|
||||
"nameRequired": "Nome é obrigatório",
|
||||
"urlTemplate": "Modelo de URL",
|
||||
"urlTemplateHint": "https://exemplo.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "Modelo de URL é obrigatório",
|
||||
"urlTemplatePlaceholders": "URL deve conter os marcadores {z}, {x} e {y}",
|
||||
"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: {}",
|
||||
"save": "Salvar"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "Perfis de Nó",
|
||||
"newProfile": "Novo Perfil",
|
||||
"builtIn": "Integrado",
|
||||
"custom": "Personalizado",
|
||||
"view": "Ver",
|
||||
"deleteProfile": "Excluir Perfil",
|
||||
"deleteProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"profileDeleted": "Perfil excluído"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "Tiles do Mapa",
|
||||
"manageProviders": "Gerenciar Provedores",
|
||||
"attribution": "Atribuição do Mapa"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "Ver Perfil",
|
||||
"newProfile": "Novo Perfil",
|
||||
"editProfile": "Editar Perfil",
|
||||
"profileName": "Nome do perfil",
|
||||
"profileNameHint": "ex., Câmera ALPR Personalizada",
|
||||
"profileNameRequired": "Nome do perfil é obrigatório",
|
||||
"requiresDirection": "Requer Direção",
|
||||
"requiresDirectionSubtitle": "Se câmeras deste tipo precisam de uma tag de direção",
|
||||
"submittable": "Enviável",
|
||||
"submittableSubtitle": "Se este perfil pode ser usado para envios de câmeras",
|
||||
"osmTags": "Tags OSM",
|
||||
"addTag": "Adicionar Tag",
|
||||
"saveProfile": "Salvar Perfil",
|
||||
"keyHint": "chave",
|
||||
"valueHint": "valor",
|
||||
"atLeastOneTagRequired": "Pelo menos uma tag é obrigatória",
|
||||
"profileSaved": "Perfil \"{}\" salvo"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "Novo Perfil de Operador",
|
||||
"editOperatorProfile": "Editar Perfil de Operador",
|
||||
"operatorName": "Nome do operador",
|
||||
"operatorNameHint": "ex., Departamento de Polícia de Austin",
|
||||
"operatorNameRequired": "Nome do operador é obrigatório",
|
||||
"operatorProfileSaved": "Perfil de operador \"{}\" salvo"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "Perfis de Operador",
|
||||
"noProfilesMessage": "Nenhum perfil de operador definido. Crie um para aplicar tags de operador aos envios de nós.",
|
||||
"tagsCount": "{} tags",
|
||||
"deleteOperatorProfile": "Excluir Perfil de Operador",
|
||||
"deleteOperatorProfileConfirm": "Tem certeza de que deseja excluir \"{}\"?",
|
||||
"operatorProfileDeleted": "Perfil de operador excluído"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "Áreas Offline",
|
||||
"noAreasTitle": "Nenhuma área offline",
|
||||
"noAreasSubtitle": "Baixe uma área do mapa para uso offline.",
|
||||
"provider": "Provedor",
|
||||
"maxZoom": "Zoom máx",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "Lat",
|
||||
"longitude": "Lon",
|
||||
"tiles": "Tiles",
|
||||
"size": "Tamanho",
|
||||
"nodes": "Nós",
|
||||
"areaIdFallback": "Área {}...",
|
||||
"renameArea": "Renomear área",
|
||||
"refreshWorldTiles": "Atualizar/rebaixar tiles mundiais",
|
||||
"deleteOfflineArea": "Excluir área offline",
|
||||
"cancelDownload": "Cancelar download",
|
||||
"renameAreaDialogTitle": "Renomear Área Offline",
|
||||
"areaNameLabel": "Nome da Área",
|
||||
"renameButton": "Renomear",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "Atualizar área",
|
||||
"refreshAreaDialogTitle": "Atualizar Área Offline",
|
||||
"refreshAreaDialogSubtitle": "Escolha o que atualizar para esta área:",
|
||||
"refreshTiles": "Atualizar Tiles do Mapa",
|
||||
"refreshTilesSubtitle": "Baixar novamente todos os tiles para imagens atualizadas",
|
||||
"refreshNodes": "Atualizar Nós",
|
||||
"refreshNodesSubtitle": "Buscar novamente os dados dos nós para esta área",
|
||||
"startRefresh": "Iniciar Atualização",
|
||||
"refreshStarted": "Atualização iniciada!",
|
||||
"refreshFailed": "Atualização falhou: {}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "Refinar Tags",
|
||||
"operatorProfile": "Perfil de Operador",
|
||||
"done": "Concluído",
|
||||
"none": "Nenhum",
|
||||
"noAdditionalOperatorTags": "Nenhuma tag adicional de operador",
|
||||
"additionalTags": "tags adicionais",
|
||||
"additionalTagsTitle": "Tags Adicionais",
|
||||
"noTagsDefinedForProfile": "Nenhuma tag definida para este perfil de operador.",
|
||||
"noOperatorProfiles": "Nenhum perfil de operador definido",
|
||||
"noOperatorProfilesMessage": "Crie perfis de operador nas Configurações para aplicar tags adicionais aos seus envios de nós."
|
||||
},
|
||||
"layerSelector": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
380
lib/localizations/zh.json
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"language": {
|
||||
"name": "中文"
|
||||
},
|
||||
"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": "下载",
|
||||
"settings": "设置",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"submit": "提交",
|
||||
"saveEdit": "保存编辑",
|
||||
"clear": "清空"
|
||||
},
|
||||
"followMe": {
|
||||
"off": "启用跟随模式(北向上)",
|
||||
"northUp": "启用跟随模式(旋转)",
|
||||
"rotating": "禁用跟随模式"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"systemDefault": "系统默认",
|
||||
"aboutInfo": "关于 / 信息",
|
||||
"aboutThisApp": "关于此应用",
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限(默认:250)。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
"offlineMode": "离线模式",
|
||||
"offlineModeSubtitle": "禁用除本地/离线区域外的所有网络请求。",
|
||||
"offlineModeWarningTitle": "活动下载",
|
||||
"offlineModeWarningMessage": "启用离线模式将取消任何活动的区域下载。您要继续吗?",
|
||||
"enableOfflineMode": "启用离线模式",
|
||||
"profiles": "配置文件",
|
||||
"profilesSubtitle": "管理节点和操作员配置文件",
|
||||
"offlineSettings": "离线设置",
|
||||
"offlineSettingsSubtitle": "管理离线模式和已下载区域",
|
||||
"advancedSettings": "高级设置",
|
||||
"advancedSettingsSubtitle": "性能、警报和地图提供商设置",
|
||||
"proximityAlerts": "邻近警报",
|
||||
"networkStatusIndicator": "网络状态指示器"
|
||||
},
|
||||
"proximityAlerts": {
|
||||
"getNotified": "接近监控设备时接收通知",
|
||||
"batteryUsage": "使用额外电量进行连续位置监控",
|
||||
"notificationsEnabled": "✓ 通知已启用",
|
||||
"notificationsDisabled": "⚠ 通知已禁用",
|
||||
"permissionRequired": "需要通知权限",
|
||||
"permissionExplanation": "推送通知已禁用。您只会看到应用内警报,当应用在后台时不会收到通知。",
|
||||
"enableNotifications": "启用通知",
|
||||
"checkingPermissions": "检查权限中...",
|
||||
"alertDistance": "警报距离:",
|
||||
"meters": "米",
|
||||
"rangeInfo": "范围:{}-{} 米(默认:{})"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点 #{}",
|
||||
"tagSheetTitle": "监控设备标签",
|
||||
"queuedForUpload": "节点已排队上传",
|
||||
"editQueuedForUpload": "节点编辑已排队上传",
|
||||
"deleteQueuedForUpload": "节点删除已排队上传",
|
||||
"confirmDeleteTitle": "删除节点",
|
||||
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。"
|
||||
},
|
||||
"addNode": {
|
||||
"profile": "配置文件",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
"editNode": {
|
||||
"title": "编辑节点 #{}",
|
||||
"profile": "配置文件",
|
||||
"direction": "方向 {}°",
|
||||
"profileNoDirectionInfo": "此配置文件不需要方向。",
|
||||
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
|
||||
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
|
||||
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
|
||||
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
|
||||
"refineTags": "细化标签",
|
||||
"refineTagsWithProfile": "细化标签({})"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载地图区域",
|
||||
"maxZoomLevel": "最大缩放级别",
|
||||
"storageEstimate": "存储估算:",
|
||||
"tilesAndSize": "{} 瓦片,{} MB",
|
||||
"minZoom": "最小缩放:",
|
||||
"maxRecommendedZoom": "最大推荐缩放:Z{}",
|
||||
"withinTileLimit": "在 {} 瓦片限制内",
|
||||
"exceedsTileLimit": "当前选择超出 {} 瓦片限制",
|
||||
"offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。",
|
||||
"downloadStarted": "下载已开始!正在获取瓦片和节点...",
|
||||
"downloadFailed": "启动下载失败:{}"
|
||||
},
|
||||
"uploadMode": {
|
||||
"title": "上传目标",
|
||||
"subtitle": "选择摄像头上传位置",
|
||||
"production": "生产环境",
|
||||
"sandbox": "沙盒",
|
||||
"simulate": "模拟",
|
||||
"productionDescription": "上传到实时 OSM 数据库(对所有用户可见)",
|
||||
"sandboxDescription": "上传到 OSM 沙盒(测试安全,定期重置)。",
|
||||
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
|
||||
},
|
||||
"auth": {
|
||||
"loggedInAs": "已登录为 {}",
|
||||
"loginToOSM": "登录 OpenStreetMap",
|
||||
"tapToLogout": "点击登出",
|
||||
"requiredToSubmit": "提交摄像头数据所需",
|
||||
"loggedOut": "已登出",
|
||||
"testConnection": "测试连接",
|
||||
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
|
||||
"connectionOK": "连接正常 - 凭据有效",
|
||||
"connectionFailed": "连接失败 - 请重新登录"
|
||||
},
|
||||
"queue": {
|
||||
"pendingUploads": "待上传:{}",
|
||||
"simulateModeEnabled": "模拟模式已启用 – 上传已模拟",
|
||||
"sandboxMode": "沙盒模式 – 上传到 OSM 沙盒",
|
||||
"tapToViewQueue": "点击查看队列",
|
||||
"clearUploadQueue": "清空上传队列",
|
||||
"removeAllPending": "移除所有 {} 个待上传项",
|
||||
"clearQueueTitle": "清空队列",
|
||||
"clearQueueConfirm": "移除所有 {} 个待上传项?",
|
||||
"queueCleared": "队列已清空",
|
||||
"uploadQueueTitle": "上传队列({} 项)",
|
||||
"queueIsEmpty": "队列为空",
|
||||
"cameraWithIndex": "摄像头 {}",
|
||||
"error": "(错误)",
|
||||
"completing": "(完成中...)",
|
||||
"destination": "目标:{}",
|
||||
"latitude": "纬度:{}",
|
||||
"longitude": "经度:{}",
|
||||
"direction": "方向:{}°",
|
||||
"attempts": "尝试次数:{}",
|
||||
"uploadFailedRetry": "上传失败。点击重试再次尝试。",
|
||||
"retryUpload": "重试上传",
|
||||
"clearAll": "全部清空"
|
||||
},
|
||||
"tileProviders": {
|
||||
"title": "瓦片提供商",
|
||||
"noProvidersConfigured": "未配置瓦片提供商",
|
||||
"tileTypesCount": "{} 种瓦片类型",
|
||||
"apiKeyConfigured": "API 密钥已配置",
|
||||
"needsApiKey": "需要 API 密钥",
|
||||
"editProvider": "编辑提供商",
|
||||
"addProvider": "添加提供商",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"providerName": "提供商名称",
|
||||
"providerNameHint": "例如,自定义地图公司",
|
||||
"providerNameRequired": "提供商名称为必填项",
|
||||
"apiKey": "API 密钥(可选)",
|
||||
"apiKeyHint": "如果瓦片类型需要,请输入 API 密钥",
|
||||
"tileTypes": "瓦片类型",
|
||||
"addType": "添加类型",
|
||||
"noTileTypesConfigured": "未配置瓦片类型",
|
||||
"atLeastOneTileTypeRequired": "至少需要一种瓦片类型",
|
||||
"manageTileProviders": "管理提供商"
|
||||
},
|
||||
"tileTypeEditor": {
|
||||
"editTileType": "编辑瓦片类型",
|
||||
"addTileType": "添加瓦片类型",
|
||||
"name": "名称",
|
||||
"nameHint": "例如,卫星",
|
||||
"nameRequired": "名称为必填项",
|
||||
"urlTemplate": "URL 模板",
|
||||
"urlTemplateHint": "https://example.com/{z}/{x}/{y}.png",
|
||||
"urlTemplateRequired": "URL 模板为必填项",
|
||||
"urlTemplatePlaceholders": "URL 必须包含 {z}、{x} 和 {y} 占位符",
|
||||
"attribution": "归属",
|
||||
"attributionHint": "© 地图提供商",
|
||||
"attributionRequired": "归属为必填项",
|
||||
"maxZoom": "最大缩放级别",
|
||||
"maxZoomHint": "最大缩放级别 (1-23)",
|
||||
"maxZoomRequired": "最大缩放为必填项",
|
||||
"maxZoomInvalid": "最大缩放必须为数字",
|
||||
"maxZoomRange": "最大缩放必须在 {} 和 {} 之间",
|
||||
"fetchPreview": "获取预览",
|
||||
"previewTileLoaded": "预览瓦片加载成功",
|
||||
"previewTileFailed": "获取预览失败:{}",
|
||||
"save": "保存"
|
||||
},
|
||||
"profiles": {
|
||||
"nodeProfiles": "节点配置文件",
|
||||
"newProfile": "新建配置文件",
|
||||
"builtIn": "内置",
|
||||
"custom": "自定义",
|
||||
"view": "查看",
|
||||
"deleteProfile": "删除配置文件",
|
||||
"deleteProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"profileDeleted": "配置文件已删除"
|
||||
},
|
||||
"mapTiles": {
|
||||
"title": "地图瓦片",
|
||||
"manageProviders": "管理提供商",
|
||||
"attribution": "地图归属"
|
||||
},
|
||||
"profileEditor": {
|
||||
"viewProfile": "查看配置文件",
|
||||
"newProfile": "新建配置文件",
|
||||
"editProfile": "编辑配置文件",
|
||||
"profileName": "配置文件名称",
|
||||
"profileNameHint": "例如,自定义 ALPR 摄像头",
|
||||
"profileNameRequired": "配置文件名称为必填项",
|
||||
"requiresDirection": "需要方向",
|
||||
"requiresDirectionSubtitle": "此类型的摄像头是否需要方向标签",
|
||||
"submittable": "可提交",
|
||||
"submittableSubtitle": "此配置文件是否可用于摄像头提交",
|
||||
"osmTags": "OSM 标签",
|
||||
"addTag": "添加标签",
|
||||
"saveProfile": "保存配置文件",
|
||||
"keyHint": "键",
|
||||
"valueHint": "值",
|
||||
"atLeastOneTagRequired": "至少需要一个标签",
|
||||
"profileSaved": "配置文件 \"{}\" 已保存"
|
||||
},
|
||||
"operatorProfileEditor": {
|
||||
"newOperatorProfile": "新建运营商配置文件",
|
||||
"editOperatorProfile": "编辑运营商配置文件",
|
||||
"operatorName": "运营商名称",
|
||||
"operatorNameHint": "例如,奥斯汀警察局",
|
||||
"operatorNameRequired": "运营商名称为必填项",
|
||||
"operatorProfileSaved": "运营商配置文件 \"{}\" 已保存"
|
||||
},
|
||||
"operatorProfiles": {
|
||||
"title": "运营商配置文件",
|
||||
"noProfilesMessage": "未定义运营商配置文件。创建一个以将运营商标签应用于节点提交。",
|
||||
"tagsCount": "{} 个标签",
|
||||
"deleteOperatorProfile": "删除运营商配置文件",
|
||||
"deleteOperatorProfileConfirm": "您确定要删除 \"{}\" 吗?",
|
||||
"operatorProfileDeleted": "运营商配置文件已删除"
|
||||
},
|
||||
"offlineAreas": {
|
||||
"title": "离线区域",
|
||||
"noAreasTitle": "无离线区域",
|
||||
"noAreasSubtitle": "下载地图区域以供离线使用。",
|
||||
"provider": "提供商",
|
||||
"maxZoom": "最大缩放",
|
||||
"zoomLevels": "Z{}-{}",
|
||||
"latitude": "纬度",
|
||||
"longitude": "经度",
|
||||
"tiles": "瓦片",
|
||||
"size": "大小",
|
||||
"nodes": "节点",
|
||||
"areaIdFallback": "区域 {}...",
|
||||
"renameArea": "重命名区域",
|
||||
"refreshWorldTiles": "刷新/重新下载世界瓦片",
|
||||
"deleteOfflineArea": "删除离线区域",
|
||||
"cancelDownload": "取消下载",
|
||||
"renameAreaDialogTitle": "重命名离线区域",
|
||||
"areaNameLabel": "区域名称",
|
||||
"renameButton": "重命名",
|
||||
"megabytes": "MB",
|
||||
"kilobytes": "KB",
|
||||
"progress": "{}%",
|
||||
"refreshArea": "刷新区域",
|
||||
"refreshAreaDialogTitle": "刷新离线区域",
|
||||
"refreshAreaDialogSubtitle": "选择要为此区域刷新的内容:",
|
||||
"refreshTiles": "刷新地图瓦片",
|
||||
"refreshTilesSubtitle": "重新下载所有瓦片以获取更新的图像",
|
||||
"refreshNodes": "刷新节点",
|
||||
"refreshNodesSubtitle": "重新获取此区域的节点数据",
|
||||
"startRefresh": "开始刷新",
|
||||
"refreshStarted": "刷新已开始!",
|
||||
"refreshFailed": "刷新失败:{}"
|
||||
},
|
||||
"refineTagsSheet": {
|
||||
"title": "细化标签",
|
||||
"operatorProfile": "运营商配置文件",
|
||||
"done": "完成",
|
||||
"none": "无",
|
||||
"noAdditionalOperatorTags": "无额外运营商标签",
|
||||
"additionalTags": "额外标签",
|
||||
"additionalTagsTitle": "额外标签",
|
||||
"noTagsDefinedForProfile": "此运营商配置文件未定义标签。",
|
||||
"noOperatorProfiles": "未定义运营商配置文件",
|
||||
"noOperatorProfilesMessage": "在设置中创建运营商配置文件,以将额外标签应用于您的节点提交。"
|
||||
},
|
||||
"layerSelector": {
|
||||
"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": "无可用地址"
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,25 @@ import 'package:provider/provider.dart';
|
||||
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';
|
||||
|
||||
|
||||
import 'widgets/tile_provider_with_cache.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize version service
|
||||
await VersionService().init();
|
||||
|
||||
// Initialize localization service
|
||||
await LocalizationService.instance.init();
|
||||
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
@@ -19,7 +33,7 @@ Future<void> main() async {
|
||||
// You can customize this splash/loading screen as needed
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
backgroundColor: Color(0xFF202020),
|
||||
backgroundColor: Color(0xFF152131),
|
||||
body: Center(
|
||||
child: Image.asset(
|
||||
'assets/app_icon.png',
|
||||
@@ -30,27 +44,36 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
}
|
||||
return const FlockMapApp();
|
||||
return const DeFlockApp();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class FlockMapApp extends StatelessWidget {
|
||||
const FlockMapApp({super.key});
|
||||
class DeFlockApp extends StatelessWidget {
|
||||
const DeFlockApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flock Map',
|
||||
title: 'DeFlock',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0080BC), // DeFlock blue
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
routes: {
|
||||
'/': (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(),
|
||||
'/settings/about': (context) => const AboutScreen(),
|
||||
},
|
||||
initialRoute: '/',
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular camera model/type.
|
||||
class CameraProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
|
||||
CameraProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic Flock ALPR camera
|
||||
factory CameraProfile.alpr() => CameraProfile(
|
||||
id: 'builtin-alpr',
|
||||
name: 'Generic Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:zone': 'traffic',
|
||||
'surveillance:type': 'ALPR', // left for backward compatibility — you may want to revisit per OSM best practice
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
);
|
||||
|
||||
CameraProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
}) =>
|
||||
CameraProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'id': id, 'name': name, 'tags': tags, 'builtin': builtin};
|
||||
|
||||
factory CameraProfile.fromJson(Map<String, dynamic> j) => CameraProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CameraProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
231
lib/models/node_profile.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of preset OSM tags that describe a particular surveillance node model/type.
|
||||
class NodeProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
final bool builtin;
|
||||
final bool requiresDirection;
|
||||
final bool submittable;
|
||||
final bool editable;
|
||||
|
||||
NodeProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
this.builtin = false,
|
||||
this.requiresDirection = true,
|
||||
this.submittable = true,
|
||||
this.editable = true,
|
||||
});
|
||||
|
||||
/// Built‑in default: Generic ALPR camera (customizable template, not submittable)
|
||||
factory NodeProfile.genericAlpr() => NodeProfile(
|
||||
id: 'builtin-generic-alpr',
|
||||
name: 'Generic ALPR',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'ALPR',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Safety ALPR camera
|
||||
factory NodeProfile.flock() => NodeProfile(
|
||||
id: 'builtin-flock',
|
||||
name: 'Flock',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Flock Safety',
|
||||
'manufacturer:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Motorola Solutions/Vigilant ALPR camera
|
||||
factory NodeProfile.motorola() => NodeProfile(
|
||||
id: 'builtin-motorola',
|
||||
name: 'Motorola/Vigilant',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Motorola Solutions',
|
||||
'manufacturer:wikidata': 'Q634815',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Genetec ALPR camera
|
||||
factory NodeProfile.genetec() => NodeProfile(
|
||||
id: 'builtin-genetec',
|
||||
name: 'Genetec',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Genetec',
|
||||
'manufacturer:wikidata': 'Q30295174',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Leonardo/ELSAG ALPR camera
|
||||
factory NodeProfile.leonardo() => NodeProfile(
|
||||
id: 'builtin-leonardo',
|
||||
name: 'Leonardo/ELSAG',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Leonardo',
|
||||
'manufacturer:wikidata': 'Q910379',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Neology ALPR camera
|
||||
factory NodeProfile.neology() => NodeProfile(
|
||||
id: 'builtin-neology',
|
||||
name: 'Neology',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'ALPR',
|
||||
'surveillance:zone': 'traffic',
|
||||
'camera:type': 'fixed',
|
||||
'manufacturer': 'Neology, Inc.',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: true,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Generic gunshot detector (customizable template, not submittable)
|
||||
factory NodeProfile.genericGunshotDetector() => NodeProfile(
|
||||
id: 'builtin-generic-gunshot',
|
||||
name: 'Generic Gunshot Detector',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: false,
|
||||
editable: false,
|
||||
);
|
||||
|
||||
/// Built‑in: ShotSpotter gunshot detector
|
||||
factory NodeProfile.shotspotter() => NodeProfile(
|
||||
id: 'builtin-shotspotter',
|
||||
name: 'ShotSpotter',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'surveillance:brand': 'ShotSpotter',
|
||||
'surveillance:brand:wikidata': 'Q107740188',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Built‑in: Flock Raven gunshot detector
|
||||
factory NodeProfile.flockRaven() => NodeProfile(
|
||||
id: 'builtin-flock-raven',
|
||||
name: 'Flock Raven',
|
||||
tags: const {
|
||||
'man_made': 'surveillance',
|
||||
'surveillance': 'public',
|
||||
'surveillance:type': 'gunshot_detector',
|
||||
'brand': 'Flock Safety',
|
||||
'brand:wikidata': 'Q108485435',
|
||||
},
|
||||
builtin: true,
|
||||
requiresDirection: false,
|
||||
submittable: true,
|
||||
editable: true,
|
||||
);
|
||||
|
||||
/// Returns true if this profile can be used for submissions
|
||||
bool get isSubmittable => submittable;
|
||||
|
||||
NodeProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
bool? builtin,
|
||||
bool? requiresDirection,
|
||||
bool? submittable,
|
||||
bool? editable,
|
||||
}) =>
|
||||
NodeProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
builtin: builtin ?? this.builtin,
|
||||
requiresDirection: requiresDirection ?? this.requiresDirection,
|
||||
submittable: submittable ?? this.submittable,
|
||||
editable: editable ?? this.editable,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
'builtin': builtin,
|
||||
'requiresDirection': requiresDirection,
|
||||
'submittable': submittable,
|
||||
'editable': editable,
|
||||
};
|
||||
|
||||
factory NodeProfile.fromJson(Map<String, dynamic> j) => NodeProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
builtin: j['builtin'] ?? false,
|
||||
requiresDirection: j['requiresDirection'] ?? true, // Default to true for backward compatibility
|
||||
submittable: j['submittable'] ?? true, // Default to true for backward compatibility
|
||||
editable: j['editable'] ?? true, // Default to true for backward compatibility
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is NodeProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
48
lib/models/operator_profile.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A bundle of OSM tags that describe a particular surveillance operator.
|
||||
/// These are applied on top of camera profile tags during submissions.
|
||||
class OperatorProfile {
|
||||
final String id;
|
||||
final String name;
|
||||
final Map<String, String> tags;
|
||||
|
||||
OperatorProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
OperatorProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
Map<String, String>? tags,
|
||||
}) =>
|
||||
OperatorProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'tags': tags,
|
||||
};
|
||||
|
||||
factory OperatorProfile.fromJson(Map<String, dynamic> j) => OperatorProfile(
|
||||
id: j['id'],
|
||||
name: j['name'],
|
||||
tags: Map<String, String>.from(j['tags']),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is OperatorProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class OsmCameraNode {
|
||||
class OsmNode {
|
||||
final int id;
|
||||
final LatLng coord;
|
||||
final Map<String, String> tags;
|
||||
|
||||
OsmCameraNode({
|
||||
OsmNode({
|
||||
required this.id,
|
||||
required this.coord,
|
||||
required this.tags,
|
||||
@@ -18,14 +18,14 @@ class OsmCameraNode {
|
||||
'tags': tags,
|
||||
};
|
||||
|
||||
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
|
||||
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||
final tags = <String, String>{};
|
||||
if (json['tags'] != null) {
|
||||
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||
tags[k.toString()] = v.toString();
|
||||
});
|
||||
}
|
||||
return OsmCameraNode(
|
||||
return OsmNode(
|
||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
||||
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||
tags: tags,
|
||||
@@ -51,5 +51,4 @@ class OsmCameraNode {
|
||||
final normalized = ((val % 360) + 360) % 360;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +1,111 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'camera_profile.dart';
|
||||
import 'node_profile.dart';
|
||||
import 'operator_profile.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
enum UploadOperation { create, modify, delete }
|
||||
|
||||
class PendingUpload {
|
||||
final LatLng coord;
|
||||
final double direction;
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
final OperatorProfile? operatorProfile;
|
||||
final UploadMode uploadMode; // Capture upload destination when queued
|
||||
final UploadOperation operation; // Type of operation: create, modify, or delete
|
||||
final int? originalNodeId; // If this is modify/delete, the ID of the original OSM node
|
||||
int? submittedNodeId; // The actual node ID returned by OSM after successful submission
|
||||
int attempts;
|
||||
bool error;
|
||||
bool completing; // True when upload succeeded but item is showing checkmark briefly
|
||||
|
||||
PendingUpload({
|
||||
required this.coord,
|
||||
required this.direction,
|
||||
required this.profile,
|
||||
this.operatorProfile,
|
||||
required this.uploadMode,
|
||||
required this.operation,
|
||||
this.originalNodeId,
|
||||
this.submittedNodeId,
|
||||
this.attempts = 0,
|
||||
this.error = false,
|
||||
});
|
||||
this.completing = false,
|
||||
}) : assert(
|
||||
(operation == UploadOperation.create && originalNodeId == null) ||
|
||||
(operation != UploadOperation.create && originalNodeId != null),
|
||||
'originalNodeId must be null for create operations and non-null for modify/delete operations'
|
||||
);
|
||||
|
||||
// True if this is an edit of an existing node, false if it's a new node
|
||||
bool get isEdit => operation == UploadOperation.modify;
|
||||
|
||||
// True if this is a deletion of an existing node
|
||||
bool get isDeletion => operation == UploadOperation.delete;
|
||||
|
||||
// Get display name for the upload destination
|
||||
String get uploadModeDisplayName {
|
||||
switch (uploadMode) {
|
||||
case UploadMode.production:
|
||||
return 'Production';
|
||||
case UploadMode.sandbox:
|
||||
return 'Sandbox';
|
||||
case UploadMode.simulate:
|
||||
return 'Simulate';
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined tags from node profile and operator profile
|
||||
Map<String, String> getCombinedTags() {
|
||||
final tags = Map<String, String>.from(profile.tags);
|
||||
|
||||
// Add operator profile tags (they override node profile tags if there are conflicts)
|
||||
if (operatorProfile != null) {
|
||||
tags.addAll(operatorProfile!.tags);
|
||||
}
|
||||
|
||||
// Add direction if required
|
||||
if (profile.requiresDirection) {
|
||||
tags['direction'] = direction.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': coord.latitude,
|
||||
'lon': coord.longitude,
|
||||
'dir': direction,
|
||||
'profile': profile.toJson(),
|
||||
'operatorProfile': operatorProfile?.toJson(),
|
||||
'uploadMode': uploadMode.index,
|
||||
'operation': operation.index,
|
||||
'originalNodeId': originalNodeId,
|
||||
'submittedNodeId': submittedNodeId,
|
||||
'attempts': attempts,
|
||||
'error': error,
|
||||
'completing': completing,
|
||||
};
|
||||
|
||||
factory PendingUpload.fromJson(Map<String, dynamic> j) => PendingUpload(
|
||||
coord: LatLng(j['lat'], j['lon']),
|
||||
direction: j['dir'],
|
||||
profile: j['profile'] is Map<String, dynamic>
|
||||
? CameraProfile.fromJson(j['profile'])
|
||||
: CameraProfile.alpr(),
|
||||
? NodeProfile.fromJson(j['profile'])
|
||||
: NodeProfile.genericAlpr(),
|
||||
operatorProfile: j['operatorProfile'] != null
|
||||
? OperatorProfile.fromJson(j['operatorProfile'])
|
||||
: null,
|
||||
uploadMode: j['uploadMode'] != null
|
||||
? UploadMode.values[j['uploadMode']]
|
||||
: UploadMode.production, // Default for legacy entries
|
||||
operation: j['operation'] != null
|
||||
? UploadOperation.values[j['operation']]
|
||||
: (j['originalNodeId'] != null ? UploadOperation.modify : UploadOperation.create), // Legacy compatibility
|
||||
originalNodeId: j['originalNodeId'],
|
||||
submittedNodeId: j['submittedNodeId'],
|
||||
attempts: j['attempts'] ?? 0,
|
||||
error: j['error'] ?? false,
|
||||
completing: j['completing'] ?? false, // Default to false for legacy entries
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
198
lib/models/tile_provider.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// A specific tile type within a provider
|
||||
class TileType {
|
||||
final String id;
|
||||
final String name;
|
||||
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,
|
||||
required this.name,
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
this.previewTile,
|
||||
this.maxZoom = 18, // Default max zoom level
|
||||
});
|
||||
|
||||
/// Create URL for a specific tile, replacing template variables
|
||||
String getTileUrl(int z, int x, int y, {String? apiKey}) {
|
||||
String url = urlTemplate
|
||||
.replaceAll('{z}', z.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
url = url.replaceAll('{api_key}', apiKey);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Check if this tile type needs an API key
|
||||
bool get requiresApiKey => urlTemplate.contains('{api_key}');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'urlTemplate': urlTemplate,
|
||||
'attribution': attribution,
|
||||
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
|
||||
'maxZoom': maxZoom,
|
||||
};
|
||||
|
||||
static TileType fromJson(Map<String, dynamic> json) => TileType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
urlTemplate: json['urlTemplate'],
|
||||
attribution: json['attribution'],
|
||||
previewTile: json['previewTile'] != null
|
||||
? base64Decode(json['previewTile'])
|
||||
: null,
|
||||
maxZoom: json['maxZoom'] ?? 18, // Default to 18 if not specified
|
||||
);
|
||||
|
||||
TileType copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
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
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileType && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// A tile provider containing multiple tile types
|
||||
class TileProvider {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? apiKey;
|
||||
final List<TileType> tileTypes;
|
||||
|
||||
const TileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.apiKey,
|
||||
required this.tileTypes,
|
||||
});
|
||||
|
||||
/// Check if this provider is usable (has API key if any tile types need it)
|
||||
bool get isUsable {
|
||||
final needsKey = tileTypes.any((type) => type.requiresApiKey);
|
||||
return !needsKey || (apiKey != null && apiKey!.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Get available tile types (those that don't need API key or have one)
|
||||
List<TileType> get availableTileTypes {
|
||||
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'apiKey': apiKey,
|
||||
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
|
||||
};
|
||||
|
||||
static TileProvider fromJson(Map<String, dynamic> json) => TileProvider(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
apiKey: json['apiKey'],
|
||||
tileTypes: (json['tileTypes'] as List)
|
||||
.map((typeJson) => TileType.fromJson(typeJson))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
TileProvider copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? apiKey,
|
||||
List<TileType>? tileTypes,
|
||||
}) => TileProvider(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
tileTypes: tileTypes ?? this.tileTypes,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TileProvider && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// Factory for creating default tile providers
|
||||
class DefaultTileProviders {
|
||||
/// Create the default set of tile providers
|
||||
static List<TileProvider> createDefaults() {
|
||||
return [
|
||||
TileProvider(
|
||||
id: 'openstreetmap',
|
||||
name: 'OpenStreetMap',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'osm_street',
|
||||
name: 'Street Map',
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
),
|
||||
],
|
||||
),
|
||||
TileProvider(
|
||||
id: 'mapbox',
|
||||
name: 'Mapbox',
|
||||
tileTypes: [
|
||||
TileType(
|
||||
id: 'mapbox_satellite',
|
||||
name: 'Satellite',
|
||||
urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
|
||||
attribution: '© Mapbox © Maxar',
|
||||
),
|
||||
TileType(
|
||||
id: 'mapbox_streets',
|
||||
name: 'Streets',
|
||||
urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}',
|
||||
attribution: '© Mapbox © OpenStreetMap',
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
113
lib/screens/about_screen.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
lib/screens/advanced_settings_screen.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
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 {
|
||||
const AdvancedSettingsScreen({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('settings.advancedSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
MaxNodesSection(),
|
||||
Divider(),
|
||||
ProximityAlertsSection(),
|
||||
Divider(),
|
||||
SuspectedLocationsSection(),
|
||||
Divider(),
|
||||
NetworkStatusSection(),
|
||||
Divider(),
|
||||
TileProviderSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'package:flock_map_app/dev_config.dart';
|
||||
import '../app_state.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../widgets/tile_provider_with_cache.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../widgets/add_camera_sheet.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';
|
||||
import '../dev_config.dart';
|
||||
import '../widgets/map_view.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
import '../widgets/add_node_sheet.dart';
|
||||
import '../widgets/edit_node_sheet.dart';
|
||||
import '../widgets/node_tag_sheet.dart';
|
||||
import '../widgets/camera_provider_with_cache.dart';
|
||||
import '../services/offline_areas/offline_tile_utils.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});
|
||||
@@ -19,260 +30,653 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final MapController _mapController = MapController();
|
||||
bool _followMe = true;
|
||||
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;
|
||||
|
||||
// Track selected node for highlighting
|
||||
int? _selectedNodeId;
|
||||
|
||||
void _openAddCameraSheet() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapController = AnimatedMapController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getFollowMeTooltip(FollowMeMode mode) {
|
||||
final locService = LocalizationService.instance;
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return locService.t('followMe.off');
|
||||
case FollowMeMode.northUp:
|
||||
return locService.t('followMe.northUp');
|
||||
case FollowMeMode.rotating:
|
||||
return locService.t('followMe.rotating');
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getFollowMeIcon(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return Icons.gps_off;
|
||||
case FollowMeMode.northUp:
|
||||
return Icons.gps_fixed;
|
||||
case FollowMeMode.rotating:
|
||||
return Icons.navigation;
|
||||
}
|
||||
}
|
||||
|
||||
FollowMeMode _getNextFollowMeMode(FollowMeMode mode) {
|
||||
switch (mode) {
|
||||
case FollowMeMode.off:
|
||||
return FollowMeMode.northUp;
|
||||
case FollowMeMode.northUp:
|
||||
return FollowMeMode.rotating;
|
||||
case FollowMeMode.rotating:
|
||||
return FollowMeMode.off;
|
||||
}
|
||||
}
|
||||
|
||||
void _openAddNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when adding a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
appState.startAddSession();
|
||||
final session = appState.session!; // guaranteed non‑null now
|
||||
|
||||
_scaffoldKey.currentState!.showBottomSheet(
|
||||
(ctx) => AddCameraSheet(session: session),
|
||||
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(() {
|
||||
_addSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||
});
|
||||
},
|
||||
child: AddNodeSheet(session: session),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_addSheetHeight = 0.0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _openEditNodeSheet() {
|
||||
final appState = context.read<AppState>();
|
||||
// Disable follow-me when editing a camera so the map doesn't jump around
|
||||
appState.setFollowMeMode(FollowMeMode.off);
|
||||
|
||||
// Set transition flag to prevent map bounce
|
||||
_transitioningToEdit = true;
|
||||
|
||||
// Close any existing tag sheet first
|
||||
if (_tagSheetHeight > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
final session = appState.editSession!; // should be non-null when this is called
|
||||
|
||||
// Small delay to let tag sheet close smoothly
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
|
||||
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(() {
|
||||
_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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height when sheet is dismissed
|
||||
controller.closed.then((_) {
|
||||
setState(() {
|
||||
_editSheetHeight = 0.0;
|
||||
_transitioningToEdit = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
// Start smooth centering animation simultaneously with sheet opening
|
||||
// Use the same duration as SheetAwareMap (300ms) for coordinated animation
|
||||
try {
|
||||
_mapController.animateTo(
|
||||
dest: node.coord,
|
||||
zoom: _mapController.mapController.camera.zoom,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
} catch (_) {
|
||||
// Map controller not ready, fallback to immediate move
|
||||
try {
|
||||
_mapController.mapController.move(node.coord, _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: NodeTagSheet(
|
||||
node: node,
|
||||
onEditPressed: () {
|
||||
final appState = context.read<AppState>();
|
||||
appState.startEditSession(node);
|
||||
// This will trigger _openEditNodeSheet via the existing auto-show logic
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Reset height and selection when sheet is dismissed (unless transitioning to edit)
|
||||
controller.closed.then((_) {
|
||||
if (!_transitioningToEdit) {
|
||||
setState(() {
|
||||
_tagSheetHeight = 0.0;
|
||||
_selectedNodeId = null; // Clear selection
|
||||
});
|
||||
}
|
||||
// If transitioning to edit, keep the height until edit sheet takes over
|
||||
});
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
// Auto-open edit sheet when edit session starts
|
||||
if (appState.editSession != null && !_editSheetShown) {
|
||||
_editSheetShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _openEditNodeSheet());
|
||||
} else if (appState.editSession == null) {
|
||||
_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
|
||||
: (_navigationSheetHeight > 0
|
||||
? _navigationSheetHeight
|
||||
: _tagSheetHeight));
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<TileProviderWithCache>(create: (_) => TileProviderWithCache()),
|
||||
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
|
||||
],
|
||||
child: Scaffold(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
title: const Text('Flock Map'),
|
||||
automaticallyImplyLeading: false, // Disable automatic back button
|
||||
title: SvgPicture.asset(
|
||||
'assets/deflock-logo.svg',
|
||||
height: 28,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _followMe ? 'Disable follow‑me' : 'Enable follow‑me',
|
||||
icon: Icon(_followMe ? Icons.gps_fixed : Icons.gps_off),
|
||||
onPressed: () => setState(() => _followMe = !_followMe),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MapView(
|
||||
controller: _mapController,
|
||||
followMe: _followMe,
|
||||
onUserGesture: () {
|
||||
if (_followMe) setState(() => _followMe = false);
|
||||
},
|
||||
),
|
||||
floatingActionButton: appState.session == null
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.extended(
|
||||
onPressed: _openAddCameraSheet,
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
label: const Text('Tag Camera'),
|
||||
heroTag: 'tag_camera_fab',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FloatingActionButton.extended(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController),
|
||||
),
|
||||
icon: const Icon(Icons.download_for_offline),
|
||||
label: const Text('Download'),
|
||||
heroTag: 'download_fab',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download area dialog ---
|
||||
class DownloadAreaDialog extends StatefulWidget {
|
||||
final MapController controller;
|
||||
const DownloadAreaDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<DownloadAreaDialog> createState() => _DownloadAreaDialogState();
|
||||
}
|
||||
|
||||
class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
|
||||
double _zoom = 15;
|
||||
int? _minZoom;
|
||||
int? _tileCount;
|
||||
double? _mbEstimate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
}
|
||||
|
||||
void _recomputeEstimates() {
|
||||
var bounds = widget.controller.camera.visibleBounds;
|
||||
// If the visible area is nearly zero, nudge the bounds for estimation
|
||||
const double epsilon = 0.0002;
|
||||
final latSpan = (bounds.north - bounds.south).abs();
|
||||
final lngSpan = (bounds.east - bounds.west).abs();
|
||||
if (latSpan < epsilon && lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
} else if (latSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude - epsilon, bounds.southWest.longitude),
|
||||
LatLng(bounds.northEast.latitude + epsilon, bounds.northEast.longitude)
|
||||
);
|
||||
} else if (lngSpan < epsilon) {
|
||||
bounds = LatLngBounds(
|
||||
LatLng(bounds.southWest.latitude, bounds.southWest.longitude - epsilon),
|
||||
LatLng(bounds.northEast.latitude, bounds.northEast.longitude + epsilon)
|
||||
);
|
||||
}
|
||||
final minZoom = findDynamicMinZoom(bounds);
|
||||
final maxZoom = _zoom.toInt();
|
||||
final nTiles = computeTileList(bounds, minZoom, maxZoom).length;
|
||||
final totalMb = (nTiles * kTileEstimateKb) / 1024.0;
|
||||
setState(() {
|
||||
_minZoom = minZoom;
|
||||
_tileCount = nTiles;
|
||||
_mbEstimate = totalMb;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bounds = widget.controller.camera.visibleBounds;
|
||||
final maxZoom = _zoom.toInt();
|
||||
double sliderMin;
|
||||
double sliderMax;
|
||||
int sliderDivisions;
|
||||
double sliderValue;
|
||||
// Generate slider min/max/divisions with clarity
|
||||
if (_minZoom != null) {
|
||||
sliderMin = _minZoom!.toDouble();
|
||||
} else {
|
||||
sliderMin = 12.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
sliderMax = candidateMax > 19 ? 19.0 : candidateMax.toDouble();
|
||||
} else {
|
||||
sliderMax = 19.0; //fallback
|
||||
}
|
||||
if (_minZoom != null) {
|
||||
final candidateMax = _minZoom! + kMaxUserDownloadZoomSpan;
|
||||
int diff = (candidateMax > 19 ? 19 : candidateMax) - _minZoom!;
|
||||
sliderDivisions = diff > 0 ? diff : 1;
|
||||
} else {
|
||||
sliderDivisions = 7; //fallback
|
||||
}
|
||||
sliderValue = _zoom.clamp(sliderMin, sliderMax);
|
||||
// We recompute estimates when the zoom slider changes
|
||||
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.download_for_offline),
|
||||
SizedBox(width: 10),
|
||||
Text("Download Map Area"),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Max zoom level'),
|
||||
Text('Z${_zoom.toStringAsFixed(0)}'),
|
||||
],
|
||||
),
|
||||
|
||||
Slider(
|
||||
min: sliderMin,
|
||||
max: sliderMax,
|
||||
divisions: sliderDivisions,
|
||||
label: 'Z${_zoom.toStringAsFixed(0)}',
|
||||
value: sliderValue,
|
||||
onChanged: (v) {
|
||||
setState(() => _zoom = v);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _recomputeEstimates());
|
||||
tooltip: _getFollowMeTooltip(appState.followMeMode),
|
||||
icon: Icon(_getFollowMeIcon(appState.followMeMode)),
|
||||
onPressed: () {
|
||||
final oldMode = appState.followMeMode;
|
||||
final newMode = _getNextFollowMeMode(oldMode);
|
||||
debugPrint('[HomeScreen] Follow mode changed: $oldMode → $newMode');
|
||||
appState.setFollowMeMode(newMode);
|
||||
// If enabling follow-me, retry location init in case permission was granted
|
||||
if (newMode != FollowMeMode.off) {
|
||||
_mapViewKey.currentState?.retryLocationInit();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Storage estimate:'),
|
||||
Text(_mbEstimate == null
|
||||
? '…'
|
||||
: '${_tileCount} tiles, ${_mbEstimate!.toStringAsFixed(1)} MB'),
|
||||
],
|
||||
AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => IconButton(
|
||||
tooltip: LocalizationService.instance.settings,
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
MapView(
|
||||
key: _mapViewKey,
|
||||
controller: _mapController,
|
||||
followMeMode: appState.followMeMode,
|
||||
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(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context).shadowColor.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, -2),
|
||||
)
|
||||
],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7, // 70% for primary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => ElevatedButton.icon(
|
||||
icon: Icon(Icons.add_location_alt),
|
||||
label: Text(LocalizationService.instance.tagNode),
|
||||
onPressed: _openAddNodeSheet,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 3, // 30% for secondary action
|
||||
child: AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(Icons.download_for_offline),
|
||||
label: Text(LocalizationService.instance.download),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size(0, 48),
|
||||
textStyle: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_minZoom != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Min zoom:'),
|
||||
Text('Z$_minZoom'),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final id = DateTime.now().toIso8601String().replaceAll(':', '-');
|
||||
final appDocDir = await OfflineAreaService().getOfflineAreaDir();
|
||||
final dir = "${appDocDir.path}/$id";
|
||||
// Fire and forget: don't await download, so dialog closes immediately
|
||||
// ignore: unawaited_futures
|
||||
OfflineAreaService().downloadArea(
|
||||
id: id,
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom ?? 12,
|
||||
maxZoom: maxZoom,
|
||||
directory: dir,
|
||||
onProgress: (progress) {},
|
||||
onComplete: (status) {},
|
||||
);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download started!'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start download: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
25
lib/screens/language_settings_screen.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/language_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class LanguageSettingsScreen extends StatelessWidget {
|
||||
const LanguageSettingsScreen({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('settings.language')),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: LanguageSection(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/screens/offline_settings_screen.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/offline_mode_section.dart';
|
||||
import 'settings/sections/offline_areas_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class OfflineSettingsScreen extends StatelessWidget {
|
||||
const OfflineSettingsScreen({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('settings.offlineSettings')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
OfflineModeSection(),
|
||||
Divider(),
|
||||
OfflineAreasSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/screens/operator_profile_editor.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/operator_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class OperatorProfileEditor extends StatefulWidget {
|
||||
const OperatorProfileEditor({super.key, required this.profile});
|
||||
|
||||
final OperatorProfile profile;
|
||||
|
||||
@override
|
||||
State<OperatorProfileEditor> createState() => _OperatorProfileEditorState();
|
||||
}
|
||||
|
||||
class _OperatorProfileEditorState extends State<OperatorProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('operator', ''),
|
||||
MapEntry('operator:type', ''),
|
||||
MapEntry('operator:wikidata', ''),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
_tags = [..._defaultTags];
|
||||
} else {
|
||||
_tags = widget.profile.tags.entries.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.profile.name.isEmpty ? locService.t('operatorProfileEditor.newOperatorProfile') : locService.t('operatorProfileEditor.editOperatorProfile')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('operatorProfileEditor.operatorName'),
|
||||
hintText: locService.t('operatorProfileEditor.operatorNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('operatorProfileEditor.operatorNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
final tagMap = <String, String>{};
|
||||
for (final e in _tags) {
|
||||
if (e.key.trim().isEmpty || e.value.trim().isEmpty) continue;
|
||||
tagMap[e.key.trim()] = e.value.trim();
|
||||
}
|
||||
|
||||
final newProfile = widget.profile.copyWith(
|
||||
id: widget.profile.id.isEmpty ? const Uuid().v4() : widget.profile.id,
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateOperatorProfile(newProfile);
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(locService.t('operatorProfileEditor.operatorProfileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/camera_profile.dart';
|
||||
import '../models/node_profile.dart';
|
||||
import '../app_state.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
const ProfileEditor({super.key, required this.profile});
|
||||
|
||||
final CameraProfile profile;
|
||||
final NodeProfile profile;
|
||||
|
||||
@override
|
||||
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||
@@ -17,6 +18,8 @@ class ProfileEditor extends StatefulWidget {
|
||||
class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late List<MapEntry<String, String>> _tags;
|
||||
late bool _requiresDirection;
|
||||
late bool _submittable;
|
||||
|
||||
static const _defaultTags = [
|
||||
MapEntry('man_made', 'surveillance'),
|
||||
@@ -33,6 +36,8 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController(text: widget.profile.name);
|
||||
_requiresDirection = widget.profile.requiresDirection;
|
||||
_submittable = widget.profile.submittable;
|
||||
|
||||
if (widget.profile.tags.isEmpty) {
|
||||
// New profile → start with sensible defaults
|
||||
@@ -50,47 +55,77 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(widget.profile.name.isEmpty ? 'New Profile' : 'Edit Profile'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Profile name',
|
||||
hintText: 'e.g., Custom ALPR Camera',
|
||||
),
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(!widget.profile.editable
|
||||
? locService.t('profileEditor.viewProfile')
|
||||
: (widget.profile.name.isEmpty ? locService.t('profileEditor.newProfile') : locService.t('profileEditor.editProfile'))),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('OSM Tags',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add tag'),
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
readOnly: !widget.profile.editable,
|
||||
decoration: InputDecoration(
|
||||
labelText: locService.t('profileEditor.profileName'),
|
||||
hintText: locService.t('profileEditor.profileNameHint'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (widget.profile.editable) ...[
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.requiresDirection')),
|
||||
subtitle: Text(locService.t('profileEditor.requiresDirectionSubtitle')),
|
||||
value: _requiresDirection,
|
||||
onChanged: (value) => setState(() => _requiresDirection = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(locService.t('profileEditor.submittable')),
|
||||
subtitle: Text(locService.t('profileEditor.submittableSubtitle')),
|
||||
value: _submittable,
|
||||
onChanged: (value) => setState(() => _submittable = value ?? true),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(locService.t('profileEditor.osmTags'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
if (widget.profile.editable)
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _tags.add(const MapEntry('', ''))),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(locService.t('profileEditor.addTag')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.profile.editable)
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: Text(locService.t('profileEditor.saveProfile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._buildTagRows(),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save Profile'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTagRows() {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return List.generate(_tags.length, (i) {
|
||||
final keyController = TextEditingController(text: _tags[i].key);
|
||||
final valueController = TextEditingController(text: _tags[i].value);
|
||||
@@ -102,32 +137,39 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'key',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.keyHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: keyController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(v, _tags[i].value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: locService.t('profileEditor.valueHint'),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
controller: valueController,
|
||||
onChanged: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
readOnly: !widget.profile.editable,
|
||||
onChanged: !widget.profile.editable
|
||||
? null
|
||||
: (v) => _tags[i] = MapEntry(_tags[i].key, v),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
if (widget.profile.editable)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => setState(() => _tags.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -135,10 +177,12 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final locService = LocalizationService.instance;
|
||||
final name = _nameCtrl.text.trim();
|
||||
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Profile name is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.profileNameRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,7 +194,7 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
|
||||
if (tagMap.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('At least one tag is required')));
|
||||
.showSnackBar(SnackBar(content: Text(locService.t('profileEditor.atLeastOneTagRequired'))));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,13 +203,16 @@ class _ProfileEditorState extends State<ProfileEditor> {
|
||||
name: name,
|
||||
tags: tagMap,
|
||||
builtin: false,
|
||||
requiresDirection: _requiresDirection,
|
||||
submittable: _submittable,
|
||||
editable: true, // All custom profiles are editable by definition
|
||||
);
|
||||
|
||||
context.read<AppState>().addOrUpdateProfile(newProfile);
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Profile "${newProfile.name}" saved')),
|
||||
SnackBar(content: Text(locService.t('profileEditor.profileSaved', params: [newProfile.name]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
30
lib/screens/profiles_settings_screen.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'settings/sections/node_profiles_section.dart';
|
||||
import 'settings/sections/operator_profiles_section.dart';
|
||||
import '../services/localization_service.dart';
|
||||
|
||||
class ProfilesSettingsScreen extends StatelessWidget {
|
||||
const ProfilesSettingsScreen({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('settings.profiles')),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
NodeProfilesSection(),
|
||||
Divider(),
|
||||
OperatorProfilesSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
lib/screens/settings/sections/about_section.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class AboutSection extends StatelessWidget {
|
||||
const AboutSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(locService.t('settings.aboutInfo')),
|
||||
onTap: () async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(context).loadString('assets/info.txt'),
|
||||
builder: (context, snapshot) => AlertDialog(
|
||||
title: Text(locService.t('settings.aboutThisApp')),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
snapshot.connectionState == ConnectionState.done
|
||||
? (snapshot.data ?? 'No info available.')
|
||||
: 'Loading...',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(locService.ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
77
lib/screens/settings/sections/auth_section.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class AuthSection extends StatelessWidget {
|
||||
const AuthSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
final appState = context.watch<AppState>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
appState.isLoggedIn ? Icons.person : Icons.login,
|
||||
color: appState.isLoggedIn ? Colors.green : null,
|
||||
),
|
||||
title: Text(appState.isLoggedIn
|
||||
? locService.t('auth.loggedInAs', params: [appState.username])
|
||||
: locService.t('auth.loginToOSM')),
|
||||
subtitle: appState.isLoggedIn
|
||||
? Text(locService.t('auth.tapToLogout'))
|
||||
: Text(locService.t('auth.requiredToSubmit')),
|
||||
onTap: () async {
|
||||
if (appState.isLoggedIn) {
|
||||
await appState.logout();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(locService.t('auth.loggedOut')),
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Start login flow - the user will be redirected to browser
|
||||
await appState.forceLogin();
|
||||
|
||||
// Don't show immediate feedback - the UI will update automatically
|
||||
// when the OAuth callback completes and notifyListeners() is called
|
||||
}
|
||||
},
|
||||
),
|
||||
if (appState.isLoggedIn)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wifi_protected_setup),
|
||||
title: Text(locService.t('auth.testConnection')),
|
||||
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
|
||||
onTap: () async {
|
||||
final isValid = await appState.validateToken();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isValid
|
||||
? locService.t('auth.connectionOK')
|
||||
: locService.t('auth.connectionFailed')),
|
||||
backgroundColor: isValid ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isValid) {
|
||||
await appState.logout();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/screens/settings/sections/language_section.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
|
||||
@override
|
||||
State<LanguageSection> createState() => _LanguageSectionState();
|
||||
}
|
||||
|
||||
class _LanguageSectionState extends State<LanguageSection> {
|
||||
String? _selectedLanguage;
|
||||
Map<String, String> _languageNames = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelectedLanguage();
|
||||
_loadLanguageNames();
|
||||
}
|
||||
|
||||
_loadSelectedLanguage() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_selectedLanguage = prefs.getString('language_code');
|
||||
});
|
||||
}
|
||||
|
||||
_loadLanguageNames() async {
|
||||
final locService = LocalizationService.instance;
|
||||
final Map<String, String> names = {};
|
||||
|
||||
for (String langCode in locService.availableLanguages) {
|
||||
names[langCode] = await locService.getLanguageDisplayName(langCode);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_languageNames = names;
|
||||
});
|
||||
}
|
||||
|
||||
_setLanguage(String? languageCode) async {
|
||||
await LocalizationService.instance.setLanguage(languageCode);
|
||||
setState(() {
|
||||
_selectedLanguage = languageCode;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
value: null,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// English always appears second (if available)
|
||||
if (locService.availableLanguages.contains('en'))
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames['en'] ?? 'English'),
|
||||
value: 'en',
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
// Other language options (excluding English since it's already shown)
|
||||
...locService.availableLanguages
|
||||
.where((langCode) => langCode != 'en')
|
||||
.map((langCode) =>
|
||||
RadioListTile<String>(
|
||||
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
|
||||
value: langCode,
|
||||
groupValue: _selectedLanguage,
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||