Compare commits

...
Author SHA1 Message Date
stopflock 5176c62e72 stupid if true 2025-10-16 16:07:02 -05:00
stopflock 3f35c2d6a1 Bump ver 2025-10-16 15:56:00 -05:00
stopflock 60b826d00e Explain why we need location permission on iOS 2025-10-16 15:52:52 -05:00
stopflock ca63aa95e3 Debug cleanup 2025-10-16 15:25:13 -05:00
stopflock fe0f298c0e debug ASC API key 2025-10-16 12:35:30 -05:00
stopflock 0ac158eb4a Send to apple on successful tag build 2025-10-16 12:21:12 -05:00
stopflock 7eb680c677 Fin 2025-10-16 11:19:21 -05:00
stopflock a30dace404 Build worked - now provisioning profile for .ipa packaging 2025-10-16 11:11:40 -05:00
stopflock 50d2c6cbf6 Brain wipe - take 2 2025-10-16 10:55:19 -05:00
stopflock 925804e546 <Sad Mac> Steve is dead :( 2025-10-16 10:18:48 -05:00
stopflock 4076d9657a Actually use keychain we create 2025-10-16 09:54:42 -05:00
stopflock 789930049a keychain hell 2025-10-16 09:48:32 -05:00
stopflock 09019915e7 Allow manual GH actions 2025-10-16 00:17:17 -05:00
stopflock 16e1927ff1 add debug output for ios builds 2025-10-16 00:05:22 -05:00
stopflock 02e43f78c3 Now trying to follow an actual guide 2025-10-15 23:39:51 -05:00
stopflock 8a109029ca Please lord apple codesigning 2025-10-15 23:20:38 -05:00
stopflock cd5315b919 Specify team ID and provisioning profile for signing apple builds 2025-10-15 22:56:47 -05:00
stopflock 03f3419f72 Code signing for apple 2025-10-15 22:26:02 -05:00
stopflock 7ace123b4b auto-refresh suspected csv, put url in dev_config 2025-10-14 22:15:52 -05:00
stopflock 08f017fb0f TODOs 3 2025-10-13 17:04:03 -05:00
stopflock 7a199a3258 TODOs 2 2025-10-13 15:03:02 -05:00
stopflock 8c999c04cd TODOs 2025-10-13 14:55:55 -05:00
stopflock dc8dc9f11b visual tweaking 2025-10-09 19:31:32 -05:00
stopflock 93f0d9edae Proper fix for building locally and through GH actions 2025-10-09 19:29:05 -05:00
stopflock 793e735452 Bump version, actually account for HiDiPi displays. Previous commit does not build. 2025-10-09 12:58:19 -05:00
stopflock 6a2c1230d2 This it the right way to do client IDs / secrets and local builds 2025-10-09 11:15:21 -05:00
stopflock b8834cd256 Account for HiDiPi displays 2025-10-09 10:42:07 -05:00
stopflock b8b9d4b797 deflock-imize node icons fully 2025-10-09 00:02:21 -05:00
stopflock 4b1111a0a3 node colors 2025-10-08 22:40:12 -05:00
stopflock f80f125599 macos debug builds w/o signing 2025-10-08 22:39:56 -05:00
stopflock afa0ff94b2 Remove keys.dart, update workflow 2025-10-08 10:43:48 -05:00
stopflock 02f3cb0077 center help links on about page, bump version 2025-10-07 23:00:59 -05:00
stopflock c671f29930 OSM client IDs/keys as repo secrets, more links on about/info screen. 2025-10-07 16:33:33 -05:00
stopflock 68068214bb deflock links on about screen 2025-10-07 15:15:09 -05:00
stopflock b00db130d7 Suspected location localizations, credit alprwatch 2025-10-07 14:03:11 -05:00
stopflock 5c28057fa1 All nodes stop rendering below zoom 10, more cameras -> nodes 2025-10-07 10:02:57 -05:00
stopflock 106277faf4 Suspected nodes respect min zoom level, max number rendered 2025-10-06 22:51:30 -05:00
stopflock f9351ba272 Suspected location exclusion zone, drawn under real nodes, and update progress bar 2025-10-06 22:21:05 -05:00
stopflock 4a44ab96d6 Sorta working suspected locations 2025-10-06 21:07:08 -05:00
stopflock 904af42cbf Fix asset filenames version field 2025-10-06 19:43:43 -05:00
stopflock cc0386ee97 UX and bones of suspected locations 2025-10-06 19:36:54 -05:00
stopflock 08238eaad2 stray period 2025-10-06 19:01:34 -05:00
stopflock 3fbcd8f092 add google versioncode 2025-10-06 18:53:29 -05:00
stopflock aeea503060 Name asset files in release correctly, remove finished todos 2025-10-06 14:48:13 -05:00
stopflock 69084be7bd add permission for attaching assets to release 2025-10-06 12:42:27 -05:00
stopflock 14b52f8018 Bump version to 1.1 2025-10-06 12:03:26 -05:00
stopflock 5301810c0e run actions on tags on master, attach to release 2025-10-06 11:59:05 -05:00
stopflock 23713acb99 Merge pull request #20 from FoggedLens/vector-tiles
Everything but Vector tiles:

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

No files matched your search

+147 -10
View File
@@ -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
+3
View File
@@ -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
# ─────────────────────────────── # ───────────────────────────────
+3 -3
View File
@@ -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)
+10
View File
@@ -0,0 +1,10 @@
# Local OSM client ID configuration for builds
# Copy this file to build_keys.conf and fill in your values
# This file is gitignored to keep your keys secret
#
# Get your client IDs from:
# Production: https://www.openstreetmap.org/oauth2/applications
# Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications
OSM_PROD_CLIENTID=your_production_client_id_here
OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here
+60 -3
View File
@@ -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
+12
View File
@@ -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";
+2
View File
@@ -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>
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}
-3
View File
@@ -1,3 +0,0 @@
// Example OSM OAuth key config
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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"
} }
} }
+35
View File
@@ -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": "无可用地址"
} }
} }
+212
View File
@@ -0,0 +1,212 @@
import 'dart:convert';
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
class SuspectedLocation {
final String ticketNo;
final String? urlFull;
final String? addr;
final String? street;
final String? city;
final String? state;
final String? digSiteIntersectingStreet;
final String? digWorkDoneFor;
final String? digSiteRemarks;
final Map<String, dynamic>? geoJson;
final LatLng centroid;
final List<LatLng> bounds;
SuspectedLocation({
required this.ticketNo,
this.urlFull,
this.addr,
this.street,
this.city,
this.state,
this.digSiteIntersectingStreet,
this.digWorkDoneFor,
this.digSiteRemarks,
this.geoJson,
required this.centroid,
required this.bounds,
});
/// Create from CSV row data
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
final locationString = row['location'] as String?;
LatLng centroid = const LatLng(0, 0);
List<LatLng> bounds = [];
Map<String, dynamic>? geoJson;
// Parse GeoJSON if available
if (locationString != null && locationString.isNotEmpty) {
try {
geoJson = jsonDecode(locationString) as Map<String, dynamic>;
final coordinates = _extractCoordinatesFromGeoJson(geoJson);
centroid = coordinates.centroid;
bounds = coordinates.bounds;
} catch (e) {
// If GeoJSON parsing fails, use default coordinates
print('[SuspectedLocation] Failed to parse GeoJSON for ticket ${row['ticket_no']}: $e');
print('[SuspectedLocation] Location string: $locationString');
}
}
return SuspectedLocation(
ticketNo: row['ticket_no']?.toString() ?? '',
urlFull: row['url_full']?.toString(),
addr: row['addr']?.toString(),
street: row['street']?.toString(),
city: row['city']?.toString(),
state: row['state']?.toString(),
digSiteIntersectingStreet: row['dig_site_intersecting_street']?.toString(),
digWorkDoneFor: row['dig_work_done_for']?.toString(),
digSiteRemarks: row['dig_site_remarks']?.toString(),
geoJson: geoJson,
centroid: centroid,
bounds: bounds,
);
}
/// Extract coordinates from GeoJSON
static ({LatLng centroid, List<LatLng> bounds}) _extractCoordinatesFromGeoJson(Map<String, dynamic> geoJson) {
try {
// The geoJson IS the geometry object (not wrapped in a 'geometry' property)
final coordinates = geoJson['coordinates'] as List?;
if (coordinates == null || coordinates.isEmpty) {
print('[SuspectedLocation] No coordinates found in GeoJSON');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
final List<LatLng> points = [];
// Handle different geometry types
final type = geoJson['type'] as String?;
switch (type) {
case 'Point':
if (coordinates.length >= 2) {
final point = LatLng(
(coordinates[1] as num).toDouble(),
(coordinates[0] as num).toDouble(),
);
points.add(point);
}
break;
case 'Polygon':
// Polygon coordinates are [[[lng, lat], ...]]
if (coordinates.isNotEmpty) {
final ring = coordinates[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
break;
case 'MultiPolygon':
// MultiPolygon coordinates are [[[[lng, lat], ...], ...], ...]
for (final polygon in coordinates) {
if (polygon is List && polygon.isNotEmpty) {
final ring = polygon[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
}
break;
default:
print('Unsupported geometry type: $type');
}
if (points.isEmpty) {
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
// Calculate centroid
double sumLat = 0;
double sumLng = 0;
for (final point in points) {
sumLat += point.latitude;
sumLng += point.longitude;
}
final centroid = LatLng(sumLat / points.length, sumLng / points.length);
return (centroid: centroid, bounds: points);
} catch (e) {
print('Error extracting coordinates from GeoJSON: $e');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
}
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'ticket_no': ticketNo,
'url_full': urlFull,
'addr': addr,
'street': street,
'city': city,
'state': state,
'dig_site_intersecting_street': digSiteIntersectingStreet,
'dig_work_done_for': digWorkDoneFor,
'dig_site_remarks': digSiteRemarks,
'geo_json': geoJson,
'centroid_lat': centroid.latitude,
'centroid_lng': centroid.longitude,
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
};
/// Create from stored JSON
factory SuspectedLocation.fromJson(Map<String, dynamic> json) {
final boundsData = json['bounds'] as List?;
final bounds = boundsData?.map((b) => LatLng(
(b[0] as num).toDouble(),
(b[1] as num).toDouble(),
)).toList() ?? <LatLng>[];
return SuspectedLocation(
ticketNo: json['ticket_no'] ?? '',
urlFull: json['url_full'],
addr: json['addr'],
street: json['street'],
city: json['city'],
state: json['state'],
digSiteIntersectingStreet: json['dig_site_intersecting_street'],
digWorkDoneFor: json['dig_work_done_for'],
digSiteRemarks: json['dig_site_remarks'],
geoJson: json['geo_json'],
centroid: LatLng(
(json['centroid_lat'] as num).toDouble(),
(json['centroid_lng'] as num).toDouble(),
),
bounds: bounds,
);
}
/// Get a formatted display address
String get displayAddress {
final parts = <String>[];
if (addr?.isNotEmpty == true) parts.add(addr!);
if (street?.isNotEmpty == true) parts.add(street!);
if (city?.isNotEmpty == true) parts.add(city!);
if (state?.isNotEmpty == true) parts.add(state!);
return parts.isNotEmpty ? parts.join(', ') : 'No address available';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SuspectedLocation &&
runtimeType == other.runtimeType &&
ticketNo == other.ticketNo;
@override
int get hashCode => ticketNo.hashCode;
}
+57 -1
View File
@@ -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(),
+49
View File
@@ -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));
},
),
),
),
],
],
);
},
);
}
}
+229
View File
@@ -0,0 +1,229 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import '../models/suspected_location.dart';
import 'suspected_location_service.dart';
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
class SuspectedLocationEntry {
final Map<String, dynamic> rawData;
final LatLng centroid;
SuspectedLocationEntry({required this.rawData, required this.centroid});
Map<String, dynamic> toJson() => {
'rawData': rawData,
'centroid': [centroid.latitude, centroid.longitude],
};
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
final centroidList = json['centroid'] as List;
return SuspectedLocationEntry(
rawData: Map<String, dynamic>.from(json['rawData']),
centroid: LatLng(
(centroidList[0] as num).toDouble(),
(centroidList[1] as num).toDouble(),
),
);
}
}
class SuspectedLocationCache extends ChangeNotifier {
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
factory SuspectedLocationCache() => _instance;
SuspectedLocationCache._();
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
List<SuspectedLocationEntry> _processedEntries = [];
DateTime? _lastFetchTime;
final Map<String, List<SuspectedLocation>> _boundsCache = {};
/// Get suspected locations within specific bounds (cached)
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
if (!SuspectedLocationService().isEnabled) {
debugPrint('[SuspectedLocationCache] Service not enabled');
return [];
}
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
// Check cache first
if (_boundsCache.containsKey(boundsKey)) {
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
return _boundsCache[boundsKey]!;
}
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
final locations = <SuspectedLocation>[];
int inBoundsCount = 0;
for (final entry in _processedEntries) {
// Quick bounds check using pre-calculated centroid
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat <= bounds.north && lat >= bounds.south &&
lng <= bounds.east && lng >= bounds.west) {
try {
// Only create SuspectedLocation object if it's in bounds
final location = SuspectedLocation.fromCsvRow(entry.rawData);
locations.add(location);
inBoundsCount++;
} catch (e) {
// Skip invalid entries
continue;
}
}
}
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
// Cache the result
_boundsCache[boundsKey] = locations;
// Limit cache size to prevent memory issues
if (_boundsCache.length > 100) {
final oldestKey = _boundsCache.keys.first;
_boundsCache.remove(oldestKey);
}
return locations;
}
/// Load processed data from storage
Future<void> loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load last fetch time
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
if (lastFetchMs != null) {
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
}
// Load processed data
final processedDataString = prefs.getString(_prefsKeyProcessedData);
if (processedDataString != null) {
final List<dynamic> processedDataList = jsonDecode(processedDataString);
_processedEntries = processedDataList
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
}
} catch (e) {
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
_processedEntries.clear();
_lastFetchTime = null;
}
}
/// Process raw CSV data and save to storage (calculates centroids once)
Future<void> processAndSave(
List<Map<String, dynamic>> rawData,
DateTime fetchTime, {
void Function(String message, double? progress)? onProgress,
}) async {
try {
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
final processedEntries = <SuspectedLocationEntry>[];
int validCount = 0;
int errorCount = 0;
int zeroCoordCount = 0;
for (int i = 0; i < rawData.length; i++) {
final rowData = rawData[i];
// Report progress every 1000 entries
if (i % 1000 == 0) {
final progress = i / rawData.length;
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
}
try {
// Create a temporary SuspectedLocation to extract the centroid
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
// Only save if we have a valid centroid (not at 0,0)
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
processedEntries.add(SuspectedLocationEntry(
rawData: rowData,
centroid: tempLocation.centroid,
));
validCount++;
} else {
zeroCoordCount++;
}
} catch (e) {
errorCount++;
continue;
}
}
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
_processedEntries = processedEntries;
_lastFetchTime = fetchTime;
// Clear bounds cache since data changed
_boundsCache.clear();
final prefs = await SharedPreferences.getInstance();
// Save processed data
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
await prefs.setString(_prefsKeyProcessedData, processedDataString);
// Save last fetch time
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
// Log coordinate ranges for debugging
if (processedEntries.isNotEmpty) {
double minLat = processedEntries.first.centroid.latitude;
double maxLat = minLat;
double minLng = processedEntries.first.centroid.longitude;
double maxLng = minLng;
for (final entry in processedEntries) {
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
}
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
notifyListeners();
} catch (e) {
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
}
}
/// Clear all cached data
void clear() {
_processedEntries.clear();
_boundsCache.clear();
_lastFetchTime = null;
notifyListeners();
}
/// Get last fetch time
DateTime? get lastFetchTime => _lastFetchTime;
/// Get total count of processed entries
int get totalCount => _processedEntries.length;
/// Check if we have data
bool get hasData => _processedEntries.isNotEmpty;
}
@@ -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),
));
}
}
+16
View File
@@ -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();
}
}
} }
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/foundation.dart';
import '../models/suspected_location.dart';
import '../services/suspected_location_service.dart';
class SuspectedLocationState extends ChangeNotifier {
final SuspectedLocationService _service = SuspectedLocationService();
SuspectedLocation? _selectedLocation;
bool _isLoading = false;
/// Currently selected suspected location (for detail view)
SuspectedLocation? get selectedLocation => _selectedLocation;
/// Get suspected locations in bounds (this should be called by the map view)
List<SuspectedLocation> getLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _service.getLocationsInBounds(
north: north,
south: south,
east: east,
west: west,
);
}
/// Whether suspected locations are enabled
bool get isEnabled => _service.isEnabled;
/// Whether currently loading data
bool get isLoading => _isLoading || _service.isLoading;
/// Last time data was fetched
DateTime? get lastFetchTime => _service.lastFetchTime;
/// Initialize the state
Future<void> init({bool offlineMode = false}) async {
await _service.init(offlineMode: offlineMode);
notifyListeners();
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled);
notifyListeners();
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.refreshData(onProgress: onProgress);
return success;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Select a suspected location for detail view
void selectLocation(SuspectedLocation location) {
_selectedLocation = location;
notifyListeners();
}
/// Clear the selected location
void clearSelection() {
_selectedLocation = null;
notifyListeners();
}
}
+2 -2
View File
@@ -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),
), ),
), ),
); );
+3 -2
View File
@@ -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,
+29 -13
View File
@@ -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
View File
@@ -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;
}
} }
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class SuspectedLocationIcon extends StatelessWidget {
const SuspectedLocationIcon({super.key});
@override
Widget build(BuildContext context) {
return Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.orange,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.help_outline,
color: Colors.white,
size: 12,
),
);
}
}
@@ -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),
),
],
);
}
}
+156
View File
@@ -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')),
),
],
),
],
),
),
),
);
},
);
}
}
+8
View File
@@ -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
+1 -1
View File
@@ -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 = (
+4 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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