From 7dd0e2b6338386eb5b000dfaf40e28a5d4f8f8e7 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 19 Jul 2026 01:48:49 -0400 Subject: [PATCH] perf(#307): count rail filters in one pass over contacts From the Gemini pre-PR review (standards#145). ContactFilterRail computed each row's count with its own `where().length`, so the contact list was walked once per filter. On a ~350-contact radio that is ~2100 iterations, re-run on every MeshCoreConnector notification, and those arrive per packet during sync. Now a single pass fills all six counts, and the per-contact unread lookup happens at most once instead of once per filter. Also documents why context.select is NOT used in ChannelDrawerList, since the review recommended it: `channels` is `List.unmodifiable(_channels)`, a fresh instance per call, and Dart lists have no value equality, so select would rebuild exactly as often as watch. Selecting on `length` instead would go stale on a rename or reorder. watch is correct here; the comment records that so the next reader does not re-litigate it. Review findings not acted on: - Claimed BLOCKER (context across an async gap in _blockChannelSender) is a false positive: `mounted` guards are already present at :614 and :618 and the messenger is captured before the await. It is also pre-existing code from #172, not this branch. - _lastChannelSendAt not cleared on channel switch: real behavior change, but the suggested fix is questionable. That field is a send cooldown protecting the radio, so clearing it on switch would let the limit be bypassed by hopping channels. Raised with the owner rather than changed unilaterally. flutter analyze clean, dart format clean, 445 tests pass. --- lib/widgets/channel_drawer_list.dart | 7 +++++ lib/widgets/contact_filter_rail.dart | 38 ++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/lib/widgets/channel_drawer_list.dart b/lib/widgets/channel_drawer_list.dart index 1527a28..7bdd267 100644 --- a/lib/widgets/channel_drawer_list.dart +++ b/lib/widgets/channel_drawer_list.dart @@ -25,6 +25,13 @@ class ChannelDrawerList extends StatelessWidget { @override Widget build(BuildContext context) { + // watch, deliberately. context.select cannot help here: `channels` is + // `List.unmodifiable(_channels)`, a fresh instance per call, and Dart lists + // have no value equality, so select would see a new object every + // notification and rebuild just as often. Selecting on `length` instead + // would go stale on a rename or reorder. The rebuild is cheap regardless: + // ListView.builder only builds visible tiles, and each tile isolates its + // own unread count with select. final channels = context.watch().channels; if (channels.isEmpty) { diff --git a/lib/widgets/contact_filter_rail.dart b/lib/widgets/contact_filter_rail.dart index b10b093..a631f22 100644 --- a/lib/widgets/contact_filter_rail.dart +++ b/lib/widgets/contact_filter_rail.dart @@ -83,13 +83,39 @@ class ContactFilterRail extends StatelessWidget { } } + /// Counts every filter in ONE pass over the contact list. + /// + /// The obvious shape (a `where().length` per row) walks the whole list once + /// per filter, so a 350-contact radio costs ~2100 iterations on every + /// MeshCoreConnector notification, and those arrive per packet during sync. + /// One pass keeps it at N, and the per-contact unread lookup is done at most + /// once rather than once per filter. + Map _countsByFilter( + MeshCoreConnector connector, + bool unreadOnly, + ) { + final counts = {for (final f in _entries) f: 0}; + for (final contact in connector.contacts) { + // Repeaters are exempt: unread is never tracked for them, so an + // unread-only view must not drop them from their own row's count. + final hasUnread = + !unreadOnly || connector.getUnreadCountForContact(contact) > 0; + for (final filter in _entries) { + if (!_matches(contact, filter)) continue; + if (unreadOnly && typeSupportsUnread(filter) && !hasUnread) continue; + counts[filter] = counts[filter]! + 1; + } + } + return counts; + } + @override Widget build(BuildContext context) { final connector = context.watch(); final viewState = context.watch(); final selected = viewState.contactsTypeFilter; final unreadOnly = viewState.contactsShowUnreadOnly; - final contacts = connector.contacts; + final counts = _countsByFilter(connector, unreadOnly); return ListView( padding: EdgeInsets.zero, @@ -99,15 +125,7 @@ class ContactFilterRail extends StatelessWidget { icon: _icon(filter), label: _label(context, filter), selected: filter == selected, - // Count what the user would actually land on, so the number never - // disagrees with the list after tapping. - count: contacts.where((c) { - if (!_matches(c, filter)) return false; - if (unreadOnly && typeSupportsUnread(filter)) { - return connector.getUnreadCountForContact(c) > 0; - } - return true; - }).length, + count: counts[filter] ?? 0, onTap: () { final scaffold = Scaffold.maybeOf(context); if (scaffold?.isDrawerOpen ?? false) {