diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 408bae9..ecacc7e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -17,6 +17,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
+ isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
@@ -50,3 +51,7 @@ flutter {
source = "../.."
}
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+}
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c47f597..ffccb1b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
+
+
_settingsState.maxCameras;
UploadMode get uploadMode => _settingsState.uploadMode;
FollowMeMode get followMeMode => _settingsState.followMeMode;
+ bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
+ int get proximityAlertDistance => _settingsState.proximityAlertDistance;
// Tile provider state
List get tileProviders => _settingsState.tileProviders;
@@ -162,7 +164,7 @@ class AppState extends ChangeNotifier {
_sessionState.startAddSession(enabledProfiles);
}
- void startEditSession(OsmCameraNode node) {
+ void startEditSession(OsmNode node) {
_sessionState.startEditSession(node, enabledProfiles);
}
@@ -218,7 +220,7 @@ class AppState extends ChangeNotifier {
}
}
- void deleteNode(OsmCameraNode node) {
+ void deleteNode(OsmNode node) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
_startUploader();
}
@@ -270,6 +272,16 @@ class AppState extends ChangeNotifier {
await _settingsState.setFollowMeMode(mode);
}
+ /// Set proximity alerts enabled/disabled
+ Future setProximityAlertsEnabled(bool enabled) async {
+ await _settingsState.setProximityAlertsEnabled(enabled);
+ }
+
+ /// Set proximity alert distance
+ Future setProximityAlertDistance(int distance) async {
+ await _settingsState.setProximityAlertDistance(distance);
+ }
+
// ---------- Queue Methods ----------
void clearQueue() {
_uploadQueueState.clearQueue();
diff --git a/lib/dev_config.dart b/lib/dev_config.dart
index 843bfa9..5084fd7 100644
--- a/lib/dev_config.dart
+++ b/lib/dev_config.dart
@@ -46,6 +46,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
+// Proximity alerts configuration
+const int kProximityAlertDefaultDistance = 200; // meters
+const int kProximityAlertMinDistance = 50; // meters
+const int kProximityAlertMaxDistance = 1000; // meters
+const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
+
// Last map location and settings storage
const String kLastMapLatKey = 'last_map_latitude';
const String kLastMapLngKey = 'last_map_longitude';
diff --git a/lib/models/osm_camera_node.dart b/lib/models/osm_node.dart
similarity index 91%
rename from lib/models/osm_camera_node.dart
rename to lib/models/osm_node.dart
index dab9cd5..84c1a77 100644
--- a/lib/models/osm_camera_node.dart
+++ b/lib/models/osm_node.dart
@@ -1,11 +1,11 @@
import 'package:latlong2/latlong.dart';
-class OsmCameraNode {
+class OsmNode {
final int id;
final LatLng coord;
final Map tags;
- OsmCameraNode({
+ OsmNode({
required this.id,
required this.coord,
required this.tags,
@@ -18,14 +18,14 @@ class OsmCameraNode {
'tags': tags,
};
- factory OsmCameraNode.fromJson(Map json) {
+ factory OsmNode.fromJson(Map json) {
final tags = {};
if (json['tags'] != null) {
(json['tags'] as Map).forEach((k, v) {
tags[k.toString()] = v.toString();
});
}
- return OsmCameraNode(
+ return OsmNode(
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
tags: tags,
@@ -51,5 +51,4 @@ class OsmCameraNode {
final normalized = ((val % 360) + 360) % 360;
return normalized;
}
-}
-
+}
\ No newline at end of file
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 9131e92..28fc96e 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -8,6 +8,7 @@ import 'settings_screen_sections/offline_areas_section.dart';
import 'settings_screen_sections/offline_mode_section.dart';
import 'settings_screen_sections/about_section.dart';
import 'settings_screen_sections/max_nodes_section.dart';
+import 'settings_screen_sections/proximity_alerts_section.dart';
import 'settings_screen_sections/tile_provider_section.dart';
import 'settings_screen_sections/language_section.dart';
import '../services/localization_service.dart';
@@ -40,6 +41,8 @@ class SettingsScreen extends StatelessWidget {
const Divider(),
const MaxNodesSection(),
const Divider(),
+ const ProximityAlertsSection(),
+ const Divider(),
const TileProviderSection(),
const Divider(),
const OfflineModeSection(),
diff --git a/lib/screens/settings_screen_sections/proximity_alerts_section.dart b/lib/screens/settings_screen_sections/proximity_alerts_section.dart
new file mode 100644
index 0000000..40ead36
--- /dev/null
+++ b/lib/screens/settings_screen_sections/proximity_alerts_section.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
+import '../../app_state.dart';
+import '../../services/localization_service.dart';
+import '../../dev_config.dart';
+
+/// Settings section for proximity alerts configuration
+/// Follows brutalist principles: simple, explicit UI that matches existing patterns
+class ProximityAlertsSection extends StatefulWidget {
+ const ProximityAlertsSection({super.key});
+
+ @override
+ State createState() => _ProximityAlertsSectionState();
+}
+
+class _ProximityAlertsSectionState extends State {
+ late final TextEditingController _distanceController;
+
+ @override
+ void initState() {
+ super.initState();
+ final appState = context.read();
+ _distanceController = TextEditingController(
+ text: appState.proximityAlertDistance.toString(),
+ );
+ }
+
+ @override
+ void dispose() {
+ _distanceController.dispose();
+ super.dispose();
+ }
+
+ void _updateDistance(AppState appState) {
+ final text = _distanceController.text.trim();
+ final distance = int.tryParse(text);
+ if (distance != null) {
+ appState.setProximityAlertDistance(distance);
+ } else {
+ // Reset to current value if invalid
+ _distanceController.text = appState.proximityAlertDistance.toString();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, appState, child) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Proximity Alerts',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+
+ // Enable/disable toggle
+ SwitchListTile(
+ title: const Text('Enable proximity alerts'),
+ subtitle: const Text(
+ 'Get notified when approaching surveillance devices\n'
+ 'Uses extra battery for continuous location monitoring',
+ style: TextStyle(fontSize: 12),
+ ),
+ value: appState.proximityAlertsEnabled,
+ onChanged: (enabled) {
+ appState.setProximityAlertsEnabled(enabled);
+ },
+ contentPadding: EdgeInsets.zero,
+ ),
+
+ // Distance setting (only show when enabled)
+ if (appState.proximityAlertsEnabled) ...[
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ const Text('Alert distance: '),
+ SizedBox(
+ width: 80,
+ child: TextField(
+ controller: _distanceController,
+ keyboardType: TextInputType.number,
+ inputFormatters: [
+ FilteringTextInputFormatter.digitsOnly,
+ ],
+ decoration: const InputDecoration(
+ isDense: true,
+ contentPadding: EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 8,
+ ),
+ border: OutlineInputBorder(),
+ ),
+ onSubmitted: (_) => _updateDistance(appState),
+ onEditingComplete: () => _updateDistance(appState),
+ ),
+ ),
+ const SizedBox(width: 8),
+ const Text('meters'),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Range: $kProximityAlertMinDistance-$kProximityAlertMaxDistance meters (default: $kProximityAlertDefaultDistance)',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
+ ),
+ ),
+ ],
+ ],
+ );
+ },
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart
index 9c5ad29..9b9bb83 100644
--- a/lib/services/map_data_provider.dart
+++ b/lib/services/map_data_provider.dart
@@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import '../models/node_profile.dart';
-import '../models/osm_camera_node.dart';
+import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_submodules/nodes_from_overpass.dart';
import 'map_data_submodules/nodes_from_osm_api.dart';
@@ -35,7 +35,7 @@ class MapDataProvider {
/// Fetch surveillance nodes from OSM/Overpass or local storage.
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
- Future> getNodes({
+ Future> getNodes({
required LatLngBounds bounds,
required List profiles,
UploadMode uploadMode = UploadMode.production,
@@ -70,7 +70,7 @@ class MapDataProvider {
if (uploadMode == UploadMode.sandbox) {
// Offline + Sandbox = no nodes (local cache is production data)
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
- return [];
+ return [];
} else {
// Offline + Production = use local cache
return fetchLocalNodes(
@@ -90,7 +90,7 @@ class MapDataProvider {
);
} else {
// Production mode: fetch both remote and local, then merge with deduplication
- final List>> futures = [];
+ final List>> futures = [];
// Always try to get local nodes (fast, cached)
futures.add(fetchLocalNodes(
@@ -107,7 +107,7 @@ class MapDataProvider {
maxResults: AppState.instance.maxCameras,
).catchError((e) {
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
- return []; // Return empty list on remote failure
+ return []; // Return empty list on remote failure
}));
// Wait for both, then merge with deduplication by node ID
@@ -116,7 +116,7 @@ class MapDataProvider {
final remoteNodes = results[1];
// Merge with deduplication - prefer remote data over local for same node ID
- final Map mergedNodes = {};
+ final Map mergedNodes = {};
// Add local nodes first
for (final node in localNodes) {
@@ -140,7 +140,7 @@ class MapDataProvider {
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
- Future> getAllNodesForDownload({
+ Future> getAllNodesForDownload({
required LatLngBounds bounds,
required List profiles,
UploadMode uploadMode = UploadMode.production,
@@ -214,7 +214,7 @@ class MapDataProvider {
}
/// Fetch remote nodes with Overpass first, OSM API fallback
- Future> _fetchRemoteNodes({
+ Future> _fetchRemoteNodes({
required LatLngBounds bounds,
required List profiles,
UploadMode uploadMode = UploadMode.production,
diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart
index 46ec4b4..4ca3728 100644
--- a/lib/services/map_data_submodules/nodes_from_local.dart
+++ b/lib/services/map_data_submodules/nodes_from_local.dart
@@ -3,19 +3,19 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
-import '../../models/osm_camera_node.dart';
+import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
-Future> fetchLocalNodes({
+Future> fetchLocalNodes({
required LatLngBounds bounds,
required List profiles,
int? maxNodes,
}) async {
final areas = OfflineAreaService().offlineAreas;
- final Map deduped = {};
+ final Map deduped = {};
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
@@ -38,7 +38,7 @@ Future> fetchLocalNodes({
}
// Try in-memory first, else load from disk
-Future> _loadAreaNodes(OfflineArea area) async {
+Future> _loadAreaNodes(OfflineArea area) async {
if (area.nodes.isNotEmpty) {
return area.nodes;
}
@@ -58,7 +58,7 @@ Future> _loadAreaNodes(OfflineArea area) async {
try {
final str = await fileToLoad.readAsString();
final jsonList = jsonDecode(str) as List;
- return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
+ return jsonList.map((e) => OsmNode.fromJson(e)).toList();
} catch (e) {
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e');
}
@@ -74,14 +74,14 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) {
pt.longitude <= bounds.northEast.longitude;
}
-bool _matchesAnyProfile(OsmCameraNode node, List profiles) {
+bool _matchesAnyProfile(OsmNode node, List profiles) {
for (final prof in profiles) {
if (_nodeMatchesProfile(node, prof)) return true;
}
return false;
}
-bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
+bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
for (final e in profile.tags.entries) {
if (node.tags[e.key] != e.value) return false; // All profile tags must match
}
diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart
index 01a8363..71a8aee 100644
--- a/lib/services/map_data_submodules/nodes_from_osm_api.dart
+++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart
@@ -6,13 +6,13 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:xml/xml.dart';
import '../../models/node_profile.dart';
-import '../../models/osm_camera_node.dart';
+import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
-Future> fetchOsmApiNodes({
+Future> fetchOsmApiNodes({
required LatLngBounds bounds,
required List profiles,
UploadMode uploadMode = UploadMode.production,
@@ -47,7 +47,7 @@ Future> fetchOsmApiNodes({
// Parse XML response
final document = XmlDocument.parse(response.body);
- final nodes = [];
+ final nodes = [];
// Find all node elements
for (final nodeElement in document.findAllElements('node')) {
@@ -73,7 +73,7 @@ Future> fetchOsmApiNodes({
// Check if this node matches any of our profiles
if (_nodeMatchesProfiles(tags, profiles)) {
- nodes.add(OsmCameraNode(
+ nodes.add(OsmNode(
id: id,
coord: LatLng(lat, lon),
tags: tags,
diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart
index 0437090..6eb4cc2 100644
--- a/lib/services/map_data_submodules/nodes_from_overpass.dart
+++ b/lib/services/map_data_submodules/nodes_from_overpass.dart
@@ -5,13 +5,13 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/node_profile.dart';
-import '../../models/osm_camera_node.dart';
+import '../../models/osm_node.dart';
import '../../models/pending_upload.dart';
import '../../app_state.dart';
import '../network_status.dart';
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
-Future> fetchOverpassNodes({
+Future> fetchOverpassNodes({
required LatLngBounds bounds,
required List profiles,
UploadMode uploadMode = UploadMode.production,
@@ -49,7 +49,7 @@ Future> fetchOverpassNodes({
NetworkStatus.instance.reportOverpassSuccess();
final nodes = elements.whereType