mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-09-23 01:30:42 +02:00
feat: Added Notifications to app
This commit is contained in:
committed by
Ryan Brown
parent
2c64fe610e
commit
51037f3710
@@ -9,6 +9,7 @@ import '../../dev_config.dart';
|
||||
import '../../app_state.dart' show FollowMeMode;
|
||||
import '../../services/proximity_alert_service.dart';
|
||||
import '../../services/coordinate_validation.dart';
|
||||
import '../../services/localization_service.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../models/node_profile.dart';
|
||||
|
||||
@@ -21,7 +22,11 @@ import '../../models/node_profile.dart';
|
||||
class GpsController {
|
||||
StreamSubscription<Position>? _positionSub;
|
||||
Timer? _retryTimer;
|
||||
|
||||
|
||||
/// Whether the live stream was started with background delivery armed, so a
|
||||
/// proximity-alerts toggle knows whether it actually needs to rebuild it.
|
||||
bool _streamIsBackgroundCapable = false;
|
||||
|
||||
// Location state
|
||||
LatLng? _currentLocation;
|
||||
bool _hasLocation = false;
|
||||
@@ -32,7 +37,7 @@ class GpsController {
|
||||
FollowMeMode Function()? _getCurrentFollowMeMode;
|
||||
bool Function()? _getProximityAlertsEnabled;
|
||||
int Function()? _getProximityAlertDistance;
|
||||
List<OsmNode> Function()? _getNearbyNodes;
|
||||
List<OsmNode> Function(LatLng userLocation, int radiusMeters)? _getNearbyNodes;
|
||||
List<NodeProfile> Function()? _getEnabledProfiles;
|
||||
VoidCallback? _onMapMovedProgrammatically;
|
||||
bool Function()? _isUserInteracting;
|
||||
@@ -50,7 +55,7 @@ class GpsController {
|
||||
required FollowMeMode Function() getCurrentFollowMeMode,
|
||||
required bool Function() getProximityAlertsEnabled,
|
||||
required int Function() getProximityAlertDistance,
|
||||
required List<OsmNode> Function() getNearbyNodes,
|
||||
required List<OsmNode> Function(LatLng userLocation, int radiusMeters) getNearbyNodes,
|
||||
required List<NodeProfile> Function() getEnabledProfiles,
|
||||
VoidCallback? onMapMovedProgrammatically,
|
||||
bool Function()? isUserInteracting,
|
||||
@@ -86,6 +91,38 @@ class GpsController {
|
||||
_handleInitialFollowMeAnimation(newMode, oldMode);
|
||||
}
|
||||
|
||||
/// Force a fresh fix, e.g. when the app returns from the background.
|
||||
///
|
||||
/// The position stream can stay silent after a resume — and a simulated
|
||||
/// location that "teleported" while the app was suspended produces no
|
||||
/// movement event at all — leaving _currentLocation stale, so proximity
|
||||
/// alerts would keep evaluating against the old position.
|
||||
Future<void> refreshLocation() async {
|
||||
debugPrint('[GpsController] Refreshing location after resume');
|
||||
|
||||
// Restart the stream first: iOS often stops delivering to a subscription
|
||||
// that spanned suspension.
|
||||
if (_positionSub != null) {
|
||||
_stopLocationTracking();
|
||||
_startPositionStream();
|
||||
}
|
||||
|
||||
try {
|
||||
// geolocator 10.x: getCurrentPosition takes desiredAccuracy, unlike
|
||||
// getPositionStream above which takes a LocationSettings object.
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
// Match the stream's forceLocationManager: true, so the one-shot and
|
||||
// the stream cannot disagree about where we are on Android.
|
||||
forceAndroidLocationManager: true,
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
);
|
||||
_onPositionReceived(position);
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] Failed to refresh location: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Manual retry (e.g., user pressed follow-me button)
|
||||
Future<void> retryLocationInit() async {
|
||||
debugPrint('[GpsController] Manual retry of location initialization');
|
||||
@@ -149,24 +186,29 @@ class GpsController {
|
||||
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
|
||||
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
|
||||
|
||||
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
|
||||
// Both platforms stop feeding a backgrounded app by default, which is why
|
||||
// alerts only ever fired with the app on screen. Keeping the stream alive
|
||||
// in the background is visible to the user (persistent notification on
|
||||
// Android, blue status bar on iOS), so only ask for it when proximity
|
||||
// alerts are actually switched on.
|
||||
final background = _getProximityAlertsEnabled?.call() ?? false;
|
||||
|
||||
debugPrint(
|
||||
'[GpsController] Starting GPS position stream '
|
||||
'(${distanceFilter}m filter, background=$background)',
|
||||
);
|
||||
|
||||
try {
|
||||
_positionSub = Geolocator.getPositionStream(
|
||||
locationSettings: defaultTargetPlatform == TargetPlatform.android
|
||||
? AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
forceLocationManager: true,
|
||||
)
|
||||
: LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
),
|
||||
locationSettings: _buildLocationSettings(
|
||||
distanceFilter: distanceFilter,
|
||||
background: background,
|
||||
),
|
||||
).listen(
|
||||
_onPositionReceived,
|
||||
onError: _onPositionError,
|
||||
);
|
||||
_streamIsBackgroundCapable = background;
|
||||
} catch (e) {
|
||||
debugPrint('[GpsController] Failed to start position stream: $e');
|
||||
_hasLocation = false;
|
||||
@@ -175,6 +217,79 @@ class GpsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-specific location settings.
|
||||
///
|
||||
/// When [background] is false these are exactly the old foreground-only
|
||||
/// settings, so nothing changes for users who leave proximity alerts off.
|
||||
LocationSettings _buildLocationSettings({
|
||||
required int distanceFilter,
|
||||
required bool background,
|
||||
}) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
return AndroidSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
forceLocationManager: true,
|
||||
// Supplying this config is what makes geolocator run its
|
||||
// GeolocatorLocationService as a foreground service. Without it
|
||||
// Android throttles a backgrounded app to a few updates per hour and
|
||||
// then stops entirely, so alerts never fire.
|
||||
foregroundNotificationConfig: background
|
||||
? ForegroundNotificationConfig(
|
||||
notificationTitle:
|
||||
locService.t('proximityAlerts.backgroundServiceTitle'),
|
||||
notificationText:
|
||||
locService.t('proximityAlerts.backgroundServiceText'),
|
||||
notificationChannelName:
|
||||
locService.t('proximityAlerts.backgroundServiceChannel'),
|
||||
// Without a wake lock the system sleeps and delivers the
|
||||
// queued positions in one burst on wake — far too late to warn
|
||||
// anyone about a device they already drove past.
|
||||
enableWakeLock: true,
|
||||
setOngoing: true,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
return AppleSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
// Pairs with UIBackgroundModes=location in Info.plist. Together they
|
||||
// stop iOS from suspending the app on background, which is what killed
|
||||
// the position stream (and therefore every alert) before.
|
||||
allowBackgroundLocationUpdates: background,
|
||||
// iOS otherwise pauses updates when it decides you have stopped moving
|
||||
// and never reliably resumes them — a silent death for alerts.
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
// The blue status bar pill. Non-negotiable honesty: the app is reading
|
||||
// location off screen and the user should be able to see that.
|
||||
showBackgroundLocationIndicator: background,
|
||||
activityType: ActivityType.otherNavigation,
|
||||
);
|
||||
}
|
||||
|
||||
return LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuild the stream when the proximity-alerts setting is toggled, so
|
||||
/// background delivery is armed (or dropped) immediately rather than at the
|
||||
/// next unrelated stream restart.
|
||||
void updateProximityAlertsEnabled(bool enabled) {
|
||||
if (_positionSub == null || _streamIsBackgroundCapable == enabled) return;
|
||||
|
||||
debugPrint('[GpsController] Proximity alerts $enabled — rebuilding stream');
|
||||
_stopLocationTracking();
|
||||
_startPositionStream();
|
||||
}
|
||||
|
||||
/// Restart position stream with current follow-me settings
|
||||
void _restartPositionStream() {
|
||||
if (_positionSub == null) {
|
||||
@@ -217,7 +332,7 @@ class GpsController {
|
||||
|
||||
// Handle proximity alerts
|
||||
_checkProximityAlerts(newLocation);
|
||||
|
||||
|
||||
// Handle follow-me animations
|
||||
_handleFollowMeUpdate(position, newLocation);
|
||||
}
|
||||
@@ -239,13 +354,20 @@ class GpsController {
|
||||
void _checkProximityAlerts(LatLng userLocation) {
|
||||
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
|
||||
if (!proximityEnabled) return;
|
||||
|
||||
final nearbyNodes = _getNearbyNodes?.call() ?? [];
|
||||
if (nearbyNodes.isEmpty) return;
|
||||
|
||||
|
||||
final alertDistance = _getProximityAlertDistance?.call() ?? 200;
|
||||
|
||||
// Look up nodes around where the user actually is, not around whatever the
|
||||
// map camera happens to show. Backgrounded, the camera never moves, so the
|
||||
// old viewport-based lookup went stale the moment you drove out of it —
|
||||
// and even in the foreground it missed alerts whenever the map was panned
|
||||
// away from your position.
|
||||
final nearbyNodes = _getNearbyNodes?.call(userLocation, alertDistance) ?? [];
|
||||
if (nearbyNodes.isEmpty) return;
|
||||
|
||||
final enabledProfiles = _getEnabledProfiles?.call() ?? [];
|
||||
|
||||
|
||||
|
||||
ProximityAlertService().checkProximity(
|
||||
userLocation: userLocation,
|
||||
nodes: nearbyNodes,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../app_state.dart' show AppState, FollowMeMode;
|
||||
import '../services/offline_area_service.dart';
|
||||
import '../services/geo_bounds.dart';
|
||||
|
||||
import '../models/osm_node.dart';
|
||||
import '../models/suspected_location.dart';
|
||||
@@ -64,7 +65,7 @@ class MapView extends StatefulWidget {
|
||||
State<MapView> createState() => MapViewState();
|
||||
}
|
||||
|
||||
class MapViewState extends State<MapView> {
|
||||
class MapViewState extends State<MapView> with WidgetsBindingObserver {
|
||||
late final AnimatedMapController _controller;
|
||||
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
|
||||
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
|
||||
@@ -126,6 +127,8 @@ class MapViewState extends State<MapView> {
|
||||
_dataManager = MapDataManager();
|
||||
_interactionManager = MapInteractionManager();
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Initialize proximity alert service
|
||||
ProximityAlertService().initialize(
|
||||
onVisualAlert: () {
|
||||
@@ -186,16 +189,16 @@ class MapViewState extends State<MapView> {
|
||||
}
|
||||
return 200;
|
||||
},
|
||||
getNearbyNodes: () {
|
||||
getNearbyNodes: (userLocation, radiusMeters) {
|
||||
if (mounted) {
|
||||
try {
|
||||
final LatLngBounds mapBounds;
|
||||
try {
|
||||
mapBounds = _controller.mapController.camera.visibleBounds;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
return NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds);
|
||||
// Deliberately not the map camera's visibleBounds: while the app
|
||||
// is backgrounded the camera is frozen wherever the user left it,
|
||||
// so a viewport lookup returns nodes from a place they may be
|
||||
// miles from by now. Box around the live GPS position instead.
|
||||
return NodeProviderWithCache.instance.getCachedNodesForBounds(
|
||||
boundsAround(userLocation, radiusMeters),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[MapView] Could not get nearby nodes: $e');
|
||||
return [];
|
||||
@@ -231,8 +234,18 @@ class MapViewState extends State<MapView> {
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// The position stream can go quiet across suspension, so pull a fresh fix
|
||||
// rather than trusting the last one we saw before backgrounding.
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_gpsController.refreshLocation();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_cameraDebounce.dispose();
|
||||
_tileDebounce.dispose();
|
||||
_mapPositionDebounce.dispose();
|
||||
@@ -329,6 +342,11 @@ class MapViewState extends State<MapView> {
|
||||
// Keep screen awake based on user setting
|
||||
_updateWakelock(appState.keepScreenAwake);
|
||||
|
||||
// Arm or drop background location delivery when the proximity alert
|
||||
// setting is toggled. No-op when the stream already matches, same as
|
||||
// _updateWakelock above.
|
||||
_gpsController.updateProximityAlertsEnabled(appState.proximityAlertsEnabled);
|
||||
|
||||
// Check if enabled profiles changed and refresh nodes if needed
|
||||
_nodeController.checkAndHandleProfileChanges(
|
||||
currentEnabledProfiles: appState.enabledProfiles,
|
||||
|
||||
Reference in New Issue
Block a user