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) {