mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-21 02:17:21 +02:00
More camera -> node, notifications for approaching
This commit is contained in:
@@ -17,6 +17,7 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||||
@@ -50,3 +51,7 @@ flutter {
|
|||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<!-- Location permissions for blue‑dot positioning -->
|
<!-- Location permissions for blue‑dot positioning -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
|
||||||
|
<!-- Notification permission for proximity alerts -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
+15
-3
@@ -4,7 +4,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import 'models/node_profile.dart';
|
import 'models/node_profile.dart';
|
||||||
import 'models/operator_profile.dart';
|
import 'models/operator_profile.dart';
|
||||||
import 'models/osm_camera_node.dart';
|
import 'models/osm_node.dart';
|
||||||
import 'models/pending_upload.dart';
|
import 'models/pending_upload.dart';
|
||||||
import 'models/tile_provider.dart';
|
import 'models/tile_provider.dart';
|
||||||
import 'services/offline_area_service.dart';
|
import 'services/offline_area_service.dart';
|
||||||
@@ -79,6 +79,8 @@ class AppState extends ChangeNotifier {
|
|||||||
int get maxCameras => _settingsState.maxCameras;
|
int get maxCameras => _settingsState.maxCameras;
|
||||||
UploadMode get uploadMode => _settingsState.uploadMode;
|
UploadMode get uploadMode => _settingsState.uploadMode;
|
||||||
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
FollowMeMode get followMeMode => _settingsState.followMeMode;
|
||||||
|
bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled;
|
||||||
|
int get proximityAlertDistance => _settingsState.proximityAlertDistance;
|
||||||
|
|
||||||
// Tile provider state
|
// Tile provider state
|
||||||
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
List<TileProvider> get tileProviders => _settingsState.tileProviders;
|
||||||
@@ -162,7 +164,7 @@ class AppState extends ChangeNotifier {
|
|||||||
_sessionState.startAddSession(enabledProfiles);
|
_sessionState.startAddSession(enabledProfiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
void startEditSession(OsmCameraNode node) {
|
void startEditSession(OsmNode node) {
|
||||||
_sessionState.startEditSession(node, enabledProfiles);
|
_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);
|
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode);
|
||||||
_startUploader();
|
_startUploader();
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,16 @@ class AppState extends ChangeNotifier {
|
|||||||
await _settingsState.setFollowMeMode(mode);
|
await _settingsState.setFollowMeMode(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set proximity alerts enabled/disabled
|
||||||
|
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||||
|
await _settingsState.setProximityAlertsEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set proximity alert distance
|
||||||
|
Future<void> setProximityAlertDistance(int distance) async {
|
||||||
|
await _settingsState.setProximityAlertDistance(distance);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Queue Methods ----------
|
// ---------- Queue Methods ----------
|
||||||
void clearQueue() {
|
void clearQueue() {
|
||||||
_uploadQueueState.clearQueue();
|
_uploadQueueState.clearQueue();
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
|
|||||||
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600);
|
||||||
const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation
|
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
|
// Last map location and settings storage
|
||||||
const String kLastMapLatKey = 'last_map_latitude';
|
const String kLastMapLatKey = 'last_map_latitude';
|
||||||
const String kLastMapLngKey = 'last_map_longitude';
|
const String kLastMapLngKey = 'last_map_longitude';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
class OsmCameraNode {
|
class OsmNode {
|
||||||
final int id;
|
final int id;
|
||||||
final LatLng coord;
|
final LatLng coord;
|
||||||
final Map<String, String> tags;
|
final Map<String, String> tags;
|
||||||
|
|
||||||
OsmCameraNode({
|
OsmNode({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.coord,
|
required this.coord,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
@@ -18,14 +18,14 @@ class OsmCameraNode {
|
|||||||
'tags': tags,
|
'tags': tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory OsmCameraNode.fromJson(Map<String, dynamic> json) {
|
factory OsmNode.fromJson(Map<String, dynamic> json) {
|
||||||
final tags = <String, String>{};
|
final tags = <String, String>{};
|
||||||
if (json['tags'] != null) {
|
if (json['tags'] != null) {
|
||||||
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
(json['tags'] as Map<String, dynamic>).forEach((k, v) {
|
||||||
tags[k.toString()] = v.toString();
|
tags[k.toString()] = v.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return OsmCameraNode(
|
return OsmNode(
|
||||||
id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0,
|
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()),
|
coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
@@ -51,5 +51,4 @@ class OsmCameraNode {
|
|||||||
final normalized = ((val % 360) + 360) % 360;
|
final normalized = ((val % 360) + 360) % 360;
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import 'settings_screen_sections/offline_areas_section.dart';
|
|||||||
import 'settings_screen_sections/offline_mode_section.dart';
|
import 'settings_screen_sections/offline_mode_section.dart';
|
||||||
import 'settings_screen_sections/about_section.dart';
|
import 'settings_screen_sections/about_section.dart';
|
||||||
import 'settings_screen_sections/max_nodes_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/tile_provider_section.dart';
|
||||||
import 'settings_screen_sections/language_section.dart';
|
import 'settings_screen_sections/language_section.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
@@ -40,6 +41,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
const MaxNodesSection(),
|
const MaxNodesSection(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
const ProximityAlertsSection(),
|
||||||
|
const Divider(),
|
||||||
const TileProviderSection(),
|
const TileProviderSection(),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const OfflineModeSection(),
|
const OfflineModeSection(),
|
||||||
|
|||||||
@@ -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<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||||
|
late final TextEditingController _distanceController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final appState = context.read<AppState>();
|
||||||
|
_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<AppState>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import 'map_data_submodules/nodes_from_overpass.dart';
|
import 'map_data_submodules/nodes_from_overpass.dart';
|
||||||
import 'map_data_submodules/nodes_from_osm_api.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.
|
/// Fetch surveillance nodes from OSM/Overpass or local storage.
|
||||||
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
/// Remote is default. If source is MapSource.auto, remote is tried first unless offline.
|
||||||
Future<List<OsmCameraNode>> getNodes({
|
Future<List<OsmNode>> getNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -70,7 +70,7 @@ class MapDataProvider {
|
|||||||
if (uploadMode == UploadMode.sandbox) {
|
if (uploadMode == UploadMode.sandbox) {
|
||||||
// Offline + Sandbox = no nodes (local cache is production data)
|
// Offline + Sandbox = no nodes (local cache is production data)
|
||||||
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
|
debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)');
|
||||||
return <OsmCameraNode>[];
|
return <OsmNode>[];
|
||||||
} else {
|
} else {
|
||||||
// Offline + Production = use local cache
|
// Offline + Production = use local cache
|
||||||
return fetchLocalNodes(
|
return fetchLocalNodes(
|
||||||
@@ -90,7 +90,7 @@ class MapDataProvider {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Production mode: fetch both remote and local, then merge with deduplication
|
// Production mode: fetch both remote and local, then merge with deduplication
|
||||||
final List<Future<List<OsmCameraNode>>> futures = [];
|
final List<Future<List<OsmNode>>> futures = [];
|
||||||
|
|
||||||
// Always try to get local nodes (fast, cached)
|
// Always try to get local nodes (fast, cached)
|
||||||
futures.add(fetchLocalNodes(
|
futures.add(fetchLocalNodes(
|
||||||
@@ -107,7 +107,7 @@ class MapDataProvider {
|
|||||||
maxResults: AppState.instance.maxCameras,
|
maxResults: AppState.instance.maxCameras,
|
||||||
).catchError((e) {
|
).catchError((e) {
|
||||||
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
|
debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.');
|
||||||
return <OsmCameraNode>[]; // Return empty list on remote failure
|
return <OsmNode>[]; // Return empty list on remote failure
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Wait for both, then merge with deduplication by node ID
|
// Wait for both, then merge with deduplication by node ID
|
||||||
@@ -116,7 +116,7 @@ class MapDataProvider {
|
|||||||
final remoteNodes = results[1];
|
final remoteNodes = results[1];
|
||||||
|
|
||||||
// Merge with deduplication - prefer remote data over local for same node ID
|
// Merge with deduplication - prefer remote data over local for same node ID
|
||||||
final Map<int, OsmCameraNode> mergedNodes = {};
|
final Map<int, OsmNode> mergedNodes = {};
|
||||||
|
|
||||||
// Add local nodes first
|
// Add local nodes first
|
||||||
for (final node in localNodes) {
|
for (final node in localNodes) {
|
||||||
@@ -140,7 +140,7 @@ class MapDataProvider {
|
|||||||
|
|
||||||
/// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries)
|
/// 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.
|
/// Only use for offline area download, not for map browsing! Ignores maxCameras config.
|
||||||
Future<List<OsmCameraNode>> getAllNodesForDownload({
|
Future<List<OsmNode>> getAllNodesForDownload({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -214,7 +214,7 @@ class MapDataProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||||
Future<List<OsmCameraNode>> _fetchRemoteNodes({
|
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
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 '../../models/node_profile.dart';
|
||||||
import '../offline_area_service.dart';
|
import '../offline_area_service.dart';
|
||||||
import '../offline_areas/offline_area_models.dart';
|
import '../offline_areas/offline_area_models.dart';
|
||||||
|
|
||||||
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
/// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list.
|
||||||
Future<List<OsmCameraNode>> fetchLocalNodes({
|
Future<List<OsmNode>> fetchLocalNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
int? maxNodes,
|
int? maxNodes,
|
||||||
}) async {
|
}) async {
|
||||||
final areas = OfflineAreaService().offlineAreas;
|
final areas = OfflineAreaService().offlineAreas;
|
||||||
final Map<int, OsmCameraNode> deduped = {};
|
final Map<int, OsmNode> deduped = {};
|
||||||
|
|
||||||
for (final area in areas) {
|
for (final area in areas) {
|
||||||
if (area.status != OfflineAreaStatus.complete) continue;
|
if (area.status != OfflineAreaStatus.complete) continue;
|
||||||
@@ -38,7 +38,7 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try in-memory first, else load from disk
|
// Try in-memory first, else load from disk
|
||||||
Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
Future<List<OsmNode>> _loadAreaNodes(OfflineArea area) async {
|
||||||
if (area.nodes.isNotEmpty) {
|
if (area.nodes.isNotEmpty) {
|
||||||
return area.nodes;
|
return area.nodes;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ Future<List<OsmCameraNode>> _loadAreaNodes(OfflineArea area) async {
|
|||||||
try {
|
try {
|
||||||
final str = await fileToLoad.readAsString();
|
final str = await fileToLoad.readAsString();
|
||||||
final jsonList = jsonDecode(str) as List;
|
final jsonList = jsonDecode(str) as List;
|
||||||
return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList();
|
return jsonList.map((e) => OsmNode.fromJson(e)).toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $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;
|
pt.longitude <= bounds.northEast.longitude;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> profiles) {
|
||||||
for (final prof in profiles) {
|
for (final prof in profiles) {
|
||||||
if (_nodeMatchesProfile(node, prof)) return true;
|
if (_nodeMatchesProfile(node, prof)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
|
||||||
for (final e in profile.tags.entries) {
|
for (final e in profile.tags.entries) {
|
||||||
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
if (node.tags[e.key] != e.value) return false; // All profile tags must match
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:xml/xml.dart';
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
import '../../models/node_profile.dart';
|
import '../../models/node_profile.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../network_status.dart';
|
import '../network_status.dart';
|
||||||
|
|
||||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
/// 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).
|
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||||
Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
Future<List<OsmNode>> fetchOsmApiNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -47,7 +47,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
|||||||
|
|
||||||
// Parse XML response
|
// Parse XML response
|
||||||
final document = XmlDocument.parse(response.body);
|
final document = XmlDocument.parse(response.body);
|
||||||
final nodes = <OsmCameraNode>[];
|
final nodes = <OsmNode>[];
|
||||||
|
|
||||||
// Find all node elements
|
// Find all node elements
|
||||||
for (final nodeElement in document.findAllElements('node')) {
|
for (final nodeElement in document.findAllElements('node')) {
|
||||||
@@ -73,7 +73,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
|||||||
|
|
||||||
// Check if this node matches any of our profiles
|
// Check if this node matches any of our profiles
|
||||||
if (_nodeMatchesProfiles(tags, profiles)) {
|
if (_nodeMatchesProfiles(tags, profiles)) {
|
||||||
nodes.add(OsmCameraNode(
|
nodes.add(OsmNode(
|
||||||
id: id,
|
id: id,
|
||||||
coord: LatLng(lat, lon),
|
coord: LatLng(lat, lon),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
|
||||||
import '../../models/node_profile.dart';
|
import '../../models/node_profile.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../../models/pending_upload.dart';
|
import '../../models/pending_upload.dart';
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../network_status.dart';
|
import '../network_status.dart';
|
||||||
|
|
||||||
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
/// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles.
|
||||||
Future<List<OsmCameraNode>> fetchOverpassNodes({
|
Future<List<OsmNode>> fetchOverpassNodes({
|
||||||
required LatLngBounds bounds,
|
required LatLngBounds bounds,
|
||||||
required List<NodeProfile> profiles,
|
required List<NodeProfile> profiles,
|
||||||
UploadMode uploadMode = UploadMode.production,
|
UploadMode uploadMode = UploadMode.production,
|
||||||
@@ -49,7 +49,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
|
|||||||
NetworkStatus.instance.reportOverpassSuccess();
|
NetworkStatus.instance.reportOverpassSuccess();
|
||||||
|
|
||||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||||
return OsmCameraNode(
|
return OsmNode(
|
||||||
id: element['id'],
|
id: element['id'],
|
||||||
coord: LatLng(element['lat'], element['lon']),
|
coord: LatLng(element['lat'], element['lon']),
|
||||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||||
@@ -101,7 +101,7 @@ $outputClause
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up pending uploads that now appear in Overpass results
|
/// Clean up pending uploads that now appear in Overpass results
|
||||||
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
|
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||||
try {
|
try {
|
||||||
final appState = AppState.instance;
|
final appState = AppState.instance;
|
||||||
final pendingUploads = appState.pendingUploads;
|
final pendingUploads = appState.pendingUploads;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
|
||||||
class NodeCache {
|
class NodeCache {
|
||||||
@@ -8,10 +8,10 @@ class NodeCache {
|
|||||||
factory NodeCache() => instance;
|
factory NodeCache() => instance;
|
||||||
NodeCache._internal();
|
NodeCache._internal();
|
||||||
|
|
||||||
final Map<int, OsmCameraNode> _nodes = {};
|
final Map<int, OsmNode> _nodes = {};
|
||||||
|
|
||||||
/// Add or update a batch of nodes in the cache.
|
/// Add or update a batch of nodes in the cache.
|
||||||
void addOrUpdate(List<OsmCameraNode> nodes) {
|
void addOrUpdate(List<OsmNode> nodes) {
|
||||||
for (var node in nodes) {
|
for (var node in nodes) {
|
||||||
final existing = _nodes[node.id];
|
final existing = _nodes[node.id];
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -22,7 +22,7 @@ class NodeCache {
|
|||||||
mergedTags[entry.key] = entry.value;
|
mergedTags[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_nodes[node.id] = OsmCameraNode(
|
_nodes[node.id] = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: mergedTags,
|
tags: mergedTags,
|
||||||
@@ -34,14 +34,14 @@ class NodeCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Query for all cached nodes currently within the given LatLngBounds.
|
/// Query for all cached nodes currently within the given LatLngBounds.
|
||||||
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
|
List<OsmNode> queryByBounds(LatLngBounds bounds) {
|
||||||
return _nodes.values
|
return _nodes.values
|
||||||
.where((node) => _inBounds(node.coord, bounds))
|
.where((node) => _inBounds(node.coord, bounds))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve all cached nodes.
|
/// Retrieve all cached nodes.
|
||||||
List<OsmCameraNode> getAll() => _nodes.values.toList();
|
List<OsmNode> getAll() => _nodes.values.toList();
|
||||||
|
|
||||||
/// Optionally clear the cache (rarely needed)
|
/// Optionally clear the cache (rarely needed)
|
||||||
void clear() => _nodes.clear();
|
void clear() => _nodes.clear();
|
||||||
@@ -53,7 +53,7 @@ class NodeCache {
|
|||||||
final cleanTags = Map<String, String>.from(node.tags);
|
final cleanTags = Map<String, String>.from(node.tags);
|
||||||
cleanTags.remove('_pending_edit');
|
cleanTags.remove('_pending_edit');
|
||||||
|
|
||||||
_nodes[nodeId] = OsmCameraNode(
|
_nodes[nodeId] = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: cleanTags,
|
tags: cleanTags,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'offline_areas/offline_area_models.dart';
|
|||||||
import 'offline_areas/offline_tile_utils.dart';
|
import 'offline_areas/offline_tile_utils.dart';
|
||||||
import 'offline_areas/offline_area_downloader.dart';
|
import 'offline_areas/offline_area_downloader.dart';
|
||||||
|
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import 'map_data_provider.dart';
|
import 'map_data_provider.dart';
|
||||||
import 'package:deflockapp/dev_config.dart';
|
import 'package:deflockapp/dev_config.dart';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
|
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../map_data_provider.dart';
|
import '../map_data_provider.dart';
|
||||||
import 'offline_area_models.dart';
|
import 'offline_area_models.dart';
|
||||||
import 'offline_tile_utils.dart';
|
import 'offline_tile_utils.dart';
|
||||||
@@ -182,7 +182,7 @@ class OfflineAreaDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save nodes to disk as JSON
|
/// Save nodes to disk as JSON
|
||||||
static Future<void> saveNodes(List<OsmCameraNode> nodes, String dir) async {
|
static Future<void> saveNodes(List<OsmNode> nodes, String dir) async {
|
||||||
final file = File('$dir/nodes.json');
|
final file = File('$dir/nodes.json');
|
||||||
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
|
|
||||||
/// Status of an offline area
|
/// Status of an offline area
|
||||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||||
@@ -17,7 +17,7 @@ class OfflineArea {
|
|||||||
double progress; // 0.0 - 1.0
|
double progress; // 0.0 - 1.0
|
||||||
int tilesDownloaded;
|
int tilesDownloaded;
|
||||||
int tilesTotal;
|
int tilesTotal;
|
||||||
List<OsmCameraNode> nodes;
|
List<OsmNode> nodes;
|
||||||
int sizeBytes; // Disk size in bytes
|
int sizeBytes; // Disk size in bytes
|
||||||
final bool isPermanent; // Not user-deletable if true
|
final bool isPermanent; // Not user-deletable if true
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class OfflineArea {
|
|||||||
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
||||||
tilesTotal: json['tilesTotal'] ?? 0,
|
tilesTotal: json['tilesTotal'] ?? 0,
|
||||||
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
|
nodes: (json['nodes'] as List? ?? json['cameras'] as List? ?? [])
|
||||||
.map((e) => OsmCameraNode.fromJson(e)).toList(),
|
.map((e) => OsmNode.fromJson(e)).toList(),
|
||||||
sizeBytes: json['sizeBytes'] ?? 0,
|
sizeBytes: json['sizeBytes'] ?? 0,
|
||||||
isPermanent: json['isPermanent'] ?? false,
|
isPermanent: json['isPermanent'] ?? false,
|
||||||
tileProviderId: json['tileProviderId'],
|
tileProviderId: json['tileProviderId'],
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/osm_node.dart';
|
||||||
|
import '../models/node_profile.dart';
|
||||||
|
import '../dev_config.dart';
|
||||||
|
|
||||||
|
/// Simple data class for tracking recent proximity alerts to prevent spam
|
||||||
|
class RecentAlert {
|
||||||
|
final int nodeId;
|
||||||
|
final DateTime alertTime;
|
||||||
|
|
||||||
|
RecentAlert({required this.nodeId, required this.alertTime});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for handling proximity alerts when approaching surveillance nodes
|
||||||
|
/// Follows brutalist principles: simple, explicit, easy to understand
|
||||||
|
class ProximityAlertService {
|
||||||
|
static final ProximityAlertService _instance = ProximityAlertService._internal();
|
||||||
|
factory ProximityAlertService() => _instance;
|
||||||
|
ProximityAlertService._internal();
|
||||||
|
|
||||||
|
FlutterLocalNotificationsPlugin? _notifications;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Simple in-memory tracking of recent alerts to prevent spam
|
||||||
|
final List<RecentAlert> _recentAlerts = [];
|
||||||
|
static const Duration _alertCooldown = kProximityAlertCooldown;
|
||||||
|
|
||||||
|
// Callback for showing in-app visual alerts
|
||||||
|
VoidCallback? _onVisualAlert;
|
||||||
|
|
||||||
|
/// Initialize the notification plugin
|
||||||
|
Future<void> initialize({VoidCallback? onVisualAlert}) async {
|
||||||
|
_onVisualAlert = onVisualAlert;
|
||||||
|
|
||||||
|
_notifications = FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
const iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: true,
|
||||||
|
requestBadgePermission: true,
|
||||||
|
requestSoundPermission: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initSettings = InitializationSettings(
|
||||||
|
android: androidSettings,
|
||||||
|
iOS: iosSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final initialized = await _notifications!.initialize(initSettings);
|
||||||
|
_isInitialized = initialized ?? false;
|
||||||
|
debugPrint('[ProximityAlertService] Initialized: $_isInitialized');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ProximityAlertService] Failed to initialize: $e');
|
||||||
|
_isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check proximity to nodes and trigger alerts if needed
|
||||||
|
/// This should be called on GPS position updates
|
||||||
|
Future<void> checkProximity({
|
||||||
|
required LatLng userLocation,
|
||||||
|
required List<OsmNode> nodes,
|
||||||
|
required List<NodeProfile> enabledProfiles,
|
||||||
|
required int alertDistance,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized || nodes.isEmpty) return;
|
||||||
|
|
||||||
|
// Clean up old alerts (anything older than cooldown period)
|
||||||
|
final cutoffTime = DateTime.now().subtract(_alertCooldown);
|
||||||
|
_recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime));
|
||||||
|
|
||||||
|
// Check each node for proximity
|
||||||
|
for (final node in nodes) {
|
||||||
|
// Skip if we recently alerted for this node
|
||||||
|
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate distance using Geolocator's distanceBetween
|
||||||
|
final distance = Geolocator.distanceBetween(
|
||||||
|
userLocation.latitude,
|
||||||
|
userLocation.longitude,
|
||||||
|
node.coord.latitude,
|
||||||
|
node.coord.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if within alert distance
|
||||||
|
if (distance <= alertDistance) {
|
||||||
|
// Determine node type for alert message
|
||||||
|
final nodeType = _getNodeTypeDescription(node, enabledProfiles);
|
||||||
|
|
||||||
|
// Trigger both push notification and visual alert
|
||||||
|
await _showNotification(node, nodeType, distance.round());
|
||||||
|
_showVisualAlert();
|
||||||
|
|
||||||
|
// Track this alert to prevent spam
|
||||||
|
_recentAlerts.add(RecentAlert(
|
||||||
|
nodeId: node.id,
|
||||||
|
alertTime: DateTime.now(),
|
||||||
|
));
|
||||||
|
|
||||||
|
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show push notification for proximity alert
|
||||||
|
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
|
||||||
|
if (!_isInitialized || _notifications == null) return;
|
||||||
|
|
||||||
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
'proximity_alerts',
|
||||||
|
'Proximity Alerts',
|
||||||
|
channelDescription: 'Notifications when approaching surveillance devices',
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
enableVibration: true,
|
||||||
|
playSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const iosDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: false,
|
||||||
|
presentSound: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: iosDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
final title = 'Surveillance Device Nearby';
|
||||||
|
final body = '$nodeType detected ${distance}m ahead';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _notifications!.show(
|
||||||
|
node.id, // Use node ID as notification ID
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
notificationDetails,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ProximityAlertService] Failed to show notification: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger visual alert in the app UI
|
||||||
|
void _showVisualAlert() {
|
||||||
|
_onVisualAlert?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a user-friendly description of the node type
|
||||||
|
String _getNodeTypeDescription(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||||
|
final tags = node.tags;
|
||||||
|
|
||||||
|
// Check for specific surveillance types
|
||||||
|
if (tags.containsKey('man_made') && tags['man_made'] == 'surveillance') {
|
||||||
|
final surveillanceType = tags['surveillance:type'] ?? 'surveillance device';
|
||||||
|
if (surveillanceType == 'camera') return 'Camera';
|
||||||
|
if (surveillanceType == 'ALPR') return 'License plate reader';
|
||||||
|
return 'Surveillance device';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for emergency devices
|
||||||
|
if (tags.containsKey('emergency') && tags['emergency'] == 'siren') {
|
||||||
|
return 'Emergency siren';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to checking enabled profiles to see what type this might be
|
||||||
|
for (final profile in enabledProfiles) {
|
||||||
|
bool matches = true;
|
||||||
|
for (final entry in profile.tags.entries) {
|
||||||
|
if (node.tags[entry.key] != entry.value) {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) {
|
||||||
|
return profile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Surveillance device';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get count of recent alerts (for debugging/testing)
|
||||||
|
int get recentAlertCount => _recentAlerts.length;
|
||||||
|
|
||||||
|
/// Clear recent alerts (for testing)
|
||||||
|
void clearRecentAlerts() {
|
||||||
|
_recentAlerts.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/operator_profile.dart';
|
import '../models/operator_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
|
|
||||||
// ------------------ AddNodeSession ------------------
|
// ------------------ AddNodeSession ------------------
|
||||||
class AddNodeSession {
|
class AddNodeSession {
|
||||||
@@ -23,7 +23,7 @@ class EditNodeSession {
|
|||||||
required this.target,
|
required this.target,
|
||||||
});
|
});
|
||||||
|
|
||||||
final OsmCameraNode originalNode; // The original node being edited
|
final OsmNode originalNode; // The original node being edited
|
||||||
NodeProfile profile;
|
NodeProfile profile;
|
||||||
OperatorProfile? operatorProfile;
|
OperatorProfile? operatorProfile;
|
||||||
double directionDegrees;
|
double directionDegrees;
|
||||||
@@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void startEditSession(OsmCameraNode node, List<NodeProfile> enabledProfiles) {
|
void startEditSession(OsmNode node, List<NodeProfile> enabledProfiles) {
|
||||||
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList();
|
||||||
|
|
||||||
// Try to find a matching profile based on the node's tags
|
// Try to find a matching profile based on the node's tags
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ class SettingsState extends ChangeNotifier {
|
|||||||
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
static const String _selectedTileTypePrefsKey = 'selected_tile_type';
|
||||||
static const String _legacyTestModePrefsKey = 'test_mode';
|
static const String _legacyTestModePrefsKey = 'test_mode';
|
||||||
static const String _followMeModePrefsKey = 'follow_me_mode';
|
static const String _followMeModePrefsKey = 'follow_me_mode';
|
||||||
|
static const String _proximityAlertsEnabledPrefsKey = 'proximity_alerts_enabled';
|
||||||
|
static const String _proximityAlertDistancePrefsKey = 'proximity_alert_distance';
|
||||||
|
|
||||||
bool _offlineMode = false;
|
bool _offlineMode = false;
|
||||||
int _maxCameras = 250;
|
int _maxCameras = 250;
|
||||||
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
|
||||||
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
FollowMeMode _followMeMode = FollowMeMode.northUp;
|
||||||
|
bool _proximityAlertsEnabled = false;
|
||||||
|
int _proximityAlertDistance = kProximityAlertDefaultDistance;
|
||||||
List<TileProvider> _tileProviders = [];
|
List<TileProvider> _tileProviders = [];
|
||||||
String _selectedTileTypeId = '';
|
String _selectedTileTypeId = '';
|
||||||
|
|
||||||
@@ -37,6 +41,8 @@ class SettingsState extends ChangeNotifier {
|
|||||||
int get maxCameras => _maxCameras;
|
int get maxCameras => _maxCameras;
|
||||||
UploadMode get uploadMode => _uploadMode;
|
UploadMode get uploadMode => _uploadMode;
|
||||||
FollowMeMode get followMeMode => _followMeMode;
|
FollowMeMode get followMeMode => _followMeMode;
|
||||||
|
bool get proximityAlertsEnabled => _proximityAlertsEnabled;
|
||||||
|
int get proximityAlertDistance => _proximityAlertDistance;
|
||||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||||
String get selectedTileTypeId => _selectedTileTypeId;
|
String get selectedTileTypeId => _selectedTileTypeId;
|
||||||
|
|
||||||
@@ -85,6 +91,10 @@ class SettingsState extends ChangeNotifier {
|
|||||||
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
_maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load proximity alerts settings
|
||||||
|
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
|
||||||
|
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
|
||||||
|
|
||||||
// Load upload mode (including migration from old test_mode bool)
|
// Load upload mode (including migration from old test_mode bool)
|
||||||
if (prefs.containsKey(_uploadModePrefsKey)) {
|
if (prefs.containsKey(_uploadModePrefsKey)) {
|
||||||
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
final idx = prefs.getInt(_uploadModePrefsKey) ?? 0;
|
||||||
@@ -253,4 +263,26 @@ class SettingsState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set proximity alerts enabled/disabled
|
||||||
|
Future<void> setProximityAlertsEnabled(bool enabled) async {
|
||||||
|
if (_proximityAlertsEnabled != enabled) {
|
||||||
|
_proximityAlertsEnabled = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_proximityAlertsEnabledPrefsKey, enabled);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set proximity alert distance in meters
|
||||||
|
Future<void> setProximityAlertDistance(int distance) async {
|
||||||
|
if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance;
|
||||||
|
if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance;
|
||||||
|
if (_proximityAlertDistance != distance) {
|
||||||
|
_proximityAlertDistance = distance;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_proximityAlertDistancePrefsKey, distance);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/pending_upload.dart';
|
import '../models/pending_upload.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../services/node_cache.dart';
|
import '../services/node_cache.dart';
|
||||||
import '../services/uploader.dart';
|
import '../services/uploader.dart';
|
||||||
@@ -46,7 +46,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
final tags = upload.getCombinedTags();
|
final tags = upload.getCombinedTags();
|
||||||
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction
|
||||||
|
|
||||||
final tempNode = OsmCameraNode(
|
final tempNode = OsmNode(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
coord: upload.coord,
|
coord: upload.coord,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
@@ -80,7 +80,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
final originalTags = Map<String, String>.from(session.originalNode.tags);
|
||||||
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit
|
||||||
|
|
||||||
final originalNode = OsmCameraNode(
|
final originalNode = OsmNode(
|
||||||
id: session.originalNode.id,
|
id: session.originalNode.id,
|
||||||
coord: session.originalNode.coord, // Keep at original location
|
coord: session.originalNode.coord, // Keep at original location
|
||||||
tags: originalTags,
|
tags: originalTags,
|
||||||
@@ -92,7 +92,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
editedTags['_pending_upload'] = 'true'; // Mark as pending upload
|
||||||
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing
|
||||||
|
|
||||||
final editedNode = OsmCameraNode(
|
final editedNode = OsmNode(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
coord: upload.coord, // At new location
|
coord: upload.coord, // At new location
|
||||||
tags: editedTags,
|
tags: editedTags,
|
||||||
@@ -106,7 +106,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add a node deletion to the upload queue
|
// Add a node deletion to the upload queue
|
||||||
void addFromNodeDeletion(OsmCameraNode node, {required UploadMode uploadMode}) {
|
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) {
|
||||||
final upload = PendingUpload(
|
final upload = PendingUpload(
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
|
direction: node.directionDeg ?? 0, // Use existing direction or default to 0
|
||||||
@@ -123,7 +123,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
final deletionTags = Map<String, String>.from(node.tags);
|
final deletionTags = Map<String, String>.from(node.tags);
|
||||||
deletionTags['_pending_deletion'] = 'true';
|
deletionTags['_pending_deletion'] = 'true';
|
||||||
|
|
||||||
final nodeWithDeletionTag = OsmCameraNode(
|
final nodeWithDeletionTag = OsmNode(
|
||||||
id: node.id,
|
id: node.id,
|
||||||
coord: node.coord,
|
coord: node.coord,
|
||||||
tags: deletionTags,
|
tags: deletionTags,
|
||||||
@@ -259,7 +259,7 @@ class UploadQueueState extends ChangeNotifier {
|
|||||||
// Create the node with real ID and clean tags (remove temp markers)
|
// Create the node with real ID and clean tags (remove temp markers)
|
||||||
final tags = item.getCombinedTags();
|
final tags = item.getCombinedTags();
|
||||||
|
|
||||||
final realNode = OsmCameraNode(
|
final realNode = OsmNode(
|
||||||
id: realNodeId,
|
id: realNodeId,
|
||||||
coord: item.coord,
|
coord: item.coord,
|
||||||
tags: tags, // Clean tags without _pending_upload markers
|
tags: tags, // Clean tags without _pending_upload markers
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../services/map_data_provider.dart';
|
|||||||
import '../services/node_cache.dart';
|
import '../services/node_cache.dart';
|
||||||
import '../services/network_status.dart';
|
import '../services/network_status.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
|
|
||||||
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
|
/// Provides surveillance nodes for a map view, using an in-memory cache and optionally
|
||||||
@@ -21,7 +21,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Call this to get (quickly) all cached overlays for the given view.
|
/// Call this to get (quickly) all cached overlays for the given view.
|
||||||
/// Filters by currently enabled profiles.
|
/// Filters by currently enabled profiles.
|
||||||
List<OsmCameraNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
|
||||||
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
final allNodes = NodeCache.instance.queryByBounds(bounds);
|
||||||
final enabledProfiles = AppState.instance.enabledProfiles;
|
final enabledProfiles = AppState.instance.enabledProfiles;
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a node matches any of the provided profiles
|
/// Check if a node matches any of the provided profiles
|
||||||
bool _matchesAnyProfile(OsmCameraNode node, List<NodeProfile> profiles) {
|
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> profiles) {
|
||||||
for (final profile in profiles) {
|
for (final profile in profiles) {
|
||||||
if (_nodeMatchesProfile(node, profile)) return true;
|
if (_nodeMatchesProfile(node, profile)) return true;
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ class CameraProviderWithCache extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a node matches a specific profile (all profile tags must match)
|
/// Check if a node matches a specific profile (all profile tags must match)
|
||||||
bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) {
|
bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) {
|
||||||
for (final entry in profile.tags.entries) {
|
for (final entry in profile.tags.entries) {
|
||||||
if (node.tags[entry.key] != entry.value) return false;
|
if (node.tags[entry.key] != entry.value) return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../../dev_config.dart';
|
import '../../dev_config.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
import '../node_tag_sheet.dart';
|
import '../node_tag_sheet.dart';
|
||||||
import '../camera_icon.dart';
|
import '../camera_icon.dart';
|
||||||
|
|
||||||
/// Smart marker widget for camera with single/double tap distinction
|
/// Smart marker widget for camera with single/double tap distinction
|
||||||
class CameraMapMarker extends StatefulWidget {
|
class CameraMapMarker extends StatefulWidget {
|
||||||
final OsmCameraNode node;
|
final OsmNode node;
|
||||||
final MapController mapController;
|
final MapController mapController;
|
||||||
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key);
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
|
|||||||
/// Helper class to build marker layers for cameras and user location
|
/// Helper class to build marker layers for cameras and user location
|
||||||
class CameraMarkersBuilder {
|
class CameraMarkersBuilder {
|
||||||
static List<Marker> buildCameraMarkers({
|
static List<Marker> buildCameraMarkers({
|
||||||
required List<OsmCameraNode> cameras,
|
required List<OsmNode> cameras,
|
||||||
required MapController mapController,
|
required MapController mapController,
|
||||||
LatLng? userLocation,
|
LatLng? userLocation,
|
||||||
}) {
|
}) {
|
||||||
@@ -104,7 +104,7 @@ class CameraMarkersBuilder {
|
|||||||
return markers;
|
return markers;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isValidCameraCoordinate(OsmCameraNode node) {
|
static bool _isValidCameraCoordinate(OsmNode node) {
|
||||||
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||||
node.coord.latitude.abs() <= 90 &&
|
node.coord.latitude.abs() <= 90 &&
|
||||||
node.coord.longitude.abs() <= 180;
|
node.coord.longitude.abs() <= 180;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../../app_state.dart';
|
import '../../app_state.dart';
|
||||||
import '../../dev_config.dart';
|
import '../../dev_config.dart';
|
||||||
import '../../models/osm_camera_node.dart';
|
import '../../models/osm_node.dart';
|
||||||
|
|
||||||
/// Helper class to build direction cone polygons for cameras
|
/// Helper class to build direction cone polygons for cameras
|
||||||
class DirectionConesBuilder {
|
class DirectionConesBuilder {
|
||||||
static List<Polygon> buildDirectionCones({
|
static List<Polygon> buildDirectionCones({
|
||||||
required List<OsmCameraNode> cameras,
|
required List<OsmNode> cameras,
|
||||||
required double zoom,
|
required double zoom,
|
||||||
AddNodeSession? session,
|
AddNodeSession? session,
|
||||||
EditNodeSession? editSession,
|
EditNodeSession? editSession,
|
||||||
@@ -52,7 +52,7 @@ class DirectionConesBuilder {
|
|||||||
return overlays;
|
return overlays;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isValidCameraWithDirection(OsmCameraNode node) {
|
static bool _isValidCameraWithDirection(OsmNode node) {
|
||||||
return node.hasDirection &&
|
return node.hasDirection &&
|
||||||
node.directionDeg != null &&
|
node.directionDeg != null &&
|
||||||
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
(node.coord.latitude != 0 || node.coord.longitude != 0) &&
|
||||||
@@ -60,7 +60,7 @@ class DirectionConesBuilder {
|
|||||||
node.coord.longitude.abs() <= 180;
|
node.coord.longitude.abs() <= 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isPendingUpload(OsmCameraNode node) {
|
static bool _isPendingUpload(OsmNode node) {
|
||||||
return node.tags.containsKey('_pending_upload') &&
|
return node.tags.containsKey('_pending_upload') &&
|
||||||
node.tags['_pending_upload'] == 'true';
|
node.tags['_pending_upload'] == 'true';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../../dev_config.dart';
|
import '../../dev_config.dart';
|
||||||
import '../../app_state.dart' show FollowMeMode;
|
import '../../app_state.dart' show FollowMeMode;
|
||||||
|
import '../../services/proximity_alert_service.dart';
|
||||||
|
import '../../models/osm_node.dart';
|
||||||
|
import '../../models/node_profile.dart';
|
||||||
|
|
||||||
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
|
/// Manages GPS location tracking, follow-me modes, and location-based map animations.
|
||||||
/// Handles GPS permissions, position streams, and follow-me behavior.
|
/// Handles GPS permissions, position streams, and follow-me behavior.
|
||||||
@@ -81,6 +84,11 @@ class GpsController {
|
|||||||
required FollowMeMode followMeMode,
|
required FollowMeMode followMeMode,
|
||||||
required AnimatedMapController controller,
|
required AnimatedMapController controller,
|
||||||
required VoidCallback onLocationUpdated,
|
required VoidCallback onLocationUpdated,
|
||||||
|
// Optional parameters for proximity alerts
|
||||||
|
bool proximityAlertsEnabled = false,
|
||||||
|
int proximityAlertDistance = 200,
|
||||||
|
List<OsmNode> nearbyNodes = const [],
|
||||||
|
List<NodeProfile> enabledProfiles = const [],
|
||||||
}) {
|
}) {
|
||||||
final latLng = LatLng(position.latitude, position.longitude);
|
final latLng = LatLng(position.latitude, position.longitude);
|
||||||
_currentLatLng = latLng;
|
_currentLatLng = latLng;
|
||||||
@@ -88,6 +96,16 @@ class GpsController {
|
|||||||
// Notify that location was updated (for setState, etc.)
|
// Notify that location was updated (for setState, etc.)
|
||||||
onLocationUpdated();
|
onLocationUpdated();
|
||||||
|
|
||||||
|
// Check proximity alerts if enabled
|
||||||
|
if (proximityAlertsEnabled && nearbyNodes.isNotEmpty) {
|
||||||
|
ProximityAlertService().checkProximity(
|
||||||
|
userLocation: latLng,
|
||||||
|
nodes: nearbyNodes,
|
||||||
|
enabledProfiles: enabledProfiles,
|
||||||
|
alertDistance: proximityAlertDistance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle follow-me animations if enabled - use current mode from app state
|
// Handle follow-me animations if enabled - use current mode from app state
|
||||||
if (followMeMode != FollowMeMode.off) {
|
if (followMeMode != FollowMeMode.off) {
|
||||||
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode');
|
||||||
@@ -131,6 +149,10 @@ class GpsController {
|
|||||||
required AnimatedMapController controller,
|
required AnimatedMapController controller,
|
||||||
required VoidCallback onLocationUpdated,
|
required VoidCallback onLocationUpdated,
|
||||||
required FollowMeMode Function() getCurrentFollowMeMode,
|
required FollowMeMode Function() getCurrentFollowMeMode,
|
||||||
|
required bool Function() getProximityAlertsEnabled,
|
||||||
|
required int Function() getProximityAlertDistance,
|
||||||
|
required List<OsmNode> Function() getNearbyNodes,
|
||||||
|
required List<NodeProfile> Function() getEnabledProfiles,
|
||||||
}) async {
|
}) async {
|
||||||
final perm = await Geolocator.requestPermission();
|
final perm = await Geolocator.requestPermission();
|
||||||
if (perm == LocationPermission.denied ||
|
if (perm == LocationPermission.denied ||
|
||||||
@@ -142,11 +164,20 @@ class GpsController {
|
|||||||
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
_positionSub = Geolocator.getPositionStream().listen((Position position) {
|
||||||
// Get the current follow-me mode from the app state each time
|
// Get the current follow-me mode from the app state each time
|
||||||
final currentFollowMeMode = getCurrentFollowMeMode();
|
final currentFollowMeMode = getCurrentFollowMeMode();
|
||||||
|
final proximityAlertsEnabled = getProximityAlertsEnabled();
|
||||||
|
final proximityAlertDistance = getProximityAlertDistance();
|
||||||
|
final nearbyNodes = getNearbyNodes();
|
||||||
|
final enabledProfiles = getEnabledProfiles();
|
||||||
|
|
||||||
processPositionUpdate(
|
processPositionUpdate(
|
||||||
position: position,
|
position: position,
|
||||||
followMeMode: currentFollowMeMode,
|
followMeMode: currentFollowMeMode,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onLocationUpdated: onLocationUpdated,
|
onLocationUpdated: onLocationUpdated,
|
||||||
|
proximityAlertsEnabled: proximityAlertsEnabled,
|
||||||
|
proximityAlertDistance: proximityAlertDistance,
|
||||||
|
nearbyNodes: nearbyNodes,
|
||||||
|
enabledProfiles: enabledProfiles,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/offline_area_service.dart';
|
import '../services/offline_area_service.dart';
|
||||||
import '../services/network_status.dart';
|
import '../services/network_status.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../models/node_profile.dart';
|
import '../models/node_profile.dart';
|
||||||
import '../models/tile_provider.dart';
|
import '../models/tile_provider.dart';
|
||||||
import 'debouncer.dart';
|
import 'debouncer.dart';
|
||||||
@@ -21,8 +21,10 @@ import 'map/tile_layer_manager.dart';
|
|||||||
import 'map/camera_refresh_controller.dart';
|
import 'map/camera_refresh_controller.dart';
|
||||||
import 'map/gps_controller.dart';
|
import 'map/gps_controller.dart';
|
||||||
import 'network_status_indicator.dart';
|
import 'network_status_indicator.dart';
|
||||||
|
import 'proximity_alert_banner.dart';
|
||||||
import '../dev_config.dart';
|
import '../dev_config.dart';
|
||||||
import '../app_state.dart' show FollowMeMode;
|
import '../app_state.dart' show FollowMeMode;
|
||||||
|
import '../services/proximity_alert_service.dart';
|
||||||
|
|
||||||
class MapView extends StatefulWidget {
|
class MapView extends StatefulWidget {
|
||||||
final AnimatedMapController controller;
|
final AnimatedMapController controller;
|
||||||
@@ -55,6 +57,9 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Track zoom to clear queue on zoom changes
|
// Track zoom to clear queue on zoom changes
|
||||||
double? _lastZoom;
|
double? _lastZoom;
|
||||||
|
|
||||||
|
// State for proximity alert banner
|
||||||
|
bool _showProximityBanner = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -68,6 +73,17 @@ class MapViewState extends State<MapView> {
|
|||||||
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
_cameraController.initialize(onCamerasUpdated: _onCamerasUpdated);
|
||||||
_gpsController = GpsController();
|
_gpsController = GpsController();
|
||||||
|
|
||||||
|
// Initialize proximity alert service
|
||||||
|
ProximityAlertService().initialize(
|
||||||
|
onVisualAlert: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showProximityBanner = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Load last map position before initializing GPS
|
// Load last map position before initializing GPS
|
||||||
_positionManager.loadLastMapPosition().then((_) {
|
_positionManager.loadLastMapPosition().then((_) {
|
||||||
// Move to last known position after loading and widget is built
|
// Move to last known position after loading and widget is built
|
||||||
@@ -93,6 +109,59 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
return FollowMeMode.off;
|
return FollowMeMode.off;
|
||||||
},
|
},
|
||||||
|
getProximityAlertsEnabled: () {
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
return context.read<AppState>().proximityAlertsEnabled;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MapView] Could not read proximity alerts enabled: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
getProximityAlertDistance: () {
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
return context.read<AppState>().proximityAlertDistance;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MapView] Could not read proximity alert distance: $e');
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 200;
|
||||||
|
},
|
||||||
|
getNearbyNodes: () {
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
final cameraProvider = context.read<CameraProviderWithCache>();
|
||||||
|
LatLngBounds? mapBounds;
|
||||||
|
try {
|
||||||
|
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return mapBounds != null
|
||||||
|
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||||
|
: [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MapView] Could not get nearby nodes: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getEnabledProfiles: () {
|
||||||
|
if (mounted) {
|
||||||
|
try {
|
||||||
|
return context.read<AppState>().enabledProfiles;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[MapView] Could not read enabled profiles: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch initial cameras
|
// Fetch initial cameras
|
||||||
@@ -265,7 +334,7 @@ class MapViewState extends State<MapView> {
|
|||||||
}
|
}
|
||||||
final cameras = (mapBounds != null)
|
final cameras = (mapBounds != null)
|
||||||
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
? cameraProvider.getCachedNodesForBounds(mapBounds)
|
||||||
: <OsmCameraNode>[];
|
: <OsmNode>[];
|
||||||
|
|
||||||
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
final markers = CameraMarkersBuilder.buildCameraMarkers(
|
||||||
cameras: cameras,
|
cameras: cameras,
|
||||||
@@ -402,12 +471,22 @@ class MapViewState extends State<MapView> {
|
|||||||
|
|
||||||
// Network status indicator (top-left)
|
// Network status indicator (top-left)
|
||||||
const NetworkStatusIndicator(),
|
const NetworkStatusIndicator(),
|
||||||
|
|
||||||
|
// Proximity alert banner (top)
|
||||||
|
ProximityAlertBanner(
|
||||||
|
isVisible: _showProximityBanner,
|
||||||
|
onDismiss: () {
|
||||||
|
setState(() {
|
||||||
|
_showProximityBanner = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build polylines connecting original cameras to their edited positions
|
/// Build polylines connecting original cameras to their edited positions
|
||||||
List<Polyline> _buildEditLines(List<OsmCameraNode> cameras) {
|
List<Polyline> _buildEditLines(List<OsmNode> cameras) {
|
||||||
final lines = <Polyline>[];
|
final lines = <Polyline>[];
|
||||||
|
|
||||||
// Create a lookup map of original node IDs to their coordinates
|
// Create a lookup map of original node IDs to their coordinates
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/osm_camera_node.dart';
|
import '../models/osm_node.dart';
|
||||||
import '../app_state.dart';
|
import '../app_state.dart';
|
||||||
import '../services/localization_service.dart';
|
import '../services/localization_service.dart';
|
||||||
|
|
||||||
class NodeTagSheet extends StatelessWidget {
|
class NodeTagSheet extends StatelessWidget {
|
||||||
final OsmCameraNode node;
|
final OsmNode node;
|
||||||
|
|
||||||
const NodeTagSheet({super.key, required this.node});
|
const NodeTagSheet({super.key, required this.node});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Simple red banner that flashes briefly when proximity alert is triggered
|
||||||
|
/// Follows brutalist principles: simple, explicit functionality
|
||||||
|
class ProximityAlertBanner extends StatefulWidget {
|
||||||
|
final bool isVisible;
|
||||||
|
final VoidCallback? onDismiss;
|
||||||
|
|
||||||
|
const ProximityAlertBanner({
|
||||||
|
super.key,
|
||||||
|
required this.isVisible,
|
||||||
|
this.onDismiss,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProximityAlertBanner> createState() => _ProximityAlertBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProximityAlertBannerState extends State<ProximityAlertBanner>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animation = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ProximityAlertBanner oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (widget.isVisible != oldWidget.isVisible) {
|
||||||
|
if (widget.isVisible) {
|
||||||
|
_controller.forward();
|
||||||
|
// Auto-hide after 3 seconds
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) {
|
||||||
|
_controller.reverse().then((_) {
|
||||||
|
widget.onDismiss?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
if (_animation.value == 0.0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, -60 * (1 - _animation.value)),
|
||||||
|
child: Container(
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade600,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.3),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
_controller.reverse().then((_) {
|
||||||
|
widget.onDismiss?.call();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Surveillance device nearby',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.11"
|
||||||
desktop_webview_window:
|
desktop_webview_window:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -150,6 +158,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
version: "0.14.4"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "17.2.4"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.2.0"
|
||||||
flutter_map:
|
flutter_map:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -624,6 +656,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
http: ^1.2.1
|
http: ^1.2.1
|
||||||
flutter_svg: ^2.0.10
|
flutter_svg: ^2.0.10
|
||||||
xml: ^6.4.2
|
xml: ^6.4.2
|
||||||
|
flutter_local_notifications: ^17.2.2
|
||||||
|
|
||||||
# Auth, storage, prefs
|
# Auth, storage, prefs
|
||||||
oauth2_client: ^4.2.0
|
oauth2_client: ^4.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user