diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 408bae9..ecacc7e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,6 +17,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() @@ -50,3 +51,7 @@ flutter { source = "../.." } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c47f597..ffccb1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + + + _settingsState.maxCameras; UploadMode get uploadMode => _settingsState.uploadMode; FollowMeMode get followMeMode => _settingsState.followMeMode; + bool get proximityAlertsEnabled => _settingsState.proximityAlertsEnabled; + int get proximityAlertDistance => _settingsState.proximityAlertDistance; // Tile provider state List get tileProviders => _settingsState.tileProviders; @@ -162,7 +164,7 @@ class AppState extends ChangeNotifier { _sessionState.startAddSession(enabledProfiles); } - void startEditSession(OsmCameraNode node) { + void startEditSession(OsmNode node) { _sessionState.startEditSession(node, enabledProfiles); } @@ -218,7 +220,7 @@ class AppState extends ChangeNotifier { } } - void deleteNode(OsmCameraNode node) { + void deleteNode(OsmNode node) { _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode); _startUploader(); } @@ -270,6 +272,16 @@ class AppState extends ChangeNotifier { await _settingsState.setFollowMeMode(mode); } + /// Set proximity alerts enabled/disabled + Future setProximityAlertsEnabled(bool enabled) async { + await _settingsState.setProximityAlertsEnabled(enabled); + } + + /// Set proximity alert distance + Future setProximityAlertDistance(int distance) async { + await _settingsState.setProximityAlertDistance(distance); + } + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 843bfa9..5084fd7 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -46,6 +46,12 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); const Duration kFollowMeAnimationDuration = Duration(milliseconds: 600); const double kMinSpeedForRotationMps = 1.0; // Minimum speed (m/s) to apply rotation +// Proximity alerts configuration +const int kProximityAlertDefaultDistance = 200; // meters +const int kProximityAlertMinDistance = 50; // meters +const int kProximityAlertMaxDistance = 1000; // meters +const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node + // Last map location and settings storage const String kLastMapLatKey = 'last_map_latitude'; const String kLastMapLngKey = 'last_map_longitude'; diff --git a/lib/models/osm_camera_node.dart b/lib/models/osm_node.dart similarity index 91% rename from lib/models/osm_camera_node.dart rename to lib/models/osm_node.dart index dab9cd5..84c1a77 100644 --- a/lib/models/osm_camera_node.dart +++ b/lib/models/osm_node.dart @@ -1,11 +1,11 @@ import 'package:latlong2/latlong.dart'; -class OsmCameraNode { +class OsmNode { final int id; final LatLng coord; final Map tags; - OsmCameraNode({ + OsmNode({ required this.id, required this.coord, required this.tags, @@ -18,14 +18,14 @@ class OsmCameraNode { 'tags': tags, }; - factory OsmCameraNode.fromJson(Map json) { + factory OsmNode.fromJson(Map json) { final tags = {}; if (json['tags'] != null) { (json['tags'] as Map).forEach((k, v) { tags[k.toString()] = v.toString(); }); } - return OsmCameraNode( + return OsmNode( id: json['id'] is int ? json['id'] as int : int.tryParse(json['id'].toString()) ?? 0, coord: LatLng((json['lat'] as num).toDouble(), (json['lon'] as num).toDouble()), tags: tags, @@ -51,5 +51,4 @@ class OsmCameraNode { final normalized = ((val % 360) + 360) % 360; return normalized; } -} - +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9131e92..28fc96e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import 'settings_screen_sections/offline_areas_section.dart'; import 'settings_screen_sections/offline_mode_section.dart'; import 'settings_screen_sections/about_section.dart'; import 'settings_screen_sections/max_nodes_section.dart'; +import 'settings_screen_sections/proximity_alerts_section.dart'; import 'settings_screen_sections/tile_provider_section.dart'; import 'settings_screen_sections/language_section.dart'; import '../services/localization_service.dart'; @@ -40,6 +41,8 @@ class SettingsScreen extends StatelessWidget { const Divider(), const MaxNodesSection(), const Divider(), + const ProximityAlertsSection(), + const Divider(), const TileProviderSection(), const Divider(), const OfflineModeSection(), diff --git a/lib/screens/settings_screen_sections/proximity_alerts_section.dart b/lib/screens/settings_screen_sections/proximity_alerts_section.dart new file mode 100644 index 0000000..40ead36 --- /dev/null +++ b/lib/screens/settings_screen_sections/proximity_alerts_section.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../app_state.dart'; +import '../../services/localization_service.dart'; +import '../../dev_config.dart'; + +/// Settings section for proximity alerts configuration +/// Follows brutalist principles: simple, explicit UI that matches existing patterns +class ProximityAlertsSection extends StatefulWidget { + const ProximityAlertsSection({super.key}); + + @override + State createState() => _ProximityAlertsSectionState(); +} + +class _ProximityAlertsSectionState extends State { + late final TextEditingController _distanceController; + + @override + void initState() { + super.initState(); + final appState = context.read(); + _distanceController = TextEditingController( + text: appState.proximityAlertDistance.toString(), + ); + } + + @override + void dispose() { + _distanceController.dispose(); + super.dispose(); + } + + void _updateDistance(AppState appState) { + final text = _distanceController.text.trim(); + final distance = int.tryParse(text); + if (distance != null) { + appState.setProximityAlertDistance(distance); + } else { + // Reset to current value if invalid + _distanceController.text = appState.proximityAlertDistance.toString(); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Proximity Alerts', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Enable/disable toggle + SwitchListTile( + title: const Text('Enable proximity alerts'), + subtitle: const Text( + 'Get notified when approaching surveillance devices\n' + 'Uses extra battery for continuous location monitoring', + style: TextStyle(fontSize: 12), + ), + value: appState.proximityAlertsEnabled, + onChanged: (enabled) { + appState.setProximityAlertsEnabled(enabled); + }, + contentPadding: EdgeInsets.zero, + ), + + // Distance setting (only show when enabled) + if (appState.proximityAlertsEnabled) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Text('Alert distance: '), + SizedBox( + width: 80, + child: TextField( + controller: _distanceController, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _updateDistance(appState), + onEditingComplete: () => _updateDistance(appState), + ), + ), + const SizedBox(width: 8), + const Text('meters'), + ], + ), + const SizedBox(height: 8), + Text( + 'Range: $kProximityAlertMinDistance-$kProximityAlertMaxDistance meters (default: $kProximityAlertDefaultDistance)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6), + ), + ), + ], + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 9c5ad29..9b9bb83 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -3,7 +3,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter/foundation.dart'; import '../models/node_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; import 'map_data_submodules/nodes_from_overpass.dart'; import 'map_data_submodules/nodes_from_osm_api.dart'; @@ -35,7 +35,7 @@ class MapDataProvider { /// Fetch surveillance nodes from OSM/Overpass or local storage. /// Remote is default. If source is MapSource.auto, remote is tried first unless offline. - Future> getNodes({ + Future> getNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -70,7 +70,7 @@ class MapDataProvider { if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[MapDataProvider] Offline + Sandbox mode: returning no nodes (local cache is production data)'); - return []; + return []; } else { // Offline + Production = use local cache return fetchLocalNodes( @@ -90,7 +90,7 @@ class MapDataProvider { ); } else { // Production mode: fetch both remote and local, then merge with deduplication - final List>> futures = []; + final List>> futures = []; // Always try to get local nodes (fast, cached) futures.add(fetchLocalNodes( @@ -107,7 +107,7 @@ class MapDataProvider { maxResults: AppState.instance.maxCameras, ).catchError((e) { debugPrint('[MapDataProvider] Remote node fetch failed, error: $e. Continuing with local only.'); - return []; // Return empty list on remote failure + return []; // Return empty list on remote failure })); // Wait for both, then merge with deduplication by node ID @@ -116,7 +116,7 @@ class MapDataProvider { final remoteNodes = results[1]; // Merge with deduplication - prefer remote data over local for same node ID - final Map mergedNodes = {}; + final Map mergedNodes = {}; // Add local nodes first for (final node in localNodes) { @@ -140,7 +140,7 @@ class MapDataProvider { /// Bulk/paged node fetch for offline downloads (handling paging, dedup, and Overpass retries) /// Only use for offline area download, not for map browsing! Ignores maxCameras config. - Future> getAllNodesForDownload({ + Future> getAllNodesForDownload({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -214,7 +214,7 @@ class MapDataProvider { } /// Fetch remote nodes with Overpass first, OSM API fallback - Future> _fetchRemoteNodes({ + Future> _fetchRemoteNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, diff --git a/lib/services/map_data_submodules/nodes_from_local.dart b/lib/services/map_data_submodules/nodes_from_local.dart index 46ec4b4..4ca3728 100644 --- a/lib/services/map_data_submodules/nodes_from_local.dart +++ b/lib/services/map_data_submodules/nodes_from_local.dart @@ -3,19 +3,19 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../models/node_profile.dart'; import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; /// Fetch surveillance nodes from all offline areas intersecting the bounds/profile list. -Future> fetchLocalNodes({ +Future> fetchLocalNodes({ required LatLngBounds bounds, required List profiles, int? maxNodes, }) async { final areas = OfflineAreaService().offlineAreas; - final Map deduped = {}; + final Map deduped = {}; for (final area in areas) { if (area.status != OfflineAreaStatus.complete) continue; @@ -38,7 +38,7 @@ Future> fetchLocalNodes({ } // Try in-memory first, else load from disk -Future> _loadAreaNodes(OfflineArea area) async { +Future> _loadAreaNodes(OfflineArea area) async { if (area.nodes.isNotEmpty) { return area.nodes; } @@ -58,7 +58,7 @@ Future> _loadAreaNodes(OfflineArea area) async { try { final str = await fileToLoad.readAsString(); final jsonList = jsonDecode(str) as List; - return jsonList.map((e) => OsmCameraNode.fromJson(e)).toList(); + return jsonList.map((e) => OsmNode.fromJson(e)).toList(); } catch (e) { debugPrint('[_loadAreaNodes] Error loading nodes from ${fileToLoad.path}: $e'); } @@ -74,14 +74,14 @@ bool _pointInBounds(LatLng pt, LatLngBounds bounds) { pt.longitude <= bounds.northEast.longitude; } -bool _matchesAnyProfile(OsmCameraNode node, List profiles) { +bool _matchesAnyProfile(OsmNode node, List profiles) { for (final prof in profiles) { if (_nodeMatchesProfile(node, prof)) return true; } return false; } -bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) { +bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) { for (final e in profile.tags.entries) { if (node.tags[e.key] != e.value) return false; // All profile tags must match } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 01a8363..71a8aee 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -6,13 +6,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../network_status.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). -Future> fetchOsmApiNodes({ +Future> fetchOsmApiNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -47,7 +47,7 @@ Future> fetchOsmApiNodes({ // Parse XML response final document = XmlDocument.parse(response.body); - final nodes = []; + final nodes = []; // Find all node elements for (final nodeElement in document.findAllElements('node')) { @@ -73,7 +73,7 @@ Future> fetchOsmApiNodes({ // Check if this node matches any of our profiles if (_nodeMatchesProfiles(tags, profiles)) { - nodes.add(OsmCameraNode( + nodes.add(OsmNode( id: id, coord: LatLng(lat, lon), tags: tags, diff --git a/lib/services/map_data_submodules/nodes_from_overpass.dart b/lib/services/map_data_submodules/nodes_from_overpass.dart index 0437090..6eb4cc2 100644 --- a/lib/services/map_data_submodules/nodes_from_overpass.dart +++ b/lib/services/map_data_submodules/nodes_from_overpass.dart @@ -5,13 +5,13 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import '../../models/node_profile.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../../models/pending_upload.dart'; import '../../app_state.dart'; import '../network_status.dart'; /// Fetches surveillance nodes from the Overpass OSM API for the given bounds and profiles. -Future> fetchOverpassNodes({ +Future> fetchOverpassNodes({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, @@ -49,7 +49,7 @@ Future> fetchOverpassNodes({ NetworkStatus.instance.reportOverpassSuccess(); final nodes = elements.whereType>().map((element) { - return OsmCameraNode( + return OsmNode( id: element['id'], coord: LatLng(element['lat'], element['lon']), tags: Map.from(element['tags'] ?? {}), @@ -101,7 +101,7 @@ $outputClause } /// Clean up pending uploads that now appear in Overpass results -void _cleanupCompletedUploads(List overpassNodes) { +void _cleanupCompletedUploads(List overpassNodes) { try { final appState = AppState.instance; final pendingUploads = appState.pendingUploads; diff --git a/lib/services/node_cache.dart b/lib/services/node_cache.dart index 5aae931..df781b0 100644 --- a/lib/services/node_cache.dart +++ b/lib/services/node_cache.dart @@ -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 _nodes = {}; + final Map _nodes = {}; /// Add or update a batch of nodes in the cache. - void addOrUpdate(List nodes) { + void addOrUpdate(List 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 queryByBounds(LatLngBounds bounds) { + List queryByBounds(LatLngBounds bounds) { return _nodes.values .where((node) => _inBounds(node.coord, bounds)) .toList(); } /// Retrieve all cached nodes. - List getAll() => _nodes.values.toList(); + List getAll() => _nodes.values.toList(); /// Optionally clear the cache (rarely needed) void clear() => _nodes.clear(); @@ -53,7 +53,7 @@ class NodeCache { final cleanTags = Map.from(node.tags); cleanTags.remove('_pending_edit'); - _nodes[nodeId] = OsmCameraNode( + _nodes[nodeId] = OsmNode( id: node.id, coord: node.coord, tags: cleanTags, diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 952684f..33e5c09 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -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'; diff --git a/lib/services/offline_areas/offline_area_downloader.dart b/lib/services/offline_areas/offline_area_downloader.dart index fba5328..057bfab 100644 --- a/lib/services/offline_areas/offline_area_downloader.dart +++ b/lib/services/offline_areas/offline_area_downloader.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 saveNodes(List nodes, String dir) async { + static Future saveNodes(List nodes, String dir) async { final file = File('$dir/nodes.json'); await file.writeAsString(jsonEncode(nodes.map((n) => n.toJson()).toList())); } diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index f6f34c5..61906e7 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -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 nodes; + List 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'], diff --git a/lib/services/proximity_alert_service.dart b/lib/services/proximity_alert_service.dart new file mode 100644 index 0000000..4c420a2 --- /dev/null +++ b/lib/services/proximity_alert_service.dart @@ -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 _recentAlerts = []; + static const Duration _alertCooldown = kProximityAlertCooldown; + + // Callback for showing in-app visual alerts + VoidCallback? _onVisualAlert; + + /// Initialize the notification plugin + Future 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 checkProximity({ + required LatLng userLocation, + required List nodes, + required List 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 _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 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(); + } +} \ No newline at end of file diff --git a/lib/state/session_state.dart b/lib/state/session_state.dart index ab60d0c..7f3d562 100644 --- a/lib/state/session_state.dart +++ b/lib/state/session_state.dart @@ -3,7 +3,7 @@ import 'package:latlong2/latlong.dart'; import '../models/node_profile.dart'; import '../models/operator_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; // ------------------ AddNodeSession ------------------ class AddNodeSession { @@ -23,7 +23,7 @@ class EditNodeSession { required this.target, }); - final OsmCameraNode originalNode; // The original node being edited + final OsmNode originalNode; // The original node being edited NodeProfile profile; OperatorProfile? operatorProfile; double directionDegrees; @@ -48,7 +48,7 @@ class SessionState extends ChangeNotifier { notifyListeners(); } - void startEditSession(OsmCameraNode node, List enabledProfiles) { + void startEditSession(OsmNode node, List enabledProfiles) { final submittableProfiles = enabledProfiles.where((p) => p.isSubmittable).toList(); // Try to find a matching profile based on the node's tags diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 0857960..dea3475 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -24,11 +24,15 @@ class SettingsState extends ChangeNotifier { static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_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; int _maxCameras = 250; UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.northUp; + bool _proximityAlertsEnabled = false; + int _proximityAlertDistance = kProximityAlertDefaultDistance; List _tileProviders = []; String _selectedTileTypeId = ''; @@ -37,6 +41,8 @@ class SettingsState extends ChangeNotifier { int get maxCameras => _maxCameras; UploadMode get uploadMode => _uploadMode; FollowMeMode get followMeMode => _followMeMode; + bool get proximityAlertsEnabled => _proximityAlertsEnabled; + int get proximityAlertDistance => _proximityAlertDistance; List get tileProviders => List.unmodifiable(_tileProviders); String get selectedTileTypeId => _selectedTileTypeId; @@ -85,6 +91,10 @@ class SettingsState extends ChangeNotifier { _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) if (prefs.containsKey(_uploadModePrefsKey)) { final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; @@ -253,4 +263,26 @@ class SettingsState extends ChangeNotifier { } } + /// Set proximity alerts enabled/disabled + Future 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 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(); + } + } + } \ No newline at end of file diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 9700488..704164c 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/pending_upload.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../models/node_profile.dart'; import '../services/node_cache.dart'; import '../services/uploader.dart'; @@ -46,7 +46,7 @@ class UploadQueueState extends ChangeNotifier { final tags = upload.getCombinedTags(); tags['_pending_upload'] = 'true'; // Mark as pending for potential UI distinction - final tempNode = OsmCameraNode( + final tempNode = OsmNode( id: tempId, coord: upload.coord, tags: tags, @@ -80,7 +80,7 @@ class UploadQueueState extends ChangeNotifier { final originalTags = Map.from(session.originalNode.tags); originalTags['_pending_edit'] = 'true'; // Mark original as having pending edit - final originalNode = OsmCameraNode( + final originalNode = OsmNode( id: session.originalNode.id, coord: session.originalNode.coord, // Keep at original location tags: originalTags, @@ -92,7 +92,7 @@ class UploadQueueState extends ChangeNotifier { editedTags['_pending_upload'] = 'true'; // Mark as pending upload editedTags['_original_node_id'] = session.originalNode.id.toString(); // Track original for line drawing - final editedNode = OsmCameraNode( + final editedNode = OsmNode( id: tempId, coord: upload.coord, // At new location tags: editedTags, @@ -106,7 +106,7 @@ class UploadQueueState extends ChangeNotifier { } // 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( coord: node.coord, direction: node.directionDeg ?? 0, // Use existing direction or default to 0 @@ -123,7 +123,7 @@ class UploadQueueState extends ChangeNotifier { final deletionTags = Map.from(node.tags); deletionTags['_pending_deletion'] = 'true'; - final nodeWithDeletionTag = OsmCameraNode( + final nodeWithDeletionTag = OsmNode( id: node.id, coord: node.coord, tags: deletionTags, @@ -259,7 +259,7 @@ class UploadQueueState extends ChangeNotifier { // Create the node with real ID and clean tags (remove temp markers) final tags = item.getCombinedTags(); - final realNode = OsmCameraNode( + final realNode = OsmNode( id: realNodeId, coord: item.coord, tags: tags, // Clean tags without _pending_upload markers diff --git a/lib/widgets/camera_provider_with_cache.dart b/lib/widgets/camera_provider_with_cache.dart index 6e3ce12..2e81666 100644 --- a/lib/widgets/camera_provider_with_cache.dart +++ b/lib/widgets/camera_provider_with_cache.dart @@ -7,7 +7,7 @@ import '../services/map_data_provider.dart'; import '../services/node_cache.dart'; import '../services/network_status.dart'; import '../models/node_profile.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; /// 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. /// Filters by currently enabled profiles. - List getCachedNodesForBounds(LatLngBounds bounds) { + List getCachedNodesForBounds(LatLngBounds bounds) { final allNodes = NodeCache.instance.queryByBounds(bounds); final enabledProfiles = AppState.instance.enabledProfiles; @@ -79,7 +79,7 @@ class CameraProviderWithCache extends ChangeNotifier { } /// Check if a node matches any of the provided profiles - bool _matchesAnyProfile(OsmCameraNode node, List profiles) { + bool _matchesAnyProfile(OsmNode node, List profiles) { for (final profile in profiles) { 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) - bool _nodeMatchesProfile(OsmCameraNode node, NodeProfile profile) { + bool _nodeMatchesProfile(OsmNode node, NodeProfile profile) { for (final entry in profile.tags.entries) { if (node.tags[entry.key] != entry.value) return false; } diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 8de43fa..7672f94 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -4,13 +4,13 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; -import '../../models/osm_camera_node.dart'; +import '../../models/osm_node.dart'; import '../node_tag_sheet.dart'; import '../camera_icon.dart'; /// Smart marker widget for camera with single/double tap distinction class CameraMapMarker extends StatefulWidget { - final OsmCameraNode node; + final OsmNode node; final MapController mapController; const CameraMapMarker({required this.node, required this.mapController, Key? key}) : super(key: key); @@ -76,7 +76,7 @@ class _CameraMapMarkerState extends State { /// Helper class to build marker layers for cameras and user location class CameraMarkersBuilder { static List buildCameraMarkers({ - required List cameras, + required List cameras, required MapController mapController, LatLng? userLocation, }) { @@ -104,7 +104,7 @@ class CameraMarkersBuilder { return markers; } - static bool _isValidCameraCoordinate(OsmCameraNode node) { + static bool _isValidCameraCoordinate(OsmNode node) { return (node.coord.latitude != 0 || node.coord.longitude != 0) && node.coord.latitude.abs() <= 90 && node.coord.longitude.abs() <= 180; diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index a43d1ff..f8393d9 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -5,12 +5,12 @@ import 'package:latlong2/latlong.dart'; import '../../app_state.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 class DirectionConesBuilder { static List buildDirectionCones({ - required List cameras, + required List cameras, required double zoom, AddNodeSession? session, EditNodeSession? editSession, @@ -52,7 +52,7 @@ class DirectionConesBuilder { return overlays; } - static bool _isValidCameraWithDirection(OsmCameraNode node) { + static bool _isValidCameraWithDirection(OsmNode node) { return node.hasDirection && node.directionDeg != null && (node.coord.latitude != 0 || node.coord.longitude != 0) && @@ -60,7 +60,7 @@ class DirectionConesBuilder { node.coord.longitude.abs() <= 180; } - static bool _isPendingUpload(OsmCameraNode node) { + static bool _isPendingUpload(OsmNode node) { return node.tags.containsKey('_pending_upload') && node.tags['_pending_upload'] == 'true'; } diff --git a/lib/widgets/map/gps_controller.dart b/lib/widgets/map/gps_controller.dart index c8a3033..570debd 100644 --- a/lib/widgets/map/gps_controller.dart +++ b/lib/widgets/map/gps_controller.dart @@ -6,6 +6,9 @@ import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; 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. /// Handles GPS permissions, position streams, and follow-me behavior. @@ -81,6 +84,11 @@ class GpsController { required FollowMeMode followMeMode, required AnimatedMapController controller, required VoidCallback onLocationUpdated, + // Optional parameters for proximity alerts + bool proximityAlertsEnabled = false, + int proximityAlertDistance = 200, + List nearbyNodes = const [], + List enabledProfiles = const [], }) { final latLng = LatLng(position.latitude, position.longitude); _currentLatLng = latLng; @@ -88,6 +96,16 @@ class GpsController { // Notify that location was updated (for setState, etc.) 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 if (followMeMode != FollowMeMode.off) { debugPrint('[GpsController] GPS position update: ${latLng.latitude}, ${latLng.longitude}, follow-me: $followMeMode'); @@ -131,6 +149,10 @@ class GpsController { required AnimatedMapController controller, required VoidCallback onLocationUpdated, required FollowMeMode Function() getCurrentFollowMeMode, + required bool Function() getProximityAlertsEnabled, + required int Function() getProximityAlertDistance, + required List Function() getNearbyNodes, + required List Function() getEnabledProfiles, }) async { final perm = await Geolocator.requestPermission(); if (perm == LocationPermission.denied || @@ -142,11 +164,20 @@ class GpsController { _positionSub = Geolocator.getPositionStream().listen((Position position) { // Get the current follow-me mode from the app state each time final currentFollowMeMode = getCurrentFollowMeMode(); + final proximityAlertsEnabled = getProximityAlertsEnabled(); + final proximityAlertDistance = getProximityAlertDistance(); + final nearbyNodes = getNearbyNodes(); + final enabledProfiles = getEnabledProfiles(); + processPositionUpdate( position: position, followMeMode: currentFollowMeMode, controller: controller, onLocationUpdated: onLocationUpdated, + proximityAlertsEnabled: proximityAlertsEnabled, + proximityAlertDistance: proximityAlertDistance, + nearbyNodes: nearbyNodes, + enabledProfiles: enabledProfiles, ); }); } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 36e81c3..47e90f8 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../services/offline_area_service.dart'; import '../services/network_status.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../models/node_profile.dart'; import '../models/tile_provider.dart'; import 'debouncer.dart'; @@ -21,8 +21,10 @@ import 'map/tile_layer_manager.dart'; import 'map/camera_refresh_controller.dart'; import 'map/gps_controller.dart'; import 'network_status_indicator.dart'; +import 'proximity_alert_banner.dart'; import '../dev_config.dart'; import '../app_state.dart' show FollowMeMode; +import '../services/proximity_alert_service.dart'; class MapView extends StatefulWidget { final AnimatedMapController controller; @@ -55,6 +57,9 @@ class MapViewState extends State { // Track zoom to clear queue on zoom changes double? _lastZoom; + + // State for proximity alert banner + bool _showProximityBanner = false; @override void initState() { @@ -68,6 +73,17 @@ class MapViewState extends State { _cameraController.initialize(onCamerasUpdated: _onCamerasUpdated); _gpsController = GpsController(); + // Initialize proximity alert service + ProximityAlertService().initialize( + onVisualAlert: () { + if (mounted) { + setState(() { + _showProximityBanner = true; + }); + } + }, + ); + // Load last map position before initializing GPS _positionManager.loadLastMapPosition().then((_) { // Move to last known position after loading and widget is built @@ -93,6 +109,59 @@ class MapViewState extends State { } return FollowMeMode.off; }, + getProximityAlertsEnabled: () { + if (mounted) { + try { + return context.read().proximityAlertsEnabled; + } catch (e) { + debugPrint('[MapView] Could not read proximity alerts enabled: $e'); + return false; + } + } + return false; + }, + getProximityAlertDistance: () { + if (mounted) { + try { + return context.read().proximityAlertDistance; + } catch (e) { + debugPrint('[MapView] Could not read proximity alert distance: $e'); + return 200; + } + } + return 200; + }, + getNearbyNodes: () { + if (mounted) { + try { + final cameraProvider = context.read(); + 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().enabledProfiles; + } catch (e) { + debugPrint('[MapView] Could not read enabled profiles: $e'); + return []; + } + } + return []; + }, ); // Fetch initial cameras @@ -265,7 +334,7 @@ class MapViewState extends State { } final cameras = (mapBounds != null) ? cameraProvider.getCachedNodesForBounds(mapBounds) - : []; + : []; final markers = CameraMarkersBuilder.buildCameraMarkers( cameras: cameras, @@ -402,12 +471,22 @@ class MapViewState extends State { // Network status indicator (top-left) const NetworkStatusIndicator(), + + // Proximity alert banner (top) + ProximityAlertBanner( + isVisible: _showProximityBanner, + onDismiss: () { + setState(() { + _showProximityBanner = false; + }); + }, + ), ], ); } /// Build polylines connecting original cameras to their edited positions - List _buildEditLines(List cameras) { + List _buildEditLines(List cameras) { final lines = []; // Create a lookup map of original node IDs to their coordinates diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart index 32a0cd5..0774bf4 100644 --- a/lib/widgets/node_tag_sheet.dart +++ b/lib/widgets/node_tag_sheet.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/osm_camera_node.dart'; +import '../models/osm_node.dart'; import '../app_state.dart'; import '../services/localization_service.dart'; class NodeTagSheet extends StatelessWidget { - final OsmCameraNode node; + final OsmNode node; const NodeTagSheet({super.key, required this.node}); diff --git a/lib/widgets/proximity_alert_banner.dart b/lib/widgets/proximity_alert_banner.dart new file mode 100644 index 0000000..154f3c7 --- /dev/null +++ b/lib/widgets/proximity_alert_banner.dart @@ -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 createState() => _ProximityAlertBannerState(); +} + +class _ProximityAlertBannerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _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, + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index b522d63..2285fa5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -150,6 +158,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -624,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d8f8ed9..2f10f15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: http: ^1.2.1 flutter_svg: ^2.0.10 xml: ^6.4.2 + flutter_local_notifications: ^17.2.2 # Auth, storage, prefs oauth2_client: ^4.2.0