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.
pull/324/head
Strycher 3 days ago
parent 746e102330
commit 7dd0e2b633

@ -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<MeshCoreConnector>().channels;
if (channels.isEmpty) {

@ -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<ContactTypeFilter, int> _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<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
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) {

Loading…
Cancel
Save

Powered by TurnKey Linux.