mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-06-05 14:38:01 +02:00
More camera -> node, notifications for approaching
This commit is contained in:
@@ -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<List<OsmCameraNode>> getNodes({
|
||||
Future<List<OsmNode>> getNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> 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 <OsmCameraNode>[];
|
||||
return <OsmNode>[];
|
||||
} 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<Future<List<OsmCameraNode>>> futures = [];
|
||||
final List<Future<List<OsmNode>>> 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 <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
|
||||
@@ -116,7 +116,7 @@ class MapDataProvider {
|
||||
final remoteNodes = results[1];
|
||||
|
||||
// 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
|
||||
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<List<OsmCameraNode>> getAllNodesForDownload({
|
||||
Future<List<OsmNode>> getAllNodesForDownload({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
@@ -214,7 +214,7 @@ class MapDataProvider {
|
||||
}
|
||||
|
||||
/// Fetch remote nodes with Overpass first, OSM API fallback
|
||||
Future<List<OsmCameraNode>> _fetchRemoteNodes({
|
||||
Future<List<OsmNode>> _fetchRemoteNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
|
||||
@@ -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<List<OsmCameraNode>> fetchLocalNodes({
|
||||
Future<List<OsmNode>> fetchLocalNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
int? maxNodes,
|
||||
}) async {
|
||||
final areas = OfflineAreaService().offlineAreas;
|
||||
final Map<int, OsmCameraNode> deduped = {};
|
||||
final Map<int, OsmNode> deduped = {};
|
||||
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
@@ -38,7 +38,7 @@ Future<List<OsmCameraNode>> fetchLocalNodes({
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return area.nodes;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ Future<List<OsmCameraNode>> _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<NodeProfile> profiles) {
|
||||
bool _matchesAnyProfile(OsmNode node, List<NodeProfile> 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
|
||||
}
|
||||
|
||||
@@ -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<List<OsmCameraNode>> fetchOsmApiNodes({
|
||||
Future<List<OsmNode>> fetchOsmApiNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
@@ -47,7 +47,7 @@ Future<List<OsmCameraNode>> fetchOsmApiNodes({
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = <OsmCameraNode>[];
|
||||
final nodes = <OsmNode>[];
|
||||
|
||||
// Find all node elements
|
||||
for (final nodeElement in document.findAllElements('node')) {
|
||||
@@ -73,7 +73,7 @@ Future<List<OsmCameraNode>> 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,
|
||||
|
||||
@@ -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<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
Future<List<OsmNode>> fetchOverpassNodes({
|
||||
required LatLngBounds bounds,
|
||||
required List<NodeProfile> profiles,
|
||||
UploadMode uploadMode = UploadMode.production,
|
||||
@@ -49,7 +49,7 @@ Future<List<OsmCameraNode>> fetchOverpassNodes({
|
||||
NetworkStatus.instance.reportOverpassSuccess();
|
||||
|
||||
final nodes = elements.whereType<Map<String, dynamic>>().map((element) {
|
||||
return OsmCameraNode(
|
||||
return OsmNode(
|
||||
id: element['id'],
|
||||
coord: LatLng(element['lat'], element['lon']),
|
||||
tags: Map<String, String>.from(element['tags'] ?? {}),
|
||||
@@ -101,7 +101,7 @@ $outputClause
|
||||
}
|
||||
|
||||
/// Clean up pending uploads that now appear in Overpass results
|
||||
void _cleanupCompletedUploads(List<OsmCameraNode> overpassNodes) {
|
||||
void _cleanupCompletedUploads(List<OsmNode> overpassNodes) {
|
||||
try {
|
||||
final appState = AppState.instance;
|
||||
final pendingUploads = appState.pendingUploads;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
||||
class NodeCache {
|
||||
@@ -8,10 +8,10 @@ class NodeCache {
|
||||
factory NodeCache() => instance;
|
||||
NodeCache._internal();
|
||||
|
||||
final Map<int, OsmCameraNode> _nodes = {};
|
||||
final Map<int, OsmNode> _nodes = {};
|
||||
|
||||
/// 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) {
|
||||
final existing = _nodes[node.id];
|
||||
if (existing != null) {
|
||||
@@ -22,7 +22,7 @@ class NodeCache {
|
||||
mergedTags[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
_nodes[node.id] = OsmCameraNode(
|
||||
_nodes[node.id] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: mergedTags,
|
||||
@@ -34,14 +34,14 @@ class NodeCache {
|
||||
}
|
||||
|
||||
/// Query for all cached nodes currently within the given LatLngBounds.
|
||||
List<OsmCameraNode> queryByBounds(LatLngBounds bounds) {
|
||||
List<OsmNode> queryByBounds(LatLngBounds bounds) {
|
||||
return _nodes.values
|
||||
.where((node) => _inBounds(node.coord, bounds))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Retrieve all cached nodes.
|
||||
List<OsmCameraNode> getAll() => _nodes.values.toList();
|
||||
List<OsmNode> getAll() => _nodes.values.toList();
|
||||
|
||||
/// Optionally clear the cache (rarely needed)
|
||||
void clear() => _nodes.clear();
|
||||
@@ -53,7 +53,7 @@ class NodeCache {
|
||||
final cleanTags = Map<String, String>.from(node.tags);
|
||||
cleanTags.remove('_pending_edit');
|
||||
|
||||
_nodes[nodeId] = OsmCameraNode(
|
||||
_nodes[nodeId] = OsmNode(
|
||||
id: node.id,
|
||||
coord: node.coord,
|
||||
tags: cleanTags,
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'offline_areas/offline_area_models.dart';
|
||||
import 'offline_areas/offline_tile_utils.dart';
|
||||
import 'offline_areas/offline_area_downloader.dart';
|
||||
|
||||
import '../models/osm_camera_node.dart';
|
||||
import '../models/osm_node.dart';
|
||||
import '../app_state.dart';
|
||||
import 'map_data_provider.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 '../../app_state.dart';
|
||||
import '../../models/osm_camera_node.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../map_data_provider.dart';
|
||||
import 'offline_area_models.dart';
|
||||
import 'offline_tile_utils.dart';
|
||||
@@ -182,7 +182,7 @@ class OfflineAreaDownloader {
|
||||
}
|
||||
|
||||
/// 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');
|
||||
await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -17,7 +17,7 @@ class OfflineArea {
|
||||
double progress; // 0.0 - 1.0
|
||||
int tilesDownloaded;
|
||||
int tilesTotal;
|
||||
List<OsmCameraNode> nodes;
|
||||
List<OsmNode> nodes;
|
||||
int sizeBytes; // Disk size in bytes
|
||||
final bool isPermanent; // Not user-deletable if true
|
||||
|
||||
@@ -88,7 +88,7 @@ class OfflineArea {
|
||||
tilesDownloaded: json['tilesDownloaded'] ?? 0,
|
||||
tilesTotal: json['tilesTotal'] ?? 0,
|
||||
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,
|
||||
isPermanent: json['isPermanent'] ?? false,
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user