mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-22 07:18:22 +02:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5176c62e72 | ||
|
|
3f35c2d6a1 | ||
|
|
60b826d00e | ||
|
|
ca63aa95e3 | ||
|
|
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 |
No files matched your search
+147
-10
@@ -1,5 +1,12 @@
|
|||||||
name: Build Release
|
name: Build Release
|
||||||
on: workflow_dispatch
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
get-version:
|
get-version:
|
||||||
@@ -14,7 +21,7 @@ jobs:
|
|||||||
- name: Get version from lib/dev_config.dart
|
- name: Get version from lib/dev_config.dart
|
||||||
id: set-version
|
id: set-version
|
||||||
run: |
|
run: |
|
||||||
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') >> $GITHUB_OUTPUT
|
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1) >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# - name: Extract version from pubspec.yaml
|
# - name: Extract version from pubspec.yaml
|
||||||
# id: extract_version
|
# id: extract_version
|
||||||
@@ -61,7 +68,7 @@ jobs:
|
|||||||
echo "storeFile=keystore.jks" >> android/key.properties
|
echo "storeFile=keystore.jks" >> android/key.properties
|
||||||
|
|
||||||
- name: Build Android .apk
|
- name: Build Android .apk
|
||||||
run: flutter build apk --release
|
run: flutter build apk --release --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||||
|
|
||||||
- name: Upload .apk artifact
|
- name: Upload .apk artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -110,7 +117,7 @@ jobs:
|
|||||||
echo "storeFile=keystore.jks" >> android/key.properties
|
echo "storeFile=keystore.jks" >> android/key.properties
|
||||||
|
|
||||||
- name: Build Android appBundle
|
- name: Build Android appBundle
|
||||||
run: flutter build appbundle
|
run: flutter build appbundle --dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' --dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||||
|
|
||||||
- name: Upload .aab artifact
|
- name: Upload .aab artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -140,13 +147,88 @@ jobs:
|
|||||||
dart run flutter_launcher_icons
|
dart run flutter_launcher_icons
|
||||||
dart run flutter_native_splash:create
|
dart run flutter_native_splash:create
|
||||||
|
|
||||||
# - name: Build iOS .ipa
|
- name: Install Apple certificate and provisioning profile
|
||||||
# run: flutter build ipa --release
|
env:
|
||||||
|
BUILD_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
|
||||||
- name: Build iOS .app
|
P12_PASSWORD: ""
|
||||||
|
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
flutter build ios --release --no-codesign
|
# create variables
|
||||||
./app2ipa.sh build/ios/iphoneos/Runner.app
|
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
|
||||||
|
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
|
||||||
|
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
|
||||||
|
# import certificate and provisioning profile from secrets
|
||||||
|
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||||
|
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
|
||||||
|
|
||||||
|
# create temporary keychain
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
|
# import certificate to keychain
|
||||||
|
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
|
# Set this keychain as the default
|
||||||
|
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||||
|
security default-keychain -s $KEYCHAIN_PATH
|
||||||
|
|
||||||
|
# install provisioning profile
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||||
|
|
||||||
|
# Also install using the profile's internal UUID for better compatibility
|
||||||
|
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
|
||||||
|
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||||
|
|
||||||
|
# Debug: Check what we actually have
|
||||||
|
echo "=== Certificates in keychain ==="
|
||||||
|
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||||
|
echo "=== Provisioning profiles ==="
|
||||||
|
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||||
|
echo "=== Profile UUID extracted: $UUID ==="
|
||||||
|
|
||||||
|
- name: Create export options
|
||||||
|
run: |
|
||||||
|
cat > ios/exportOptions.plist << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>export</string>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>7XG8T28436</string>
|
||||||
|
<key>provisioningProfiles</key>
|
||||||
|
<dict>
|
||||||
|
<key>me.deflock.deflockapp</key>
|
||||||
|
<string>61f9fdb9-bf2d-4d94-b249-63155ee71e74</string>
|
||||||
|
</dict>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>manual</string>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build iOS .ipa
|
||||||
|
run: |
|
||||||
|
flutter build ipa --release \
|
||||||
|
--export-options-plist=ios/exportOptions.plist \
|
||||||
|
--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' \
|
||||||
|
--dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
|
||||||
|
cp build/ios/ipa/*.ipa Runner.ipa
|
||||||
|
|
||||||
|
- name: Clean up keychain and provisioning profile
|
||||||
|
run: |
|
||||||
|
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
||||||
|
rm ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -154,3 +236,58 @@ jobs:
|
|||||||
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
|
name: deflock_v${{ needs.get-version.outputs.version }}.ipa
|
||||||
path: Runner.ipa
|
path: Runner.ipa
|
||||||
if-no-files-found: 'error'
|
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 and decode API key
|
||||||
|
mkdir -p ~/private_keys
|
||||||
|
echo -n "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8
|
||||||
|
|
||||||
|
# Upload to App Store Connect / TestFlight
|
||||||
|
xcrun altool --upload-app \
|
||||||
|
--type ios \
|
||||||
|
--file Runner.ipa \
|
||||||
|
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
|
||||||
|
--apiIssuer $APP_STORE_CONNECT_ISSUER_ID
|
||||||
|
|
||||||
|
# Clean up sensitive files
|
||||||
|
rm -rf ~/private_keys
|
||||||
|
|
||||||
|
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
|
||||||
@@ -93,6 +93,9 @@ Thumbs.db
|
|||||||
*.keystore
|
*.keystore
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local OSM client ID configuration (contains secrets)
|
||||||
|
build_keys.conf
|
||||||
|
|
||||||
# ───────────────────────────────
|
# ───────────────────────────────
|
||||||
# For now - not targeting these
|
# For now - not targeting these
|
||||||
# ───────────────────────────────
|
# ───────────────────────────────
|
||||||
|
|||||||
@@ -79,14 +79,14 @@ cp lib/keys.dart.example lib/keys.dart
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Needed Bugfixes
|
### Needed Bugfixes
|
||||||
- Are offline areas really working? Are they preferred for fast loading even when online? Check working.
|
- Are offline areas preferred for fast loading even when online? Check working.
|
||||||
|
- 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
|
### Current Development
|
||||||
- Swap in alprwatch.org/directions avoidance routing API
|
- Swap in alprwatch.org/directions avoidance routing API
|
||||||
- Help button with links to email, discord, and website
|
- Help button with links to email, discord, and website
|
||||||
- Move download button?
|
|
||||||
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
- Clean cache when nodes have disappeared / been deleted by others / queue item was deleted
|
||||||
- Clean up dev_config
|
|
||||||
- Improve offline area node refresh live display
|
- Improve offline area node refresh live display
|
||||||
- Add default operator profiles (Lowe’s etc)
|
- Add default operator profiles (Lowe’s etc)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+60
-3
@@ -4,6 +4,35 @@
|
|||||||
BUILD_IOS=true
|
BUILD_IOS=true
|
||||||
BUILD_ANDROID=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
|
# Parse arguments
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
@@ -18,19 +47,47 @@ for arg in "$@"; do
|
|||||||
echo " --ios Build only iOS"
|
echo " --ios Build only iOS"
|
||||||
echo " --android Build only Android"
|
echo " --android Build only Android"
|
||||||
echo " (default builds both)"
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ')
|
# Load client IDs from build_keys.conf
|
||||||
|
if [ ! -f "build_keys.conf" ]; then
|
||||||
|
echo "Error: build_keys.conf not found"
|
||||||
|
echo "Copy build_keys.conf.example to build_keys.conf and fill in your OSM client IDs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading OSM client IDs from build_keys.conf..."
|
||||||
|
OSM_PROD_CLIENTID=$(read_from_file "OSM_PROD_CLIENTID")
|
||||||
|
OSM_SANDBOX_CLIENTID=$(read_from_file "OSM_SANDBOX_CLIENTID")
|
||||||
|
|
||||||
|
# Check required keys
|
||||||
|
if [ -z "$OSM_PROD_CLIENTID" ]; then
|
||||||
|
echo "Error: OSM_PROD_CLIENTID not found in build_keys.conf"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$OSM_SANDBOX_CLIENTID" ]; then
|
||||||
|
echo "Error: OSM_SANDBOX_CLIENTID not found in build_keys.conf"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the dart-define arguments
|
||||||
|
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
|
||||||
|
|
||||||
|
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
|
||||||
echo
|
echo
|
||||||
echo "Building app version ${appver}..."
|
echo "Building app version ${appver}..."
|
||||||
echo
|
echo
|
||||||
|
|
||||||
if [ "$BUILD_IOS" = true ]; then
|
if [ "$BUILD_IOS" = true ]; then
|
||||||
echo "Building iOS..."
|
echo "Building iOS..."
|
||||||
flutter build ios --no-codesign || exit 1
|
flutter build ios --no-codesign $DART_DEFINE_ARGS || exit 1
|
||||||
|
|
||||||
echo "Converting .app to .ipa..."
|
echo "Converting .app to .ipa..."
|
||||||
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
|
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
|
||||||
@@ -42,7 +99,7 @@ fi
|
|||||||
|
|
||||||
if [ "$BUILD_ANDROID" = true ]; then
|
if [ "$BUILD_ANDROID" = true ]; then
|
||||||
echo "Building Android..."
|
echo "Building Android..."
|
||||||
flutter build apk || exit 1
|
flutter build apk $DART_DEFINE_ARGS || exit 1
|
||||||
|
|
||||||
echo "Moving Android files..."
|
echo "Moving Android files..."
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
|
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1
|
||||||
|
|||||||
@@ -470,7 +470,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -479,6 +482,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -652,7 +656,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -661,6 +668,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -674,7 +682,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = 7XG8T28436;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -683,6 +694,7 @@
|
|||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>This app needs your location to show nearby cameras.</string>
|
<string>This app needs your location to show nearby cameras.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'models/node_profile.dart';
|
|||||||
import 'models/operator_profile.dart';
|
import 'models/operator_profile.dart';
|
||||||
import 'models/osm_node.dart';
|
import 'models/osm_node.dart';
|
||||||
import 'models/pending_upload.dart';
|
import 'models/pending_upload.dart';
|
||||||
|
import 'models/suspected_location.dart';
|
||||||
import 'models/tile_provider.dart';
|
import 'models/tile_provider.dart';
|
||||||
import 'models/search_result.dart';
|
import 'models/search_result.dart';
|
||||||
import 'services/offline_area_service.dart';
|
import 'services/offline_area_service.dart';
|
||||||
@@ -19,6 +20,7 @@ import 'state/profile_state.dart';
|
|||||||
import 'state/search_state.dart';
|
import 'state/search_state.dart';
|
||||||
import 'state/session_state.dart';
|
import 'state/session_state.dart';
|
||||||
import 'state/settings_state.dart';
|
import 'state/settings_state.dart';
|
||||||
|
import 'state/suspected_location_state.dart';
|
||||||
import 'state/upload_queue_state.dart';
|
import 'state/upload_queue_state.dart';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
@@ -38,6 +40,7 @@ class AppState extends ChangeNotifier {
|
|||||||
late final SearchState _searchState;
|
late final SearchState _searchState;
|
||||||
late final SessionState _sessionState;
|
late final SessionState _sessionState;
|
||||||
late final SettingsState _settingsState;
|
late final SettingsState _settingsState;
|
||||||
|
late final SuspectedLocationState _suspectedLocationState;
|
||||||
late final UploadQueueState _uploadQueueState;
|
late final UploadQueueState _uploadQueueState;
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -51,6 +54,7 @@ class AppState extends ChangeNotifier {
|
|||||||
_searchState = SearchState();
|
_searchState = SearchState();
|
||||||
_sessionState = SessionState();
|
_sessionState = SessionState();
|
||||||
_settingsState = SettingsState();
|
_settingsState = SettingsState();
|
||||||
|
_suspectedLocationState = SuspectedLocationState();
|
||||||
_uploadQueueState = UploadQueueState();
|
_uploadQueueState = UploadQueueState();
|
||||||
|
|
||||||
// Set up state change listeners
|
// Set up state change listeners
|
||||||
@@ -61,6 +65,7 @@ class AppState extends ChangeNotifier {
|
|||||||
_searchState.addListener(_onStateChanged);
|
_searchState.addListener(_onStateChanged);
|
||||||
_sessionState.addListener(_onStateChanged);
|
_sessionState.addListener(_onStateChanged);
|
||||||
_settingsState.addListener(_onStateChanged);
|
_settingsState.addListener(_onStateChanged);
|
||||||
|
_suspectedLocationState.addListener(_onStateChanged);
|
||||||
_uploadQueueState.addListener(_onStateChanged);
|
_uploadQueueState.addListener(_onStateChanged);
|
||||||
|
|
||||||
_init();
|
_init();
|
||||||
@@ -127,6 +132,7 @@ class AppState extends ChangeNotifier {
|
|||||||
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||||
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||||
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
|
||||||
|
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
|
||||||
|
|
||||||
// Tile provider state
|
// Tile provider state
|
||||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||||
@@ -139,6 +145,12 @@ class AppState extends ChangeNotifier {
|
|||||||
int get pendingCount => _uploadQueueState.pendingCount;
|
int get pendingCount => _uploadQueueState.pendingCount;
|
||||||
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
|
||||||
|
|
||||||
|
// Suspected location state
|
||||||
|
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
|
||||||
|
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
|
||||||
|
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;
|
||||||
|
DateTime? get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
|
||||||
|
|
||||||
void _onStateChanged() {
|
void _onStateChanged() {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -153,6 +165,7 @@ class AppState extends ChangeNotifier {
|
|||||||
|
|
||||||
await _operatorProfileState.init();
|
await _operatorProfileState.init();
|
||||||
await _profileState.init();
|
await _profileState.init();
|
||||||
|
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
|
||||||
await _uploadQueueState.init();
|
await _uploadQueueState.init();
|
||||||
await _authState.init(_settingsState.uploadMode);
|
await _authState.init(_settingsState.uploadMode);
|
||||||
|
|
||||||
@@ -408,6 +421,11 @@ class AppState extends ChangeNotifier {
|
|||||||
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
|
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set suspected location minimum distance from real nodes
|
||||||
|
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||||
|
await _settingsState.setSuspectedLocationMinDistance(distance);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Queue Methods ----------
|
// ---------- Queue Methods ----------
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
_uploadQueueState.clearQueue();
|
_uploadQueueState.clearQueue();
|
||||||
@@ -422,6 +440,39 @@ class AppState extends ChangeNotifier {
|
|||||||
_startUploader(); // resume uploader if not busy
|
_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 ----------
|
// ---------- Private Methods ----------
|
||||||
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
/// Attempts to fetch missing tile preview images in the background (fire and forget)
|
||||||
void _fetchMissingTilePreviews() {
|
void _fetchMissingTilePreviews() {
|
||||||
@@ -449,6 +500,7 @@ class AppState extends ChangeNotifier {
|
|||||||
_searchState.removeListener(_onStateChanged);
|
_searchState.removeListener(_onStateChanged);
|
||||||
_sessionState.removeListener(_onStateChanged);
|
_sessionState.removeListener(_onStateChanged);
|
||||||
_settingsState.removeListener(_onStateChanged);
|
_settingsState.removeListener(_onStateChanged);
|
||||||
|
_suspectedLocationState.removeListener(_onStateChanged);
|
||||||
_uploadQueueState.removeListener(_onStateChanged);
|
_uploadQueueState.removeListener(_onStateChanged);
|
||||||
|
|
||||||
_uploadQueueState.dispose();
|
_uploadQueueState.dispose();
|
||||||
|
|||||||
+29
-12
@@ -12,9 +12,12 @@ const int kPreviewTileY = 101300;
|
|||||||
const int kPreviewTileX = 41904;
|
const int kPreviewTileX = 41904;
|
||||||
|
|
||||||
// Direction cone for map view
|
// Direction cone for map view
|
||||||
const double kDirectionConeHalfAngle = 30.0; // degrees
|
const double kDirectionConeHalfAngle = 35.0; // degrees
|
||||||
const double kDirectionConeBaseLength = 0.001; // multiplier
|
const double kDirectionConeBaseLength = 5; // multiplier
|
||||||
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
|
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
|
||||||
|
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
|
||||||
|
// Base values for thickness - use helper functions below for pixel-ratio scaling
|
||||||
|
const double _kDirectionConeBorderWidthBase = 1.6;
|
||||||
|
|
||||||
// Bottom button bar positioning
|
// Bottom button bar positioning
|
||||||
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
|
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
|
||||||
@@ -37,6 +40,9 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
|
|||||||
const String kClientName = 'DeFlock';
|
const String kClientName = 'DeFlock';
|
||||||
// Note: Version is now dynamically retrieved from VersionService
|
// 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
|
// 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
|
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||||
|
|
||||||
@@ -86,12 +92,23 @@ const int kAbsoluteMaxTileCount = 50000;
|
|||||||
const int kAbsoluteMaxZoom = 23;
|
const int kAbsoluteMaxZoom = 23;
|
||||||
|
|
||||||
// Node icon configuration
|
// Node icon configuration
|
||||||
const double kNodeIconDiameter = 20.0;
|
const double kNodeIconDiameter = 18.0;
|
||||||
const double kNodeRingThickness = 4.0;
|
const double _kNodeRingThicknessBase = 2.5;
|
||||||
const double kNodeDotOpacity = 0.4; // Opacity for the grey dot interior
|
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
|
||||||
const Color kNodeRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
|
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
|
||||||
const Color kNodeRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
|
const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white
|
||||||
const Color kNodeRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
|
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
|
||||||
const Color kNodeRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
|
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
|
||||||
const Color kNodeRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
|
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
|
||||||
const Color kNodeRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
|
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;
|
||||||
|
}
|
||||||
+13
-4
@@ -1,7 +1,16 @@
|
|||||||
// OpenStreetMap OAuth client IDs for this app.
|
// OpenStreetMap OAuth client IDs for this app.
|
||||||
//
|
// These must be provided via --dart-define at build time.
|
||||||
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
|
|
||||||
|
|
||||||
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
|
String get kOsmProdClientId {
|
||||||
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
|
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';
|
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Britisch (mi, ft)",
|
"imperial": "Britisch (mi, ft)",
|
||||||
"meters": "Meter",
|
"meters": "Meter",
|
||||||
"feet": "Fuß"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Imperial (mi, ft)",
|
"imperial": "Imperial (mi, ft)",
|
||||||
"meters": "meters",
|
"meters": "meters",
|
||||||
"feet": "feet"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Imperial (mi, ft)",
|
"imperial": "Imperial (mi, ft)",
|
||||||
"meters": "metros",
|
"meters": "metros",
|
||||||
"feet": "pies"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Impérial (mi, ft)",
|
"imperial": "Impérial (mi, ft)",
|
||||||
"meters": "mètres",
|
"meters": "mètres",
|
||||||
"feet": "pieds"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Imperiale (mi, ft)",
|
"imperial": "Imperiale (mi, ft)",
|
||||||
"meters": "metri",
|
"meters": "metri",
|
||||||
"feet": "piedi"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "Imperial (mi, ft)",
|
"imperial": "Imperial (mi, ft)",
|
||||||
"meters": "metros",
|
"meters": "metros",
|
||||||
"feet": "pés"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,5 +341,40 @@
|
|||||||
"imperial": "英制(英里,英尺)",
|
"imperial": "英制(英里,英尺)",
|
||||||
"meters": "米",
|
"meters": "米",
|
||||||
"feet": "英尺"
|
"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": "无可用地址"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
class AboutScreen extends StatelessWidget {
|
class AboutScreen extends StatelessWidget {
|
||||||
const AboutScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final locService = LocalizationService.instance;
|
final locService = LocalizationService.instance;
|
||||||
@@ -17,28 +33,32 @@ class AboutScreen extends StatelessWidget {
|
|||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
locService.t('about.title'),
|
locService.t('about.title'),
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
locService.t('about.description'),
|
locService.t('about.description'),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
locService.t('about.features'),
|
locService.t('about.features'),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
locService.t('about.initiative'),
|
locService.t('about.initiative'),
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
@@ -48,10 +68,46 @@ class AboutScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildHelpLinks(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHelpLinks(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildLinkText(context, 'About DeFlock', 'https://deflock.me/about'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLinkText(context, 'Donate', 'https://deflock.me/donate'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLinkText(BuildContext context, String text, String url) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _launchUrl(url, context),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'settings/sections/max_nodes_section.dart';
|
import 'settings/sections/max_nodes_section.dart';
|
||||||
import 'settings/sections/proximity_alerts_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/tile_provider_section.dart';
|
||||||
import 'settings/sections/network_status_section.dart';
|
import 'settings/sections/network_status_section.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
@@ -25,6 +26,8 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
|||||||
Divider(),
|
Divider(),
|
||||||
ProximityAlertsSection(),
|
ProximityAlertsSection(),
|
||||||
Divider(),
|
Divider(),
|
||||||
|
SuspectedLocationsSection(),
|
||||||
|
Divider(),
|
||||||
NetworkStatusSection(),
|
NetworkStatusSection(),
|
||||||
Divider(),
|
Divider(),
|
||||||
TileProviderSection(),
|
TileProviderSection(),
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import '../widgets/download_area_dialog.dart';
|
|||||||
import '../widgets/measured_sheet.dart';
|
import '../widgets/measured_sheet.dart';
|
||||||
import '../widgets/navigation_sheet.dart';
|
import '../widgets/navigation_sheet.dart';
|
||||||
import '../widgets/search_bar.dart';
|
import '../widgets/search_bar.dart';
|
||||||
|
import '../widgets/suspected_location_sheet.dart';
|
||||||
import '../models/osm_node.dart';
|
import '../models/osm_node.dart';
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
import '../models/search_result.dart';
|
import '../models/search_result.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -455,6 +457,52 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void openSuspectedLocationSheet(SuspectedLocation location) {
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
appState.selectSuspectedLocation(location);
|
||||||
|
|
||||||
|
// Start smooth centering animation simultaneously with sheet opening
|
||||||
|
try {
|
||||||
|
_mapController.animateTo(
|
||||||
|
dest: location.centroid,
|
||||||
|
zoom: _mapController.mapController.camera.zoom,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Map controller not ready, fallback to immediate move
|
||||||
|
try {
|
||||||
|
_mapController.mapController.move(location.centroid, _mapController.mapController.camera.zoom);
|
||||||
|
} catch (_) {
|
||||||
|
// Controller really not ready, skip centering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = _scaffoldKey.currentState!.showBottomSheet(
|
||||||
|
(ctx) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom, // Only safe area, no keyboard
|
||||||
|
),
|
||||||
|
child: MeasuredSheet(
|
||||||
|
onHeightChanged: (height) {
|
||||||
|
setState(() {
|
||||||
|
_tagSheetHeight = height + MediaQuery.of(context).padding.bottom;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: SuspectedLocationSheet(location: location),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset height and clear selection when sheet is dismissed
|
||||||
|
controller.closed.then((_) {
|
||||||
|
setState(() {
|
||||||
|
_tagSheetHeight = 0.0;
|
||||||
|
});
|
||||||
|
appState.clearSuspectedLocationSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appState = context.watch<AppState>();
|
final appState = context.watch<AppState>();
|
||||||
@@ -536,6 +584,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
sheetHeight: activeSheetHeight,
|
sheetHeight: activeSheetHeight,
|
||||||
selectedNodeId: _selectedNodeId,
|
selectedNodeId: _selectedNodeId,
|
||||||
onNodeTap: openNodeTagSheet,
|
onNodeTap: openNodeTagSheet,
|
||||||
|
onSuspectedLocationTap: openSuspectedLocationSheet,
|
||||||
onSearchPressed: _onNavigationButtonPressed,
|
onSearchPressed: _onNavigationButtonPressed,
|
||||||
onUserGesture: () {
|
onUserGesture: () {
|
||||||
if (appState.followMeMode != FollowMeMode.off) {
|
if (appState.followMeMode != FollowMeMode.off) {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../app_state.dart';
|
||||||
|
import '../../../services/localization_service.dart';
|
||||||
|
import '../../../widgets/suspected_location_progress_dialog.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationsSection extends StatelessWidget {
|
||||||
|
const SuspectedLocationsSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: LocalizationService.instance,
|
||||||
|
builder: (context, child) {
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
final appState = context.watch<AppState>();
|
||||||
|
final isEnabled = appState.suspectedLocationsEnabled;
|
||||||
|
final isLoading = appState.suspectedLocationsLoading;
|
||||||
|
final lastFetch = appState.suspectedLocationsLastFetch;
|
||||||
|
|
||||||
|
String getLastFetchText() {
|
||||||
|
if (lastFetch == null) {
|
||||||
|
return locService.t('suspectedLocations.neverFetched');
|
||||||
|
} else {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(lastFetch);
|
||||||
|
if (diff.inDays > 0) {
|
||||||
|
return locService.t('suspectedLocations.daysAgo', params: [diff.inDays.toString()]);
|
||||||
|
} else if (diff.inHours > 0) {
|
||||||
|
return locService.t('suspectedLocations.hoursAgo', params: [diff.inHours.toString()]);
|
||||||
|
} else if (diff.inMinutes > 0) {
|
||||||
|
return locService.t('suspectedLocations.minutesAgo', params: [diff.inMinutes.toString()]);
|
||||||
|
} else {
|
||||||
|
return locService.t('suspectedLocations.justNow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleRefresh() async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Show simple progress dialog
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (progressContext) => SuspectedLocationProgressDialog(
|
||||||
|
title: locService.t('suspectedLocations.updating'),
|
||||||
|
message: locService.t('suspectedLocations.downloadingAndProcessing'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start the refresh
|
||||||
|
final success = await appState.refreshSuspectedLocations();
|
||||||
|
|
||||||
|
// Close progress dialog
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
|
// Show result snackbar
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(success
|
||||||
|
? locService.t('suspectedLocations.updateSuccess')
|
||||||
|
: locService.t('suspectedLocations.updateFailed')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('suspectedLocations.title'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Enable/disable switch
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.help_outline),
|
||||||
|
title: Text(locService.t('suspectedLocations.showSuspectedLocations')),
|
||||||
|
subtitle: Text(locService.t('suspectedLocations.showSuspectedLocationsSubtitle')),
|
||||||
|
trailing: Switch(
|
||||||
|
value: isEnabled,
|
||||||
|
onChanged: (enabled) {
|
||||||
|
appState.setSuspectedLocationsEnabled(enabled);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isEnabled) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Last update time
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.schedule),
|
||||||
|
title: Text(locService.t('suspectedLocations.lastUpdated')),
|
||||||
|
subtitle: Text(getLastFetchText()),
|
||||||
|
trailing: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: handleRefresh,
|
||||||
|
tooltip: locService.t('suspectedLocations.refreshNow'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Data info with credit
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: Text(locService.t('suspectedLocations.dataSource')),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(locService.t('suspectedLocations.dataSourceDescription')),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
locService.t('suspectedLocations.dataSourceCredit'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Minimum distance setting
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.social_distance),
|
||||||
|
title: Text(locService.t('suspectedLocations.minimumDistance')),
|
||||||
|
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
|
||||||
|
trailing: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
suffixText: 'm',
|
||||||
|
),
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
final distance = int.tryParse(value) ?? 100;
|
||||||
|
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
|
import 'suspected_location_service.dart';
|
||||||
|
|
||||||
|
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
|
||||||
|
class SuspectedLocationEntry {
|
||||||
|
final Map<String, dynamic> rawData;
|
||||||
|
final LatLng centroid;
|
||||||
|
|
||||||
|
SuspectedLocationEntry({required this.rawData, required this.centroid});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'rawData': rawData,
|
||||||
|
'centroid': [centroid.latitude, centroid.longitude],
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final centroidList = json['centroid'] as List;
|
||||||
|
return SuspectedLocationEntry(
|
||||||
|
rawData: Map<String, dynamic>.from(json['rawData']),
|
||||||
|
centroid: LatLng(
|
||||||
|
(centroidList[0] as num).toDouble(),
|
||||||
|
(centroidList[1] as num).toDouble(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SuspectedLocationCache extends ChangeNotifier {
|
||||||
|
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
|
||||||
|
factory SuspectedLocationCache() => _instance;
|
||||||
|
SuspectedLocationCache._();
|
||||||
|
|
||||||
|
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
|
||||||
|
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
|
||||||
|
|
||||||
|
List<SuspectedLocationEntry> _processedEntries = [];
|
||||||
|
DateTime? _lastFetchTime;
|
||||||
|
final Map<String, List<SuspectedLocation>> _boundsCache = {};
|
||||||
|
|
||||||
|
/// Get suspected locations within specific bounds (cached)
|
||||||
|
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
|
||||||
|
if (!SuspectedLocationService().isEnabled) {
|
||||||
|
debugPrint('[SuspectedLocationCache] Service not enabled');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
|
||||||
|
|
||||||
|
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (_boundsCache.containsKey(boundsKey)) {
|
||||||
|
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
|
||||||
|
return _boundsCache[boundsKey]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
|
||||||
|
final locations = <SuspectedLocation>[];
|
||||||
|
int inBoundsCount = 0;
|
||||||
|
|
||||||
|
for (final entry in _processedEntries) {
|
||||||
|
// Quick bounds check using pre-calculated centroid
|
||||||
|
final lat = entry.centroid.latitude;
|
||||||
|
final lng = entry.centroid.longitude;
|
||||||
|
|
||||||
|
if (lat <= bounds.north && lat >= bounds.south &&
|
||||||
|
lng <= bounds.east && lng >= bounds.west) {
|
||||||
|
try {
|
||||||
|
// Only create SuspectedLocation object if it's in bounds
|
||||||
|
final location = SuspectedLocation.fromCsvRow(entry.rawData);
|
||||||
|
locations.add(location);
|
||||||
|
inBoundsCount++;
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid entries
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
_boundsCache[boundsKey] = locations;
|
||||||
|
|
||||||
|
// Limit cache size to prevent memory issues
|
||||||
|
if (_boundsCache.length > 100) {
|
||||||
|
final oldestKey = _boundsCache.keys.first;
|
||||||
|
_boundsCache.remove(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load processed data from storage
|
||||||
|
Future<void> loadFromStorage() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Load last fetch time
|
||||||
|
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
|
||||||
|
if (lastFetchMs != null) {
|
||||||
|
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load processed data
|
||||||
|
final processedDataString = prefs.getString(_prefsKeyProcessedData);
|
||||||
|
if (processedDataString != null) {
|
||||||
|
final List<dynamic> processedDataList = jsonDecode(processedDataString);
|
||||||
|
_processedEntries = processedDataList
|
||||||
|
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
|
||||||
|
_processedEntries.clear();
|
||||||
|
_lastFetchTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process raw CSV data and save to storage (calculates centroids once)
|
||||||
|
Future<void> processAndSave(
|
||||||
|
List<Map<String, dynamic>> rawData,
|
||||||
|
DateTime fetchTime, {
|
||||||
|
void Function(String message, double? progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
|
||||||
|
|
||||||
|
final processedEntries = <SuspectedLocationEntry>[];
|
||||||
|
int validCount = 0;
|
||||||
|
int errorCount = 0;
|
||||||
|
int zeroCoordCount = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < rawData.length; i++) {
|
||||||
|
final rowData = rawData[i];
|
||||||
|
|
||||||
|
// Report progress every 1000 entries
|
||||||
|
if (i % 1000 == 0) {
|
||||||
|
final progress = i / rawData.length;
|
||||||
|
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a temporary SuspectedLocation to extract the centroid
|
||||||
|
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
|
||||||
|
|
||||||
|
// Only save if we have a valid centroid (not at 0,0)
|
||||||
|
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
|
||||||
|
processedEntries.add(SuspectedLocationEntry(
|
||||||
|
rawData: rowData,
|
||||||
|
centroid: tempLocation.centroid,
|
||||||
|
));
|
||||||
|
validCount++;
|
||||||
|
} else {
|
||||||
|
zeroCoordCount++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
|
||||||
|
|
||||||
|
_processedEntries = processedEntries;
|
||||||
|
_lastFetchTime = fetchTime;
|
||||||
|
|
||||||
|
// Clear bounds cache since data changed
|
||||||
|
_boundsCache.clear();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Save processed data
|
||||||
|
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
|
||||||
|
await prefs.setString(_prefsKeyProcessedData, processedDataString);
|
||||||
|
|
||||||
|
// Save last fetch time
|
||||||
|
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
// Log coordinate ranges for debugging
|
||||||
|
if (processedEntries.isNotEmpty) {
|
||||||
|
double minLat = processedEntries.first.centroid.latitude;
|
||||||
|
double maxLat = minLat;
|
||||||
|
double minLng = processedEntries.first.centroid.longitude;
|
||||||
|
double maxLng = minLng;
|
||||||
|
|
||||||
|
for (final entry in processedEntries) {
|
||||||
|
final lat = entry.centroid.latitude;
|
||||||
|
final lng = entry.centroid.longitude;
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached data
|
||||||
|
void clear() {
|
||||||
|
_processedEntries.clear();
|
||||||
|
_boundsCache.clear();
|
||||||
|
_lastFetchTime = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get last fetch time
|
||||||
|
DateTime? get lastFetchTime => _lastFetchTime;
|
||||||
|
|
||||||
|
/// Get total count of processed entries
|
||||||
|
int get totalCount => _processedEntries.length;
|
||||||
|
|
||||||
|
/// Check if we have data
|
||||||
|
bool get hasData => _processedEntries.isNotEmpty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:csv/csv.dart';
|
||||||
|
|
||||||
|
import '../dev_config.dart';
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
|
import 'suspected_location_cache.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationService {
|
||||||
|
static final SuspectedLocationService _instance = SuspectedLocationService._();
|
||||||
|
factory SuspectedLocationService() => _instance;
|
||||||
|
SuspectedLocationService._();
|
||||||
|
|
||||||
|
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
|
||||||
|
static const Duration _maxAge = Duration(days: 7);
|
||||||
|
static const Duration _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
final SuspectedLocationCache _cache = SuspectedLocationCache();
|
||||||
|
bool _isEnabled = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
/// Get last fetch time
|
||||||
|
DateTime? get lastFetchTime => _cache.lastFetchTime;
|
||||||
|
|
||||||
|
/// Check if suspected locations are enabled
|
||||||
|
bool get isEnabled => _isEnabled;
|
||||||
|
|
||||||
|
/// Check if currently loading
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
/// Initialize the service - load from storage and check if refresh needed
|
||||||
|
Future<void> init({bool offlineMode = false}) async {
|
||||||
|
await _loadFromStorage();
|
||||||
|
|
||||||
|
// Load cache data
|
||||||
|
await _cache.loadFromStorage();
|
||||||
|
|
||||||
|
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
|
||||||
|
if (_isEnabled && _shouldRefresh() && !offlineMode) {
|
||||||
|
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
|
||||||
|
await _fetchData();
|
||||||
|
} else if (_isEnabled && _shouldRefresh() && offlineMode) {
|
||||||
|
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${_cache.lastFetchTime != null ? 'outdated' : 'missing'}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable suspected locations
|
||||||
|
Future<void> setEnabled(bool enabled) async {
|
||||||
|
_isEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefsKeyEnabled, enabled);
|
||||||
|
|
||||||
|
// If enabling for the first time and no data, fetch it
|
||||||
|
if (enabled && !_cache.hasData) {
|
||||||
|
await _fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If disabling, clear the cache
|
||||||
|
if (!enabled) {
|
||||||
|
_cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually refresh the data
|
||||||
|
Future<bool> refreshData({
|
||||||
|
void Function(String message, double? progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
return await _fetchData(onProgress: onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if data should be refreshed
|
||||||
|
bool _shouldRefresh() {
|
||||||
|
if (!_cache.hasData) return true;
|
||||||
|
if (_cache.lastFetchTime == null) return true;
|
||||||
|
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings from shared preferences
|
||||||
|
Future<void> _loadFromStorage() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Load enabled state
|
||||||
|
_isEnabled = prefs.getBool(_prefsKeyEnabled) ?? false;
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationService] Loaded settings - enabled: $_isEnabled');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SuspectedLocationService] Error loading from storage: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch data from the CSV URL
|
||||||
|
Future<bool> _fetchData({
|
||||||
|
void Function(String message, double? progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
if (_isLoading) return false;
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
try {
|
||||||
|
onProgress?.call('Downloading CSV data...', null);
|
||||||
|
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(kSuspectedLocationsCsvUrl),
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
|
||||||
|
},
|
||||||
|
).timeout(_timeout);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call('Parsing CSV data...', 0.2);
|
||||||
|
|
||||||
|
// Parse CSV with proper field separator and quote handling
|
||||||
|
final csvData = const CsvToListConverter(
|
||||||
|
fieldDelimiter: ',',
|
||||||
|
textDelimiter: '"',
|
||||||
|
eol: '\n',
|
||||||
|
).convert(response.body);
|
||||||
|
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
|
||||||
|
|
||||||
|
if (csvData.isEmpty) {
|
||||||
|
debugPrint('[SuspectedLocationService] Empty CSV data');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First row should be headers
|
||||||
|
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
|
||||||
|
debugPrint('[SuspectedLocationService] Headers: $headers');
|
||||||
|
final dataRows = csvData.skip(1);
|
||||||
|
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
|
||||||
|
|
||||||
|
// Find required column indices
|
||||||
|
final ticketNoIndex = headers.indexOf('ticket_no');
|
||||||
|
final urlFullIndex = headers.indexOf('url_full');
|
||||||
|
final addrIndex = headers.indexOf('addr');
|
||||||
|
final streetIndex = headers.indexOf('street');
|
||||||
|
final cityIndex = headers.indexOf('city');
|
||||||
|
final stateIndex = headers.indexOf('state');
|
||||||
|
final digSiteIntersectingStreetIndex = headers.indexOf('dig_site_intersecting_street');
|
||||||
|
final digWorkDoneForIndex = headers.indexOf('dig_work_done_for');
|
||||||
|
final digSiteRemarksIndex = headers.indexOf('dig_site_remarks');
|
||||||
|
final locationIndex = headers.indexOf('location');
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
|
||||||
|
|
||||||
|
if (ticketNoIndex == -1 || locationIndex == -1) {
|
||||||
|
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse rows and store as raw data (don't process GeoJSON yet)
|
||||||
|
final List<Map<String, dynamic>> rawDataList = [];
|
||||||
|
int rowIndex = 0;
|
||||||
|
int validRows = 0;
|
||||||
|
for (final row in dataRows) {
|
||||||
|
rowIndex++;
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> rowData = {};
|
||||||
|
|
||||||
|
if (ticketNoIndex < row.length) rowData['ticket_no'] = row[ticketNoIndex];
|
||||||
|
if (urlFullIndex != -1 && urlFullIndex < row.length) rowData['url_full'] = row[urlFullIndex];
|
||||||
|
if (addrIndex != -1 && addrIndex < row.length) rowData['addr'] = row[addrIndex];
|
||||||
|
if (streetIndex != -1 && streetIndex < row.length) rowData['street'] = row[streetIndex];
|
||||||
|
if (cityIndex != -1 && cityIndex < row.length) rowData['city'] = row[cityIndex];
|
||||||
|
if (stateIndex != -1 && stateIndex < row.length) rowData['state'] = row[stateIndex];
|
||||||
|
if (digSiteIntersectingStreetIndex != -1 && digSiteIntersectingStreetIndex < row.length) {
|
||||||
|
rowData['dig_site_intersecting_street'] = row[digSiteIntersectingStreetIndex];
|
||||||
|
}
|
||||||
|
if (digWorkDoneForIndex != -1 && digWorkDoneForIndex < row.length) {
|
||||||
|
rowData['dig_work_done_for'] = row[digWorkDoneForIndex];
|
||||||
|
}
|
||||||
|
if (digSiteRemarksIndex != -1 && digSiteRemarksIndex < row.length) {
|
||||||
|
rowData['dig_site_remarks'] = row[digSiteRemarksIndex];
|
||||||
|
}
|
||||||
|
if (locationIndex < row.length) rowData['location'] = row[locationIndex];
|
||||||
|
|
||||||
|
// Basic validation - must have ticket_no and location
|
||||||
|
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
|
||||||
|
rowData['location']?.toString().isNotEmpty == true) {
|
||||||
|
rawDataList.add(rowData);
|
||||||
|
validRows++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress every 1000 rows
|
||||||
|
if (rowIndex % 1000 == 0) {
|
||||||
|
final progress = 0.4 + (rowIndex / dataRows.length) * 0.4; // 40% to 80% of total
|
||||||
|
onProgress?.call('Processing row $rowIndex...', progress);
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
// Skip rows that can't be parsed
|
||||||
|
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.call('Calculating coordinates...', 0.8);
|
||||||
|
|
||||||
|
final fetchTime = DateTime.now();
|
||||||
|
|
||||||
|
// Process raw data and save (calculates centroids once)
|
||||||
|
await _cache.processAndSave(rawDataList, fetchTime, onProgress: (message, progress) {
|
||||||
|
// Map cache progress to final 20% (0.8 to 1.0)
|
||||||
|
final finalProgress = 0.8 + (progress ?? 0) * 0.2;
|
||||||
|
onProgress?.call(message, finalProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
onProgress?.call('Complete!', 1.0);
|
||||||
|
|
||||||
|
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
debugPrint('[SuspectedLocationService] Error fetching data: $e');
|
||||||
|
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get suspected locations within a bounding box
|
||||||
|
List<SuspectedLocation> getLocationsInBounds({
|
||||||
|
required double north,
|
||||||
|
required double south,
|
||||||
|
required double east,
|
||||||
|
required double west,
|
||||||
|
}) {
|
||||||
|
return _cache.getLocationsForBounds(LatLngBounds(
|
||||||
|
LatLng(north, west),
|
||||||
|
LatLng(south, east),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class SettingsState extends ChangeNotifier {
|
|||||||
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
|
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
|
||||||
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
|
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
|
||||||
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
|
||||||
|
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||||
|
|
||||||
bool _offlineMode = false;
|
bool _offlineMode = false;
|
||||||
int _maxCameras = 250;
|
int _maxCameras = 250;
|
||||||
@@ -35,6 +36,7 @@ class SettingsState extends ChangeNotifier {
|
|||||||
bool _proximityAlertsEnabled = false;
|
bool _proximityAlertsEnabled = false;
|
||||||
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||||
bool _networkStatusIndicatorEnabled = false;
|
bool _networkStatusIndicatorEnabled = false;
|
||||||
|
int _suspectedLocationMinDistance = 100; // meters
|
||||||
List<TileProvider> _tileProviders = [];
|
List<TileProvider> _tileProviders = [];
|
||||||
String _selectedTileTypeId = '';
|
String _selectedTileTypeId = '';
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ class SettingsState extends ChangeNotifier {
|
|||||||
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||||
int get proximityAlertDistance => _proximityAlertDistance;
|
int get proximityAlertDistance => _proximityAlertDistance;
|
||||||
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
|
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
|
||||||
|
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
|
||||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||||
String get selectedTileTypeId => _selectedTileTypeId;
|
String get selectedTileTypeId => _selectedTileTypeId;
|
||||||
|
|
||||||
@@ -101,6 +104,9 @@ class SettingsState extends ChangeNotifier {
|
|||||||
// Load network status indicator setting
|
// Load network status indicator setting
|
||||||
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
|
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
|
||||||
|
|
||||||
|
// Load suspected location minimum distance
|
||||||
|
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
|
||||||
|
|
||||||
// Load upload mode (including migration from old test_mode bool)
|
// Load upload mode (including migration from old test_mode bool)
|
||||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||||
@@ -323,4 +329,14 @@ class SettingsState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set suspected location minimum distance from real nodes
|
||||||
|
Future<void> setSuspectedLocationMinDistance(int distance) async {
|
||||||
|
if (_suspectedLocationMinDistance != distance) {
|
||||||
|
_suspectedLocationMinDistance = distance;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_suspectedLocationMinDistancePrefsKey, distance);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
|
import '../services/suspected_location_service.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationState extends ChangeNotifier {
|
||||||
|
final SuspectedLocationService _service = SuspectedLocationService();
|
||||||
|
|
||||||
|
SuspectedLocation? _selectedLocation;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
/// Currently selected suspected location (for detail view)
|
||||||
|
SuspectedLocation? get selectedLocation => _selectedLocation;
|
||||||
|
|
||||||
|
/// Get suspected locations in bounds (this should be called by the map view)
|
||||||
|
List<SuspectedLocation> getLocationsInBounds({
|
||||||
|
required double north,
|
||||||
|
required double south,
|
||||||
|
required double east,
|
||||||
|
required double west,
|
||||||
|
}) {
|
||||||
|
return _service.getLocationsInBounds(
|
||||||
|
north: north,
|
||||||
|
south: south,
|
||||||
|
east: east,
|
||||||
|
west: west,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether suspected locations are enabled
|
||||||
|
bool get isEnabled => _service.isEnabled;
|
||||||
|
|
||||||
|
/// Whether currently loading data
|
||||||
|
bool get isLoading => _isLoading || _service.isLoading;
|
||||||
|
|
||||||
|
/// Last time data was fetched
|
||||||
|
DateTime? get lastFetchTime => _service.lastFetchTime;
|
||||||
|
|
||||||
|
/// Initialize the state
|
||||||
|
Future<void> init({bool offlineMode = false}) async {
|
||||||
|
await _service.init(offlineMode: offlineMode);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable suspected locations
|
||||||
|
Future<void> setEnabled(bool enabled) async {
|
||||||
|
await _service.setEnabled(enabled);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually refresh the data
|
||||||
|
Future<bool> refreshData({
|
||||||
|
void Function(String message, double? progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final success = await _service.refreshData(onProgress: onProgress);
|
||||||
|
return success;
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a suspected location for detail view
|
||||||
|
void selectLocation(SuspectedLocation location) {
|
||||||
|
_selectedLocation = location;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the selected location
|
||||||
|
void clearSelection() {
|
||||||
|
_selectedLocation = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -40,10 +40,10 @@ class CameraIcon extends StatelessWidget {
|
|||||||
height: kNodeIconDiameter,
|
height: kNodeIconDiameter,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: Colors.black.withOpacity(kNodeDotOpacity),
|
color: _ringColor.withOpacity(kNodeDotOpacity),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _ringColor,
|
color: _ringColor,
|
||||||
width: kNodeRingThickness,
|
width: getNodeRingThickness(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class CameraMarkersBuilder {
|
|||||||
LatLng? userLocation,
|
LatLng? userLocation,
|
||||||
int? selectedNodeId,
|
int? selectedNodeId,
|
||||||
void Function(OsmNode)? onNodeTap,
|
void Function(OsmNode)? onNodeTap,
|
||||||
|
bool shouldDim = false,
|
||||||
}) {
|
}) {
|
||||||
final markers = <Marker>[
|
final markers = <Marker>[
|
||||||
// Camera markers
|
// Camera markers
|
||||||
@@ -103,14 +104,14 @@ class CameraMarkersBuilder {
|
|||||||
.map((n) {
|
.map((n) {
|
||||||
// Check if this node should be highlighted (selected) or dimmed
|
// Check if this node should be highlighted (selected) or dimmed
|
||||||
final isSelected = selectedNodeId == n.id;
|
final isSelected = selectedNodeId == n.id;
|
||||||
final shouldDim = selectedNodeId != null && !isSelected;
|
final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected);
|
||||||
|
|
||||||
return Marker(
|
return Marker(
|
||||||
point: n.coord,
|
point: n.coord,
|
||||||
width: kNodeIconDiameter,
|
width: kNodeIconDiameter,
|
||||||
height: kNodeIconDiameter,
|
height: kNodeIconDiameter,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: shouldDim ? 0.5 : 1.0,
|
opacity: shouldDimNode ? 0.5 : 1.0,
|
||||||
child: CameraMapMarker(
|
child: CameraMapMarker(
|
||||||
node: n,
|
node: n,
|
||||||
mapController: mapController,
|
mapController: mapController,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class DirectionConesBuilder {
|
|||||||
required double zoom,
|
required double zoom,
|
||||||
AddNodeSession? session,
|
AddNodeSession? session,
|
||||||
EditNodeSession? editSession,
|
EditNodeSession? editSession,
|
||||||
|
required BuildContext context,
|
||||||
}) {
|
}) {
|
||||||
final overlays = <Polygon>[];
|
final overlays = <Polygon>[];
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class DirectionConesBuilder {
|
|||||||
session.target!,
|
session.target!,
|
||||||
session.directionDegrees,
|
session.directionDegrees,
|
||||||
zoom,
|
zoom,
|
||||||
|
context: context,
|
||||||
isSession: true,
|
isSession: true,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,7 @@ class DirectionConesBuilder {
|
|||||||
editSession.target,
|
editSession.target,
|
||||||
editSession.directionDegrees,
|
editSession.directionDegrees,
|
||||||
zoom,
|
zoom,
|
||||||
|
context: context,
|
||||||
isSession: true,
|
isSession: true,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -46,6 +49,7 @@ class DirectionConesBuilder {
|
|||||||
n.coord,
|
n.coord,
|
||||||
n.directionDeg!,
|
n.directionDeg!,
|
||||||
zoom,
|
zoom,
|
||||||
|
context: context,
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,40 +73,52 @@ class DirectionConesBuilder {
|
|||||||
LatLng origin,
|
LatLng origin,
|
||||||
double bearingDeg,
|
double bearingDeg,
|
||||||
double zoom, {
|
double zoom, {
|
||||||
|
required BuildContext context,
|
||||||
bool isPending = false,
|
bool isPending = false,
|
||||||
bool isSession = false,
|
bool isSession = false,
|
||||||
}) {
|
}) {
|
||||||
final halfAngle = kDirectionConeHalfAngle;
|
final halfAngle = kDirectionConeHalfAngle;
|
||||||
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
|
|
||||||
|
|
||||||
// Number of points to create the arc (more = smoother curve)
|
// Calculate pixel-based radii
|
||||||
|
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
|
||||||
|
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
|
||||||
|
|
||||||
|
// Convert pixels to coordinate distances with zoom scaling
|
||||||
|
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
|
||||||
|
final outerRadius = outerRadiusPx * pixelToCoordinate;
|
||||||
|
final innerRadius = innerRadiusPx * pixelToCoordinate;
|
||||||
|
|
||||||
|
// Number of points for the outer arc (within our directional range)
|
||||||
const int arcPoints = 12;
|
const int arcPoints = 12;
|
||||||
|
|
||||||
LatLng project(double deg) {
|
LatLng project(double deg, double distance) {
|
||||||
final rad = deg * math.pi / 180;
|
final rad = deg * math.pi / 180;
|
||||||
final dLat = length * math.cos(rad);
|
final dLat = distance * math.cos(rad);
|
||||||
final dLon =
|
final dLon =
|
||||||
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
|
||||||
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build pizza slice with curved edge
|
// Build outer arc points only within our directional sector
|
||||||
final points = <LatLng>[origin];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
// Add arc points from left to right
|
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
|
||||||
for (int i = 0; i <= arcPoints; i++) {
|
for (int i = 0; i <= arcPoints; i++) {
|
||||||
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||||
points.add(project(angle));
|
points.add(project(angle, outerRadius));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the shape back to origin
|
// Add inner arc points from right to left (to close the donut shape)
|
||||||
points.add(origin);
|
for (int i = arcPoints; i >= 0; i--) {
|
||||||
|
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
|
||||||
|
points.add(project(angle, innerRadius));
|
||||||
|
}
|
||||||
|
|
||||||
return Polygon(
|
return Polygon(
|
||||||
points: points,
|
points: points,
|
||||||
color: kDirectionConeColor.withOpacity(0.25),
|
color: kDirectionConeColor.withOpacity(kDirectionConeOpacity),
|
||||||
borderColor: kDirectionConeColor,
|
borderColor: kDirectionConeColor,
|
||||||
borderStrokeWidth: 1,
|
borderStrokeWidth: getDirectionConeBorderWidth(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../dev_config.dart';
|
||||||
|
import '../../models/suspected_location.dart';
|
||||||
|
import '../suspected_location_sheet.dart';
|
||||||
|
import '../suspected_location_icon.dart';
|
||||||
|
|
||||||
|
/// Smart marker widget for suspected location with single/double tap distinction
|
||||||
|
class SuspectedLocationMapMarker extends StatefulWidget {
|
||||||
|
final SuspectedLocation location;
|
||||||
|
final MapController mapController;
|
||||||
|
final void Function(SuspectedLocation)? onLocationTap;
|
||||||
|
|
||||||
|
const SuspectedLocationMapMarker({
|
||||||
|
required this.location,
|
||||||
|
required this.mapController,
|
||||||
|
this.onLocationTap,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SuspectedLocationMapMarker> createState() => _SuspectedLocationMapMarkerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
|
||||||
|
Timer? _tapTimer;
|
||||||
|
// From dev_config.dart for build-time parameters
|
||||||
|
static const Duration tapTimeout = kMarkerTapTimeout;
|
||||||
|
|
||||||
|
void _onTap() {
|
||||||
|
_tapTimer = Timer(tapTimeout, () {
|
||||||
|
// Use callback if provided, otherwise fallback to direct modal
|
||||||
|
if (widget.onLocationTap != null) {
|
||||||
|
widget.onLocationTap!(widget.location);
|
||||||
|
} else {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SuspectedLocationSheet(location: widget.location),
|
||||||
|
showDragHandle: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDoubleTap() {
|
||||||
|
_tapTimer?.cancel();
|
||||||
|
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tapTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onTap,
|
||||||
|
onDoubleTap: _onDoubleTap,
|
||||||
|
child: const SuspectedLocationIcon(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper class to build marker layers for suspected locations
|
||||||
|
class SuspectedLocationMarkersBuilder {
|
||||||
|
static List<Marker> buildSuspectedLocationMarkers({
|
||||||
|
required List<SuspectedLocation> locations,
|
||||||
|
required MapController mapController,
|
||||||
|
String? selectedLocationId,
|
||||||
|
void Function(SuspectedLocation)? onLocationTap,
|
||||||
|
}) {
|
||||||
|
final markers = <Marker>[];
|
||||||
|
|
||||||
|
for (final location in locations) {
|
||||||
|
if (!_isValidCoordinate(location.centroid)) continue;
|
||||||
|
|
||||||
|
// Check if this location should be highlighted (selected) or dimmed
|
||||||
|
final isSelected = selectedLocationId == location.ticketNo;
|
||||||
|
final shouldDim = selectedLocationId != null && !isSelected;
|
||||||
|
|
||||||
|
markers.add(
|
||||||
|
Marker(
|
||||||
|
point: location.centroid,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: shouldDim ? 0.5 : 1.0,
|
||||||
|
child: SuspectedLocationMapMarker(
|
||||||
|
location: location,
|
||||||
|
mapController: mapController,
|
||||||
|
onLocationTap: onLocationTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isValidCoordinate(LatLng coord) {
|
||||||
|
return (coord.latitude != 0 || coord.longitude != 0) &&
|
||||||
|
coord.latitude.abs() <= 90 &&
|
||||||
|
coord.longitude.abs() <= 180;
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-29
@@ -9,6 +9,7 @@ import '../services/offline_area_service.dart';
|
|||||||
import '../services/network_status.dart';
|
import '../services/network_status.dart';
|
||||||
import '../models/osm_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
import '../models/tile_provider.dart';
|
import '../models/tile_provider.dart';
|
||||||
import 'debouncer.dart';
|
import 'debouncer.dart';
|
||||||
import 'camera_provider_with_cache.dart';
|
import 'camera_provider_with_cache.dart';
|
||||||
@@ -20,6 +21,7 @@ import 'map/map_position_manager.dart';
|
|||||||
import 'map/tile_layer_manager.dart';
|
import 'map/tile_layer_manager.dart';
|
||||||
import 'map/camera_refresh_controller.dart';
|
import 'map/camera_refresh_controller.dart';
|
||||||
import 'map/gps_controller.dart';
|
import 'map/gps_controller.dart';
|
||||||
|
import 'map/suspected_location_markers.dart';
|
||||||
import 'network_status_indicator.dart';
|
import 'network_status_indicator.dart';
|
||||||
import 'provisional_pin.dart';
|
import 'provisional_pin.dart';
|
||||||
import 'proximity_alert_banner.dart';
|
import 'proximity_alert_banner.dart';
|
||||||
@@ -38,6 +40,7 @@ class MapView extends StatefulWidget {
|
|||||||
this.sheetHeight = 0.0,
|
this.sheetHeight = 0.0,
|
||||||
this.selectedNodeId,
|
this.selectedNodeId,
|
||||||
this.onNodeTap,
|
this.onNodeTap,
|
||||||
|
this.onSuspectedLocationTap,
|
||||||
this.onSearchPressed,
|
this.onSearchPressed,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ class MapView extends StatefulWidget {
|
|||||||
final double sheetHeight;
|
final double sheetHeight;
|
||||||
final int? selectedNodeId;
|
final int? selectedNodeId;
|
||||||
final void Function(OsmNode)? onNodeTap;
|
final void Function(OsmNode)? onNodeTap;
|
||||||
|
final void Function(SuspectedLocation)? onSuspectedLocationTap;
|
||||||
final VoidCallback? onSearchPressed;
|
final VoidCallback? onSearchPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,7 +82,7 @@ class MapViewState extends State<MapView> {
|
|||||||
_tileManager = TileLayerManager();
|
_tileManager = TileLayerManager();
|
||||||
_tileManager.initialize();
|
_tileManager.initialize();
|
||||||
_cameraController = CameraRefreshController();
|
_cameraController = CameraRefreshController();
|
||||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
|
||||||
_gpsController = GpsController();
|
_gpsController = GpsController();
|
||||||
|
|
||||||
// Initialize proximity alert service
|
// Initialize proximity alert service
|
||||||
@@ -174,7 +178,7 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Fetch initial cameras
|
// Fetch initial cameras
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_refreshCamerasFromProvider();
|
_refreshNodesFromProvider();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +199,7 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
void _onCamerasUpdated() {
|
void _onNodesUpdated() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +217,8 @@ class MapViewState extends State<MapView> {
|
|||||||
static Future<void> clearStoredMapPosition() =>
|
static Future<void> clearStoredMapPosition() =>
|
||||||
MapPositionManager.clearStoredMapPosition();
|
MapPositionManager.clearStoredMapPosition();
|
||||||
|
|
||||||
/// Get minimum zoom level for camera fetching based on upload mode
|
/// Get minimum zoom level for node fetching based on upload mode
|
||||||
int _getMinZoomForCameras(BuildContext context) {
|
int _getMinZoomForNodes(BuildContext context) {
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
final uploadMode = appState.uploadMode;
|
final uploadMode = appState.uploadMode;
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
void _refreshCamerasFromProvider() {
|
void _refreshNodesFromProvider() {
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
_cameraController.refreshCamerasFromProvider(
|
_cameraController.refreshCamerasFromProvider(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@@ -295,7 +299,7 @@ class MapViewState extends State<MapView> {
|
|||||||
// Check if enabled profiles changed and refresh cameras if needed
|
// Check if enabled profiles changed and refresh cameras if needed
|
||||||
_cameraController.checkAndHandleProfileChanges(
|
_cameraController.checkAndHandleProfileChanges(
|
||||||
currentEnabledProfiles: appState.enabledProfiles,
|
currentEnabledProfiles: appState.enabledProfiles,
|
||||||
onProfilesChanged: _refreshCamerasFromProvider,
|
onProfilesChanged: _refreshNodesFromProvider,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if tile type OR offline mode changed and clear cache if needed
|
// Check if tile type OR offline mode changed and clear cache if needed
|
||||||
@@ -326,26 +330,74 @@ class MapViewState extends State<MapView> {
|
|||||||
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
|
||||||
Widget cameraLayers = Consumer<CameraProviderWithCache>(
|
Widget cameraLayers = Consumer<CameraProviderWithCache>(
|
||||||
builder: (context, cameraProvider, child) {
|
builder: (context, cameraProvider, child) {
|
||||||
|
// Get current zoom level and map bounds (shared by all logic)
|
||||||
|
double currentZoom = 15.0; // fallback
|
||||||
LatLngBounds? mapBounds;
|
LatLngBounds? mapBounds;
|
||||||
try {
|
try {
|
||||||
|
currentZoom = _controller.mapController.camera.zoom;
|
||||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
// Controller not ready yet, use fallback values
|
||||||
mapBounds = null;
|
mapBounds = null;
|
||||||
}
|
}
|
||||||
final cameras = (mapBounds != null)
|
|
||||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
final minZoom = _getMinZoomForNodes(context);
|
||||||
: <OsmNode>[];
|
List<OsmNode> nodes;
|
||||||
|
|
||||||
|
if (currentZoom >= minZoom) {
|
||||||
|
// Above minimum zoom - get cached nodes
|
||||||
|
nodes = (mapBounds != null)
|
||||||
|
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||||
|
: <OsmNode>[];
|
||||||
|
} else {
|
||||||
|
// Below minimum zoom - don't render any nodes
|
||||||
|
nodes = <OsmNode>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we should dim node markers (when suspected location is selected)
|
||||||
|
final shouldDimNodes = appState.selectedSuspectedLocation != null;
|
||||||
|
|
||||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||||
cameras: cameras,
|
cameras: nodes,
|
||||||
mapController: _controller.mapController,
|
mapController: _controller.mapController,
|
||||||
userLocation: _gpsController.currentLocation,
|
userLocation: _gpsController.currentLocation,
|
||||||
selectedNodeId: widget.selectedNodeId,
|
selectedNodeId: widget.selectedNodeId,
|
||||||
onNodeTap: widget.onNodeTap,
|
onNodeTap: widget.onNodeTap,
|
||||||
|
shouldDim: shouldDimNodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get current zoom level for direction cones
|
// Build suspected location markers (respect same zoom and count limits as nodes)
|
||||||
double currentZoom = 15.0; // fallback
|
final suspectedLocationMarkers = <Marker>[];
|
||||||
|
if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= minZoom) {
|
||||||
|
final suspectedLocations = appState.getSuspectedLocationsInBounds(
|
||||||
|
north: mapBounds.north,
|
||||||
|
south: mapBounds.south,
|
||||||
|
east: mapBounds.east,
|
||||||
|
west: mapBounds.west,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply same node count limit as surveillance nodes
|
||||||
|
final maxNodes = appState.maxCameras;
|
||||||
|
final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList();
|
||||||
|
|
||||||
|
// Filter out suspected locations that are too close to real nodes
|
||||||
|
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
|
||||||
|
suspectedLocations: limitedSuspectedLocations,
|
||||||
|
realNodes: nodes,
|
||||||
|
minDistance: appState.suspectedLocationMinDistance,
|
||||||
|
);
|
||||||
|
|
||||||
|
suspectedLocationMarkers.addAll(
|
||||||
|
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
|
||||||
|
locations: filteredSuspectedLocations,
|
||||||
|
mapController: _controller.mapController,
|
||||||
|
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
|
||||||
|
onLocationTap: widget.onSuspectedLocationTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current zoom level for direction cones (already have currentZoom)
|
||||||
try {
|
try {
|
||||||
currentZoom = _controller.mapController.camera.zoom;
|
currentZoom = _controller.mapController.camera.zoom;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -353,14 +405,30 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||||
cameras: cameras,
|
cameras: nodes,
|
||||||
zoom: currentZoom,
|
zoom: currentZoom,
|
||||||
session: session,
|
session: session,
|
||||||
editSession: editSession,
|
editSession: editSession,
|
||||||
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build edit lines connecting original cameras to their edited positions
|
// Add suspected location bounds if one is selected
|
||||||
final editLines = _buildEditLines(cameras);
|
if (appState.selectedSuspectedLocation != null) {
|
||||||
|
final selectedLocation = appState.selectedSuspectedLocation!;
|
||||||
|
if (selectedLocation.bounds.isNotEmpty) {
|
||||||
|
overlays.add(
|
||||||
|
Polygon(
|
||||||
|
points: selectedLocation.bounds,
|
||||||
|
color: Colors.orange.withOpacity(0.3),
|
||||||
|
borderColor: Colors.orange,
|
||||||
|
borderStrokeWidth: 2.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edit lines connecting original nodes to their edited positions
|
||||||
|
final editLines = _buildEditLines(nodes);
|
||||||
|
|
||||||
// Build center marker for add/edit sessions
|
// Build center marker for add/edit sessions
|
||||||
final centerMarkers = <Marker>[];
|
final centerMarkers = <Marker>[];
|
||||||
@@ -436,7 +504,7 @@ class MapViewState extends State<MapView> {
|
|||||||
PolygonLayer(polygons: overlays),
|
PolygonLayer(polygons: overlays),
|
||||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||||
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
||||||
MarkerLayer(markers: [...markers, ...centerMarkers]),
|
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -489,10 +557,10 @@ class MapViewState extends State<MapView> {
|
|||||||
_positionManager.saveMapPosition(pos.center, pos.zoom);
|
_positionManager.saveMapPosition(pos.center, pos.zoom);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
|
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
|
||||||
final minZoom = _getMinZoomForCameras(context);
|
final minZoom = _getMinZoomForNodes(context);
|
||||||
if (pos.zoom >= minZoom) {
|
if (pos.zoom >= minZoom) {
|
||||||
_cameraDebounce(_refreshCamerasFromProvider);
|
_cameraDebounce(_refreshNodesFromProvider);
|
||||||
} else {
|
} else {
|
||||||
// Skip nodes at low zoom - report immediate completion (brutalist approach)
|
// Skip nodes at low zoom - report immediate completion (brutalist approach)
|
||||||
NetworkStatus.instance.reportNodeComplete();
|
NetworkStatus.instance.reportNodeComplete();
|
||||||
@@ -552,27 +620,27 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build polylines connecting original cameras to their edited positions
|
/// Build polylines connecting original cameras to their edited positions
|
||||||
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
|
List<Polyline> _buildEditLines(List<OsmNode> nodes) {
|
||||||
final lines = <Polyline>[];
|
final lines = <Polyline>[];
|
||||||
|
|
||||||
// Create a lookup map of original node IDs to their coordinates
|
// Create a lookup map of original node IDs to their coordinates
|
||||||
final originalNodes = <int, LatLng>{};
|
final originalNodes = <int, LatLng>{};
|
||||||
for (final camera in cameras) {
|
for (final node in nodes) {
|
||||||
if (camera.tags['_pending_edit'] == 'true') {
|
if (node.tags['_pending_edit'] == 'true') {
|
||||||
originalNodes[camera.id] = camera.coord;
|
originalNodes[node.id] = node.coord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find edited cameras and draw lines to their originals
|
// Find edited nodes and draw lines to their originals
|
||||||
for (final camera in cameras) {
|
for (final node in nodes) {
|
||||||
final originalIdStr = camera.tags['_original_node_id'];
|
final originalIdStr = node.tags['_original_node_id'];
|
||||||
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
|
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
|
||||||
final originalId = int.tryParse(originalIdStr);
|
final originalId = int.tryParse(originalIdStr);
|
||||||
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
final originalCoord = originalId != null ? originalNodes[originalId] : null;
|
||||||
|
|
||||||
if (originalCoord != null) {
|
if (originalCoord != null) {
|
||||||
lines.add(Polyline(
|
lines.add(Polyline(
|
||||||
points: [originalCoord, camera.coord],
|
points: [originalCoord, node.coord],
|
||||||
color: kNodeRingColorPending,
|
color: kNodeRingColorPending,
|
||||||
strokeWidth: 3.0,
|
strokeWidth: 3.0,
|
||||||
));
|
));
|
||||||
@@ -582,5 +650,40 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter suspected locations that are too close to real nodes
|
||||||
|
List<SuspectedLocation> _filterSuspectedLocationsByProximity({
|
||||||
|
required List<SuspectedLocation> suspectedLocations,
|
||||||
|
required List<OsmNode> realNodes,
|
||||||
|
required int minDistance, // in meters
|
||||||
|
}) {
|
||||||
|
if (minDistance <= 0) return suspectedLocations;
|
||||||
|
|
||||||
|
const distance = Distance();
|
||||||
|
final filteredLocations = <SuspectedLocation>[];
|
||||||
|
|
||||||
|
for (final suspected in suspectedLocations) {
|
||||||
|
bool tooClose = false;
|
||||||
|
|
||||||
|
for (final realNode in realNodes) {
|
||||||
|
final distanceMeters = distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
suspected.centroid,
|
||||||
|
realNode.coord,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distanceMeters < minDistance) {
|
||||||
|
tooClose = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tooClose) {
|
||||||
|
filteredLocations.add(suspected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredLocations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationIcon extends StatelessWidget {
|
||||||
|
const SuspectedLocationIcon({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.orange,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.help_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationProgressDialog extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final double? progress; // 0.0 to 1.0, null for indeterminate
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
const SuspectedLocationProgressDialog({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.progress,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.help_outline, color: Colors.orange),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(title)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(message),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (progress != null)
|
||||||
|
LinearProgressIndicator(value: progress)
|
||||||
|
else
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (progress != null)
|
||||||
|
Text(
|
||||||
|
'${(progress! * 100).toInt()}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (onCancel != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: onCancel,
|
||||||
|
child: Text(LocalizationService.instance.cancel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../models/suspected_location.dart';
|
||||||
|
import '../app_state.dart';
|
||||||
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
|
class SuspectedLocationSheet extends StatelessWidget {
|
||||||
|
final SuspectedLocation location;
|
||||||
|
|
||||||
|
const SuspectedLocationSheet({super.key, required this.location});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: LocalizationService.instance,
|
||||||
|
builder: (context, child) {
|
||||||
|
final appState = context.watch<AppState>();
|
||||||
|
final locService = LocalizationService.instance;
|
||||||
|
|
||||||
|
Future<void> _launchUrl() async {
|
||||||
|
if (location.urlFull?.isNotEmpty == true) {
|
||||||
|
final uri = Uri.parse(location.urlFull!);
|
||||||
|
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: ${location.urlFull}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create display data map using localized labels
|
||||||
|
final Map<String, String?> displayData = {
|
||||||
|
locService.t('suspectedLocation.ticketNo'): location.ticketNo,
|
||||||
|
locService.t('suspectedLocation.address'): location.addr,
|
||||||
|
locService.t('suspectedLocation.street'): location.street,
|
||||||
|
locService.t('suspectedLocation.city'): location.city,
|
||||||
|
locService.t('suspectedLocation.state'): location.state,
|
||||||
|
locService.t('suspectedLocation.intersectingStreet'): location.digSiteIntersectingStreet,
|
||||||
|
locService.t('suspectedLocation.workDoneFor'): location.digWorkDoneFor,
|
||||||
|
locService.t('suspectedLocation.remarks'): location.digSiteRemarks,
|
||||||
|
locService.t('suspectedLocation.url'): location.urlFull,
|
||||||
|
};
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('suspectedLocation.title', params: [location.ticketNo]),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Display all fields
|
||||||
|
...displayData.entries.where((e) => e.value?.isNotEmpty == true).map(
|
||||||
|
(e) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
e.key,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: e.key == 'URL' && e.value?.isNotEmpty == true
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: _launchUrl,
|
||||||
|
child: Text(
|
||||||
|
e.value!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
softWrap: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
e.value ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
softWrap: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Coordinates info
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
locService.t('suspectedLocation.coordinates'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${location.centroid.latitude.toStringAsFixed(6)}, ${location.centroid.longitude.toStringAsFixed(6)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
softWrap: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(locService.t('actions.close')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,5 +38,13 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_macos_build_settings(target)
|
flutter_additional_macos_build_settings(target)
|
||||||
|
|
||||||
|
# Ensure minimum deployment target is 10.13 or higher for all pods
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
deployment_target = config.build_settings['MACOSX_DEPLOYMENT_TARGET']
|
||||||
|
if deployment_target && deployment_target.to_f < 10.13
|
||||||
|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.13'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -572,7 +572,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>keychain-access-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
+9
-1
@@ -89,6 +89,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
|
csv:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: csv
|
||||||
|
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
dart_earcut:
|
dart_earcut:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -705,7 +713,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.2"
|
version: "2.2.2"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
name: deflockapp
|
name: deflockapp
|
||||||
description: Map public surveillance infrastructure with OpenStreetMap
|
description: Map public surveillance infrastructure with OpenStreetMap
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.0.10
|
version: 1.2.3+4 # The thing after the + is the version code, incremented with each release
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||||
@@ -20,6 +20,7 @@ dependencies:
|
|||||||
flutter_svg: ^2.0.10
|
flutter_svg: ^2.0.10
|
||||||
xml: ^6.4.2
|
xml: ^6.4.2
|
||||||
flutter_local_notifications: ^17.2.2
|
flutter_local_notifications: ^17.2.2
|
||||||
|
url_launcher: ^6.3.0
|
||||||
|
|
||||||
# Auth, storage, prefs
|
# Auth, storage, prefs
|
||||||
oauth2_client: ^4.2.0
|
oauth2_client: ^4.2.0
|
||||||
@@ -30,6 +31,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
uuid: ^4.0.0
|
uuid: ^4.0.0
|
||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
|
csv: ^6.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|||||||
Reference in new issue
Block a user