diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5ecc21a73..dbb9bf17f 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 2d68a26df..3c6214adb 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -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( - 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( - 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 = []; + + void addDivider() => children.add(const _SectionDivider()); + + void addChannelRows( + List 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 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( + 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( + 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], ), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index f7344086e..7412944a0 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -22,6 +22,7 @@ class _ChannelTile extends ConsumerWidget { this.isMuted = false, this.onMarkRead, this.sectionId, + super.key, }); @override diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 6e17845d7..38050bed7 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -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 channels; - final Set unreadChannelIds; - final Set 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 onSortModeChange; - final Future 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> _sortMenuItems( ), ]; -class _ChannelSection extends StatelessWidget { - final String title; - final IconData icon; - final bool expanded; - final VoidCallback onToggle; - final List channels; - final bool showTopDivider; - final Set unreadChannelIds; - final Set mutedChannelIds; - final String? currentPubkey; - final String emptyLabel; - final ChannelSortMode? sortMode; - final ValueChanged? onSortModeChange; - final Future 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), - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index f925724b5..8d0370c1f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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( + 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(