feat: Added Notifications to app

This commit is contained in:
Joeseph Ferland
2026-08-27 10:55:11 -07:00
committed by Ryan Brown
parent 2c64fe610e
commit 51037f3710
21 changed files with 681 additions and 202 deletions
+40
View File
@@ -0,0 +1,40 @@
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:latlong2/latlong.dart';
/// Mean meters per degree of latitude. Constant enough at any latitude for
/// picking a node lookup box.
const double _metersPerDegreeLat = 111320.0;
/// Smallest cos(latitude) we will divide by. Past ~89.4° the true factor tends
/// to zero and the longitude span would blow up to the whole globe.
const double _minCosLatitude = 0.01;
/// A square of roughly [radiusMeters] in every direction around [center].
///
/// Used to pull cached nodes near a GPS position, as opposed to near whatever
/// the map camera is showing — the camera is frozen while the app is
/// backgrounded, so it is not a usable proxy for where the user is.
///
/// A degree of longitude shrinks by cos(latitude), so the box is widened east
/// to west to keep the real-world radius honest as you move away from the
/// equator.
LatLngBounds boundsAround(LatLng center, num radiusMeters) {
final radius = radiusMeters.toDouble().abs();
final latDelta = radius / _metersPerDegreeLat;
final cosLat = math.cos(center.latitude * math.pi / 180).abs();
final lngDelta =
radius / (_metersPerDegreeLat * math.max(cosLat, _minCosLatitude));
return LatLngBounds(
LatLng(
(center.latitude - latDelta).clamp(-90.0, 90.0),
(center.longitude - lngDelta).clamp(-180.0, 180.0),
),
LatLng(
(center.latitude + latDelta).clamp(-90.0, 90.0),
(center.longitude + lngDelta).clamp(-180.0, 180.0),
),
);
}
+162 -76
View File
@@ -12,7 +12,7 @@ import '../dev_config.dart';
class RecentAlert {
final int nodeId;
final DateTime alertTime;
RecentAlert({required this.nodeId, required this.alertTime});
}
@@ -24,125 +24,189 @@ class ProximityAlertService {
ProximityAlertService._internal();
FlutterLocalNotificationsPlugin? _notifications;
bool _isInitialized = false;
/// Whether the plugin itself is usable. Deliberately NOT derived from
/// initialize()'s return value: on iOS that reports the outcome of the
/// permission request, which we intentionally disable below, so it returns
/// false even though the plugin works fine. Gating on it made every
/// notification path dead on iOS.
bool _pluginReady = false;
/// Guards against re-initializing on every settings visit.
Future<void>? _initFuture;
// 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 and request permissions
Future<void> initialize({VoidCallback? onVisualAlert}) async {
_onVisualAlert = onVisualAlert;
/// Initialize the notification plugin. Permissions are requested separately,
/// on demand, when the user enables proximity alerts.
Future<void> initialize({VoidCallback? onVisualAlert}) {
if (onVisualAlert != null) _onVisualAlert = onVisualAlert;
return _initFuture ??= _doInitialize();
}
Future<void> _doInitialize() async {
_notifications = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
final initialized = await _notifications!.initialize(initSettings);
_isInitialized = initialized ?? false;
// Note: We don't request notification permissions here anymore.
// Permissions are requested on-demand when user enables proximity alerts.
debugPrint('[ProximityAlertService] Initialized: $_isInitialized (permissions deferred)');
await _notifications!.initialize(initSettings);
// Completing without throwing is the real signal that the plugin is up.
_pluginReady = true;
debugPrint('[ProximityAlertService] Plugin ready (permissions deferred)');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_isInitialized = false;
_pluginReady = false;
_initFuture = null; // Allow a later retry
}
}
/// Ensure the plugin is up before touching it, so callers that arrive before
/// MapView's fire-and-forget initialize() completes still work.
Future<bool> _ensureReady() async {
if (_pluginReady) return true;
await initialize();
return _pluginReady;
}
/// Request notification permissions on both platforms
/// Request notification permissions on both platforms.
///
/// Note that on iOS the system prompt appears only ONCE, ever. After the
/// user has answered it, this call returns silently without showing
/// anything — which is why callers must check the result and fall back to
/// sending the user to system settings.
Future<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
// Request permissions - this will show the permission dialog on Android 13+
final result = await _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android notification permission result: $result');
// Also request for iOS (though this was already done in initialization)
await _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
final android = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final result = await android.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android permission result: $result');
return;
}
final ios = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (ios != null) {
final result = await ios.requestPermissions(
alert: true,
badge: true,
sound: true,
);
debugPrint('[ProximityAlertService] iOS permission result: $result');
}
} catch (e) {
debugPrint('[ProximityAlertService] Failed to request permissions: $e');
}
}
/// Open the OS settings page for this app, so the user can grant
/// notifications after the one-shot iOS prompt has already been answered.
/// Uses Geolocator's platform channel purely as a way to open app settings;
/// it is not location-specific.
Future<bool> openSystemSettings() async {
try {
return await Geolocator.openAppSettings();
} catch (e) {
debugPrint('[ProximityAlertService] Failed to open app settings: $e');
return false;
}
}
/// Check proximity to nodes and trigger alerts if needed
/// This should be called on GPS position updates
/// 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;
if (!_pluginReady || 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
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) continue;
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(),
));
_recentAlerts.add(RecentAlert(nodeId: node.id, alertTime: DateTime.now()));
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
}
}
}
/// Notification IDs must fit in a signed 32-bit int, but OSM node IDs are
/// well past that (currently ~12 billion), so passing one through raises
/// "must fit within the size of a 32-bit integer" and the notification is
/// dropped. Fold to a stable positive 31-bit value instead.
///
/// Deterministic across runs, so re-alerting for the same node replaces the
/// existing notification rather than stacking a duplicate.
static int _notificationId(int nodeId) => nodeId.abs() % 0x7FFFFFFF;
/// Show push notification for proximity alert
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
if (!_isInitialized || _notifications == null) return;
await _show(
id: _notificationId(node.id),
title: 'Surveillance Device Nearby',
body: '$nodeType detected ${distance}m ahead',
);
}
Future<void> _show({
required int id,
required String title,
required String body,
}) async {
if (!_pluginReady || _notifications == null) {
debugPrint('[ProximityAlertService] NOT SHOWN: plugin not ready');
return;
}
// A notification that silently no-ops because permission was never granted
// is indistinguishable from one that was never triggered. Say which.
if (!await areNotificationsEnabled()) {
debugPrint(
'[ProximityAlertService] NOT SHOWN: OS notification permission not '
'granted. Settings → Proximity Alerts → Enable Notifications.',
);
return;
}
const androidDetails = AndroidNotificationDetails(
'proximity_alerts',
'Proximity Alerts',
@@ -154,7 +218,13 @@ class ProximityAlertService {
);
const iosDetails = DarwinNotificationDetails(
// presentAlert is dead on iOS 14+: the plugin's willPresentNotification
// only consults presentBanner/presentList there and ignores presentAlert
// entirely. Setting just presentAlert produced no visible banner at all.
// Keep it for iOS 13 and below, but presentBanner is what actually works.
presentAlert: true,
presentBanner: true,
presentList: true,
presentBadge: false,
presentSound: true,
);
@@ -164,16 +234,16 @@ class ProximityAlertService {
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
id, // Use node ID as notification ID
title,
body,
notificationDetails,
);
debugPrint('[ProximityAlertService] SHOWN id=$id "$title" — $body');
} on ArgumentError catch (e) {
debugPrint('[ProximityAlertService] Rejected notification id=$id: $e');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to show notification: $e');
}
@@ -226,30 +296,46 @@ class ProximityAlertService {
_recentAlerts.clear();
}
/// Check if notification permissions are granted
/// Check if notification permissions are granted.
Future<bool> areNotificationsEnabled() async {
if (!_isInitialized || _notifications == null) return false;
if (!await _ensureReady() || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
final result = await androidImpl.areNotificationsEnabled();
return result ?? false;
}
// For iOS, assume enabled if we got this far (permissions were requested during init)
return true;
// iOS: actually ask the system rather than assuming. The old code
// returned a blind `true` here, so the UI could never tell whether
// notifications were really authorized.
final iosImpl = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosImpl != null) {
final options = await iosImpl.checkPermissions();
if (options == null) return false;
return options.isEnabled || options.isProvisionalEnabled;
}
return false;
} catch (e) {
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
return false;
}
}
/// Request permissions again (can be called from settings)
/// Request permissions and report whether they ended up granted.
///
/// A `false` result does NOT mean the prompt was declined just now — on iOS
/// the prompt may not have appeared at all because it was already answered
/// in a previous launch. Callers should offer [openSystemSettings] on false.
Future<bool> requestNotificationPermissions() async {
if (!await _ensureReady()) return false;
await _requestNotificationPermissions();
return await areNotificationsEnabled();
final enabled = await areNotificationsEnabled();
debugPrint('[ProximityAlertService] Permissions after request: $enabled');
return enabled;
}
}