diff --git a/lib/app_state.dart b/lib/app_state.dart index 271f3c8..0e0a5de 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -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(); @@ -139,6 +144,13 @@ class AppState extends ChangeNotifier { int get pendingCount => _uploadQueueState.pendingCount; List get pendingUploads => _uploadQueueState.pendingUploads; + // Suspected location state + List get suspectedLocations => _suspectedLocationState.locations; + 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(); await _uploadQueueState.init(); await _authState.init(_settingsState.uploadMode); @@ -422,6 +435,37 @@ class AppState extends ChangeNotifier { _startUploader(); // resume uploader if not busy } + // ---------- Suspected Location Methods ---------- + Future setSuspectedLocationsEnabled(bool enabled) async { + await _suspectedLocationState.setEnabled(enabled); + } + + Future refreshSuspectedLocations() async { + return await _suspectedLocationState.refreshData(); + } + + void selectSuspectedLocation(SuspectedLocation location) { + _suspectedLocationState.selectLocation(location); + } + + void clearSuspectedLocationSelection() { + _suspectedLocationState.clearSelection(); + } + + List 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 +493,7 @@ class AppState extends ChangeNotifier { _searchState.removeListener(_onStateChanged); _sessionState.removeListener(_onStateChanged); _settingsState.removeListener(_onStateChanged); + _suspectedLocationState.removeListener(_onStateChanged); _uploadQueueState.removeListener(_onStateChanged); _uploadQueueState.dispose(); diff --git a/lib/models/suspected_location.dart b/lib/models/suspected_location.dart new file mode 100644 index 0000000..48b17d7 --- /dev/null +++ b/lib/models/suspected_location.dart @@ -0,0 +1,215 @@ +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? geoJson; + final LatLng centroid; + final List 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 row) { + final locationString = row['location'] as String?; + LatLng centroid = const LatLng(0, 0); + List bounds = []; + Map? geoJson; + + // Parse GeoJSON if available + if (locationString != null && locationString.isNotEmpty) { + try { + print('[SuspectedLocation] Parsing GeoJSON for ticket ${row['ticket_no']}, length: ${locationString.length}'); + geoJson = jsonDecode(locationString) as Map; + final coordinates = _extractCoordinatesFromGeoJson(geoJson); + centroid = coordinates.centroid; + bounds = coordinates.bounds; + print('[SuspectedLocation] Successfully parsed, centroid: $centroid, bounds count: ${bounds.length}'); + } catch (e, stackTrace) { + // 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'); + } + } + + 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 bounds}) _extractCoordinatesFromGeoJson(Map geoJson) { + try { + final geometry = geoJson['geometry'] as Map?; + final coordinates = geometry?['coordinates'] as List?; + + if (coordinates == null || coordinates.isEmpty) { + return (centroid: const LatLng(0, 0), bounds: []); + } + + final List points = []; + + // Handle different geometry types + final type = geometry?['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: []); + } + + // 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: []); + } + } + + /// Convert to JSON for storage + Map 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 json) { + final boundsData = json['bounds'] as List?; + final bounds = boundsData?.map((b) => LatLng( + (b[0] as num).toDouble(), + (b[1] as num).toDouble(), + )).toList() ?? []; + + 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 = []; + 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; +} \ No newline at end of file diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index acad603..32e87c4 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -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(), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7a49cf2..d086b2f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 with TickerProviderStateMixin { }); } + void openSuspectedLocationSheet(SuspectedLocation location) { + final appState = context.read(); + 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(); @@ -536,6 +584,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { sheetHeight: activeSheetHeight, selectedNodeId: _selectedNodeId, onNodeTap: openNodeTagSheet, + onSuspectedLocationTap: openSuspectedLocationSheet, onSearchPressed: _onNavigationButtonPressed, onUserGesture: () { if (appState.followMeMode != FollowMeMode.off) { diff --git a/lib/screens/settings/sections/suspected_locations_section.dart b/lib/screens/settings/sections/suspected_locations_section.dart new file mode 100644 index 0000000..3d180c2 --- /dev/null +++ b/lib/screens/settings/sections/suspected_locations_section.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../app_state.dart'; +import '../../../services/localization_service.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(); + final isEnabled = appState.suspectedLocationsEnabled; + final isLoading = appState.suspectedLocationsLoading; + final lastFetch = appState.suspectedLocationsLastFetch; + + String getLastFetchText() { + if (lastFetch == null) { + return 'Never fetched'; + } else { + final now = DateTime.now(); + final diff = now.difference(lastFetch); + if (diff.inDays > 0) { + return '${diff.inDays} days ago'; + } else if (diff.inHours > 0) { + return '${diff.inHours} hours ago'; + } else if (diff.inMinutes > 0) { + return '${diff.inMinutes} minutes ago'; + } else { + return 'Just now'; + } + } + } + + Future handleRefresh() async { + final success = await appState.refreshSuspectedLocations(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? 'Suspected locations updated successfully' + : 'Failed to update suspected locations'), + ), + ); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Suspected Locations', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + + // Enable/disable switch + ListTile( + leading: const Icon(Icons.help_outline), + title: const Text('Show Suspected Locations'), + subtitle: const Text('Show question mark markers for suspected surveillance sites from utility permit data'), + 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: const Text('Last Updated'), + subtitle: Text(getLastFetchText()), + trailing: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.refresh), + onPressed: handleRefresh, + tooltip: 'Refresh now', + ), + ), + + // Data info + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Data Source'), + subtitle: const Text('Utility permit data indicating potential surveillance infrastructure installation sites'), + ), + ], + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/services/suspected_location_service.dart b/lib/services/suspected_location_service.dart new file mode 100644 index 0000000..003c3c2 --- /dev/null +++ b/lib/services/suspected_location_service.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:csv/csv.dart'; + +import '../models/suspected_location.dart'; + +class SuspectedLocationService { + static final SuspectedLocationService _instance = SuspectedLocationService._(); + factory SuspectedLocationService() => _instance; + 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 _locations = []; + DateTime? _lastFetchTime; + bool _isEnabled = false; + bool _isLoading = false; + + /// Get all suspected locations + List get locations => List.unmodifiable(_locations); + + /// Get last fetch time + DateTime? get lastFetchTime => _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 init() async { + await _loadFromStorage(); + + // Only auto-fetch if enabled and data is stale or missing + if (_isEnabled && _shouldRefresh()) { + await _fetchData(); + } + } + + /// Enable or disable suspected locations + Future 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 && _locations.isEmpty) { + await _fetchData(); + } + + // If disabling, clear the data from memory (but keep in storage) + if (!enabled) { + _locations.clear(); + } + } + + /// Manually refresh the data + Future refreshData() async { + return await _fetchData(); + } + + /// Check if data should be refreshed + bool _shouldRefresh() { + if (_locations.isEmpty) return true; + if (_lastFetchTime == null) return true; + return DateTime.now().difference(_lastFetchTime!) > _maxAge; + } + + /// Load data from shared preferences + Future _loadFromStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + + // 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 jsonList = jsonDecode(jsonString); + _locations = jsonList + .map((json) => SuspectedLocation.fromJson(json as Map)) + .toList(); + debugPrint('[SuspectedLocationService] Loaded ${_locations.length} suspected locations from storage'); + } + } catch (e) { + debugPrint('[SuspectedLocationService] Error loading from storage: $e'); + _locations.clear(); + _lastFetchTime = null; + } + } + + /// Save data to shared preferences + Future _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'); + } + } + + /// Fetch data from the CSV URL + Future _fetchData() async { + if (_isLoading) return false; + + _isLoading = true; + try { + debugPrint('[SuspectedLocationService] Fetching CSV data from $_csvUrl'); + + final response = await http.get( + Uri.parse(_csvUrl), + headers: { + 'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)', + }, + ).timeout(_timeout); + + if (response.statusCode != 200) { + debugPrint('[SuspectedLocationService] HTTP error ${response.statusCode}'); + return false; + } + + // 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 + final List newLocations = []; + int rowIndex = 0; + for (final row in dataRows) { + rowIndex++; + try { + final Map 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]; + + 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}'); + } + + final location = SuspectedLocation.fromCsvRow(rowData); + newLocations.add(location); + } 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(); + + // Save to storage + await _saveToStorage(); + + debugPrint('[SuspectedLocationService] Successfully fetched and parsed ${_locations.length} suspected locations'); + 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 getLocationsInBounds({ + required double north, + required double south, + 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(); + } +} \ No newline at end of file diff --git a/lib/state/suspected_location_state.dart b/lib/state/suspected_location_state.dart new file mode 100644 index 0000000..96c2ba8 --- /dev/null +++ b/lib/state/suspected_location_state.dart @@ -0,0 +1,79 @@ +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; + + /// All suspected locations + List get locations => _service.locations; + + /// 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 init() async { + await _service.init(); + notifyListeners(); + } + + /// Enable or disable suspected locations + Future setEnabled(bool enabled) async { + await _service.setEnabled(enabled); + notifyListeners(); + } + + /// Manually refresh the data + Future refreshData() async { + _isLoading = true; + notifyListeners(); + + try { + final success = await _service.refreshData(); + 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(); + } + + /// Get suspected locations within a bounding box + List getLocationsInBounds({ + required double north, + required double south, + required double east, + required double west, + }) { + return _service.getLocationsInBounds( + north: north, + south: south, + east: east, + west: west, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 21db422..f0edb34 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -95,6 +95,7 @@ class CameraMarkersBuilder { LatLng? userLocation, int? selectedNodeId, void Function(OsmNode)? onNodeTap, + bool shouldDim = false, }) { final markers = [ // 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, diff --git a/lib/widgets/map/suspected_location_markers.dart b/lib/widgets/map/suspected_location_markers.dart new file mode 100644 index 0000000..b74e099 --- /dev/null +++ b/lib/widgets/map/suspected_location_markers.dart @@ -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 createState() => _SuspectedLocationMapMarkerState(); +} + +class _SuspectedLocationMapMarkerState extends State { + 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 buildSuspectedLocationMarkers({ + required List locations, + required MapController mapController, + String? selectedLocationId, + void Function(SuspectedLocation)? onLocationTap, + }) { + final markers = []; + + 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; + } +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index ddd578a..6522cc1 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -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 @@ -336,14 +340,38 @@ class MapViewState extends State { ? cameraProvider.getCachedNodesForBounds(mapBounds) : []; + // Determine if we should dim camera markers (when suspected location is selected) + final shouldDimCameras = appState.selectedSuspectedLocation != null; + final markers = CameraMarkersBuilder.buildCameraMarkers( cameras: cameras, mapController: _controller.mapController, userLocation: _gpsController.currentLocation, selectedNodeId: widget.selectedNodeId, onNodeTap: widget.onNodeTap, + shouldDim: shouldDimCameras, ); + // Build suspected location markers + final suspectedLocationMarkers = []; + if (appState.suspectedLocationsEnabled && mapBounds != null) { + final suspectedLocations = appState.getSuspectedLocationsInBounds( + north: mapBounds.north, + south: mapBounds.south, + east: mapBounds.east, + west: mapBounds.west, + ); + + suspectedLocationMarkers.addAll( + SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( + locations: suspectedLocations, + mapController: _controller.mapController, + selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, + onLocationTap: widget.onSuspectedLocationTap, + ), + ); + } + // Get current zoom level for direction cones double currentZoom = 15.0; // fallback try { @@ -359,6 +387,21 @@ class MapViewState extends State { editSession: editSession, ); + // 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 cameras to their edited positions final editLines = _buildEditLines(cameras); @@ -436,7 +479,7 @@ class MapViewState extends State { PolygonLayer(polygons: overlays), if (editLines.isNotEmpty) PolylineLayer(polylines: editLines), if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines), - MarkerLayer(markers: [...markers, ...centerMarkers]), + MarkerLayer(markers: [...markers, ...suspectedLocationMarkers, ...centerMarkers]), ], ); } diff --git a/lib/widgets/suspected_location_icon.dart b/lib/widgets/suspected_location_icon.dart new file mode 100644 index 0000000..7f327bc --- /dev/null +++ b/lib/widgets/suspected_location_icon.dart @@ -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, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/suspected_location_sheet.dart b/lib/widgets/suspected_location_sheet.dart new file mode 100644 index 0000000..fd101d8 --- /dev/null +++ b/lib/widgets/suspected_location_sheet.dart @@ -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(); + final locService = LocalizationService.instance; + + Future _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 + final Map displayData = { + 'Ticket No': location.ticketNo, + 'Address': location.addr, + 'Street': location.street, + 'City': location.city, + 'State': location.state, + 'Intersecting Street': location.digSiteIntersectingStreet, + 'Work Done For': location.digWorkDoneFor, + 'Remarks': location.digSiteRemarks, + '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( + 'Suspected Location #${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( + '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')), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2bf35ab..31e9d8c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index 3dfde94..ee32dbc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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