Sorta working suspected locations

This commit is contained in:
stopflock
2025-10-06 21:07:08 -05:00
parent 904af42cbf
commit 4a44ab96d6
7 changed files with 316 additions and 104 deletions

View File

@@ -145,7 +145,6 @@ class AppState extends ChangeNotifier {
List<PendingUpload> get pendingUploads => _uploadQueueState.pendingUploads;
// Suspected location state
List<SuspectedLocation> get suspectedLocations => _suspectedLocationState.locations;
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
bool get suspectedLocationsEnabled => _suspectedLocationState.isEnabled;
bool get suspectedLocationsLoading => _suspectedLocationState.isLoading;

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
/// A suspected surveillance location from the CSV data
@@ -41,16 +42,39 @@ class SuspectedLocation {
// Parse GeoJSON if available
if (locationString != null && locationString.isNotEmpty) {
try {
print('[SuspectedLocation] Parsing GeoJSON for ticket ${row['ticket_no']}, length: ${locationString.length}');
// Only log first few entries to avoid spam
final ticketNo = row['ticket_no']?.toString() ?? 'unknown';
if (ticketNo.endsWith('0') || ticketNo.endsWith('1') || ticketNo.endsWith('2')) {
print('[SuspectedLocation] Raw location string for ticket $ticketNo: ${locationString.substring(0, math.min(100, locationString.length))}...');
}
if (ticketNo.endsWith('0') || ticketNo.endsWith('1') || ticketNo.endsWith('2')) {
if (centroid.latitude != 0 || centroid.longitude != 0) {
print('[SuspectedLocation] Successfully parsed centroid: $centroid');
} else {
print('[SuspectedLocation] Parsed but got zero coordinates');
}
}
geoJson = jsonDecode(locationString) as Map<String, dynamic>;
final coordinates = _extractCoordinatesFromGeoJson(geoJson);
centroid = coordinates.centroid;
bounds = coordinates.bounds;
print('[SuspectedLocation] Successfully parsed, centroid: $centroid, bounds count: ${bounds.length}');
} catch (e, stackTrace) {
if (ticketNo.endsWith('0') || ticketNo.endsWith('1') || ticketNo.endsWith('2')) {
if (centroid.latitude != 0 || centroid.longitude != 0) {
print('[SuspectedLocation] Successfully parsed centroid: $centroid');
} else {
print('[SuspectedLocation] Parsed but got zero coordinates');
}
}
if (ticketNo.endsWith('0') || ticketNo.endsWith('1') || ticketNo.endsWith('2')) {
if (centroid.latitude != 0 || centroid.longitude != 0) {
print('[SuspectedLocation] Successfully parsed centroid: $centroid');
} else {
print('[SuspectedLocation] Parsed but got zero coordinates');
}
}
} catch (e) {
// If GeoJSON parsing fails, use default coordinates
print('[SuspectedLocation] Failed to parse GeoJSON for ticket ${row['ticket_no']}: $e');
print('[SuspectedLocation] Stack trace: $stackTrace');
print('[SuspectedLocation] Location string: $locationString');
}
}
@@ -74,17 +98,17 @@ class SuspectedLocation {
/// Extract coordinates from GeoJSON
static ({LatLng centroid, List<LatLng> bounds}) _extractCoordinatesFromGeoJson(Map<String, dynamic> geoJson) {
try {
final geometry = geoJson['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List?;
// 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 = geometry?['type'] as String?;
final type = geoJson['type'] as String?;
switch (type) {
case 'Point':
if (coordinates.length >= 2) {

View File

@@ -0,0 +1,224 @@
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) 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];
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++;
if (i < 3) { // Log first few zero coord cases
debugPrint('[SuspectedLocationCache] Row $i has zero coordinates: ticket=${rowData['ticket_no']}, location=${rowData['location']?.toString().length} chars');
}
}
} catch (e) {
errorCount++;
if (errorCount <= 5) { // Log first few errors
debugPrint('[SuspectedLocationCache] Row $i error: $e, ticket=${rowData['ticket_no']}');
}
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

@@ -1,11 +1,14 @@
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 '../models/suspected_location.dart';
import 'suspected_location_cache.dart';
class SuspectedLocationService {
static final SuspectedLocationService _instance = SuspectedLocationService._();
@@ -13,22 +16,16 @@ class SuspectedLocationService {
SuspectedLocationService._();
static const String _csvUrl = 'https://alprwatch.org/pub/flock_utilities_mini_2025-10-06.csv';
static const String _prefsKeyData = 'suspected_locations_data';
static const String _prefsKeyLastFetch = 'suspected_locations_last_fetch';
static const String _prefsKeyEnabled = 'suspected_locations_enabled';
static const Duration _maxAge = Duration(days: 7);
static const Duration _timeout = Duration(seconds: 30);
List<SuspectedLocation> _locations = [];
DateTime? _lastFetchTime;
final SuspectedLocationCache _cache = SuspectedLocationCache();
bool _isEnabled = false;
bool _isLoading = false;
/// Get all suspected locations
List<SuspectedLocation> get locations => List.unmodifiable(_locations);
/// Get last fetch time
DateTime? get lastFetchTime => _lastFetchTime;
DateTime? get lastFetchTime => _cache.lastFetchTime;
/// Check if suspected locations are enabled
bool get isEnabled => _isEnabled;
@@ -40,6 +37,9 @@ class SuspectedLocationService {
Future<void> init() async {
await _loadFromStorage();
// Load cache data
await _cache.loadFromStorage();
// Only auto-fetch if enabled and data is stale or missing
if (_isEnabled && _shouldRefresh()) {
await _fetchData();
@@ -53,13 +53,13 @@ class SuspectedLocationService {
await prefs.setBool(_prefsKeyEnabled, enabled);
// If enabling for the first time and no data, fetch it
if (enabled && _locations.isEmpty) {
if (enabled && !_cache.hasData) {
await _fetchData();
}
// If disabling, clear the data from memory (but keep in storage)
// If disabling, clear the cache
if (!enabled) {
_locations.clear();
_cache.clear();
}
}
@@ -70,12 +70,12 @@ class SuspectedLocationService {
/// Check if data should be refreshed
bool _shouldRefresh() {
if (_locations.isEmpty) return true;
if (_lastFetchTime == null) return true;
return DateTime.now().difference(_lastFetchTime!) > _maxAge;
if (!_cache.hasData) return true;
if (_cache.lastFetchTime == null) return true;
return DateTime.now().difference(_cache.lastFetchTime!) > _maxAge;
}
/// Load data from shared preferences
/// Load settings from shared preferences
Future<void> _loadFromStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -83,50 +83,9 @@ class SuspectedLocationService {
// Load enabled state
_isEnabled = prefs.getBool(_prefsKeyEnabled) ?? false;
// Load last fetch time
final lastFetchMs = prefs.getInt(_prefsKeyLastFetch);
if (lastFetchMs != null) {
_lastFetchTime = DateTime.fromMillisecondsSinceEpoch(lastFetchMs);
}
// Only load data if enabled
if (!_isEnabled) {
return;
}
// Load data
final jsonString = prefs.getString(_prefsKeyData);
if (jsonString != null) {
final List<dynamic> jsonList = jsonDecode(jsonString);
_locations = jsonList
.map((json) => SuspectedLocation.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[SuspectedLocationService] Loaded ${_locations.length} suspected locations from storage');
}
debugPrint('[SuspectedLocationService] Loaded settings - enabled: $_isEnabled');
} catch (e) {
debugPrint('[SuspectedLocationService] Error loading from storage: $e');
_locations.clear();
_lastFetchTime = null;
}
}
/// Save data to shared preferences
Future<void> _saveToStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
// Save data
final jsonString = jsonEncode(_locations.map((loc) => loc.toJson()).toList());
await prefs.setString(_prefsKeyData, jsonString);
// Save last fetch time
if (_lastFetchTime != null) {
await prefs.setInt(_prefsKeyLastFetch, _lastFetchTime!.millisecondsSinceEpoch);
}
debugPrint('[SuspectedLocationService] Saved ${_locations.length} suspected locations to storage');
} catch (e) {
debugPrint('[SuspectedLocationService] Error saving to storage: $e');
}
}
@@ -188,9 +147,10 @@ class SuspectedLocationService {
return false;
}
// Parse rows
final List<SuspectedLocation> newLocations = [];
// 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 {
@@ -213,28 +173,30 @@ class SuspectedLocationService {
}
if (locationIndex < row.length) rowData['location'] = row[locationIndex];
debugPrint('[SuspectedLocationService] Row $rowIndex data keys: ${rowData.keys.toList()}');
if (rowIndex <= 3) { // Log first few rows
debugPrint('[SuspectedLocationService] Row $rowIndex ticket_no: ${rowData['ticket_no']}, location length: ${rowData['location']?.toString().length}');
// Basic validation - must have ticket_no and location
if (rowData['ticket_no']?.toString().isNotEmpty == true &&
rowData['location']?.toString().isNotEmpty == true) {
rawDataList.add(rowData);
validRows++;
}
final location = SuspectedLocation.fromCsvRow(rowData);
newLocations.add(location);
// Log progress every 1000 rows
if (rowIndex % 1000 == 0) {
debugPrint('[SuspectedLocationService] Processing row $rowIndex...');
}
} catch (e, stackTrace) {
// Skip rows that can't be parsed
debugPrint('[SuspectedLocationService] Error parsing row $rowIndex: $e');
debugPrint('[SuspectedLocationService] Stack trace: $stackTrace');
continue;
}
}
_locations = newLocations;
_lastFetchTime = DateTime.now();
final fetchTime = DateTime.now();
// Save to storage
await _saveToStorage();
// Process raw data and save (calculates centroids once)
await _cache.processAndSave(rawDataList, fetchTime);
debugPrint('[SuspectedLocationService] Successfully fetched and parsed ${_locations.length} suspected locations');
debugPrint('[SuspectedLocationService] Successfully fetched and stored $validRows valid raw entries (${rawDataList.length} total)');
return true;
} catch (e, stackTrace) {
@@ -253,13 +215,9 @@ class SuspectedLocationService {
required double east,
required double west,
}) {
if (!_isEnabled || _locations.isEmpty) return [];
return _locations.where((location) {
final lat = location.centroid.latitude;
final lng = location.centroid.longitude;
return lat <= north && lat >= south && lng <= east && lng >= west;
}).toList();
return _cache.getLocationsForBounds(LatLngBounds(
LatLng(north, west),
LatLng(south, east),
));
}
}

View File

@@ -12,8 +12,20 @@ class SuspectedLocationState extends ChangeNotifier {
/// Currently selected suspected location (for detail view)
SuspectedLocation? get selectedLocation => _selectedLocation;
/// All suspected locations
List<SuspectedLocation> get locations => _service.locations;
/// 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;
@@ -62,18 +74,5 @@ class SuspectedLocationState extends ChangeNotifier {
notifyListeners();
}
/// Get suspected locations within a bounding box
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,
);
}
}

View File

@@ -355,6 +355,8 @@ class MapViewState extends State<MapView> {
// Build suspected location markers
final suspectedLocationMarkers = <Marker>[];
if (appState.suspectedLocationsEnabled && mapBounds != null) {
debugPrint('[MapView] Suspected locations enabled, getting bounds: N${mapBounds.north.toStringAsFixed(4)}, S${mapBounds.south.toStringAsFixed(4)}, E${mapBounds.east.toStringAsFixed(4)}, W${mapBounds.west.toStringAsFixed(4)}');
final suspectedLocations = appState.getSuspectedLocationsInBounds(
north: mapBounds.north,
south: mapBounds.south,
@@ -362,6 +364,8 @@ class MapViewState extends State<MapView> {
west: mapBounds.west,
);
debugPrint('[MapView] Found ${suspectedLocations.length} suspected locations in bounds');
suspectedLocationMarkers.addAll(
SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers(
locations: suspectedLocations,
@@ -370,6 +374,10 @@ class MapViewState extends State<MapView> {
onLocationTap: widget.onSuspectedLocationTap,
),
);
debugPrint('[MapView] Created ${suspectedLocationMarkers.length} suspected location markers');
} else {
debugPrint('[MapView] Suspected locations not enabled (${appState.suspectedLocationsEnabled}) or no mapBounds ($mapBounds)');
}
// Get current zoom level for direction cones

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.0+3 # The thing after the + is the google versionCode
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+