From 7eeb4129e1f99a074d6b2af87824794b93ae318c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:16:55 -0400 Subject: [PATCH 1/8] Add Top Heard row count and server-name label helpers --- lib/models/user_preferences.dart | 39 ++- lib/utils/repeater_nicknames.dart | 40 ++- .../user_preferences_top_heard_test.dart | 88 ++++++ test/utils/repeater_nicknames_test.dart | 271 ++++++++++++++++++ 4 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 test/models/user_preferences_top_heard_test.dart diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 29eee7a7..01b460f1 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -116,12 +116,25 @@ class UserPreferences { /// Auto-stop auto-ping after 30 minutes of idle (no movement) final bool autoStopAfterIdle; - /// Show top 3 repeaters by SNR on the map during wardriving + /// Show the Top Heard repeaters overlay (best SNR first) on the map during + /// wardriving final bool showTopRepeaters; - /// Double the size of the Top 3 repeaters overlay for easier reading + /// Double the size of the Top Heard overlay for easier reading final bool largerTopRepeaters; + /// Fewest and most rows the Top Heard overlay can show. + static const int topHeardRowsMin = 3; + static const int topHeardRowsMax = 8; + + /// Rows the in-app Top Heard overlay shows (the RX row is extra). The Live + /// Activity, CarPlay and watch always show 3. + final int topHeardRows; + + /// Top Heard rows with no nickname show the repeater's server name instead + /// of its hex ID. + final bool topHeardShowNames; + /// Live Activity heard rows show resolved repeater names (fewer, named /// rows). When false the card packs a two-column hex grid instead. final bool liveActivityShowNames; @@ -204,6 +217,8 @@ class UserPreferences { this.autoStopAfterIdle = true, this.showTopRepeaters = false, this.largerTopRepeaters = false, + this.topHeardRows = 3, + this.topHeardShowNames = false, this.liveActivityShowNames = true, this.markerStyle = 'dot', this.gpsMarkerStyle = 'arrow', @@ -271,6 +286,8 @@ class UserPreferences { autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, largerTopRepeaters: (json['largerTopRepeaters'] as bool?) ?? false, + topHeardRows: _topHeardRowsFromJson(json['topHeardRows']), + topHeardShowNames: (json['topHeardShowNames'] as bool?) ?? false, liveActivityShowNames: (json['liveActivityShowNames'] as bool?) ?? true, markerStyle: (json['markerStyle'] as String?) ?? 'dot', gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), @@ -323,6 +340,14 @@ class UserPreferences { return value; } + /// A stored Top Heard row count, clamped to the supported range. A missing + /// or non-numeric value (or NaN/infinity) gives the default of 3, so a bad + /// stored value never reaches the UI. + static int _topHeardRowsFromJson(Object? value) { + if (value is! num || !value.isFinite) return 3; + return value.toInt().clamp(topHeardRowsMin, topHeardRowsMax); + } + /// Convert to JSON (for persistence) Map toJson() { return { @@ -363,6 +388,8 @@ class UserPreferences { 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, 'largerTopRepeaters': largerTopRepeaters, + 'topHeardRows': topHeardRows, + 'topHeardShowNames': topHeardShowNames, 'liveActivityShowNames': liveActivityShowNames, 'markerStyle': markerStyle, 'gpsMarkerStyle': gpsMarkerStyle, @@ -419,6 +446,8 @@ class UserPreferences { bool? autoStopAfterIdle, bool? showTopRepeaters, bool? largerTopRepeaters, + int? topHeardRows, + bool? topHeardShowNames, bool? liveActivityShowNames, String? markerStyle, String? gpsMarkerStyle, @@ -478,6 +507,8 @@ class UserPreferences { autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, largerTopRepeaters: largerTopRepeaters ?? this.largerTopRepeaters, + topHeardRows: topHeardRows ?? this.topHeardRows, + topHeardShowNames: topHeardShowNames ?? this.topHeardShowNames, liveActivityShowNames: liveActivityShowNames ?? this.liveActivityShowNames, markerStyle: markerStyle ?? this.markerStyle, @@ -562,6 +593,8 @@ class UserPreferences { other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && other.showTopRepeaters == showTopRepeaters && + other.topHeardRows == topHeardRows && + other.topHeardShowNames == topHeardShowNames && other.liveActivityShowNames == liveActivityShowNames && other.markerStyle == markerStyle && other.gpsMarkerStyle == gpsMarkerStyle && @@ -613,6 +646,8 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + topHeardRows, + topHeardShowNames, liveActivityShowNames, markerStyle, gpsMarkerStyle, diff --git a/lib/utils/repeater_nicknames.dart b/lib/utils/repeater_nicknames.dart index 2250b318..690df61f 100644 --- a/lib/utils/repeater_nicknames.dart +++ b/lib/utils/repeater_nicknames.dart @@ -143,30 +143,58 @@ class RepeaterNicknames { return key == null ? null : nicknames[key]?.nickname; } - /// The nickname for a heard row, or null when the row does not resolve to - /// exactly one repeater that has one. + /// [name], the server's repeater name, cut to [maxLength] user-perceived + /// characters for a label with `…` appended when it was cut, or null when + /// [name] is blank. + /// + /// A name of [maxLength] characters or fewer is returned trimmed with no + /// ellipsis. The cut counts grapheme clusters, like [normalizeNickname], so an + /// emoji or an accented letter is never split. + static String? shortenServerName(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + final chars = trimmed.characters; + if (chars.length <= maxLength) return trimmed; + return '${chars.take(maxLength).toString().trimRight()}…'; + } + + /// The label for a heard row, or null (show the hex) when the row does not + /// resolve to exactly one repeater that has one. + /// + /// That is the repeater's nickname, or, with [useServerName], its server name + /// through [shortenServerName] when it has no nickname. The server name is + /// skipped for the placeholder name `Unknown` and for an anonymised + /// repeater ([Repeater.isHidden]). /// /// Resolves through [ExternalSurfaceGeoBuilder.resolveOverlayRow]: the /// fullest [identity] the row arrived with first (a discovery response's /// public key, a trace's target), then [displayId], the hash it is shown /// under. /// - /// An empty [nicknames] returns null without scanning [repeaters], which is - /// the common case and runs on every overlay rebuild. + /// With [useServerName] false, an empty [nicknames] returns null without + /// scanning [repeaters], which is the common case and runs on every overlay + /// rebuild. static String? labelFor({ required String displayId, String? identity, required List repeaters, int? hopBytes, required Map nicknames, + bool useServerName = false, }) { - if (nicknames.isEmpty) return null; + if (nicknames.isEmpty && !useServerName) return null; final match = ExternalSurfaceGeoBuilder.resolveOverlayRow( displayId: displayId, identity: identity, repeaters: repeaters, hopBytes: hopBytes, ); - return match == null ? null : nicknameFor(match, nicknames); + if (match == null) return null; + final nickname = nicknameFor(match, nicknames); + if (nickname != null) return nickname; + if (!useServerName || match.name == 'Unknown' || match.isHidden) { + return null; + } + return shortenServerName(match.name); } } diff --git a/test/models/user_preferences_top_heard_test.dart b/test/models/user_preferences_top_heard_test.dart new file mode 100644 index 00000000..c34d4f0e --- /dev/null +++ b/test/models/user_preferences_top_heard_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/user_preferences.dart'; + +void main() { + test('the row range is 3 to 8', () { + expect(UserPreferences.topHeardRowsMin, 3); + expect(UserPreferences.topHeardRowsMax, 8); + }); + + test('Top Heard shows 3 rows with hex IDs by default', () { + const prefs = UserPreferences(); + expect(prefs.topHeardRows, 3); + expect(prefs.topHeardShowNames, isFalse); + expect(prefs.toJson()['topHeardRows'], 3); + expect(prefs.toJson()['topHeardShowNames'], false); + }); + + test('round trips non-default values through json', () { + const prefs = UserPreferences(topHeardRows: 6, topHeardShowNames: true); + final back = UserPreferences.fromJson(prefs.toJson()); + expect(back.topHeardRows, 6); + expect(back.topHeardShowNames, isTrue); + expect(back, prefs); + }); + + test('copyWith changes only the field it is given', () { + const prefs = UserPreferences(topHeardRows: 5, topHeardShowNames: true); + + final rows = prefs.copyWith(topHeardRows: 8); + expect(rows.topHeardRows, 8); + expect(rows.topHeardShowNames, isTrue); + + final names = prefs.copyWith(topHeardShowNames: false); + expect(names.topHeardShowNames, isFalse); + expect(names.topHeardRows, 5); + + expect(prefs.copyWith(), prefs); + }); + + test('missing keys fall back to the defaults', () { + final prefs = UserPreferences.fromJson({}); + expect(prefs.topHeardRows, 3); + expect(prefs.topHeardShowNames, isFalse); + }); + + test('the row count is clamped to 3..8', () { + int rows(Object? value) => + UserPreferences.fromJson({'topHeardRows': value}).topHeardRows; + + expect(rows(3), 3); + expect(rows(8), 8); + expect(rows(5), 5); + expect(rows(0), 3); + expect(rows(-4), 3); + expect(rows(2), 3); + expect(rows(9), 8); + expect(rows(99), 8); + }); + + test('a numeric row count is truncated, anything else is the default', () { + int rows(Object? value) => + UserPreferences.fromJson({'topHeardRows': value}).topHeardRows; + + expect(rows(5.0), 5); + expect(rows(5.9), 5); + expect(rows(double.nan), 3); + expect(rows(double.infinity), 3); + expect(rows('6'), 3); + expect(rows(null), 3); + expect(rows(true), 3); + expect(rows(const [6]), 3); + }); + + test('equality and hashCode follow both fields', () { + const base = UserPreferences(topHeardRows: 5, topHeardShowNames: true); + const same = UserPreferences(topHeardRows: 5, topHeardShowNames: true); + const otherRows = UserPreferences(topHeardRows: 6, topHeardShowNames: true); + const otherNames = + UserPreferences(topHeardRows: 5, topHeardShowNames: false); + + expect(base, same); + expect(base.hashCode, same.hashCode); + expect(base, isNot(otherRows)); + expect(base.hashCode, isNot(otherRows.hashCode)); + expect(base, isNot(otherNames)); + expect(base.hashCode, isNot(otherNames.hashCode)); + }); +} diff --git a/test/utils/repeater_nicknames_test.dart b/test/utils/repeater_nicknames_test.dart index ee2f48ce..f6cd9b60 100644 --- a/test/utils/repeater_nicknames_test.dart +++ b/test/utils/repeater_nicknames_test.dart @@ -339,5 +339,276 @@ void main() { isNull, ); }); + + group('with useServerName', () { + test('a nickname beats the server name', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + useServerName: true, + ), + 'Mell', + ); + }); + + test('the server name is used when there is no nickname', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5099', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + useServerName: true, + ), + 'Queen An\u2026', + ); + }); + + test('an empty nickname map still resolves the server name', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5099', + repeaters: [mell, other], + hopBytes: 2, + nicknames: const {}, + useServerName: true, + ), + 'Queen An\u2026', + ); + }); + + test('a long server name is shortened', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [mell, other], + hopBytes: 2, + nicknames: const {}, + useServerName: true, + ), + 'Capitol\u2026', + ); + }); + + test('a short server name is shown whole', () { + final short = _repeater('r3', _key('7A11'), hopBytes: 2, name: 'Hilltop'); + + expect( + RepeaterNicknames.labelFor( + displayId: '7A11', + repeaters: [short], + hopBytes: 2, + nicknames: const {}, + useServerName: true, + ), + 'Hilltop', + ); + }); + + test('the placeholder name Unknown gives null', () { + final unknown = + _repeater('r3', _key('7A11'), hopBytes: 2, name: 'Unknown'); + + expect( + RepeaterNicknames.labelFor( + displayId: '7A11', + repeaters: [unknown], + hopBytes: 2, + nicknames: const {}, + useServerName: true, + ), + isNull, + ); + }); + + test('an anonymised name gives null, prefix or suffix', () { + final prefixed = + _repeater('r3', _key('7A11'), hopBytes: 2, name: '\u{1F6AB} Secret'); + final suffixed = + _repeater('r4', _key('7B22'), hopBytes: 2, name: 'Secret \u{1F6AB}'); + + for (final (id, hidden) in [('7A11', prefixed), ('7B22', suffixed)]) { + expect(hidden.isHidden, isTrue); + expect( + RepeaterNicknames.labelFor( + displayId: id, + repeaters: [prefixed, suffixed], + hopBytes: 2, + nicknames: const {}, + useServerName: true, + ), + isNull, + reason: '$id is anonymised, so its name must not be shown', + ); + } + }); + + test('a nickname still names an anonymised repeater', () { + final hidden = _repeater('r3', _key('7A11'), + hopBytes: 2, name: '\u{1F6AB} Secret'); + + expect( + RepeaterNicknames.labelFor( + displayId: '7A11', + repeaters: [hidden], + hopBytes: 2, + nicknames: {_key('7A11'): _nick('Mine')}, + useServerName: true, + ), + 'Mine', + ); + }); + + test('a hash shared by two repeaters gives null, never a guess', () { + expect( + RepeaterNicknames.labelFor( + displayId: '50', + repeaters: [mell, other], + hopBytes: 1, + nicknames: nicknames, + useServerName: true, + ), + isNull, + ); + expect( + RepeaterNicknames.labelFor( + displayId: '50', + repeaters: [mell, other], + hopBytes: 1, + nicknames: const {}, + useServerName: true, + ), + isNull, + ); + }); + + test('a row that matches no repeater gives null', () { + expect( + RepeaterNicknames.labelFor( + displayId: 'AB', + repeaters: [mell, other], + hopBytes: 1, + nicknames: const {}, + useServerName: true, + ), + isNull, + ); + }); + + test('the shared hash plus a discovery key names the server name', () { + expect( + RepeaterNicknames.labelFor( + displayId: '50', + identity: keyOther, + repeaters: [mell, other], + hopBytes: 1, + nicknames: const {}, + useServerName: true, + ), + 'Queen An\u2026', + ); + }); + }); + + group('without useServerName', () { + test('the server name is never used', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5099', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + ), + isNull, + ); + expect( + RepeaterNicknames.labelFor( + displayId: '5099', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + useServerName: false, + ), + isNull, + ); + }); + + test('an empty nickname map still returns null', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [mell, other], + hopBytes: 2, + nicknames: const {}, + useServerName: false, + ), + isNull, + ); + }); + }); + }); + + group('shortenServerName', () { + test('a name of exactly ${RepeaterNicknames.maxLength} characters has no ' + 'ellipsis', () { + expect(RepeaterNicknames.shortenServerName('Lakeview'), 'Lakeview'); + }); + + test('a name of ${RepeaterNicknames.maxLength + 1} characters is cut and ' + 'gets an ellipsis', () { + expect(RepeaterNicknames.shortenServerName('Lakeviews'), 'Lakeview\u2026'); + }); + + test('a longer name is cut to the first characters', () { + expect( + RepeaterNicknames.shortenServerName('SE-JKP-Nyk\u00F6ping-4F'), + 'SE-JKP-N\u2026', + ); + }); + + test('a cut that lands on a space leaves no gap before the ellipsis', () { + expect(RepeaterNicknames.shortenServerName('Capitol Hill'), 'Capitol\u2026'); + }); + + test('surrounding whitespace is trimmed and not counted', () { + expect(RepeaterNicknames.shortenServerName(' Lakeview '), 'Lakeview'); + expect( + RepeaterNicknames.shortenServerName(' Lakeviews '), + 'Lakeview\u2026', + ); + }); + + test('a short name is returned as is', () { + expect(RepeaterNicknames.shortenServerName('Mell'), 'Mell'); + }); + + test('an emoji at the cut is kept whole', () { + // Seven letters then a family emoji (one grapheme, several code points). + const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'; + expect( + RepeaterNicknames.shortenServerName('Abcdefg${family}Zed'), + 'Abcdefg$family\u2026', + ); + expect( + RepeaterNicknames.shortenServerName('Abcdefg$family'), + 'Abcdefg$family', + reason: 'eight characters including the emoji need no ellipsis', + ); + }); + + test('the ellipsis is a single character', () { + expect('\u2026'.length, 1); + expect(RepeaterNicknames.shortenServerName('Lakeviews')!.endsWith('\u2026'), + isTrue); + }); + + test('blank gives null', () { + expect(RepeaterNicknames.shortenServerName(''), isNull); + expect(RepeaterNicknames.shortenServerName(' '), isNull); + expect(RepeaterNicknames.shortenServerName('\t\n'), isNull); + }); }); } From b69e3cb89f223de8f98881c8ba9733d0add38455 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:21:53 -0400 Subject: [PATCH 2/8] Let Top Heard show more rows and repeater names --- DEVELOPMENT.md | 17 +++- docs/LIVE_ACTIVITIES.md | 2 +- lib/providers/app_state_provider.dart | 50 ++++++++---- lib/screens/settings/map_settings_page.dart | 78 +++++++++++++++++-- .../live_activity/live_activity_heard.dart | 11 +-- 5 files changed, 128 insertions(+), 30 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8fc984f8..c6f6524d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -242,6 +242,15 @@ catalogue, which keep server names. A change goes through `_siriRepeaterCatalogRevision`, call `_notifyMapNow()` (the overlay rebuilds on `mapRevision`) and schedule the Live Activity, watch and Siri sync. +The "Repeater Names in Top Heard" setting (`UserPreferences.topHeardShowNames`, +Settings > Map, off by default and phone-local like the nicknames) lets a Top +Heard row with no nickname show the repeater's server name in place of the hex. +`RepeaterNicknames.labelFor(useServerName:)` cuts the name to +`RepeaterNicknames.maxLength` (8) grapheme clusters plus "…". A name of +'Unknown' or a hidden 🚫 name keeps the hex. It uses the same unique-match rule +as a nickname, and a nickname always wins. It affects only the in-app Top Heard +box; the Live Activity and watch have their own name rules above. + On the map, the minimized repeater pill shows the nickname as a small tag after the name. The expanded repeater sheet has a Nickname button beside Manage, the same height and shape, reading "Nickname" until one is set and then the @@ -2338,10 +2347,14 @@ future breaking change makes undecodable still reaches the "update the iPhone app" prompt instead of going silently stale. **One heard list.** The map's Top Heard box (`_topRepeatersOverlay`, the -latest ping's top three by SNR, plus the passive RX slot) is the single source +latest ping's rows by SNR, plus the passive RX slot) is the single source for the watch's heard rows (`ExternalSurfaceGeoBuilder.buildHeard`) and the Live Activity's rows (`buildLiveActivityHeard` in -`lib/services/live_activity/live_activity_heard.dart`). It is replaced only by a +`lib/services/live_activity/live_activity_heard.dart`). The stored list holds up +to `UserPreferences.topHeardRowsMax` (8) rows. The in-app box shows +`topHeardRows` of them (3 to 8, Settings > Map > Top Heard Rows), and the watch +and Live Activity take the first three (`_surfaceTopHeardRows`), which keeps the +watch's `maxHeard` cap from dropping the RX row. It is replaced only by a ping that heard something (direct TX echoes, discovery nodes, a successful trace at its window's close), so a silent ping leaves the last heard set in place on all three; the Live Activity marks rows from before the latest send as last diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md index a8812429..d470b29c 100644 --- a/docs/LIVE_ACTIVITIES.md +++ b/docs/LIVE_ACTIVITIES.md @@ -6,7 +6,7 @@ MeshMapper starts one read-only Live Activity while an automatic wardriving sess - Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states) - System-rendered countdowns based on absolute timer deadlines -- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them. Repeater names (when "Repeater Names on Live Activity" is on) show the user's own nickname when set in Settings > Map > Repeater Nicknames, otherwise the server name +- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. It stays three whatever the in-app Top Heard Rows setting is. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them. Repeater names (when "Repeater Names on Live Activity" is on) show the user's own nickname when set in Settings > Map > Repeater Nicknames, otherwise the server name - TX/RX counters, upload queue, zone, connection state, and stale-update warnings - Lock Screen, Dynamic Island, and one iOS 18 small-family layout shared by the paired watch's Smart Stack card and the CarPlay dashboard. CarPlay's canvas overlaps the watch card sizes and nothing names the surface, so the card solves its row count and font from the measured height instead of guessing diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0f3c42f0..7af8f488 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -471,12 +471,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Top repeaters overlay, updated live on each ping event. The map's Top // Heard box, the watch's heard rows and the Live Activity's rows all read - // this one list, so the three surfaces cannot disagree. + // this one list, so the three surfaces cannot disagree. It holds up to + // UserPreferences.topHeardRowsMax rows; the map shows the user's row + // setting of them and the other surfaces the first [_surfaceTopHeardRows]. List<({String repeaterId, double snr, OverlayPingType type})> _topRepeatersOverlay = []; DateTime? _topRepeatersOverlayUpdatedAt; - /// How many repeaters the latest ping heard in all, beyond the three the + /// Ping rows the Live Activity, CarPlay and the watch show, whatever the + /// in-app Top Heard Rows setting is. + static const int _surfaceTopHeardRows = 3; + + /// How many repeaters the latest ping heard in all, beyond the rows the /// overlay keeps. The Live Activity's "HEARD NOW n" header counts them. int _topRepeatersOverlayTotalCount = 0; @@ -883,7 +889,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { RepeaterNicknames.nicknameFor(repeater, _repeaterNicknames); /// The text for a Top Heard row: its nickname when the row resolves to one - /// repeater that has one, else [repeaterId] unchanged. + /// repeater that has one, else that repeater's server name when the Top + /// Heard names setting is on, else [repeaterId] unchanged. String topHeardLabel(String repeaterId) { final id = repeaterId.toUpperCase(); return RepeaterNicknames.labelFor( @@ -892,6 +899,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: _repeaters, hopBytes: _hopBytes, nicknames: _repeaterNicknames, + useServerName: _preferences.topHeardShowNames, ) ?? repeaterId; } @@ -1377,16 +1385,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); - /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + /// The in-app Top Heard rows: the latest ping's repeaters by best SNR from + /// TX/DISC/Trace pings, up to the user's Top Heard Rows setting. List<({String repeaterId, double snr, OverlayPingType type})> - get topRepeatersBySnr => _topRepeatersOverlay; + get topRepeatersBySnr => + _topRepeatersOverlay.take(_preferences.topHeardRows).toList(); /// Best RX observation in the current 5-second window ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. - /// Replaces all 3 slots entirely (no carryover from previous pings). Only a - /// ping that heard something calls this, so a silent ping leaves the last + /// Replaces every stored row entirely (no carryover from previous pings), + /// keeping up to [UserPreferences.topHeardRowsMax] so a higher Top Heard Rows + /// setting takes effect on the next rebuild without waiting for a ping. Only + /// a ping that heard something calls this, so a silent ping leaves the last /// heard set in place on the map, the watch and the Live Activity alike. /// - Parameter identities: display hash to the fullest identity this ping /// carried for it, for the ping types that carry more than a path byte. @@ -1405,7 +1417,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .map((e) => (repeaterId: e.key, snr: e.value, type: type)) .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); - _topRepeatersOverlay = fresh.take(3).toList(); + _topRepeatersOverlay = + fresh.take(UserPreferences.topHeardRowsMax).toList(); _topRepeatersOverlayTotalCount = fresh.length; _topRepeatersOverlayUpdatedAt = DateTime.now(); } @@ -1466,7 +1479,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Clear all overlay state (top 3 + RX slot). + /// Clear all overlay state (top heard rows + RX slot). void _clearOverlayState() { _topRepeatersOverlay = []; _topRepeatersOverlayTotalCount = 0; @@ -2374,10 +2387,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final position = _resolveWatchPosition(); final ranking = _resolveRankingPosition(); - // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top - // three by SNR plus the current RX slot. Same source, so the two surfaces - // can never disagree. - final top = _topRepeatersOverlay; + // The wrist mirrors the map's "Top Heard" overlay: the top three of the + // same stored list, plus the current RX slot. Same source, so the two + // surfaces can never disagree. The list is cut here because + // ExternalSurfaceGeoBuilder.buildHeard caps its rows at + // ExternalSurfaceGeoWire.maxHeard, so more ping rows would drop the RX row. + final top = _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(); final rxSlot = _rxOverlaySlot; // Overlay IDs are hex path hashes. Resolved from the fullest identity each @@ -3532,15 +3547,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? (endsAt: t.endTime!, durationMs: null, remainingSec: t.remainingSec) : null; - /// The Live Activity's rows are the map's Top Heard box, the same list the - /// watch mirrors, so the three surfaces cannot disagree. The ordering, the - /// RX slot and the current-or-last rule live in [buildLiveActivityHeard]. + /// The Live Activity's rows are the first three of the map's Top Heard list, + /// the same three the watch mirrors, so the surfaces cannot disagree. The + /// ordering, the RX slot and the current-or-last rule live in + /// [buildLiveActivityHeard]. ({ List repeaters, int totalCount, bool isCurrent, }) _buildLiveActivityRepeaters() => buildLiveActivityHeard( - top: _topRepeatersOverlay, + top: _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(), topTotalCount: _topRepeatersOverlayTotalCount, rxSlot: _rxOverlaySlot, topAt: _topRepeatersOverlayUpdatedAt, diff --git a/lib/screens/settings/map_settings_page.dart b/lib/screens/settings/map_settings_page.dart index 99f8f0d2..75bac182 100644 --- a/lib/screens/settings/map_settings_page.dart +++ b/lib/screens/settings/map_settings_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../../models/user_preferences.dart'; import '../../providers/app_state_provider.dart'; import '../offline_maps_screen.dart'; import 'repeater_nicknames_page.dart'; @@ -115,19 +116,35 @@ class MapSettingsPage extends StatelessWidget { ), SwitchListTile( secondary: const Icon(Icons.cell_tower), - title: const Text('Top Repeaters on Map'), - subtitle: - const Text('Show top 3 repeaters by SNR from last ping'), + title: const Text('Top Heard on Map'), + subtitle: const Text('Strongest repeaters from the last ping'), value: prefs.showTopRepeaters, onChanged: (value) { appState .updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), - if (prefs.showTopRepeaters) + if (prefs.showTopRepeaters) ...[ + ListTile( + leading: const Icon(Icons.format_list_numbered), + title: const Text('Top Heard Rows'), + subtitle: Text('${prefs.topHeardRows} rows'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showTopHeardRowsSelector(context, appState), + ), + SwitchListTile( + secondary: const Icon(Icons.badge_outlined), + title: const Text('Repeater Names in Top Heard'), + subtitle: const Text('Names instead of hex IDs'), + value: prefs.topHeardShowNames, + onChanged: (value) { + appState.updatePreferences( + prefs.copyWith(topHeardShowNames: value)); + }, + ), SwitchListTile( secondary: const Icon(Icons.text_increase), - title: const Text('Larger Top 3 Overlay'), + title: const Text('Larger Top Heard Overlay'), subtitle: const Text( 'Increase the size of the overlay for easier reading'), value: prefs.largerTopRepeaters, @@ -136,6 +153,7 @@ class MapSettingsPage extends StatelessWidget { prefs.copyWith(largerTopRepeaters: value)); }, ), + ], ListTile( leading: const Icon(Icons.label_outline), title: const Text('Repeater Nicknames'), @@ -314,6 +332,56 @@ class MapSettingsPage extends StatelessWidget { }; } + /// How many ping rows the in-app Top Heard box shows. The RX row is extra, + /// and the Live Activity, CarPlay and watch keep 3 ping rows. + void _showTopHeardRowsSelector( + BuildContext context, AppStateProvider appState) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Top Heard Rows', + style: + TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.topHeardRows, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(topHeardRows: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var n = UserPreferences.topHeardRowsMin; + n <= UserPreferences.topHeardRowsMax; + n++) + RadioListTile( + title: Text('$n rows'), + value: n, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + /// Grid Mode preset selector — mirrors the web UI's Grid Mode /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). void _showCoverageGridSelector( diff --git a/lib/services/live_activity/live_activity_heard.dart b/lib/services/live_activity/live_activity_heard.dart index 8f40bd28..50f43b14 100644 --- a/lib/services/live_activity/live_activity_heard.dart +++ b/lib/services/live_activity/live_activity_heard.dart @@ -4,11 +4,12 @@ import 'live_activity_models.dart'; /// The Live Activity's heard rows, read from the map's Top Heard box. /// -/// One source for the map, the watch and the Live Activity: [top] is the -/// latest ping's top three by SNR exactly as the map overlay stores them, and -/// [rxSlot] the best passive observation of the current interval. The order is -/// the map's too, ping rows first and the RX row trailing, never a global SNR -/// sort, so the card's first rows are always the box's first rows. +/// One source for the map, the watch and the Live Activity: [top] is the first +/// three of the latest ping's rows by SNR, in the order the map overlay stores +/// them (the in-app box may show more of that list), and [rxSlot] the best +/// passive observation of the current interval. The order is the map's too, +/// ping rows first and the RX row trailing, never a global SNR sort, so the +/// card's first rows are always the box's first rows. /// /// The one place this departs from the map is a repeater that is both a ping /// row and the RX slot: the map draws it twice, this returns it once, because From c28964853106e0b82ddef84536125d25619bca61 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:40:14 -0400 Subject: [PATCH 3/8] Tidy Top Heard names and keep the watch RX row --- lib/providers/app_state_provider.dart | 8 +-- .../geo/external_surface_geo_builder.dart | 7 ++- lib/utils/repeater_nicknames.dart | 13 +++-- lib/widgets/map_widget.dart | 5 +- .../watch/watch_geo_builder_test.dart | 19 +++++++ test/utils/repeater_nicknames_test.dart | 51 +++++++++++++++---- 6 files changed, 80 insertions(+), 23 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7af8f488..e7dd1604 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -482,8 +482,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// in-app Top Heard Rows setting is. static const int _surfaceTopHeardRows = 3; - /// How many repeaters the latest ping heard in all, beyond the rows the - /// overlay keeps. The Live Activity's "HEARD NOW n" header counts them. + /// How many repeaters the latest ping heard in all, regardless of how many + /// rows are stored or shown. The Live Activity's "HEARD NOW n" header counts + /// them. int _topRepeatersOverlayTotalCount = 0; /// The fullest identity known for each overlay row, keyed by the display hash @@ -1417,8 +1418,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .map((e) => (repeaterId: e.key, snr: e.value, type: type)) .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); - _topRepeatersOverlay = - fresh.take(UserPreferences.topHeardRowsMax).toList(); + _topRepeatersOverlay = fresh.take(UserPreferences.topHeardRowsMax).toList(); _topRepeatersOverlayTotalCount = fresh.length; _topRepeatersOverlayUpdatedAt = DateTime.now(); } diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart index 8aee6732..cd4c8bf3 100644 --- a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -403,7 +403,7 @@ class ExternalSurfaceGeoBuilder { ExternalSurfaceColor.fromColor(PingColors.snrColor(snr)); /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the - /// latest ping, then the current RX slot. + /// latest ping, then the current RX slot, which the wire cap never drops. /// /// Order is deliberate rather than a global SNR sort: it matches the map /// overlay, where the RX slot is a distinct trailing row rather than a @@ -419,11 +419,14 @@ class ExternalSurfaceGeoBuilder { Map nicknames = const {}, }) { final rows = []; + // Leave the RX row its place under the wire cap. + const cap = ExternalSurfaceGeoWire.maxHeard; + final maxPingRows = rxSlot != null && rxAt != null ? cap - 1 : cap; // Missing time means the provider cannot truthfully say when this set was // heard. Omitting such a row is safer than presenting a plausible lie in // Node Detail; every production mutation records its timestamp atomically. - for (final entry in top.where((_) => topAt != null)) { + for (final entry in top.where((_) => topAt != null).take(maxPingRows)) { rows.add(_row( id: entry.repeaterId, snr: entry.snr, diff --git a/lib/utils/repeater_nicknames.dart b/lib/utils/repeater_nicknames.dart index 690df61f..06b6b315 100644 --- a/lib/utils/repeater_nicknames.dart +++ b/lib/utils/repeater_nicknames.dart @@ -147,12 +147,15 @@ class RepeaterNicknames { /// characters for a label with `…` appended when it was cut, or null when /// [name] is blank. /// - /// A name of [maxLength] characters or fewer is returned trimmed with no - /// ellipsis. The cut counts grapheme clusters, like [normalizeNickname], so an - /// emoji or an accented letter is never split. + /// Cleaned like [normalizeNickname] first: zero-width characters dropped, + /// whitespace runs collapsed to one space, and a name of nothing but format + /// characters is blank. A name of [maxLength] characters or fewer is + /// returned with no ellipsis. The cut counts grapheme clusters, so an emoji + /// or an accented letter is never split. static String? shortenServerName(String name) { - final trimmed = name.trim(); - if (trimmed.isEmpty) return null; + final trimmed = + name.replaceAll(_zeroWidth, '').replaceAll(RegExp(r'\s+'), ' ').trim(); + if (trimmed.replaceAll(_format, '').trim().isEmpty) return null; final chars = trimmed.characters; if (chars.length <= maxLength) return trimmed; return '${chars.take(maxLength).toString().trimRight()}…'; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 3449cf2b..9711d129 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -6769,7 +6769,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Build a single overlay table row with colored dot, label, and SNR. /// [label] is the user's nickname when the row resolves to one repeater - /// that has one, else the repeater's hex ID. + /// that has one, else its server name (with Repeater Names in Top Heard on), + /// else the repeater's hex ID. TableRow _overlayRow(String label, double snr, Color dotColor, {bool isLarge = false}) { final dotSize = isLarge ? 9.0 : 6.0; @@ -6821,7 +6822,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { ); } - /// Top heard repeaters overlay (bottom-right of map) + /// Top heard repeaters overlay (top-left of map, under the GPS chip) Widget _buildTopRepeatersOverlay(AppStateProvider appState) { final topRepeaters = appState.topRepeatersBySnr; final rxSlot = appState.rxOverlaySlot; diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 6c85735d..cb6a5a82 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -557,6 +557,25 @@ void main() { ); expect(built.length, WatchWire.maxHeard); + expect(built.map((n) => n.id).toList(), ['A', 'B', 'C', 'E'], + reason: 'the RX row stays and the fourth ping row is the one cut'); + }); + + test('five ping rows and no RX slot keep the first four', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: 'A', snr: 5, type: OverlayPingType.tx), + (repeaterId: 'B', snr: 4, type: OverlayPingType.tx), + (repeaterId: 'C', snr: 3, type: OverlayPingType.tx), + (repeaterId: 'D', snr: 2, type: OverlayPingType.tx), + (repeaterId: 'E', snr: 1, type: OverlayPingType.tx), + ], + repeaterByHex: const {}, + topAt: DateTime(2026, 8, 12), + rxAt: null, + ); + + expect(built.map((n) => n.id).toList(), ['A', 'B', 'C', 'D']); }); test('uppercases the hex id and resolves name plus distance', () { diff --git a/test/utils/repeater_nicknames_test.dart b/test/utils/repeater_nicknames_test.dart index f6cd9b60..4978186d 100644 --- a/test/utils/repeater_nicknames_test.dart +++ b/test/utils/repeater_nicknames_test.dart @@ -394,7 +394,8 @@ void main() { }); test('a short server name is shown whole', () { - final short = _repeater('r3', _key('7A11'), hopBytes: 2, name: 'Hilltop'); + final short = + _repeater('r3', _key('7A11'), hopBytes: 2, name: 'Hilltop'); expect( RepeaterNicknames.labelFor( @@ -425,10 +426,10 @@ void main() { }); test('an anonymised name gives null, prefix or suffix', () { - final prefixed = - _repeater('r3', _key('7A11'), hopBytes: 2, name: '\u{1F6AB} Secret'); - final suffixed = - _repeater('r4', _key('7B22'), hopBytes: 2, name: 'Secret \u{1F6AB}'); + final prefixed = _repeater('r3', _key('7A11'), + hopBytes: 2, name: '\u{1F6AB} Secret'); + final suffixed = _repeater('r4', _key('7B22'), + hopBytes: 2, name: 'Secret \u{1F6AB}'); for (final (id, hidden) in [('7A11', prefixed), ('7B22', suffixed)]) { expect(hidden.isHidden, isTrue); @@ -552,14 +553,17 @@ void main() { }); group('shortenServerName', () { - test('a name of exactly ${RepeaterNicknames.maxLength} characters has no ' + test( + 'a name of exactly ${RepeaterNicknames.maxLength} characters has no ' 'ellipsis', () { expect(RepeaterNicknames.shortenServerName('Lakeview'), 'Lakeview'); }); - test('a name of ${RepeaterNicknames.maxLength + 1} characters is cut and ' + test( + 'a name of ${RepeaterNicknames.maxLength + 1} characters is cut and ' 'gets an ellipsis', () { - expect(RepeaterNicknames.shortenServerName('Lakeviews'), 'Lakeview\u2026'); + expect( + RepeaterNicknames.shortenServerName('Lakeviews'), 'Lakeview\u2026'); }); test('a longer name is cut to the first characters', () { @@ -570,7 +574,8 @@ void main() { }); test('a cut that lands on a space leaves no gap before the ellipsis', () { - expect(RepeaterNicknames.shortenServerName('Capitol Hill'), 'Capitol\u2026'); + expect( + RepeaterNicknames.shortenServerName('Capitol Hill'), 'Capitol\u2026'); }); test('surrounding whitespace is trimmed and not counted', () { @@ -601,7 +606,8 @@ void main() { test('the ellipsis is a single character', () { expect('\u2026'.length, 1); - expect(RepeaterNicknames.shortenServerName('Lakeviews')!.endsWith('\u2026'), + expect( + RepeaterNicknames.shortenServerName('Lakeviews')!.endsWith('\u2026'), isTrue); }); @@ -610,5 +616,30 @@ void main() { expect(RepeaterNicknames.shortenServerName(' '), isNull); expect(RepeaterNicknames.shortenServerName('\t\n'), isNull); }); + + test('a newline inside the name collapses to one space', () { + expect(RepeaterNicknames.shortenServerName('Cap\nHill'), 'Cap Hill'); + }); + + test('a run of whitespace collapses before the cut is counted', () { + expect( + RepeaterNicknames.shortenServerName('Capitol \t Hill Road'), + 'Capitol\u2026', + ); + }); + + test('a zero-width character is dropped before the cut is counted', () { + // Nine characters once the zero-width space is gone, so it is cut. + expect( + RepeaterNicknames.shortenServerName('Lake\u200BUnion'), + 'LakeUnio\u2026', + ); + }); + + test('a name of only zero-width or format characters gives null', () { + expect(RepeaterNicknames.shortenServerName('\u200B\u2060'), isNull); + expect(RepeaterNicknames.shortenServerName('\u200E\u200F'), isNull, + reason: 'direction marks alone show as nothing'); + }); }); } From 268476884ed412590bc5c73d5d86acf2abe507e3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:42:22 -0400 Subject: [PATCH 4/8] Correct the Top Heard surface cap comments --- lib/providers/app_state_provider.dart | 6 +++--- .../geo/external_surface_geo_builder.dart | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e7dd1604..f53ecff2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2389,9 +2389,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // The wrist mirrors the map's "Top Heard" overlay: the top three of the // same stored list, plus the current RX slot. Same source, so the two - // surfaces can never disagree. The list is cut here because - // ExternalSurfaceGeoBuilder.buildHeard caps its rows at - // ExternalSurfaceGeoWire.maxHeard, so more ping rows would drop the RX row. + // surfaces can never disagree. The list is cut to three here by design: the + // Live Activity, CarPlay and watch show 3 ping rows plus RX, whatever the + // in-app Top Heard Rows setting is. buildHeard's RX-keeping cap backs it up. final top = _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(); final rxSlot = _rxOverlaySlot; diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart index cd4c8bf3..7439b460 100644 --- a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -402,12 +402,13 @@ class ExternalSurfaceGeoBuilder { static ExternalSurfaceColor snrColor(double snr) => ExternalSurfaceColor.fromColor(PingColors.snrColor(snr)); - /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the - /// latest ping, then the current RX slot, which the wire cap never drops. + /// The "Top Heard" overlay rows: up to [ExternalSurfaceGeoWire.maxHeard] + /// rows, the top-SNR repeaters from the latest ping, then the current RX + /// slot, which is always kept when present (ping rows are cut to make room). /// /// Order is deliberate rather than a global SNR sort: it matches the map /// overlay, where the RX slot is a distinct trailing row rather than a - /// competitor for the top three. + /// competitor for the top-SNR rows. static List buildHeard({ required List<({String repeaterId, double snr, OverlayPingType type})> top, ({String repeaterId, double snr})? rxSlot, From f5df2871cba697c38fa8e5bbd2e93215fbd4d38b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:42:39 -0400 Subject: [PATCH 5/8] Keep the surface cap comment within 80 columns --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f53ecff2..ee729460 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2391,7 +2391,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // same stored list, plus the current RX slot. Same source, so the two // surfaces can never disagree. The list is cut to three here by design: the // Live Activity, CarPlay and watch show 3 ping rows plus RX, whatever the - // in-app Top Heard Rows setting is. buildHeard's RX-keeping cap backs it up. + // in-app Top Heard Rows setting is. buildHeard's RX-keeping cap is a backup. final top = _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(); final rxSlot = _rxOverlaySlot; From f8bf4649f04bb5475b0b0762cb762d6c7bfbf854 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 18 Oct 2026 23:42:49 -0400 Subject: [PATCH 6/8] Fit the surface cap comment in 80 columns --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ee729460..80c53584 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -2391,7 +2391,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // same stored list, plus the current RX slot. Same source, so the two // surfaces can never disagree. The list is cut to three here by design: the // Live Activity, CarPlay and watch show 3 ping rows plus RX, whatever the - // in-app Top Heard Rows setting is. buildHeard's RX-keeping cap is a backup. + // in-app Top Heard Rows setting is. buildHeard's RX cap is the backstop. final top = _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(); final rxSlot = _rxOverlaySlot; From f9e2cd64d89a497a56c9d099c96aedc4d8806146 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 19 Oct 2026 03:33:03 -0400 Subject: [PATCH 7/8] Name the Top Heard row default --- lib/models/user_preferences.dart | 11 ++++++----- test/models/user_preferences_top_heard_test.dart | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 01b460f1..9b65ab30 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -123,9 +123,10 @@ class UserPreferences { /// Double the size of the Top Heard overlay for easier reading final bool largerTopRepeaters; - /// Fewest and most rows the Top Heard overlay can show. + /// Fewest and most rows the Top Heard overlay can show, and the default. static const int topHeardRowsMin = 3; static const int topHeardRowsMax = 8; + static const int topHeardRowsDefault = 3; /// Rows the in-app Top Heard overlay shows (the RX row is extra). The Live /// Activity, CarPlay and watch always show 3. @@ -217,7 +218,7 @@ class UserPreferences { this.autoStopAfterIdle = true, this.showTopRepeaters = false, this.largerTopRepeaters = false, - this.topHeardRows = 3, + this.topHeardRows = topHeardRowsDefault, this.topHeardShowNames = false, this.liveActivityShowNames = true, this.markerStyle = 'dot', @@ -341,10 +342,10 @@ class UserPreferences { } /// A stored Top Heard row count, clamped to the supported range. A missing - /// or non-numeric value (or NaN/infinity) gives the default of 3, so a bad - /// stored value never reaches the UI. + /// or non-numeric value (or NaN/infinity) gives [topHeardRowsDefault], so a + /// bad stored value never reaches the UI. static int _topHeardRowsFromJson(Object? value) { - if (value is! num || !value.isFinite) return 3; + if (value is! num || !value.isFinite) return topHeardRowsDefault; return value.toInt().clamp(topHeardRowsMin, topHeardRowsMax); } diff --git a/test/models/user_preferences_top_heard_test.dart b/test/models/user_preferences_top_heard_test.dart index c34d4f0e..4e2be0a8 100644 --- a/test/models/user_preferences_top_heard_test.dart +++ b/test/models/user_preferences_top_heard_test.dart @@ -2,9 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/models/user_preferences.dart'; void main() { - test('the row range is 3 to 8', () { + test('the row range is 3 to 8, defaulting to 3', () { expect(UserPreferences.topHeardRowsMin, 3); expect(UserPreferences.topHeardRowsMax, 8); + expect(UserPreferences.topHeardRowsDefault, 3); }); test('Top Heard shows 3 rows with hex IDs by default', () { From 836c4cc75e097cb2900c1b30c2a746a04891a807 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 19 Oct 2026 03:43:05 -0400 Subject: [PATCH 8/8] Count an RX repeater once in the Live Activity heard total --- lib/providers/app_state_provider.dart | 14 ++++---- .../live_activity/live_activity_heard.dart | 13 ++++--- .../live_activity_heard_test.dart | 35 +++++++++++++++---- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 80c53584..222fa671 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -482,10 +482,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// in-app Top Heard Rows setting is. static const int _surfaceTopHeardRows = 3; - /// How many repeaters the latest ping heard in all, regardless of how many - /// rows are stored or shown. The Live Activity's "HEARD NOW n" header counts - /// them. - int _topRepeatersOverlayTotalCount = 0; + /// Every repeater the latest ping heard (upper-case display IDs), regardless + /// of how many rows are stored or shown. The Live Activity's "HEARD NOW n" + /// header counts them, plus the RX repeater only when it is not one of them. + Set _topRepeatersOverlayHeardIds = const {}; /// The fullest identity known for each overlay row, keyed by the display hash /// the row is shown under. @@ -1419,7 +1419,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList() ..sort((a, b) => b.snr.compareTo(a.snr)); _topRepeatersOverlay = fresh.take(UserPreferences.topHeardRowsMax).toList(); - _topRepeatersOverlayTotalCount = fresh.length; + _topRepeatersOverlayHeardIds = bestSnr.keys.toSet(); _topRepeatersOverlayUpdatedAt = DateTime.now(); } @@ -1482,7 +1482,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Clear all overlay state (top heard rows + RX slot). void _clearOverlayState() { _topRepeatersOverlay = []; - _topRepeatersOverlayTotalCount = 0; + _topRepeatersOverlayHeardIds = const {}; _topRepeatersOverlayUpdatedAt = null; _overlayIdentityById = const {}; _rxOverlaySlot = null; @@ -3557,7 +3557,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool isCurrent, }) _buildLiveActivityRepeaters() => buildLiveActivityHeard( top: _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(), - topTotalCount: _topRepeatersOverlayTotalCount, + topHeardIds: _topRepeatersOverlayHeardIds, rxSlot: _rxOverlaySlot, topAt: _topRepeatersOverlayUpdatedAt, rxAt: _liveActivityRxUpdatedAt, diff --git a/lib/services/live_activity/live_activity_heard.dart b/lib/services/live_activity/live_activity_heard.dart index 50f43b14..0efdb2e0 100644 --- a/lib/services/live_activity/live_activity_heard.dart +++ b/lib/services/live_activity/live_activity_heard.dart @@ -23,10 +23,15 @@ import 'live_activity_models.dart'; /// dimmed, so a silent ping never reads as an old result left on screen. /// A fresh RX observation during such a cycle is the only current thing, so it /// is shown on its own. +/// +/// [topHeardIds] is every repeater the latest ping heard (upper-case display +/// IDs), not only the [top] rows shown. The "HEARD NOW" total counts them, plus +/// the RX repeater only when the ping did not hear it, so an RX repeater the +/// ping ranked below the shown rows is not counted twice. ({List repeaters, int totalCount, bool isCurrent}) buildLiveActivityHeard({ required List<({String repeaterId, double snr, OverlayPingType type})> top, - required int topTotalCount, + required Set topHeardIds, required ({String repeaterId, double snr})? rxSlot, required DateTime? topAt, required DateTime? rxAt, @@ -66,7 +71,7 @@ import 'live_activity_models.dart'; } } - var totalCount = topIsCurrent ? topTotalCount : 0; + var totalCount = topIsCurrent ? topHeardIds.length : 0; if (rxIsCurrent && rxSlot != null && rxSlot.snr.isFinite) { final id = rxSlot.repeaterId.toUpperCase(); if (shown.add(id)) { @@ -79,8 +84,8 @@ import 'live_activity_models.dart'; snrColor: ExternalSurfaceGeoBuilder.snrColor(rxSlot.snr), )); } - final pingHeardIt = top.any((row) => row.repeaterId.toUpperCase() == id); - if (!pingHeardIt) totalCount++; + final pingCountedIt = topIsCurrent && topHeardIds.contains(id); + if (!pingCountedIt) totalCount++; } if (totalCount < repeaters.length) totalCount = repeaters.length; diff --git a/test/services/live_activity/live_activity_heard_test.dart b/test/services/live_activity/live_activity_heard_test.dart index 4c7059af..26b57010 100644 --- a/test/services/live_activity/live_activity_heard_test.dart +++ b/test/services/live_activity/live_activity_heard_test.dart @@ -16,7 +16,7 @@ void main() { (repeaterId: 'A1', snr: 8.5, type: OverlayPingType.tx), (repeaterId: 'B2', snr: 2.25, type: OverlayPingType.disc), ], - topTotalCount: 2, + topHeardIds: const {'A1', 'B2'}, rxSlot: (repeaterId: 'C3', snr: 9.9), topAt: t0, rxAt: t0, @@ -44,7 +44,7 @@ void main() { test('a silent ping clears the rows instead of dimming them', () { final result = buildLiveActivityHeard( top: const [(repeaterId: 'A1', snr: 8.5, type: OverlayPingType.tx)], - topTotalCount: 1, + topHeardIds: const {'A1'}, rxSlot: null, topAt: t0, rxAt: null, @@ -63,7 +63,7 @@ void main() { // The SwiftUI ForEach identifies rows by id and cannot draw one twice. final result = buildLiveActivityHeard( top: const [(repeaterId: 'a1', snr: 5, type: OverlayPingType.tx)], - topTotalCount: 1, + topHeardIds: const {'A1'}, rxSlot: (repeaterId: 'A1', snr: 9), topAt: t0, rxAt: t0, @@ -87,7 +87,7 @@ void main() { (repeaterId: 'B2', snr: 7, type: OverlayPingType.disc), (repeaterId: 'C3', snr: 6, type: OverlayPingType.disc), ], - topTotalCount: 5, + topHeardIds: const {'A1', 'B2', 'C3', 'E5', 'F6'}, rxSlot: (repeaterId: 'D4', snr: 1), topAt: t0, rxAt: t0, @@ -101,10 +101,31 @@ void main() { 'only the three the box shows, plus the RX row'); }); + test('an RX repeater the ping heard below the shown rows counts once', () { + final result = buildLiveActivityHeard( + top: const [ + (repeaterId: 'A1', snr: 8, type: OverlayPingType.disc), + (repeaterId: 'B2', snr: 7, type: OverlayPingType.disc), + (repeaterId: 'C3', snr: 6, type: OverlayPingType.disc), + ], + topHeardIds: const {'A1', 'B2', 'C3', 'D4', 'E5'}, + rxSlot: (repeaterId: 'D4', snr: 1), + topAt: t0, + rxAt: t0, + cycleStartedAt: beforeT0, + nameFor: nameFor, + ); + + expect(result.repeaters.map((r) => r.id), ['A1', 'B2', 'C3', 'D4'], + reason: 'D4 is not one of the shown ping rows, so its RX row stays'); + expect(result.totalCount, 5, + reason: 'the ping already counted D4 as its 4th repeater'); + }); + test('a fresh RX during a silent cycle shows only the RX row', () { final result = buildLiveActivityHeard( top: const [(repeaterId: 'A1', snr: 8.5, type: OverlayPingType.tx)], - topTotalCount: 1, + topHeardIds: const {'A1'}, rxSlot: (repeaterId: 'D4', snr: 1.5), topAt: t0, rxAt: t0.add(const Duration(seconds: 12)), @@ -123,7 +144,7 @@ void main() { (repeaterId: 'a1', snr: 8.5, type: OverlayPingType.tx), (repeaterId: 'b2', snr: -3, type: OverlayPingType.tx), ], - topTotalCount: 2, + topHeardIds: const {'A1', 'B2'}, rxSlot: null, topAt: t0, rxAt: null, @@ -146,7 +167,7 @@ void main() { (repeaterId: 'A1', snr: double.nan, type: OverlayPingType.tx), (repeaterId: 'B2', snr: 2, type: OverlayPingType.tx), ], - topTotalCount: 2, + topHeardIds: const {'A1', 'B2'}, rxSlot: (repeaterId: 'C3', snr: double.infinity), topAt: t0, rxAt: t0,