perf(mobile): lazily build channel list rows

Flatten section content into direct sliver children so Flutter only builds
channel rows near the viewport. Keep grouping, menus, unread state, and
collapsed-section behavior while preventing off-screen DM rows from
subscribing to profile and presence caches.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-13 16:41:52 -06:00
co-authored by Carl
parent 76f114a252
commit b796e0e1ae
5 changed files with 256 additions and 407 deletions
@@ -95,10 +95,7 @@ const double _kTopSectionBottomPadding = Grid.xxs;
/// near the channel-icon column.
const double _kTopSectionInset = Grid.twelve;
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
const Curve _kSectionCollapseCurve = Curves.easeInCubic;
const double _kSectionCollapsedScaleY = 0.98;
const double _kHeaderFrostScrollDistance = Grid.xxl;
const double _kHeaderFrostMaxBlurSigma = 23.12;
@@ -226,116 +226,30 @@ class _SliverChannelsList extends HookConsumerWidget {
};
}
return SliverPadding(
padding: EdgeInsets.only(
top: Grid.xxs,
bottom: MediaQuery.paddingOf(context).bottom,
),
sliver: SliverList.list(
children: [
if (visibleChannels.isEmpty)
const _EmptyState()
else ...[
// Starred channels (exclusive — pinned above all sections).
if (starredStreamChannels.isNotEmpty)
_ChannelSection(
title: 'Starred',
icon: LucideIcons.star,
showTopDivider: false,
expanded: starredExpanded.value,
onToggle: () => starredExpanded.value = !starredExpanded.value,
channels: starredStreamChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: '',
sortMode: sortState.sortModeFor('starred'),
onSortModeChange: (mode) => setSortMode('starred', mode),
onSelectChannel: onSelectChannel,
),
// User-defined sections for stream channels, in user-defined order.
for (final section in userSections)
_CustomChannelSection(
section: section,
channels: sortChannelsForList(
streamChannels
.where(
(c) =>
sectionAssignments[c.id] == section.id &&
!starredChannelIds.contains(c.id),
)
.toList(),
sortState.sortModeFor(sectionSortGroupKey(section.id)),
),
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
expanded: sectionExpanded(section.id),
isFirst: userSections.first.id == section.id,
isLast: userSections.last.id == section.id,
showTopDivider:
starredStreamChannels.isNotEmpty ||
userSections.first.id != section.id,
onToggle: () => toggleSection(section.id),
onRename: () async {
final name = await showBuzzDialog<String>(
context: context,
builder: (_) => _SectionNameDialog(
title: 'Rename Section',
confirmLabel: 'Rename',
initialValue: section.name,
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.renameSection(section.id, name);
}
},
onDelete: () async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text('Delete "${section.name}"?'),
content: const Text(
'Channels in this section will move back to the main list.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Delete',
style: TextStyle(color: context.colors.error),
),
),
],
),
);
if (confirmed == true) {
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
}
},
onMoveUp: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionUp(section.id),
onMoveDown: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionDown(section.id),
sortMode: sortState.sortModeFor(
sectionSortGroupKey(section.id),
),
onSortModeChange: (mode) =>
setSortMode(sectionSortGroupKey(section.id), mode),
onSelectChannel: onSelectChannel,
onMarkChannelRead: (channel) {
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts != null) {
final children = <Widget>[];
void addDivider() => children.add(const _SectionDivider());
void addChannelRows(
List<Channel> sectionChannels, {
required bool expanded,
String? sectionId,
bool allowMarkRead = false,
}) {
if (!expanded) return;
for (final channel in sectionChannels) {
children.add(
_ChannelTile(
key: ValueKey('channel-tile-${channel.id}'),
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: allowMarkRead
? () {
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts == null) return;
ref
.read(readStateProvider.notifier)
.markContextRead(
@@ -347,41 +261,174 @@ class _SliverChannelsList extends HookConsumerWidget {
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, ts);
}
},
),
_ChannelSection(
title: 'Channels',
icon: LucideIcons.hash,
showTopDivider:
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
channels: ungroupedStreamChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No stream channels yet',
sortMode: sortState.sortModeFor('channels'),
onSortModeChange: (mode) => setSortMode('channels', mode),
onSelectChannel: onSelectChannel,
),
_ChannelSection(
title: 'DMs',
icon: LucideIcons.messagesSquare,
showTopDivider: true,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
channels: sortedDmChannels,
unreadChannelIds: unreadChannelIds,
mutedChannelIds: mutedChannelIds,
currentPubkey: currentPubkey,
emptyLabel: 'No direct messages yet',
sortMode: sortState.sortModeFor('dms'),
onSortModeChange: (mode) => setSortMode('dms', mode),
onSelectChannel: onSelectChannel,
),
],
],
: null,
sectionId: sectionId,
),
);
}
children.add(const SizedBox(height: _kExpandedSectionTrailingPadding));
}
void addBuiltInSection({
required String title,
required IconData icon,
required bool showTopDivider,
required bool expanded,
required VoidCallback onToggle,
required List<Channel> sectionChannels,
required String emptyLabel,
required String sortKey,
}) {
if (showTopDivider) addDivider();
children.add(
_SectionHeader(
label: title,
icon: icon,
expanded: expanded,
onToggle: onToggle,
sortMode: sortState.sortModeFor(sortKey),
onSortModeChange: (mode) => setSortMode(sortKey, mode),
),
);
if (expanded && sectionChannels.isEmpty) {
children.add(_EmptySectionLabel(label: emptyLabel));
children.add(const SizedBox(height: _kExpandedSectionTrailingPadding));
} else {
addChannelRows(sectionChannels, expanded: expanded);
}
}
if (visibleChannels.isEmpty) {
children.add(const _EmptyState());
} else {
if (starredStreamChannels.isNotEmpty) {
addBuiltInSection(
title: 'Starred',
icon: LucideIcons.star,
showTopDivider: false,
expanded: starredExpanded.value,
onToggle: () => starredExpanded.value = !starredExpanded.value,
sectionChannels: starredStreamChannels,
emptyLabel: '',
sortKey: 'starred',
);
}
for (final section in userSections) {
final sectionChannels = sortChannelsForList(
streamChannels
.where(
(channel) =>
sectionAssignments[channel.id] == section.id &&
!starredChannelIds.contains(channel.id),
)
.toList(),
sortState.sortModeFor(sectionSortGroupKey(section.id)),
);
final expanded = sectionExpanded(section.id);
final isFirst = userSections.first.id == section.id;
if (starredStreamChannels.isNotEmpty || !isFirst) addDivider();
children.add(
_CustomSectionHeader(
section: section,
expanded: expanded,
isFirst: isFirst,
isLast: userSections.last.id == section.id,
onToggle: () => toggleSection(section.id),
onRename: () async {
final name = await showBuzzDialog<String>(
context: context,
builder: (_) => _SectionNameDialog(
title: 'Rename Section',
confirmLabel: 'Rename',
initialValue: section.name,
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.renameSection(section.id, name);
}
},
onDelete: () async {
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text('Delete "${section.name}"?'),
content: const Text(
'Channels in this section will move back to the main list.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
'Delete',
style: TextStyle(color: context.colors.error),
),
),
],
),
);
if (confirmed == true) {
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
}
},
onMoveUp: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionUp(section.id),
onMoveDown: () => ref
.read(channelSectionsProvider.notifier)
.moveSectionDown(section.id),
sortMode: sortState.sortModeFor(sectionSortGroupKey(section.id)),
onSortModeChange: (mode) =>
setSortMode(sectionSortGroupKey(section.id), mode),
),
);
addChannelRows(
sectionChannels,
expanded: expanded,
sectionId: section.id,
allowMarkRead: true,
);
}
addBuiltInSection(
title: 'Channels',
icon: LucideIcons.hash,
showTopDivider:
starredStreamChannels.isNotEmpty || userSections.isNotEmpty,
expanded: channelsExpanded.value,
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
sectionChannels: ungroupedStreamChannels,
emptyLabel: 'No stream channels yet',
sortKey: 'channels',
);
addBuiltInSection(
title: 'DMs',
icon: LucideIcons.messagesSquare,
showTopDivider: true,
expanded: dmsExpanded.value,
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
sectionChannels: sortedDmChannels,
emptyLabel: 'No direct messages yet',
sortKey: 'dms',
);
}
return SliverPadding(
padding: EdgeInsets.only(
top: Grid.xxs,
bottom: MediaQuery.paddingOf(context).bottom,
),
sliver: SliverList.builder(
itemCount: children.length,
itemBuilder: (context, index) => children[index],
),
);
}
@@ -22,6 +22,7 @@ class _ChannelTile extends ConsumerWidget {
this.isMuted = false,
this.onMarkRead,
this.sectionId,
super.key,
});
@override
@@ -2,90 +2,6 @@ part of '../channels_page.dart';
const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0);
class _CustomChannelSection extends StatelessWidget {
final ChannelSection section;
final List<Channel> channels;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final bool expanded;
final bool isFirst;
final bool isLast;
final bool showTopDivider;
final VoidCallback onToggle;
final VoidCallback onRename;
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
final void Function(Channel channel) onMarkChannelRead;
const _CustomChannelSection({
required this.section,
required this.channels,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.expanded,
required this.isFirst,
required this.isLast,
required this.showTopDivider,
required this.onToggle,
required this.onRename,
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
required this.onSelectChannel,
required this.onMarkChannelRead,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_CustomSectionHeader(
section: section,
expanded: expanded,
isFirst: isFirst,
isLast: isLast,
onToggle: onToggle,
onRename: onRename,
onDelete: onDelete,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: () => onMarkChannelRead(channel),
sectionId: section.id,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
}
class _CustomSectionHeader extends ConsumerWidget {
final ChannelSection section;
final bool expanded;
@@ -370,89 +286,26 @@ List<PopupMenuEntry<String>> _sortMenuItems(
),
];
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final List<Channel> channels;
final bool showTopDivider;
final Set<String> unreadChannelIds;
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
class _EmptySectionLabel extends StatelessWidget {
final String label;
const _ChannelSection({
required this.title,
required this.icon,
required this.expanded,
required this.onToggle,
required this.channels,
required this.showTopDivider,
required this.unreadChannelIds,
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
this.sortMode,
this.onSortModeChange,
required this.onSelectChannel,
});
const _EmptySectionLabel({required this.label});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showTopDivider) const _SectionDivider(),
_SectionHeader(
label: title,
icon: icon,
expanded: expanded,
onToggle: onToggle,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (channels.isEmpty)
Padding(
padding: const EdgeInsets.only(
left: _kChannelLabelInset,
right: _kChannelSectionInset,
top: Grid.half,
bottom: Grid.half,
),
child: Text(
emptyLabel,
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
for (final channel in channels)
_ChannelTile(
channel: channel,
isUnread: unreadChannelIds.contains(channel.id),
isMuted: mutedChannelIds.contains(channel.id),
currentPubkey: currentPubkey,
onTap: () => onSelectChannel(channel),
onMarkRead: null,
sectionId: null,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
],
);
}
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(
left: _kChannelLabelInset,
right: _kChannelSectionInset,
top: Grid.half,
bottom: Grid.half,
),
child: Text(
label,
style: contentListBodyTextStyle.copyWith(
color: context.colors.onSurfaceVariant,
),
),
);
}
class _EmptyState extends StatelessWidget {
@@ -620,98 +473,3 @@ class _SectionChevron extends StatelessWidget {
);
}
}
class _AnimatedSectionBody extends HookWidget {
final bool expanded;
final Widget child;
const _AnimatedSectionBody({required this.expanded, required this.child});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final controller = useAnimationController(
duration: reducedMotion ? Duration.zero : _kSectionExpandDuration,
reverseDuration: reducedMotion
? Duration.zero
: _kSectionCollapseDuration,
initialValue: expanded ? 1 : 0,
);
final curvedAnimation = useMemoized(
() => CurvedAnimation(
parent: controller,
curve: _kSectionExpandCurve,
reverseCurve: _kSectionCollapseCurve,
),
[controller],
);
final shouldRender = useState(expanded);
useEffect(() => curvedAnimation.dispose, [curvedAnimation]);
useEffect(() {
void handleStatus(AnimationStatus status) {
if (status == AnimationStatus.dismissed && !expanded) {
shouldRender.value = false;
}
}
controller.addStatusListener(handleStatus);
return () => controller.removeStatusListener(handleStatus);
}, [controller, expanded]);
useEffect(() {
controller.duration = reducedMotion
? Duration.zero
: _kSectionExpandDuration;
controller.reverseDuration = reducedMotion
? Duration.zero
: _kSectionCollapseDuration;
if (expanded) {
shouldRender.value = true;
if (reducedMotion) {
controller.value = 1;
} else {
unawaited(controller.forward());
}
} else if (reducedMotion) {
controller.value = 0;
shouldRender.value = false;
} else {
unawaited(controller.reverse());
}
return null;
}, [controller, expanded, reducedMotion]);
return ClipRect(
child: AnimatedBuilder(
animation: curvedAnimation,
child: shouldRender.value ? child : const SizedBox.shrink(),
builder: (context, child) {
final value = curvedAnimation.value.clamp(0.0, 1.0);
final scaleY =
_kSectionCollapsedScaleY +
((1 - _kSectionCollapsedScaleY) * value);
return Align(
alignment: Alignment.topCenter,
heightFactor: value,
child: Opacity(
opacity: value,
child: Transform.scale(
alignment: Alignment.topCenter,
scaleY: scaleY,
child: ExcludeSemantics(
excluding: !expanded,
child: IgnorePointer(ignoring: !expanded, child: child),
),
),
),
);
},
),
);
}
}
@@ -194,6 +194,52 @@ void main() {
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
});
testWidgets('lazily builds channel rows near the viewport', (tester) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final manyChannels = List.generate(
300,
(index) => Channel(
id: 'channel-$index',
name: 'channel-${index.toString().padLeft(3, '0')}',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(manyChannels)),
],
),
);
await tester.pumpAndSettle();
expect(find.text('channel-000'), findsOneWidget);
expect(find.text('channel-299'), findsNothing);
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(find.text('channel-000'), findsNothing);
expect(find.text('channel-299'), findsOneWidget);
});
testWidgets('sizes the community header for accessible text', (tester) async {
await tester.pumpWidget(
buildTestable(