@@ -136,6 +136,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
136136 // Discovered devices
137137 List <DiscoveredDevice > _discoveredDevices = [];
138138 bool _isScanning = false ;
139+ StreamSubscription <DiscoveredDevice >? _activeScanSubscription;
139140
140141 // TX/RX markers for map
141142 final List <TxPing > _txPings = [];
@@ -433,7 +434,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
433434
434435 // Update background service notification with queue size
435436 if (_autoPingEnabled) {
436- final modeName = _autoMode == AutoMode .passive ? 'Passive Mode' : 'Active Mode' ;
437+ final modeName = _autoMode == AutoMode .passive ? 'Passive Mode'
438+ : _autoMode == AutoMode .hybrid ? 'Hybrid Mode' : 'Active Mode' ;
437439 BackgroundServiceManager .updateNotification (
438440 mode: modeName,
439441 txCount: _pingStats.txCount,
@@ -717,31 +719,46 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
717719 _isAuthError = false ;
718720 notifyListeners ();
719721
720- // Listen for discovered devices
722+ // Listen for discovered devices using subscription so stopScan() can cancel
721723 DiscoveredDevice ? selectedDevice;
722- await for (final device in _bluetoothService.scanForDevices (
724+ final completer = Completer <void >();
725+ _activeScanSubscription = _bluetoothService.scanForDevices (
723726 timeout: const Duration (seconds: 15 ),
724- )) {
725- if (! _discoveredDevices.any ((d) => d.id == device.id)) {
726- _discoveredDevices.add (device);
727- selectedDevice = device;
728- notifyListeners ();
729- }
730- }
727+ ).listen (
728+ (device) {
729+ if (! _discoveredDevices.any ((d) => d.id == device.id)) {
730+ _discoveredDevices.add (device);
731+ selectedDevice = device;
732+ notifyListeners ();
733+ }
734+ },
735+ onDone: () {
736+ if (! completer.isCompleted) completer.complete ();
737+ },
738+ onError: (e) {
739+ debugError ('[SCAN] Scan error: $e ' );
740+ if (! completer.isCompleted) completer.complete ();
741+ },
742+ );
743+ await completer.future;
744+ _activeScanSubscription = null ;
731745
732746 _isScanning = false ;
733747 notifyListeners ();
734748
735749 // On web platform, the Chrome BLE picker already handles device selection,
736750 // so auto-connect immediately after the picker returns (no second click needed)
737- if (kIsWeb && selectedDevice != null ) {
751+ final webDevice = selectedDevice;
752+ if (kIsWeb && webDevice != null ) {
738753 debugLog ('[APP] Web platform: auto-connecting to selected device' );
739- await connectToDevice (selectedDevice );
754+ await connectToDevice (webDevice );
740755 }
741756 }
742757
743758 /// Stop scanning for devices
744759 Future <void > stopScan () async {
760+ await _activeScanSubscription? .cancel ();
761+ _activeScanSubscription = null ;
745762 await _bluetoothService.stopScan ();
746763 _isScanning = false ;
747764 notifyListeners ();
@@ -1077,7 +1094,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
10771094
10781095 // Update background service notification with current stats
10791096 if (_autoPingEnabled) {
1080- final modeName = _autoMode == AutoMode .passive ? 'Passive Mode' : 'Active Mode' ;
1097+ final modeName = _autoMode == AutoMode .passive ? 'Passive Mode'
1098+ : _autoMode == AutoMode .hybrid ? 'Hybrid Mode' : 'Active Mode' ;
10811099 BackgroundServiceManager .updateNotification (
10821100 mode: modeName,
10831101 txCount: _pingStats.txCount,
@@ -2083,10 +2101,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
20832101 final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active' ;
20842102 _startNoiseFloorSession (sessionLabel);
20852103
2086- // Enable heartbeat ONLY for Passive Mode (not offline mode)
2087- // Active/Hybrid Mode renews session via auto-pings every 15/30/60s
2088- // Manual Mode has natural 5-minute timeout
2089- if (isPassive && ! _preferences.offlineMode) {
2104+ // Enable heartbeat for all auto-ping modes (not offline mode)
2105+ // Heartbeat sends keepalive ~1 min before session expiry (4 min timer)
2106+ // Active/Hybrid pings renew session when moving, but heartbeat is the
2107+ // safety net when stationary (25m distance filter skips TX pings)
2108+ if (! _preferences.offlineMode) {
20902109 _apiService.enableHeartbeat (
20912110 gpsProvider: () {
20922111 // Provide current GPS coordinates for heartbeat (matching wardrive.js)
@@ -2095,9 +2114,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
20952114 return (lat: pos.latitude, lon: pos.longitude);
20962115 },
20972116 );
2098- debugLog ('[HEARTBEAT] Enabled for Passive Mode' );
2099- } else if (isTxMode) {
2100- debugLog ('[HEARTBEAT] Not enabled - ${mode .name } Mode renews via auto-pings' );
2117+ debugLog ('[HEARTBEAT] Enabled for ${mode .name } Mode' );
21012118 }
21022119
21032120 // Start background service for continuous operation
@@ -2676,6 +2693,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
26762693 _savePreferences ();
26772694 }
26782695
2696+ /// Set map auto-follow preference and persist
2697+ void setMapAutoFollow (bool value) {
2698+ _preferences = _preferences.copyWith (mapAutoFollow: value);
2699+ debugLog ('[MAP] Map auto-follow set to $value ' );
2700+ notifyListeners ();
2701+ _savePreferences ();
2702+ }
2703+
2704+ /// Set map always-north preference and persist
2705+ void setMapAlwaysNorth (bool value) {
2706+ _preferences = _preferences.copyWith (mapAlwaysNorth: value);
2707+ debugLog ('[MAP] Map always-north set to $value ' );
2708+ notifyListeners ();
2709+ _savePreferences ();
2710+ }
2711+
2712+ /// Set map rotation-locked preference and persist
2713+ void setMapRotationLocked (bool value) {
2714+ _preferences = _preferences.copyWith (mapRotationLocked: value);
2715+ debugLog ('[MAP] Map rotation-locked set to $value ' );
2716+ notifyListeners ();
2717+ _savePreferences ();
2718+ }
2719+
26792720 /// Toggle sound notifications on/off
26802721 Future <void > toggleSoundEnabled () async {
26812722 await _audioService.toggle ();
0 commit comments