Compare commits

...

41 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
40 changed files with 2124 additions and 90 deletions

View File

@@ -3,6 +3,7 @@ on:
push:
tags:
- '*'
workflow_dispatch:
permissions:
contents: write
@@ -20,7 +21,7 @@ jobs:
- name: Get version from lib/dev_config.dart
id: set-version
run: |
echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') >> $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
# id: extract_version
@@ -67,7 +68,7 @@ jobs:
echo "storeFile=keystore.jks" >> android/key.properties
- 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
uses: actions/upload-artifact@v4
@@ -116,7 +117,7 @@ jobs:
echo "storeFile=keystore.jks" >> android/key.properties
- 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
uses: actions/upload-artifact@v4
@@ -146,13 +147,88 @@ jobs:
dart run flutter_launcher_icons
dart run flutter_native_splash:create
# - name: Build iOS .ipa
# run: flutter build ipa --release
- name: Build iOS .app
- name: Install Apple certificate and provisioning profile
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
P12_PASSWORD: ""
BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
run: |
flutter build ios --release --no-codesign
./app2ipa.sh build/ios/iphoneos/Runner.app
# create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Set this keychain as the default
security list-keychain -d user -s $KEYCHAIN_PATH
security default-keychain -s $KEYCHAIN_PATH
# install provisioning profile
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
# Also install using the profile's internal UUID for better compatibility
UUID=$(security cms -D -i $PP_PATH | plutil -extract UUID xml1 -o - - | xmllint --xpath "//string/text()" -)
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
# Debug: Check what we actually have
echo "=== Certificates in keychain ==="
security find-identity -v -p codesigning $KEYCHAIN_PATH
echo "=== Provisioning profiles ==="
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
echo "=== Profile UUID extracted: $UUID ==="
- name: Create export options
run: |
cat > ios/exportOptions.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>7XG8T28436</string>
<key>provisioningProfiles</key>
<dict>
<key>me.deflock.deflockapp</key>
<string>61f9fdb9-bf2d-4d94-b249-63155ee71e74</string>
</dict>
<key>signingStyle</key>
<string>manual</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
EOF
- name: Build iOS .ipa
run: |
flutter build ipa --release \
--export-options-plist=ios/exportOptions.plist \
--dart-define=OSM_PROD_CLIENTID='${{ secrets.OSM_PROD_CLIENTID }}' \
--dart-define=OSM_SANDBOX_CLIENTID='${{ secrets.OSM_SANDBOX_CLIENTID }}'
cp build/ios/ipa/*.ipa Runner.ipa
- name: Clean up keychain and provisioning profile
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
rm ~/Library/MobileDevice/Provisioning\ Profiles/61f9fdb9-bf2d-4d94-b249-63155ee71e74.mobileprovision
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
@@ -161,6 +237,27 @@ jobs:
path: Runner.ipa
if-no-files-found: 'error'
- name: Upload to App Store Connect
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
# Create the private keys directory 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]

3
.gitignore vendored
View File

@@ -93,6 +93,9 @@ Thumbs.db
*.keystore
.env
# Local OSM client ID configuration (contains secrets)
build_keys.conf
# ───────────────────────────────
# For now - not targeting these
# ───────────────────────────────

View File

@@ -79,7 +79,9 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### 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
- Swap in alprwatch.org/directions avoidance routing API

10
build_keys.conf.example Normal file
View File

@@ -0,0 +1,10 @@
# Local OSM client ID configuration for builds
# Copy this file to build_keys.conf and fill in your values
# This file is gitignored to keep your keys secret
#
# Get your client IDs from:
# Production: https://www.openstreetmap.org/oauth2/applications
# Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications
OSM_PROD_CLIENTID=your_production_client_id_here
OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here

View File

@@ -4,6 +4,35 @@
BUILD_IOS=true
BUILD_ANDROID=true
# Function to read key=value from file
read_from_file() {
local key="$1"
local file="build_keys.conf"
if [ ! -f "$file" ]; then
return 1
fi
# Read key=value pairs, ignoring comments and empty lines
while IFS='=' read -r k v; do
# Skip comments and empty lines
if [[ "$k" =~ ^[[:space:]]*# ]] || [[ -z "$k" ]]; then
continue
fi
# Remove leading/trailing whitespace
k=$(echo "$k" | xargs)
v=$(echo "$v" | xargs)
if [ "$k" = "$key" ]; then
echo "$v"
return 0
fi
done < "$file"
return 1
}
# Parse arguments
for arg in "$@"; do
case $arg in
@@ -18,19 +47,47 @@ for arg in "$@"; do
echo " --ios Build only iOS"
echo " --android Build only Android"
echo " (default builds both)"
echo ""
echo "OSM client IDs must be configured in build_keys.conf"
echo "See build_keys.conf.example for format"
exit 1
;;
esac
done
appver=$(grep "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 "Building app version ${appver}..."
echo
if [ "$BUILD_IOS" = true ]; then
echo "Building iOS..."
flutter build ios --no-codesign || exit 1
flutter build ios --no-codesign $DART_DEFINE_ARGS || exit 1
echo "Converting .app to .ipa..."
./app2ipa.sh build/ios/iphoneos/Runner.app || exit 1
@@ -42,7 +99,7 @@ fi
if [ "$BUILD_ANDROID" = true ]; then
echo "Building Android..."
flutter build apk || exit 1
flutter build apk $DART_DEFINE_ARGS || exit 1
echo "Moving Android files..."
cp build/app/outputs/flutter-apk/app-release.apk "../deflock_v${appver}.apk" || exit 1

View File

@@ -470,7 +470,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -479,6 +482,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -652,7 +656,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -661,6 +668,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -674,7 +682,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7XG8T28436;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -683,6 +694,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = me.deflock.deflockapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "61f9fdb9-bf2d-4d94-b249-63155ee71e74";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";

View File

@@ -26,6 +26,8 @@
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<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>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

@@ -6,6 +6,7 @@ import 'models/node_profile.dart';
import 'models/operator_profile.dart';
import 'models/osm_node.dart';
import 'models/pending_upload.dart';
import 'models/suspected_location.dart';
import 'models/tile_provider.dart';
import 'models/search_result.dart';
import 'services/offline_area_service.dart';
@@ -19,6 +20,7 @@ import 'state/profile_state.dart';
import 'state/search_state.dart';
import 'state/session_state.dart';
import 'state/settings_state.dart';
import 'state/suspected_location_state.dart';
import 'state/upload_queue_state.dart';
// Re-export types
@@ -38,6 +40,7 @@ class AppState extends ChangeNotifier {
late final SearchState _searchState;
late final SessionState _sessionState;
late final SettingsState _settingsState;
late final SuspectedLocationState _suspectedLocationState;
late final UploadQueueState _uploadQueueState;
bool _isInitialized = false;
@@ -51,6 +54,7 @@ class AppState extends ChangeNotifier {
_searchState = SearchState();
_sessionState = SessionState();
_settingsState = SettingsState();
_suspectedLocationState = SuspectedLocationState();
_uploadQueueState = UploadQueueState();
// Set up state change listeners
@@ -61,6 +65,7 @@ class AppState extends ChangeNotifier {
_searchState.addListener(_onStateChanged);
_sessionState.addListener(_onStateChanged);
_settingsState.addListener(_onStateChanged);
_suspectedLocationState.addListener(_onStateChanged);
_uploadQueueState.addListener(_onStateChanged);
_init();
@@ -127,6 +132,7 @@ class AppState extends ChangeNotifier {
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _settingsState.networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _settingsState.suspectedLocationMinDistance;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -139,6 +145,12 @@ class AppState extends ChangeNotifier {
int get pendingCount => _uploadQueueState.pendingCount;
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
// Suspected location state
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;
DateTime? get suspectedLocationsLastFetch => _suspectedLocationState.lastFetchTime;
void _onStateChanged() {
notifyListeners();
}
@@ -153,6 +165,7 @@ class AppState extends ChangeNotifier {
await _operatorProfileState.init();
await _profileState.init();
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
@@ -408,6 +421,11 @@ class AppState extends ChangeNotifier {
await _settingsState.setNetworkStatusIndicatorEnabled(enabled);
}
/// Set suspected location minimum distance from real nodes
Future<void> setSuspectedLocationMinDistance(int distance) async {
await _settingsState.setSuspectedLocationMinDistance(distance);
}
// ---------- Queue Methods ----------
void clearQueue() {
_uploadQueueState.clearQueue();
@@ -422,6 +440,39 @@ class AppState extends ChangeNotifier {
_startUploader(); // resume uploader if not busy
}
// ---------- Suspected Location Methods ----------
Future<void> setSuspectedLocationsEnabled(bool enabled) async {
await _suspectedLocationState.setEnabled(enabled);
}
Future<bool> refreshSuspectedLocations({
void Function(String message, double? progress)? onProgress,
}) async {
return await _suspectedLocationState.refreshData(onProgress: onProgress);
}
void selectSuspectedLocation(SuspectedLocation location) {
_suspectedLocationState.selectLocation(location);
}
void clearSuspectedLocationSelection() {
_suspectedLocationState.clearSelection();
}
List<SuspectedLocation> getSuspectedLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _suspectedLocationState.getLocationsInBounds(
north: north,
south: south,
east: east,
west: west,
);
}
// ---------- Private Methods ----------
/// Attempts to fetch missing tile preview images in the background (fire and forget)
void _fetchMissingTilePreviews() {
@@ -449,6 +500,7 @@ class AppState extends ChangeNotifier {
_searchState.removeListener(_onStateChanged);
_sessionState.removeListener(_onStateChanged);
_settingsState.removeListener(_onStateChanged);
_suspectedLocationState.removeListener(_onStateChanged);
_uploadQueueState.removeListener(_onStateChanged);
_uploadQueueState.dispose();

View File

@@ -12,9 +12,12 @@ const int kPreviewTileY = 101300;
const int kPreviewTileX = 41904;
// Direction cone for map view
const double kDirectionConeHalfAngle = 30.0; // degrees
const double kDirectionConeBaseLength = 0.001; // multiplier
const Color kDirectionConeColor = Color(0xFF000000); // FOV cone color
const double kDirectionConeHalfAngle = 35.0; // degrees
const double kDirectionConeBaseLength = 5; // multiplier
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
// Base values for thickness - use helper functions below for pixel-ratio scaling
const double _kDirectionConeBorderWidthBase = 1.6;
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
@@ -37,6 +40,9 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea
const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/pub/flock_utilities_mini_latest.csv';
// Development/testing features - set to false for production builds
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
@@ -86,12 +92,23 @@ const int kAbsoluteMaxTileCount = 50000;
const int kAbsoluteMaxZoom = 23;
// Node icon configuration
const double kNodeIconDiameter = 20.0;
const double kNodeRingThickness = 4.0;
const double kNodeDotOpacity = 0.4; // Opacity for the grey dot interior
const Color kNodeRingColorReal = Color(0xC43F55F3); // Real nodes from OSM - blue
const Color kNodeRingColorMock = Color(0xC4FFFFFF); // Add node mock point - white
const Color kNodeRingColorPending = Color(0xC49C27B0); // Submitted/pending nodes - purple
const Color kNodeRingColorEditing = Color(0xC4FF9800); // Node being edited - orange
const Color kNodeRingColorPendingEdit = Color(0xC4757575); // Original node with pending edit - grey
const Color kNodeRingColorPendingDeletion = Color(0xA4F44336); // Node pending deletion - red, slightly transparent
const double kNodeIconDiameter = 18.0;
const double _kNodeRingThicknessBase = 2.5;
const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior
const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue
const Color kNodeRingColorMock = Color(0xD0FFFFFF); // Add node mock point - white
const Color kNodeRingColorPending = Color(0xD09C27B0); // Submitted/pending nodes - purple
const Color kNodeRingColorEditing = Color(0xD0FF9800); // Node being edited - orange
const Color kNodeRingColorPendingEdit = Color(0xD0757575); // Original node with pending edit - grey
const Color kNodeRingColorPendingDeletion = Color(0xC0F44336); // Node pending deletion - red, slightly transparent
// Helper functions for pixel-ratio scaling
double getDirectionConeBorderWidth(BuildContext context) {
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
return _kDirectionConeBorderWidthBase;
}
double getNodeRingThickness(BuildContext context) {
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
return _kNodeRingThicknessBase;
}

View File

@@ -1,7 +1,16 @@
// OpenStreetMap OAuth client IDs for this app.
//
// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead.
// These must be provided via --dart-define at build time.
const String kOsmProdClientId = 'U8p_n6IjZfQiL1KtdiwbB0-o9nto6CAKz7LC2GifJzk'; // example - replace with real
const String kOsmSandboxClientId = 'SBHWpWTKf31EdSiTApnah3Fj2rLnk2pEwBORlX0NyZI'; // example - replace with real
String get kOsmProdClientId {
const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID');
if (fromBuild.isNotEmpty) return fromBuild;
throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id');
}
String get kOsmSandboxClientId {
const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID');
if (fromBuild.isNotEmpty) return fromBuild;
throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id');
}

View File

@@ -1,3 +0,0 @@
// Example OSM OAuth key config
const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE';
const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE';

View File

@@ -341,5 +341,40 @@
"imperial": "Britisch (mi, ft)",
"meters": "Meter",
"feet": "Fuß"
},
"suspectedLocations": {
"title": "Verdächtige Standorte",
"showSuspectedLocations": "Verdächtige Standorte anzeigen",
"showSuspectedLocationsSubtitle": "Fragezeichen-Marker für vermutete Überwachungsstandorte aus Versorgungsgenehmigungsdaten anzeigen",
"lastUpdated": "Zuletzt aktualisiert",
"refreshNow": "Jetzt aktualisieren",
"dataSource": "Datenquelle",
"dataSourceDescription": "Versorgungsgenehmigungsdaten, die auf potenzielle Installationsstandorte für Überwachungsinfrastruktur hinweisen",
"dataSourceCredit": "Datensammlung und -hosting bereitgestellt von alprwatch.org",
"minimumDistance": "Mindestabstand zu echten Geräten",
"minimumDistanceSubtitle": "Verdächtige Standorte innerhalb von {}m vorhandener Überwachungsgeräte ausblenden",
"updating": "Verdächtige Standorte werden aktualisiert",
"downloadingAndProcessing": "Daten werden heruntergeladen und verarbeitet...",
"updateSuccess": "Verdächtige Standorte erfolgreich aktualisiert",
"updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen",
"neverFetched": "Nie abgerufen",
"daysAgo": "vor {} Tagen",
"hoursAgo": "vor {} Stunden",
"minutesAgo": "vor {} Minuten",
"justNow": "Gerade eben"
},
"suspectedLocation": {
"title": "Verdächtiger Standort #{}",
"ticketNo": "Ticket-Nr.",
"address": "Adresse",
"street": "Straße",
"city": "Stadt",
"state": "Bundesland",
"intersectingStreet": "Kreuzende Straße",
"workDoneFor": "Arbeit ausgeführt für",
"remarks": "Bemerkungen",
"url": "URL",
"coordinates": "Koordinaten",
"noAddressAvailable": "Keine Adresse verfügbar"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "meters",
"feet": "feet"
},
"suspectedLocations": {
"title": "Suspected Locations",
"showSuspectedLocations": "Show Suspected Locations",
"showSuspectedLocationsSubtitle": "Show question mark markers for suspected surveillance sites from utility permit data",
"lastUpdated": "Last Updated",
"refreshNow": "Refresh now",
"dataSource": "Data Source",
"dataSourceDescription": "Utility permit data indicating potential surveillance infrastructure installation sites",
"dataSourceCredit": "Data collection and hosting provided by alprwatch.org",
"minimumDistance": "Minimum Distance from Real Nodes",
"minimumDistanceSubtitle": "Hide suspected locations within {}m of existing surveillance devices",
"updating": "Updating Suspected Locations",
"downloadingAndProcessing": "Downloading and processing data...",
"updateSuccess": "Suspected locations updated successfully",
"updateFailed": "Failed to update suspected locations",
"neverFetched": "Never fetched",
"daysAgo": "{} days ago",
"hoursAgo": "{} hours ago",
"minutesAgo": "{} minutes ago",
"justNow": "Just now"
},
"suspectedLocation": {
"title": "Suspected Location #{}",
"ticketNo": "Ticket No",
"address": "Address",
"street": "Street",
"city": "City",
"state": "State",
"intersectingStreet": "Intersecting Street",
"workDoneFor": "Work Done For",
"remarks": "Remarks",
"url": "URL",
"coordinates": "Coordinates",
"noAddressAvailable": "No address available"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pies"
},
"suspectedLocations": {
"title": "Ubicaciones Sospechosas",
"showSuspectedLocations": "Mostrar Ubicaciones Sospechosas",
"showSuspectedLocationsSubtitle": "Mostrar marcadores de interrogación para sitios de vigilancia sospechosos de datos de permisos de servicios públicos",
"lastUpdated": "Última Actualización",
"refreshNow": "Actualizar ahora",
"dataSource": "Fuente de Datos",
"dataSourceDescription": "Datos de permisos de servicios públicos que indican posibles sitios de instalación de infraestructura de vigilancia",
"dataSourceCredit": "Recopilación y alojamiento de datos proporcionado por alprwatch.org",
"minimumDistance": "Distancia Mínima de Nodos Reales",
"minimumDistanceSubtitle": "Ocultar ubicaciones sospechosas dentro de {}m de dispositivos de vigilancia existentes",
"updating": "Actualizando Ubicaciones Sospechosas",
"downloadingAndProcessing": "Descargando y procesando datos...",
"updateSuccess": "Ubicaciones sospechosas actualizadas exitosamente",
"updateFailed": "Error al actualizar ubicaciones sospechosas",
"neverFetched": "Nunca obtenido",
"daysAgo": "hace {} días",
"hoursAgo": "hace {} horas",
"minutesAgo": "hace {} minutos",
"justNow": "Ahora mismo"
},
"suspectedLocation": {
"title": "Ubicación Sospechosa #{}",
"ticketNo": "No. de Ticket",
"address": "Dirección",
"street": "Calle",
"city": "Ciudad",
"state": "Estado",
"intersectingStreet": "Calle que Intersecta",
"workDoneFor": "Trabajo Realizado Para",
"remarks": "Observaciones",
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "No hay dirección disponible"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "Impérial (mi, ft)",
"meters": "mètres",
"feet": "pieds"
},
"suspectedLocations": {
"title": "Emplacements Suspects",
"showSuspectedLocations": "Afficher les Emplacements Suspects",
"showSuspectedLocationsSubtitle": "Afficher des marqueurs en point d'interrogation pour les sites de surveillance suspectés à partir des données de permis de services publics",
"lastUpdated": "Dernière Mise à Jour",
"refreshNow": "Actualiser maintenant",
"dataSource": "Source de Données",
"dataSourceDescription": "Données de permis de services publics indiquant des sites d'installation potentiels d'infrastructure de surveillance",
"dataSourceCredit": "Collecte et hébergement des données fournis par alprwatch.org",
"minimumDistance": "Distance Minimale des Nœuds Réels",
"minimumDistanceSubtitle": "Masquer les emplacements suspects dans un rayon de {}m des dispositifs de surveillance existants",
"updating": "Mise à Jour des Emplacements Suspects",
"downloadingAndProcessing": "Téléchargement et traitement des données...",
"updateSuccess": "Emplacements suspects mis à jour avec succès",
"updateFailed": "Échec de la mise à jour des emplacements suspects",
"neverFetched": "Jamais récupéré",
"daysAgo": "il y a {} jours",
"hoursAgo": "il y a {} heures",
"minutesAgo": "il y a {} minutes",
"justNow": "À l'instant"
},
"suspectedLocation": {
"title": "Emplacement Suspect #{}",
"ticketNo": "N° de Ticket",
"address": "Adresse",
"street": "Rue",
"city": "Ville",
"state": "État",
"intersectingStreet": "Rue Transversale",
"workDoneFor": "Travail Effectué Pour",
"remarks": "Remarques",
"url": "URL",
"coordinates": "Coordonnées",
"noAddressAvailable": "Aucune adresse disponible"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "Imperiale (mi, ft)",
"meters": "metri",
"feet": "piedi"
},
"suspectedLocations": {
"title": "Posizioni Sospette",
"showSuspectedLocations": "Mostra Posizioni Sospette",
"showSuspectedLocationsSubtitle": "Mostra marcatori punto interrogativo per siti di sorveglianza sospetti dai dati dei permessi dei servizi pubblici",
"lastUpdated": "Ultimo Aggiornamento",
"refreshNow": "Aggiorna ora",
"dataSource": "Fonte Dati",
"dataSourceDescription": "Dati dei permessi dei servizi pubblici che indicano potenziali siti di installazione di infrastrutture di sorveglianza",
"dataSourceCredit": "Raccolta e hosting dei dati forniti da alprwatch.org",
"minimumDistance": "Distanza Minima dai Nodi Reali",
"minimumDistanceSubtitle": "Nascondi posizioni sospette entro {}m dai dispositivi di sorveglianza esistenti",
"updating": "Aggiornamento Posizioni Sospette",
"downloadingAndProcessing": "Scaricamento e elaborazione dati...",
"updateSuccess": "Posizioni sospette aggiornate con successo",
"updateFailed": "Aggiornamento posizioni sospette fallito",
"neverFetched": "Mai recuperato",
"daysAgo": "{} giorni fa",
"hoursAgo": "{} ore fa",
"minutesAgo": "{} minuti fa",
"justNow": "Proprio ora"
},
"suspectedLocation": {
"title": "Posizione Sospetta #{}",
"ticketNo": "N. Ticket",
"address": "Indirizzo",
"street": "Via",
"city": "Città",
"state": "Stato",
"intersectingStreet": "Via che Interseca",
"workDoneFor": "Lavoro Svolto Per",
"remarks": "Osservazioni",
"url": "URL",
"coordinates": "Coordinate",
"noAddressAvailable": "Nessun indirizzo disponibile"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "Imperial (mi, ft)",
"meters": "metros",
"feet": "pés"
},
"suspectedLocations": {
"title": "Localizações Suspeitas",
"showSuspectedLocations": "Mostrar Localizações Suspeitas",
"showSuspectedLocationsSubtitle": "Mostrar marcadores de ponto de interrogação para sites de vigilância suspeitos de dados de licenças de serviços públicos",
"lastUpdated": "Última Atualização",
"refreshNow": "Atualizar agora",
"dataSource": "Fonte de Dados",
"dataSourceDescription": "Dados de licenças de serviços públicos indicando possíveis locais de instalação de infraestrutura de vigilância",
"dataSourceCredit": "Coleta e hospedagem de dados fornecidas por alprwatch.org",
"minimumDistance": "Distância Mínima de Nós Reais",
"minimumDistanceSubtitle": "Ocultar localizações suspeitas dentro de {}m de dispositivos de vigilância existentes",
"updating": "Atualizando Localizações Suspeitas",
"downloadingAndProcessing": "Baixando e processando dados...",
"updateSuccess": "Localizações suspeitas atualizadas com sucesso",
"updateFailed": "Falha ao atualizar localizações suspeitas",
"neverFetched": "Nunca buscado",
"daysAgo": "{} dias atrás",
"hoursAgo": "{} horas atrás",
"minutesAgo": "{} minutos atrás",
"justNow": "Agora mesmo"
},
"suspectedLocation": {
"title": "Localização Suspeita #{}",
"ticketNo": "N° do Ticket",
"address": "Endereço",
"street": "Rua",
"city": "Cidade",
"state": "Estado",
"intersectingStreet": "Rua que Cruza",
"workDoneFor": "Trabalho Feito Para",
"remarks": "Observações",
"url": "URL",
"coordinates": "Coordenadas",
"noAddressAvailable": "Nenhum endereço disponível"
}
}

View File

@@ -341,5 +341,40 @@
"imperial": "英制(英里,英尺)",
"meters": "米",
"feet": "英尺"
},
"suspectedLocations": {
"title": "疑似位置",
"showSuspectedLocations": "显示疑似位置",
"showSuspectedLocationsSubtitle": "根据公用事业许可数据显示疑似监控站点的问号标记",
"lastUpdated": "最后更新",
"refreshNow": "立即刷新",
"dataSource": "数据源",
"dataSourceDescription": "公用事业许可数据,表明潜在的监控基础设施安装站点",
"dataSourceCredit": "数据收集和托管由 alprwatch.org 提供",
"minimumDistance": "与真实节点的最小距离",
"minimumDistanceSubtitle": "隐藏现有监控设备{}米范围内的疑似位置",
"updating": "正在更新疑似位置",
"downloadingAndProcessing": "正在下载和处理数据...",
"updateSuccess": "疑似位置更新成功",
"updateFailed": "疑似位置更新失败",
"neverFetched": "从未获取",
"daysAgo": "{}天前",
"hoursAgo": "{}小时前",
"minutesAgo": "{}分钟前",
"justNow": "刚刚"
},
"suspectedLocation": {
"title": "疑似位置 #{}",
"ticketNo": "工单号",
"address": "地址",
"street": "街道",
"city": "城市",
"state": "州/省",
"intersectingStreet": "交叉街道",
"workDoneFor": "工作完成方",
"remarks": "备注",
"url": "网址",
"coordinates": "坐标",
"noAddressAvailable": "无可用地址"
}
}

View File

@@ -0,0 +1,212 @@
import 'dart:convert';
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
class SuspectedLocation {
final String ticketNo;
final String? urlFull;
final String? addr;
final String? street;
final String? city;
final String? state;
final String? digSiteIntersectingStreet;
final String? digWorkDoneFor;
final String? digSiteRemarks;
final Map<String, dynamic>? geoJson;
final LatLng centroid;
final List<LatLng> bounds;
SuspectedLocation({
required this.ticketNo,
this.urlFull,
this.addr,
this.street,
this.city,
this.state,
this.digSiteIntersectingStreet,
this.digWorkDoneFor,
this.digSiteRemarks,
this.geoJson,
required this.centroid,
required this.bounds,
});
/// Create from CSV row data
factory SuspectedLocation.fromCsvRow(Map<String, dynamic> row) {
final locationString = row['location'] as String?;
LatLng centroid = const LatLng(0, 0);
List<LatLng> bounds = [];
Map<String, dynamic>? geoJson;
// Parse GeoJSON if available
if (locationString != null && locationString.isNotEmpty) {
try {
geoJson = jsonDecode(locationString) as Map<String, dynamic>;
final coordinates = _extractCoordinatesFromGeoJson(geoJson);
centroid = coordinates.centroid;
bounds = coordinates.bounds;
} catch (e) {
// If GeoJSON parsing fails, use default coordinates
print('[SuspectedLocation] Failed to parse GeoJSON for ticket ${row['ticket_no']}: $e');
print('[SuspectedLocation] Location string: $locationString');
}
}
return SuspectedLocation(
ticketNo: row['ticket_no']?.toString() ?? '',
urlFull: row['url_full']?.toString(),
addr: row['addr']?.toString(),
street: row['street']?.toString(),
city: row['city']?.toString(),
state: row['state']?.toString(),
digSiteIntersectingStreet: row['dig_site_intersecting_street']?.toString(),
digWorkDoneFor: row['dig_work_done_for']?.toString(),
digSiteRemarks: row['dig_site_remarks']?.toString(),
geoJson: geoJson,
centroid: centroid,
bounds: bounds,
);
}
/// Extract coordinates from GeoJSON
static ({LatLng centroid, List<LatLng> bounds}) _extractCoordinatesFromGeoJson(Map<String, dynamic> geoJson) {
try {
// The geoJson IS the geometry object (not wrapped in a 'geometry' property)
final coordinates = geoJson['coordinates'] as List?;
if (coordinates == null || coordinates.isEmpty) {
print('[SuspectedLocation] No coordinates found in GeoJSON');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
final List<LatLng> points = [];
// Handle different geometry types
final type = geoJson['type'] as String?;
switch (type) {
case 'Point':
if (coordinates.length >= 2) {
final point = LatLng(
(coordinates[1] as num).toDouble(),
(coordinates[0] as num).toDouble(),
);
points.add(point);
}
break;
case 'Polygon':
// Polygon coordinates are [[[lng, lat], ...]]
if (coordinates.isNotEmpty) {
final ring = coordinates[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
break;
case 'MultiPolygon':
// MultiPolygon coordinates are [[[[lng, lat], ...], ...], ...]
for (final polygon in coordinates) {
if (polygon is List && polygon.isNotEmpty) {
final ring = polygon[0] as List;
for (final coord in ring) {
if (coord is List && coord.length >= 2) {
points.add(LatLng(
(coord[1] as num).toDouble(),
(coord[0] as num).toDouble(),
));
}
}
}
}
break;
default:
print('Unsupported geometry type: $type');
}
if (points.isEmpty) {
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
// Calculate centroid
double sumLat = 0;
double sumLng = 0;
for (final point in points) {
sumLat += point.latitude;
sumLng += point.longitude;
}
final centroid = LatLng(sumLat / points.length, sumLng / points.length);
return (centroid: centroid, bounds: points);
} catch (e) {
print('Error extracting coordinates from GeoJSON: $e');
return (centroid: const LatLng(0, 0), bounds: <LatLng>[]);
}
}
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'ticket_no': ticketNo,
'url_full': urlFull,
'addr': addr,
'street': street,
'city': city,
'state': state,
'dig_site_intersecting_street': digSiteIntersectingStreet,
'dig_work_done_for': digWorkDoneFor,
'dig_site_remarks': digSiteRemarks,
'geo_json': geoJson,
'centroid_lat': centroid.latitude,
'centroid_lng': centroid.longitude,
'bounds': bounds.map((p) => [p.latitude, p.longitude]).toList(),
};
/// Create from stored JSON
factory SuspectedLocation.fromJson(Map<String, dynamic> json) {
final boundsData = json['bounds'] as List?;
final bounds = boundsData?.map((b) => LatLng(
(b[0] as num).toDouble(),
(b[1] as num).toDouble(),
)).toList() ?? <LatLng>[];
return SuspectedLocation(
ticketNo: json['ticket_no'] ?? '',
urlFull: json['url_full'],
addr: json['addr'],
street: json['street'],
city: json['city'],
state: json['state'],
digSiteIntersectingStreet: json['dig_site_intersecting_street'],
digWorkDoneFor: json['dig_work_done_for'],
digSiteRemarks: json['dig_site_remarks'],
geoJson: json['geo_json'],
centroid: LatLng(
(json['centroid_lat'] as num).toDouble(),
(json['centroid_lng'] as num).toDouble(),
),
bounds: bounds,
);
}
/// Get a formatted display address
String get displayAddress {
final parts = <String>[];
if (addr?.isNotEmpty == true) parts.add(addr!);
if (street?.isNotEmpty == true) parts.add(street!);
if (city?.isNotEmpty == true) parts.add(city!);
if (state?.isNotEmpty == true) parts.add(state!);
return parts.isNotEmpty ? parts.join(', ') : 'No address available';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SuspectedLocation &&
runtimeType == other.runtimeType &&
ticketNo == other.ticketNo;
@override
int get hashCode => ticketNo.hashCode;
}

View File

@@ -1,9 +1,25 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/localization_service.dart';
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
Future<void> _launchUrl(String url, BuildContext context) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open URL: $url'),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
@@ -17,28 +33,32 @@ class AboutScreen extends StatelessWidget {
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
locService.t('about.title'),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.description'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.features'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 16),
Text(
locService.t('about.initiative'),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.start,
),
const SizedBox(height: 24),
Text(
@@ -48,10 +68,46 @@ class AboutScreen extends StatelessWidget {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
_buildHelpLinks(context),
],
),
),
),
);
}
Widget _buildHelpLinks(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildLinkText(context, 'About DeFlock', 'https://deflock.me/about'),
const SizedBox(height: 8),
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
const SizedBox(height: 8),
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
const SizedBox(height: 8),
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
const SizedBox(height: 8),
_buildLinkText(context, 'Contact', 'https://deflock.me/contact'),
const SizedBox(height: 8),
_buildLinkText(context, 'Donate', 'https://deflock.me/donate'),
],
);
}
Widget _buildLinkText(BuildContext context, String text, String url) {
return GestureDetector(
onTap: () => _launchUrl(url, context),
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
textAlign: TextAlign.center,
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'settings/sections/max_nodes_section.dart';
import 'settings/sections/proximity_alerts_section.dart';
import 'settings/sections/suspected_locations_section.dart';
import 'settings/sections/tile_provider_section.dart';
import 'settings/sections/network_status_section.dart';
import '../services/localization_service.dart';
@@ -25,6 +26,8 @@ class AdvancedSettingsScreen extends StatelessWidget {
Divider(),
ProximityAlertsSection(),
Divider(),
SuspectedLocationsSection(),
Divider(),
NetworkStatusSection(),
Divider(),
TileProviderSection(),

View File

@@ -18,7 +18,9 @@ import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
import '../widgets/search_bar.dart';
import '../widgets/suspected_location_sheet.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
import '../models/search_result.dart';
class HomeScreen extends StatefulWidget {
@@ -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
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@@ -536,6 +584,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
sheetHeight: activeSheetHeight,
selectedNodeId: _selectedNodeId,
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
onSearchPressed: _onNavigationButtonPressed,
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../app_state.dart';
import '../../../services/localization_service.dart';
import '../../../widgets/suspected_location_progress_dialog.dart';
class SuspectedLocationsSection extends StatelessWidget {
const SuspectedLocationsSection({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final isEnabled = appState.suspectedLocationsEnabled;
final isLoading = appState.suspectedLocationsLoading;
final lastFetch = appState.suspectedLocationsLastFetch;
String getLastFetchText() {
if (lastFetch == null) {
return locService.t('suspectedLocations.neverFetched');
} else {
final now = DateTime.now();
final diff = now.difference(lastFetch);
if (diff.inDays > 0) {
return locService.t('suspectedLocations.daysAgo', params: [diff.inDays.toString()]);
} else if (diff.inHours > 0) {
return locService.t('suspectedLocations.hoursAgo', params: [diff.inHours.toString()]);
} else if (diff.inMinutes > 0) {
return locService.t('suspectedLocations.minutesAgo', params: [diff.inMinutes.toString()]);
} else {
return locService.t('suspectedLocations.justNow');
}
}
}
Future<void> handleRefresh() async {
if (!context.mounted) return;
// Show simple progress dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (progressContext) => SuspectedLocationProgressDialog(
title: locService.t('suspectedLocations.updating'),
message: locService.t('suspectedLocations.downloadingAndProcessing'),
),
);
// Start the refresh
final success = await appState.refreshSuspectedLocations();
// Close progress dialog
if (context.mounted) {
Navigator.of(context).pop();
// Show result snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? locService.t('suspectedLocations.updateSuccess')
: locService.t('suspectedLocations.updateFailed')),
),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('suspectedLocations.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Enable/disable switch
ListTile(
leading: const Icon(Icons.help_outline),
title: Text(locService.t('suspectedLocations.showSuspectedLocations')),
subtitle: Text(locService.t('suspectedLocations.showSuspectedLocationsSubtitle')),
trailing: Switch(
value: isEnabled,
onChanged: (enabled) {
appState.setSuspectedLocationsEnabled(enabled);
},
),
),
if (isEnabled) ...[
const SizedBox(height: 8),
// Last update time
ListTile(
leading: const Icon(Icons.schedule),
title: Text(locService.t('suspectedLocations.lastUpdated')),
subtitle: Text(getLastFetchText()),
trailing: isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.refresh),
onPressed: handleRefresh,
tooltip: locService.t('suspectedLocations.refreshNow'),
),
),
// Data info with credit
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(locService.t('suspectedLocations.dataSource')),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('suspectedLocations.dataSourceDescription')),
const SizedBox(height: 4),
Text(
locService.t('suspectedLocations.dataSourceCredit'),
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
// Minimum distance setting
ListTile(
leading: const Icon(Icons.social_distance),
title: Text(locService.t('suspectedLocations.minimumDistance')),
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
trailing: SizedBox(
width: 80,
child: TextFormField(
initialValue: appState.suspectedLocationMinDistance.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
suffixText: 'm',
),
onFieldSubmitted: (value) {
final distance = int.tryParse(value) ?? 100;
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
},
),
),
),
],
],
);
},
);
}
}

View File

@@ -0,0 +1,229 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:latlong2/latlong.dart';
import '../models/suspected_location.dart';
import 'suspected_location_service.dart';
/// Lightweight entry with pre-calculated centroid for efficient bounds checking
class SuspectedLocationEntry {
final Map<String, dynamic> rawData;
final LatLng centroid;
SuspectedLocationEntry({required this.rawData, required this.centroid});
Map<String, dynamic> toJson() => {
'rawData': rawData,
'centroid': [centroid.latitude, centroid.longitude],
};
factory SuspectedLocationEntry.fromJson(Map<String, dynamic> json) {
final centroidList = json['centroid'] as List;
return SuspectedLocationEntry(
rawData: Map<String, dynamic>.from(json['rawData']),
centroid: LatLng(
(centroidList[0] as num).toDouble(),
(centroidList[1] as num).toDouble(),
),
);
}
}
class SuspectedLocationCache extends ChangeNotifier {
static final SuspectedLocationCache _instance = SuspectedLocationCache._();
factory SuspectedLocationCache() => _instance;
SuspectedLocationCache._();
static const String _prefsKeyProcessedData = 'suspected_locations_processed_data';
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
List<SuspectedLocationEntry> _processedEntries = [];
DateTime? _lastFetchTime;
final Map<String, List<SuspectedLocation>> _boundsCache = {};
/// Get suspected locations within specific bounds (cached)
List<SuspectedLocation> getLocationsForBounds(LatLngBounds bounds) {
if (!SuspectedLocationService().isEnabled) {
debugPrint('[SuspectedLocationCache] Service not enabled');
return [];
}
final boundsKey = '${bounds.north.toStringAsFixed(4)},${bounds.south.toStringAsFixed(4)},${bounds.east.toStringAsFixed(4)},${bounds.west.toStringAsFixed(4)}';
// debugPrint('[SuspectedLocationCache] Getting locations for bounds: $boundsKey, processed entries count: ${_processedEntries.length}');
// Check cache first
if (_boundsCache.containsKey(boundsKey)) {
debugPrint('[SuspectedLocationCache] Using cached result: ${_boundsCache[boundsKey]!.length} locations');
return _boundsCache[boundsKey]!;
}
// Filter processed entries for this bounds (very fast since centroids are pre-calculated)
final locations = <SuspectedLocation>[];
int inBoundsCount = 0;
for (final entry in _processedEntries) {
// Quick bounds check using pre-calculated centroid
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat <= bounds.north && lat >= bounds.south &&
lng <= bounds.east && lng >= bounds.west) {
try {
// Only create SuspectedLocation object if it's in bounds
final location = SuspectedLocation.fromCsvRow(entry.rawData);
locations.add(location);
inBoundsCount++;
} catch (e) {
// Skip invalid entries
continue;
}
}
}
// debugPrint('[SuspectedLocationCache] Checked ${_processedEntries.length} entries, $inBoundsCount in bounds, result: ${locations.length} locations');
// Cache the result
_boundsCache[boundsKey] = locations;
// Limit cache size to prevent memory issues
if (_boundsCache.length > 100) {
final oldestKey = _boundsCache.keys.first;
_boundsCache.remove(oldestKey);
}
return locations;
}
/// Load processed data from storage
Future<void> loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load last fetch time
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
if (lastFetchMs != null) {
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
}
// Load processed data
final processedDataString = prefs.getString(_prefsKeyProcessedData);
if (processedDataString != null) {
final List<dynamic> processedDataList = jsonDecode(processedDataString);
_processedEntries = processedDataList
.map((json) => SuspectedLocationEntry.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[SuspectedLocationCache] Loaded ${_processedEntries.length} processed entries from storage');
}
} catch (e) {
debugPrint('[SuspectedLocationCache] Error loading from storage: $e');
_processedEntries.clear();
_lastFetchTime = null;
}
}
/// Process raw CSV data and save to storage (calculates centroids once)
Future<void> processAndSave(
List<Map<String, dynamic>> rawData,
DateTime fetchTime, {
void Function(String message, double? progress)? onProgress,
}) async {
try {
debugPrint('[SuspectedLocationCache] Processing ${rawData.length} raw entries...');
final processedEntries = <SuspectedLocationEntry>[];
int validCount = 0;
int errorCount = 0;
int zeroCoordCount = 0;
for (int i = 0; i < rawData.length; i++) {
final rowData = rawData[i];
// Report progress every 1000 entries
if (i % 1000 == 0) {
final progress = i / rawData.length;
onProgress?.call('Calculating coordinates: ${i + 1}/${rawData.length}', progress);
}
try {
// Create a temporary SuspectedLocation to extract the centroid
final tempLocation = SuspectedLocation.fromCsvRow(rowData);
// Only save if we have a valid centroid (not at 0,0)
if (tempLocation.centroid.latitude != 0 || tempLocation.centroid.longitude != 0) {
processedEntries.add(SuspectedLocationEntry(
rawData: rowData,
centroid: tempLocation.centroid,
));
validCount++;
} else {
zeroCoordCount++;
}
} catch (e) {
errorCount++;
continue;
}
}
debugPrint('[SuspectedLocationCache] Processing complete - Valid: $validCount, Zero coords: $zeroCoordCount, Errors: $errorCount');
_processedEntries = processedEntries;
_lastFetchTime = fetchTime;
// Clear bounds cache since data changed
_boundsCache.clear();
final prefs = await SharedPreferences.getInstance();
// Save processed data
final processedDataString = jsonEncode(processedEntries.map((e) => e.toJson()).toList());
await prefs.setString(_prefsKeyProcessedData, processedDataString);
// Save last fetch time
await prefs.setInt(_prefsKeyLastFetch, fetchTime.millisecondsSinceEpoch);
// Log coordinate ranges for debugging
if (processedEntries.isNotEmpty) {
double minLat = processedEntries.first.centroid.latitude;
double maxLat = minLat;
double minLng = processedEntries.first.centroid.longitude;
double maxLng = minLng;
for (final entry in processedEntries) {
final lat = entry.centroid.latitude;
final lng = entry.centroid.longitude;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
debugPrint('[SuspectedLocationCache] Coordinate ranges - Lat: $minLat to $maxLat, Lng: $minLng to $maxLng');
}
debugPrint('[SuspectedLocationCache] Processed and saved $validCount valid entries (${processedEntries.length} total)');
notifyListeners();
} catch (e) {
debugPrint('[SuspectedLocationCache] Error processing and saving: $e');
}
}
/// Clear all cached data
void clear() {
_processedEntries.clear();
_boundsCache.clear();
_lastFetchTime = null;
notifyListeners();
}
/// Get last fetch time
DateTime? get lastFetchTime => _lastFetchTime;
/// Get total count of processed entries
int get totalCount => _processedEntries.length;
/// Check if we have data
bool get hasData => _processedEntries.isNotEmpty;
}

View File

@@ -0,0 +1,242 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:csv/csv.dart';
import '../dev_config.dart';
import '../models/suspected_location.dart';
import 'suspected_location_cache.dart';
class SuspectedLocationService {
static final SuspectedLocationService _instance = SuspectedLocationService._();
factory SuspectedLocationService() => _instance;
SuspectedLocationService._();
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
static const Duration _maxAge = Duration(days: 7);
static const Duration _timeout = Duration(seconds: 30);
final SuspectedLocationCache _cache = SuspectedLocationCache();
bool _isEnabled = false;
bool _isLoading = false;
/// Get last fetch time
DateTime? get lastFetchTime => _cache.lastFetchTime;
/// Check if suspected locations are enabled
bool get isEnabled => _isEnabled;
/// Check if currently loading
bool get isLoading => _isLoading;
/// Initialize the service - load from storage and check if refresh needed
Future<void> init({bool offlineMode = false}) async {
await _loadFromStorage();
// Load cache data
await _cache.loadFromStorage();
// Only auto-fetch if enabled, data is stale or missing, and we are not offline
if (_isEnabled && _shouldRefresh() && !offlineMode) {
debugPrint('[SuspectedLocationService] Auto-refreshing CSV data on startup (older than $_maxAge or missing)');
await _fetchData();
} else if (_isEnabled && _shouldRefresh() && offlineMode) {
debugPrint('[SuspectedLocationService] Skipping auto-refresh due to offline mode - data is ${_cache.lastFetchTime != null ? 'outdated' : 'missing'}');
}
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyEnabled, enabled);
// If enabling for the first time and no data, fetch it
if (enabled && !_cache.hasData) {
await _fetchData();
}
// If disabling, clear the cache
if (!enabled) {
_cache.clear();
}
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
return await _fetchData(onProgress: onProgress);
}
/// Check if data should be refreshed
bool _shouldRefresh() {
if (!_cache.hasData) return true;
if (_cache.lastFetchTime == null) return true;
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
}
/// Load settings from shared preferences
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load enabled state
_isEnabled = prefs.getBool(_prefsKeyEnabled) ?? false;
debugPrint('[SuspectedLocationService] Loaded settings - enabled: $_isEnabled');
} catch (e) {
debugPrint('[SuspectedLocationService] Error loading from storage: $e');
}
}
/// Fetch data from the CSV URL
Future<bool> _fetchData({
void Function(String message, double? progress)? onProgress,
}) async {
if (_isLoading) return false;
_isLoading = true;
try {
onProgress?.call('Downloading CSV data...', null);
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
final response = await http.get(
Uri.parse(kSuspectedLocationsCsvUrl),
headers: {
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
},
).timeout(_timeout);
if (response.statusCode != 200) {
debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}');
return false;
}
onProgress?.call('Parsing CSV data...', 0.2);
// Parse CSV with proper field separator and quote handling
final csvData = const CsvToListConverter(
fieldDelimiter: ',',
textDelimiter: '"',
eol: '\n',
).convert(response.body);
debugPrint('[SuspectedLocationService] Parsed ${csvData.length} rows from CSV');
if (csvData.isEmpty) {
debugPrint('[SuspectedLocationService] Empty CSV data');
return false;
}
// First row should be headers
final headers = csvData.first.map((h) => h.toString().toLowerCase()).toList();
debugPrint('[SuspectedLocationService] Headers: $headers');
final dataRows = csvData.skip(1);
debugPrint('[SuspectedLocationService] Data rows count: ${dataRows.length}');
// Find required column indices
final ticketNoIndex = headers.indexOf('ticket_no');
final urlFullIndex = headers.indexOf('url_full');
final addrIndex = headers.indexOf('addr');
final streetIndex = headers.indexOf('street');
final cityIndex = headers.indexOf('city');
final stateIndex = headers.indexOf('state');
final digSiteIntersectingStreetIndex = headers.indexOf('dig_site_intersecting_street');
final digWorkDoneForIndex = headers.indexOf('dig_work_done_for');
final digSiteRemarksIndex = headers.indexOf('dig_site_remarks');
final locationIndex = headers.indexOf('location');
debugPrint('[SuspectedLocationService] Column indices - ticket_no: $ticketNoIndex, location: $locationIndex');
if (ticketNoIndex == -1 || locationIndex == -1) {
debugPrint('[SuspectedLocationService] Required columns not found in CSV. Headers: $headers');
return false;
}
// Parse rows and store as raw data (don't process GeoJSON yet)
final List<Map<String, dynamic>> rawDataList = [];
int rowIndex = 0;
int validRows = 0;
for (final row in dataRows) {
rowIndex++;
try {
final Map<String, dynamic> rowData = {};
if (ticketNoIndex < row.length) rowData['ticket_no'] = row[ticketNoIndex];
if (urlFullIndex != -1 && urlFullIndex < row.length) rowData['url_full'] = row[urlFullIndex];
if (addrIndex != -1 && addrIndex < row.length) rowData['addr'] = row[addrIndex];
if (streetIndex != -1 && streetIndex < row.length) rowData['street'] = row[streetIndex];
if (cityIndex != -1 && cityIndex < row.length) rowData['city'] = row[cityIndex];
if (stateIndex != -1 && stateIndex < row.length) rowData['state'] = row[stateIndex];
if (digSiteIntersectingStreetIndex != -1 && digSiteIntersectingStreetIndex < row.length) {
rowData['dig_site_intersecting_street'] = row[digSiteIntersectingStreetIndex];
}
if (digWorkDoneForIndex != -1 && digWorkDoneForIndex < row.length) {
rowData['dig_work_done_for'] = row[digWorkDoneForIndex];
}
if (digSiteRemarksIndex != -1 && digSiteRemarksIndex < row.length) {
rowData['dig_site_remarks'] = row[digSiteRemarksIndex];
}
if (locationIndex < row.length) rowData['location'] = row[locationIndex];
// Basic validation - must have ticket_no and location
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
rowData['location']?.toString().isNotEmpty == true) {
rawDataList.add(rowData);
validRows++;
}
// Report progress every 1000 rows
if (rowIndex % 1000 == 0) {
final progress = 0.4 + (rowIndex / dataRows.length) * 0.4; // 40% to 80% of total
onProgress?.call('Processing row $rowIndex...', progress);
}
} catch (e, stackTrace) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
continue;
}
}
onProgress?.call('Calculating coordinates...', 0.8);
final fetchTime = DateTime.now();
// Process raw data and save (calculates centroids once)
await _cache.processAndSave(rawDataList, fetchTime, onProgress: (message, progress) {
// Map cache progress to final 20% (0.8 to 1.0)
final finalProgress = 0.8 + (progress ?? 0) * 0.2;
onProgress?.call(message, finalProgress);
});
onProgress?.call('Complete!', 1.0);
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
return true;
} catch (e, stackTrace) {
debugPrint('[SuspectedLocationService] Error fetching data: $e');
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
return false;
} finally {
_isLoading = false;
}
}
/// Get suspected locations within a bounding box
List<SuspectedLocation> getLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _cache.getLocationsForBounds(LatLngBounds(
LatLng(north, west),
LatLng(south, east),
));
}
}

View File

@@ -27,6 +27,7 @@ class SettingsState extends ChangeNotifier {
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
static const String _networkStatusIndicatorEnabledPrefsKey = 'network_status_indicator_enabled';
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
bool _offlineMode = false;
int _maxCameras = 250;
@@ -35,6 +36,7 @@ class SettingsState extends ChangeNotifier {
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = false;
int _suspectedLocationMinDistance = 100; // meters
List<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
@@ -46,6 +48,7 @@ class SettingsState extends ChangeNotifier {
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
int get proximityAlertDistance => _proximityAlertDistance;
bool get networkStatusIndicatorEnabled => _networkStatusIndicatorEnabled;
int get suspectedLocationMinDistance => _suspectedLocationMinDistance;
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
String get selectedTileTypeId => _selectedTileTypeId;
@@ -101,6 +104,9 @@ class SettingsState extends ChangeNotifier {
// Load network status indicator setting
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? false;
// Load suspected location minimum distance
_suspectedLocationMinDistance = prefs.getInt(_suspectedLocationMinDistancePrefsKey) ?? 100;
// Load upload mode (including migration from old test_mode bool)
if (prefs.containsKey(_uploadModePrefsKey)) {
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
@@ -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();
}
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/foundation.dart';
import '../models/suspected_location.dart';
import '../services/suspected_location_service.dart';
class SuspectedLocationState extends ChangeNotifier {
final SuspectedLocationService _service = SuspectedLocationService();
SuspectedLocation? _selectedLocation;
bool _isLoading = false;
/// Currently selected suspected location (for detail view)
SuspectedLocation? get selectedLocation => _selectedLocation;
/// Get suspected locations in bounds (this should be called by the map view)
List<SuspectedLocation> getLocationsInBounds({
required double north,
required double south,
required double east,
required double west,
}) {
return _service.getLocationsInBounds(
north: north,
south: south,
east: east,
west: west,
);
}
/// Whether suspected locations are enabled
bool get isEnabled => _service.isEnabled;
/// Whether currently loading data
bool get isLoading => _isLoading || _service.isLoading;
/// Last time data was fetched
DateTime? get lastFetchTime => _service.lastFetchTime;
/// Initialize the state
Future<void> init({bool offlineMode = false}) async {
await _service.init(offlineMode: offlineMode);
notifyListeners();
}
/// Enable or disable suspected locations
Future<void> setEnabled(bool enabled) async {
await _service.setEnabled(enabled);
notifyListeners();
}
/// Manually refresh the data
Future<bool> refreshData({
void Function(String message, double? progress)? onProgress,
}) async {
_isLoading = true;
notifyListeners();
try {
final success = await _service.refreshData(onProgress: onProgress);
return success;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Select a suspected location for detail view
void selectLocation(SuspectedLocation location) {
_selectedLocation = location;
notifyListeners();
}
/// Clear the selected location
void clearSelection() {
_selectedLocation = null;
notifyListeners();
}
}

View File

@@ -40,10 +40,10 @@ class CameraIcon extends StatelessWidget {
height: kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(kNodeDotOpacity),
color: _ringColor.withOpacity(kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: kNodeRingThickness,
width: getNodeRingThickness(context),
),
),
);

View File

@@ -95,6 +95,7 @@ class CameraMarkersBuilder {
LatLng? userLocation,
int? selectedNodeId,
void Function(OsmNode)? onNodeTap,
bool shouldDim = false,
}) {
final markers = <Marker>[
// Camera markers
@@ -103,14 +104,14 @@ class CameraMarkersBuilder {
.map((n) {
// Check if this node should be highlighted (selected) or dimmed
final isSelected = selectedNodeId == n.id;
final shouldDim = selectedNodeId != null && !isSelected;
final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected);
return Marker(
point: n.coord,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: Opacity(
opacity: shouldDim ? 0.5 : 1.0,
opacity: shouldDimNode ? 0.5 : 1.0,
child: CameraMapMarker(
node: n,
mapController: mapController,

View File

@@ -14,6 +14,7 @@ class DirectionConesBuilder {
required double zoom,
AddNodeSession? session,
EditNodeSession? editSession,
required BuildContext context,
}) {
final overlays = <Polygon>[];
@@ -23,6 +24,7 @@ class DirectionConesBuilder {
session.target!,
session.directionDegrees,
zoom,
context: context,
isSession: true,
));
}
@@ -33,6 +35,7 @@ class DirectionConesBuilder {
editSession.target,
editSession.directionDegrees,
zoom,
context: context,
isSession: true,
));
}
@@ -46,6 +49,7 @@ class DirectionConesBuilder {
n.coord,
n.directionDeg!,
zoom,
context: context,
))
);
@@ -69,40 +73,52 @@ class DirectionConesBuilder {
LatLng origin,
double bearingDeg,
double zoom, {
required BuildContext context,
bool isPending = false,
bool isSession = false,
}) {
final halfAngle = kDirectionConeHalfAngle;
final length = kDirectionConeBaseLength * math.pow(2, 15 - zoom);
// Number of points to create the arc (more = smoother curve)
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
final outerRadius = outerRadiusPx * pixelToCoordinate;
final innerRadius = innerRadiusPx * pixelToCoordinate;
// Number of points for the outer arc (within our directional range)
const int arcPoints = 12;
LatLng project(double deg) {
LatLng project(double deg, double distance) {
final rad = deg * math.pi / 180;
final dLat = length * math.cos(rad);
final dLat = distance * math.cos(rad);
final dLon =
length * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180);
return LatLng(origin.latitude + dLat, origin.longitude + dLon);
}
// Build pizza slice with curved edge
final points = <LatLng>[origin];
// Build outer arc points only within our directional sector
final points = <LatLng>[];
// Add arc points from left to right
// Add outer arc points from left to right (counterclockwise for proper polygon winding)
for (int i = 0; i <= arcPoints; i++) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
points.add(project(angle));
points.add(project(angle, outerRadius));
}
// Close the shape back to origin
points.add(origin);
// Add inner arc points from right to left (to close the donut shape)
for (int i = arcPoints; i >= 0; i--) {
final angle = bearingDeg - halfAngle + (i * 2 * halfAngle / arcPoints);
points.add(project(angle, innerRadius));
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(0.25),
color: kDirectionConeColor.withOpacity(kDirectionConeOpacity),
borderColor: kDirectionConeColor,
borderStrokeWidth: 1,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}
}

View File

@@ -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;
}
}

View File

@@ -9,6 +9,7 @@ import '../services/offline_area_service.dart';
import '../services/network_status.dart';
import '../models/osm_node.dart';
import '../models/node_profile.dart';
import '../models/suspected_location.dart';
import '../models/tile_provider.dart';
import 'debouncer.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/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'map/suspected_location_markers.dart';
import 'network_status_indicator.dart';
import 'provisional_pin.dart';
import 'proximity_alert_banner.dart';
@@ -38,6 +40,7 @@ class MapView extends StatefulWidget {
this.sheetHeight = 0.0,
this.selectedNodeId,
this.onNodeTap,
this.onSuspectedLocationTap,
this.onSearchPressed,
});
@@ -46,6 +49,7 @@ class MapView extends StatefulWidget {
final double sheetHeight;
final int? selectedNodeId;
final void Function(OsmNode)? onNodeTap;
final void Function(SuspectedLocation)? onSuspectedLocationTap;
final VoidCallback? onSearchPressed;
@override
@@ -78,7 +82,7 @@ class MapViewState extends State<MapView> {
_tileManager = TileLayerManager();
_tileManager.initialize();
_cameraController = CameraRefreshController();
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
_gpsController = GpsController();
// Initialize proximity alert service
@@ -174,7 +178,7 @@ class MapViewState extends State<MapView> {
// Fetch initial cameras
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCamerasFromProvider();
_refreshNodesFromProvider();
});
}
@@ -195,7 +199,7 @@ class MapViewState extends State<MapView> {
void _onCamerasUpdated() {
void _onNodesUpdated() {
if (mounted) setState(() {});
}
@@ -213,8 +217,8 @@ class MapViewState extends State<MapView> {
static Future<void> clearStoredMapPosition() =>
MapPositionManager.clearStoredMapPosition();
/// Get minimum zoom level for camera fetching based on upload mode
int _getMinZoomForCameras(BuildContext context) {
/// Get minimum zoom level for node fetching based on upload mode
int _getMinZoomForNodes(BuildContext context) {
final appState = context.read<AppState>();
final uploadMode = appState.uploadMode;
@@ -251,7 +255,7 @@ class MapViewState extends State<MapView> {
void _refreshCamerasFromProvider() {
void _refreshNodesFromProvider() {
final appState = context.read<AppState>();
_cameraController.refreshCamerasFromProvider(
controller: _controller,
@@ -295,7 +299,7 @@ class MapViewState extends State<MapView> {
// Check if enabled profiles changed and refresh cameras if needed
_cameraController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles,
onProfilesChanged: _refreshCamerasFromProvider,
onProfilesChanged: _refreshNodesFromProvider,
);
// 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)
Widget cameraLayers = Consumer<CameraProviderWithCache>(
builder: (context, cameraProvider, child) {
// Get current zoom level and map bounds (shared by all logic)
double currentZoom = 15.0; // fallback
LatLngBounds? mapBounds;
try {
currentZoom = _controller.mapController.camera.zoom;
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
// Controller not ready yet, use fallback values
mapBounds = null;
}
final cameras = (mapBounds != null)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
final minZoom = _getMinZoomForNodes(context);
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(
cameras: cameras,
cameras: nodes,
mapController: _controller.mapController,
userLocation: _gpsController.currentLocation,
selectedNodeId: widget.selectedNodeId,
onNodeTap: widget.onNodeTap,
shouldDim: shouldDimNodes,
);
// Get current zoom level for direction cones
double currentZoom = 15.0; // fallback
// Build suspected location markers (respect same zoom and count limits as nodes)
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 {
currentZoom = _controller.mapController.camera.zoom;
} catch (_) {
@@ -353,14 +405,30 @@ class MapViewState extends State<MapView> {
}
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: cameras,
cameras: nodes,
zoom: currentZoom,
session: session,
editSession: editSession,
context: context,
);
// Build edit lines connecting original cameras to their edited positions
final editLines = _buildEditLines(cameras);
// Add suspected location bounds if one is selected
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
final centerMarkers = <Marker>[];
@@ -436,7 +504,7 @@ class MapViewState extends State<MapView> {
PolygonLayer(polygons: overlays),
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
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);
});
// Request more cameras on any map movement/zoom at valid zoom level (slower debounce)
final minZoom = _getMinZoomForCameras(context);
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
final minZoom = _getMinZoomForNodes(context);
if (pos.zoom >= minZoom) {
_cameraDebounce(_refreshCamerasFromProvider);
_cameraDebounce(_refreshNodesFromProvider);
} else {
// Skip nodes at low zoom - report immediate completion (brutalist approach)
NetworkStatus.instance.reportNodeComplete();
@@ -552,27 +620,27 @@ class MapViewState extends State<MapView> {
}
/// Build polylines connecting original cameras to their edited positions
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
List<Polyline> _buildEditLines(List<OsmNode> nodes) {
final lines = <Polyline>[];
// Create a lookup map of original node IDs to their coordinates
final originalNodes = <int, LatLng>{};
for (final camera in cameras) {
if (camera.tags['_pending_edit'] == 'true') {
originalNodes[camera.id] = camera.coord;
for (final node in nodes) {
if (node.tags['_pending_edit'] == 'true') {
originalNodes[node.id] = node.coord;
}
}
// Find edited cameras and draw lines to their originals
for (final camera in cameras) {
final originalIdStr = camera.tags['_original_node_id'];
if (originalIdStr != null && camera.tags['_pending_upload'] == 'true') {
// Find edited nodes and draw lines to their originals
for (final node in nodes) {
final originalIdStr = node.tags['_original_node_id'];
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
final originalId = int.tryParse(originalIdStr);
final originalCoord = originalId != null ? originalNodes[originalId] : null;
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, camera.coord],
points: [originalCoord, node.coord],
color: kNodeRingColorPending,
strokeWidth: 3.0,
));
@@ -582,5 +650,40 @@ class MapViewState extends State<MapView> {
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;
}
}

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,
),
);
}
}

View File

@@ -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),
),
],
);
}
}

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')),
),
],
),
],
),
),
),
);
},
);
}
}

View File

@@ -38,5 +38,13 @@ end
post_install do |installer|
installer.pods_project.targets.each do |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

View File

@@ -572,7 +572,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (

View File

@@ -2,17 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<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>
<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>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.deflock.deflockapp</string>
</array>
</dict>
</plist>

View File

@@ -89,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -705,7 +713,7 @@ packages:
source: hosted
version: "2.2.2"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.1.0+2 # The thing after the + is the google versionCode
version: 1.2.3+4 # The thing after the + is the version code, incremented with each release
environment:
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
xml: ^6.4.2
flutter_local_notifications: ^17.2.2
url_launcher: ^6.3.0
# Auth, storage, prefs
oauth2_client: ^4.2.0
@@ -30,6 +31,7 @@ dependencies:
shared_preferences: ^2.2.2
uuid: ^4.0.0
package_info_plus: ^8.0.0
csv: ^6.0.0
dev_dependencies:
flutter_launcher_icons: ^0.14.4