mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user