mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-09-25 02:30:43 +02:00
feat: Added Notifications to app
This commit is contained in:
committed by
Ryan Brown
parent
2c64fe610e
commit
51037f3710
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user