@@ -52,6 +52,9 @@ enum AutoMode {
5252 targeted,
5353}
5454
55+ /// Ping type for the top-heard overlay dots
56+ enum OverlayPingType { tx, disc, trace, rx }
57+
5558/// Result of uploading an offline session
5659enum OfflineUploadResult {
5760 /// Upload completed successfully
@@ -169,7 +172,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
169172 final List <TraceLogEntry > _traceLogEntries = [];
170173
171174 // Top repeaters overlay — updated live on each ping event
172- List <({String repeaterId, double snr})> _topRepeatersOverlay = [];
175+ List <({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = [];
176+ ({String repeaterId, double snr})? _rxOverlaySlot;
177+ Timer ? _rxOverlayWindowTimer;
173178
174179 // Targeted mode state
175180 String ? _targetRepeaterId;
@@ -191,6 +196,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
191196 bool _antennaRestoredFromDevice = false ;
192197 bool get antennaRestoredFromDevice => _antennaRestoredFromDevice;
193198
199+ /// Per-device power overrides: maps companion name → {powerLevel, txPower}
200+ Map <String , Map <String , dynamic >> _devicePowerOverrides = {};
201+
202+ /// Whether the current power setting was auto-restored from a saved override
203+ bool _powerRestoredFromDevice = false ;
204+ bool get powerRestoredFromDevice => _powerRestoredFromDevice;
205+
194206 // Remembered device for quick reconnection (mobile only)
195207 RememberedDevice ? _rememberedDevice;
196208
@@ -345,14 +357,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
345357 List <TxPing > get txPings => List .unmodifiable (_txPings);
346358 List <RxPing > get rxPings => List .unmodifiable (_rxPings);
347359
348- /// Top 3 repeaters by best SNR (1-byte IDs only) across TX echoes and RX observations
349- List <({String repeaterId, double snr})> get topRepeatersBySnr => _topRepeatersOverlay;
360+ /// Top 3 repeaters by best SNR from TX/DISC/Trace pings
361+ List <({String repeaterId, double snr, OverlayPingType type})> get topRepeatersBySnr => _topRepeatersOverlay;
362+ /// Best RX observation in the current 5-second window
363+ ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot;
350364
351- /// Update the top repeaters overlay with results from the latest ping.
352- /// New repeaters from this ping take priority (sorted by best SNR first).
353- /// Remaining slots are filled with carryover from the previous display.
354- void _updateTopRepeaters (List <({String repeaterId, double snr})> current) {
355- // Deduplicate current entries, keeping best SNR per repeater
365+ /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping.
366+ /// Replaces all 3 slots entirely (no carryover from previous pings).
367+ void _updateTopRepeaters (List <({String repeaterId, double snr})> current, OverlayPingType type) {
356368 final bestSnr = < String , double > {};
357369 for (final r in current) {
358370 final key = r.repeaterId.toUpperCase ();
@@ -361,21 +373,34 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
361373 }
362374 }
363375 final fresh = bestSnr.entries
364- .map ((e) => (repeaterId: e.key, snr: e.value))
376+ .map ((e) => (repeaterId: e.key, snr: e.value, type : type ))
365377 .toList ()
366378 ..sort ((a, b) => b.snr.compareTo (a.snr));
379+ _topRepeatersOverlay = fresh.take (3 ).toList ();
380+ }
367381
368- if (fresh.length >= 3 ) {
369- _topRepeatersOverlay = fresh.take (3 ).toList ();
382+ /// Update the RX overlay slot with a 5-second rolling window (best SNR wins).
383+ void _updateRxOverlaySlot (String repeaterId, double snr) {
384+ final entry = (repeaterId: repeaterId.toUpperCase (), snr: snr);
385+ if (_rxOverlayWindowTimer? .isActive ?? false ) {
386+ if (_rxOverlaySlot == null || snr > _rxOverlaySlot! .snr) {
387+ _rxOverlaySlot = entry;
388+ }
370389 } else {
371- // Fill remaining slots with previous entries not already in fresh
372- final freshIds = fresh.map ((r) => r.repeaterId).toSet ();
373- final carryover = _topRepeatersOverlay
374- .where ((r) => ! freshIds.contains (r.repeaterId))
375- .toList ();
376- _topRepeatersOverlay = [...fresh, ...carryover].take (3 ).toList ();
390+ _rxOverlaySlot = entry;
391+ _rxOverlayWindowTimer = Timer (const Duration (seconds: 5 ), () {
392+ // Window closed — slot stays until next RX or cleared
393+ });
377394 }
378395 }
396+
397+ /// Clear all overlay state (top 3 + RX slot).
398+ void _clearOverlayState () {
399+ _topRepeatersOverlay = [];
400+ _rxOverlaySlot = null ;
401+ _rxOverlayWindowTimer? .cancel ();
402+ _rxOverlayWindowTimer = null ;
403+ }
379404 List <TxLogEntry > get txLogEntries => List .unmodifiable (_txLogEntries);
380405 List <RxLogEntry > get rxLogEntries => List .unmodifiable (_rxLogEntries);
381406 List <DiscLogEntry > get discLogEntries => List .unmodifiable (_discLogEntries);
@@ -616,6 +641,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
616641 debugLog ('[INIT] Loading preferences...' );
617642 await _loadPreferences ();
618643 await _loadDeviceAntennaPreferences ();
644+ await _loadDevicePowerOverrides ();
619645
620646 // Load last known GPS position for map centering
621647 await _loadLastPosition ();
@@ -1162,8 +1188,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
11621188 powerLevel: device.power,
11631189 txPower: device.txPower,
11641190 autoPowerSet: true , // Indicates power was auto-detected from device model
1191+ powerLevelSet: false , // Clear stale manual flag from previous session
11651192 );
1166- // TODO: Persist to SharedPreferences when implemented
11671193 notifyListeners ();
11681194 debugLog ('[MODEL] Device recognized: ${device .shortName } - reporting ${device .power }W in API calls' );
11691195 }
@@ -1324,8 +1350,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
13241350 ));
13251351 if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt (0 );
13261352
1327- // Update top repeaters overlay with this RX observation
1328- _updateTopRepeaters ([(repeaterId : ping.repeaterId. toUpperCase (), snr : ping.snr)] );
1353+ // Update RX overlay slot with this RX observation
1354+ _updateRxOverlaySlot ( ping.repeaterId, ping.snr);
13291355
13301356 notifyListeners ();
13311357 };
@@ -1402,7 +1428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
14021428 _updateTopRepeaters (existingEvents
14031429 .where ((e) => e.snr != null )
14041430 .map ((e) => (repeaterId: e.repeaterId.toUpperCase (), snr: e.snr! ))
1405- .toList ());
1431+ .toList (), OverlayPingType .tx );
14061432
14071433 debugLog ('[APP] Calling notifyListeners() to update UI' );
14081434 notifyListeners ();
@@ -1452,7 +1478,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
14521478 // Update top repeaters overlay with all discovered nodes from this ping
14531479 _updateTopRepeaters (discPing.discoveredNodes
14541480 .map ((n) => (repeaterId: n.repeaterId.toUpperCase (), snr: n.localSnr))
1455- .toList ());
1481+ .toList (), OverlayPingType .disc );
14561482
14571483 notifyListeners ();
14581484 };
@@ -1652,6 +1678,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
16521678 notifyListeners ();
16531679 }
16541680
1681+ // Restore per-device power override if previously saved
1682+ if (resolvedName != null && _devicePowerOverrides.containsKey (resolvedName)) {
1683+ final saved = _devicePowerOverrides[resolvedName]! ;
1684+ _preferences = _preferences.copyWith (
1685+ powerLevel: (saved['powerLevel' ] as num ).toDouble (),
1686+ txPower: (saved['txPower' ] as num ).toInt (),
1687+ autoPowerSet: false ,
1688+ powerLevelSet: true ,
1689+ );
1690+ _powerRestoredFromDevice = true ;
1691+ _savePreferences ();
1692+ debugLog ('[APP] Restored power override for "$resolvedName ": ${saved ['powerLevel' ]}W' );
1693+ notifyListeners ();
1694+ }
1695+
16551696 // Log connection status based on TX/RX permissions
16561697 if (hasApiSession) {
16571698 if (txAllowed && rxAllowed) {
@@ -1785,9 +1826,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
17851826 debugLog ('[APP] Created IMMEDIATE RX pin for repeater: ${observation .repeaterId } '
17861827 'at ${observation .lat .toStringAsFixed (5 )},${observation .lon .toStringAsFixed (5 )} '
17871828 '(batch tracking: ${_currentBatchRepeaters .length } repeaters, rxCount: ${_pingStats .rxCount })' );
1788- // Update top repeaters overlay immediately
1829+ // Update RX overlay slot immediately
17891830 if (observation.snr != null ) {
1790- _updateTopRepeaters ([(repeaterId : repeaterKey, snr : observation.snr! )] );
1831+ _updateRxOverlaySlot ( repeaterKey, observation.snr! );
17911832 }
17921833 // Play receive sound for new RX observation
17931834 _audioService.playReceiveSound ();
@@ -1894,9 +1935,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
18941935 debugLog ('[APP] Added RX log entry: repeater=${entry .repeaterId }, '
18951936 'snr=${entry .snr ?? 'null' }, pathLen=${entry .pathLength }' );
18961937
1897- // Update top repeaters overlay with this RX observation
1938+ // Update RX overlay slot with this RX observation
18981939 if (entry.snr != null ) {
1899- _updateTopRepeaters ([(repeaterId : entry.repeaterId. toUpperCase (), snr : entry.snr! )] );
1940+ _updateRxOverlaySlot ( entry.repeaterId, entry.snr! );
19001941 }
19011942
19021943 // Note: RX count is incremented in onObservation when pin is created (immediate feedback)
@@ -2183,6 +2224,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
21832224 _isAnonymousRenamed = false ;
21842225 _originalDeviceName = null ;
21852226
2227+ // Clear top-heard overlay
2228+ _clearOverlayState ();
2229+
21862230 // Existing cleanup
21872231 _meshCoreConnection? .dispose ();
21882232 _meshCoreConnection = null ;
@@ -2359,8 +2403,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
23592403 _reconnectAttempt = 0 ;
23602404 _autoPingWasEnabled = false ;
23612405
2362- // Reset antenna setting so user must choose again on next connect
2406+ // Reset antenna and power settings so user must choose again on next connect
23632407 _antennaRestoredFromDevice = false ;
2408+ _powerRestoredFromDevice = false ;
23642409 _preferences = _preferences.copyWith (externalAntenna: false , externalAntennaSet: false );
23652410 _savePreferences ();
23662411
@@ -2499,6 +2544,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
24992544 _offlineContactUri = null ;
25002545 _displayDeviceName = null ;
25012546 _antennaRestoredFromDevice = false ;
2547+ _powerRestoredFromDevice = false ;
25022548 _preferences = _preferences.copyWith (externalAntenna: false , externalAntennaSet: false );
25032549 _savePreferences ();
25042550 _currentNoiseFloor = null ;
@@ -2666,6 +2712,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
26662712 _autoPingEnabled = false ;
26672713 _idleAutoStopReference = null ;
26682714
2715+ // Clear top-heard overlay on stop
2716+ _clearOverlayState ();
2717+
26692718 // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode
26702719 // Passive Mode is listening only, no cooldown needed
26712720 if (isTxMode) {
@@ -2698,6 +2747,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
26982747 // Stop countdown timers when switching modes
26992748 _autoPingTimer.stop ();
27002749 _rxWindowTimer.stop ();
2750+ // Clear top-heard overlay on mode switch
2751+ _clearOverlayState ();
27012752 // Save offline session if offline mode is enabled
27022753 if (_preferences.offlineMode) {
27032754 await _saveOfflineSession ();
@@ -2774,6 +2825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
27742825 void clearPings () {
27752826 _txPings.clear ();
27762827 _rxPings.clear ();
2828+ _clearOverlayState ();
27772829 _pingService? .resetStats ();
27782830 notifyListeners ();
27792831 }
@@ -2785,6 +2837,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
27852837 _discLogEntries.clear ();
27862838 _traceLogEntries.clear ();
27872839 _errorLogEntries.clear ();
2840+ _clearOverlayState ();
27882841 notifyListeners ();
27892842 }
27902843
@@ -2811,7 +2864,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
28112864 // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay
28122865 final id = entry.targetRepeaterId.toUpperCase ();
28132866 final displayId = id.length > 6 ? id.substring (0 , 6 ) : id;
2814- _updateTopRepeaters ([(repeaterId: displayId, snr: entry.localSnr! )]);
2867+ _updateTopRepeaters ([(repeaterId: displayId, snr: entry.localSnr! )], OverlayPingType .trace );
28152868 }
28162869
28172870 notifyListeners ();
@@ -3530,8 +3583,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
35303583
35313584 _preferences = preferences;
35323585
3533- // Clear restored flag — user is making a manual choice now
3586+ // Clear restored flags — user is making a manual choice now
35343587 _antennaRestoredFromDevice = false ;
3588+ _powerRestoredFromDevice = false ;
35353589
35363590 // Persist antenna choice per device name (use original name, not "Anonymous")
35373591 final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName;
@@ -3541,6 +3595,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
35413595 debugLog ('[APP] Saved antenna preference for "$deviceName ": ${preferences .externalAntenna ? "external" : "device" }' );
35423596 }
35433597
3598+ // Persist power override per device name
3599+ if (deviceName != null && preferences.powerLevelSet && ! preferences.autoPowerSet) {
3600+ _devicePowerOverrides[deviceName] = {
3601+ 'powerLevel' : preferences.powerLevel,
3602+ 'txPower' : preferences.txPower,
3603+ };
3604+ _saveDevicePowerOverrides ();
3605+ debugLog ('[APP] Saved power override for "$deviceName ": ${preferences .powerLevel }W' );
3606+ } else if (deviceName != null && preferences.autoPowerSet) {
3607+ // User re-selected the auto-detected value — clear any saved override
3608+ if (_devicePowerOverrides.remove (deviceName) != null ) {
3609+ _saveDevicePowerOverrides ();
3610+ debugLog ('[APP] Cleared power override for "$deviceName " (auto-detected selected)' );
3611+ }
3612+ }
3613+
35443614 // Propagate RSSI filter setting to live trackers/validators
35453615 _syncRssiFilterSetting (preferences.disableRssiFilter);
35463616
@@ -4693,6 +4763,40 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
46934763 }
46944764 }
46954765
4766+ // ============================================
4767+ // Device Power Override Persistence
4768+ // ============================================
4769+
4770+ /// Load per-device power overrides from Hive storage
4771+ Future <void > _loadDevicePowerOverrides () async {
4772+ final box = await _openBoxSafely (_preferencesBoxName);
4773+ if (box == null ) return ;
4774+
4775+ try {
4776+ final raw = box.get ('device_power_overrides' );
4777+ if (raw != null ) {
4778+ _devicePowerOverrides = (raw as Map ).map (
4779+ (key, value) => MapEntry (key.toString (), Map <String , dynamic >.from (value as Map )),
4780+ );
4781+ debugLog ('[APP] Loaded power overrides for ${_devicePowerOverrides .length } device(s)' );
4782+ }
4783+ } catch (e) {
4784+ debugLog ('[APP] Failed to load device power overrides: $e ' );
4785+ }
4786+ }
4787+
4788+ /// Save per-device power overrides to Hive storage
4789+ Future <void > _saveDevicePowerOverrides () async {
4790+ final box = await _openBoxSafely (_preferencesBoxName);
4791+ if (box == null ) return ;
4792+
4793+ try {
4794+ await box.put ('device_power_overrides' , _devicePowerOverrides);
4795+ } catch (e) {
4796+ debugLog ('[APP] Failed to save device power overrides: $e ' );
4797+ }
4798+ }
4799+
46964800 // ============================================
46974801 // Last Connected Device Persistence
46984802 // ============================================
0 commit comments