Skip to content

Commit e4e9fae

Browse files
committed
### New Features
- Persistent radio power overrides: custom TX power settings are now saved per device, similar to antenna selection. On connect, your previously set power override is automatically applied with a visual indicator showing it's been overridden. A "Reset to Auto" button in the power selector restores the auto-detected power for your device model. ### Improvements - Redesigned the Top Heard overlay. Now shows the repeaters that heard your most recent ping sorted by SNR, with color-coded indicators per ping type (green for TX, purple for Discovery, cyan for Trace, blue for RX). Top 3 slots show the best results from your latest ping and fully replace on each new ping. A separate RX slot shows the strongest passively overheard repeater within a rolling 5-second window. Overlay clears on auto-ping stop, mode switch, disconnect, or log clear.
1 parent eccc88d commit e4e9fae

3 files changed

Lines changed: 244 additions & 72 deletions

File tree

lib/providers/app_state_provider.dart

Lines changed: 133 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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
5659
enum 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

Comments
 (0)