From d3c43ac949106b5e4e06d5f4ecc441872be0c737 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 08:55:58 +0100 Subject: [PATCH 1/5] Polish mobile navigation and menus Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 24 +- .../activity_page/header_actions.dart | 8 +- .../channel_detail_page/message_bubble.dart | 286 ++++++++-------- .../lib/features/channels/channels_page.dart | 2 + .../channels/channels_page/sections.dart | 95 +++--- .../features/channels/thread_detail_page.dart | 306 +++++++++--------- mobile/lib/features/home/home_page.dart | 15 +- mobile/lib/features/search/search_page.dart | 2 + .../widgets/anchored_popover_menu.dart} | 54 ++-- .../lib/shared/widgets/frosted_scaffold.dart | 6 + .../widgets/mobile_tab_footer_backdrop.dart | 49 +++ 11 files changed, 478 insertions(+), 369 deletions(-) rename mobile/lib/{features/activity/activity_page/popover_menu.dart => shared/widgets/anchored_popover_menu.dart} (81%) create mode 100644 mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index d5ea8d1c53..e0a176e957 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui' show SemanticsRole; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -11,6 +9,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -34,7 +33,6 @@ import 'reminders_provider.dart'; part 'activity_page/header_actions.dart'; part 'activity_page/inbox_row.dart'; part 'activity_page/lists.dart'; -part 'activity_page/popover_menu.dart'; part 'activity_page/status_views.dart'; /// Conversation-oriented Activity inbox. @@ -284,6 +282,7 @@ class ActivityPage extends HookConsumerWidget { } return FrostedScaffold( + backgroundColor: Colors.transparent, appBar: FrostedAppBar( gradient: context.appColors.topSectionGradient, automaticallyImplyLeading: false, @@ -308,14 +307,19 @@ class ActivityPage extends HookConsumerWidget { ), ], ), - body: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only( - top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), + body: Stack( + children: [ + SafeArea( + top: false, + bottom: false, + child: Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), + ), + child: body, + ), ), - child: body, - ), + ], ), ); } diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 1aab78e622..56592e2e69 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -33,10 +33,10 @@ class _FilterMenuButton extends StatelessWidget { key: const ValueKey('activity-filter-menu'), borderRadius: BorderRadius.circular(Radii.md), onTap: () async { - final selected = await _showActivityPopover( + final selected = await showAnchoredPopover( context: buttonContext, width: 240, - alignment: _ActivityPopoverAlignment.start, + alignment: AnchoredPopoverAlignment.start, offset: const Offset(0, Grid.half), menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), color: context.colors.surface.withValues(alpha: 0.98), @@ -179,10 +179,10 @@ class _InboxOptionsButton extends StatelessWidget { tooltip: 'Activity options', icon: const Icon(LucideIcons.ellipsis, size: 20), onPressed: () async { - final selected = await _showActivityPopover( + final selected = await showAnchoredPopover( context: buttonContext, width: 216, - alignment: _ActivityPopoverAlignment.end, + alignment: AnchoredPopoverAlignment.end, color: context.colors.surface, elevation: 4, shadowColor: context.colors.shadow.withValues(alpha: 0.18), diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 4a49c9b210..5c428fc67b 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -52,156 +52,166 @@ class _MessageBubble extends ConsumerWidget { } } - return Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's trailing - // gutter. InkWell still clips its ink to [borderRadius], while leaving - // overflowing message content visible. - clipBehavior: Clip.none, - child: InkWell( - key: ValueKey('message-row-${message.id}'), + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: Material( + color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: currentChannelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.xs : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + // The media carousel intentionally continues through the list's trailing + // gutter. InkWell still clips its ink to [borderRadius], while leaving + // overflowing message content visible. + clipBehavior: Clip.none, + child: InkWell( + key: ValueKey('message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: currentChannelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _UserAvatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _UserAvatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 8edd056e76..ba5d4ebf9d 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -14,6 +14,7 @@ import '../../shared/community/community_icon_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -226,6 +227,7 @@ class ChannelsPage extends HookConsumerWidget { }, [isReconnectingWithContent]); return FrostedScaffold( + backgroundColor: Colors.transparent, appBar: FrostedAppBar( horizontalInset: _kTopSectionInset, // Under a Buzz theme the community + account avatar strip carries the diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 63bd5db9d9..d8cafa3321 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -160,48 +160,61 @@ class _CustomSectionHeader extends ConsumerWidget { ), ), const Spacer(), - GestureDetector( - onTapUp: (details) async { - final overlay = - Overlay.of(context).context.findRenderObject()! - as RenderBox; - final position = RelativeRect.fromRect( - details.globalPosition & Size.zero, - Offset.zero & overlay.size, - ); - final value = await showMenu( - context: context, - position: position, - items: [ - const PopupMenuItem(value: 'rename', child: Text('Rename')), - PopupMenuItem( - value: 'move_up', - enabled: !isFirst, - child: const Text('Move Up'), - ), - PopupMenuItem( - value: 'move_down', - enabled: !isLast, - child: const Text('Move Down'), + Builder( + builder: (buttonContext) => IconButton( + key: ValueKey('section-menu-${section.id}'), + tooltip: '${section.name} options', + visualDensity: VisualDensity.compact, + icon: Icon( + LucideIcons.ellipsisVertical, + size: _kChannelIconSize, + color: sectionColor, + ), + onPressed: () async { + final value = await showAnchoredPopover( + context: buttonContext, + width: 216, + alignment: AnchoredPopoverAlignment.end, + color: context.colors.surface, + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + side: BorderSide(color: context.colors.outline), ), - const PopupMenuItem(value: 'delete', child: Text('Delete')), - ], - ); - switch (value) { - case 'rename': - onRename(); - case 'move_up': - onMoveUp(); - case 'move_down': - onMoveDown(); - case 'delete': - onDelete(); - } - }, - child: Icon( - LucideIcons.ellipsisVertical, - size: _kChannelIconSize, - color: sectionColor, + surfaceKey: ValueKey('section-popover-${section.id}'), + items: [ + const PopupMenuItem( + value: 'rename', + child: Text('Rename'), + ), + PopupMenuItem( + value: 'move_up', + enabled: !isFirst, + child: const Text('Move Up'), + ), + PopupMenuItem( + value: 'move_down', + enabled: !isLast, + child: const Text('Move Down'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], + ); + switch (value) { + case 'rename': + onRename(); + case 'move_up': + onMoveUp(); + case 'move_down': + onMoveDown(); + case 'delete': + onDelete(); + } + }, ), ), const SizedBox(width: Grid.quarter), diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0babba2394..f93196a07b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -493,168 +493,172 @@ class _ThreadMessage extends ConsumerWidget { } } - return DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, - child: InkWell( - key: ValueKey('thread-message-row-${message.id}'), + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.xs : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, + child: InkWell( + key: ValueKey('thread-message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 7c1e5792f9..a40e317469 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; @@ -17,12 +18,12 @@ class HomePage extends HookConsumerWidget { final WidgetBuilder settingsPageBuilder; - static const double _tabBarHeight = 56; + static const double _tabBarHeight = mobileTabBarHeight; static const double _tabBarRadius = _tabBarHeight / 2; static const double _tabBarInnerInset = Grid.half; static const double _selectedTabRadius = (_tabBarHeight - (_tabBarInnerInset * 2)) / 2; - static const double _tabBarBottomGap = Grid.twelve; + static const double _tabBarBottomGap = mobileTabBarBottomGap; static const double _tabBarHorizontalMargin = Grid.gutter; static const double _tabDestinationHorizontalPadding = Grid.sm; static const double _tabIconSize = 22; @@ -63,6 +64,7 @@ class HomePage extends HookConsumerWidget { ]; return Scaffold( + backgroundColor: Colors.transparent, // Keep the floating navigation and Home quick actions anchored while the // keyboard is visible on any tab. resizeToAvoidBottomInset: false, @@ -71,6 +73,7 @@ class HomePage extends HookConsumerWidget { child: Stack( fit: StackFit.expand, children: [ + Positioned.fill(child: ColoredBox(color: context.colors.surface)), Positioned.fill( child: MediaQuery( data: _mediaQueryWithFloatingTabBarClearance( @@ -80,6 +83,14 @@ class HomePage extends HookConsumerWidget { child: IndexedStack(index: tabIndex.value, children: pages), ), ), + Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: MobileTabFooterBackdrop( + height: mobileTabFooterBackdropHeight(context), + ), + ), + ), Positioned.fill( child: ChannelQuickActionsLauncher( visible: tabIndex.value == 0, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 7f4dc24b45..d76e8ff215 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -93,10 +93,12 @@ class SearchPage extends HookConsumerWidget { } return FrostedScaffold( + backgroundColor: Colors.transparent, // Keep the empty state centered in the page rather than the portion left // above the keyboard. resizeToAvoidBottomInset: false, appBar: FrostedAppBar( + automaticallyImplyLeading: false, gradient: context.appColors.topSectionGradient, title: const Text('Search'), titleStyle: headerTitleStyle, diff --git a/mobile/lib/features/activity/activity_page/popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart similarity index 81% rename from mobile/lib/features/activity/activity_page/popover_menu.dart rename to mobile/lib/shared/widgets/anchored_popover_menu.dart index 56d0a612ec..8530d07c27 100644 --- a/mobile/lib/features/activity/activity_page/popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -1,16 +1,24 @@ -part of '../activity_page.dart'; +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole; -const _activityPopoverEnterDuration = Duration(milliseconds: 150); -const _activityPopoverExitDuration = Duration(milliseconds: 110); -const _activityPopoverStartScale = 0.96; +import 'package:flutter/material.dart'; -enum _ActivityPopoverAlignment { start, end } +import '../theme/theme.dart'; -Future _showActivityPopover({ +const _popoverEnterDuration = Duration(milliseconds: 150); +const _popoverExitDuration = Duration(milliseconds: 110); +const _popoverStartScale = 0.96; + +/// The horizontal edge a popover aligns to on its triggering control. +enum AnchoredPopoverAlignment { start, end } + +/// Shows an anchored, cross-platform popup menu with the Activity controls' +/// sizing, motion, and safe-area placement. +Future showAnchoredPopover({ required BuildContext context, required List> items, required double width, - required _ActivityPopoverAlignment alignment, + required AnchoredPopoverAlignment alignment, required Color color, required ShapeBorder shape, required double elevation, @@ -36,7 +44,7 @@ Future _showActivityPopover({ final mediaQuery = MediaQuery.of(context); return navigator.push( - _ActivityPopoverRoute( + _AnchoredPopoverRoute( position: RelativeRect.fromRect(triggerRect, overlayRect), items: items, width: width, @@ -61,11 +69,11 @@ Future _showActivityPopover({ ); } -class _ActivityPopoverRoute extends PopupRoute { +class _AnchoredPopoverRoute extends PopupRoute { final RelativeRect position; final List> items; final double width; - final _ActivityPopoverAlignment alignment; + final AnchoredPopoverAlignment alignment; final Offset offset; final Color color; final ShapeBorder shape; @@ -78,7 +86,7 @@ class _ActivityPopoverRoute extends PopupRoute { final bool reducedMotion; final String _barrierLabel; - _ActivityPopoverRoute({ + _AnchoredPopoverRoute({ required this.position, required this.items, required this.width, @@ -107,11 +115,11 @@ class _ActivityPopoverRoute extends PopupRoute { @override Duration get transitionDuration => - reducedMotion ? Duration.zero : _activityPopoverEnterDuration; + reducedMotion ? Duration.zero : _popoverEnterDuration; @override Duration get reverseTransitionDuration => - reducedMotion ? Duration.zero : _activityPopoverExitDuration; + reducedMotion ? Duration.zero : _popoverExitDuration; @override Widget buildPage( @@ -123,16 +131,16 @@ class _ActivityPopoverRoute extends PopupRoute { CurveTween(curve: Curves.easeOutCubic), ); final scaleAnimation = Tween( - begin: _activityPopoverStartScale, + begin: _popoverStartScale, end: 1, ).animate(curvedAnimation); final transformOrigin = switch (alignment) { - _ActivityPopoverAlignment.start => Alignment.topLeft, - _ActivityPopoverAlignment.end => Alignment.topRight, + AnchoredPopoverAlignment.start => Alignment.topLeft, + AnchoredPopoverAlignment.end => Alignment.topRight, }; return CustomSingleChildLayout( - delegate: _ActivityPopoverLayoutDelegate( + delegate: _AnchoredPopoverLayoutDelegate( position: position, alignment: alignment, offset: offset, @@ -174,13 +182,13 @@ class _ActivityPopoverRoute extends PopupRoute { } } -class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { +class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate { final RelativeRect position; - final _ActivityPopoverAlignment alignment; + final AnchoredPopoverAlignment alignment; final Offset offset; final EdgeInsets screenPadding; - const _ActivityPopoverLayoutDelegate({ + const _AnchoredPopoverLayoutDelegate({ required this.position, required this.alignment, required this.offset, @@ -201,8 +209,8 @@ class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { Offset getPositionForChild(Size size, Size childSize) { final anchorBottom = size.height - position.bottom; final desiredX = switch (alignment) { - _ActivityPopoverAlignment.start => position.left + offset.dx, - _ActivityPopoverAlignment.end => + AnchoredPopoverAlignment.start => position.left + offset.dx, + AnchoredPopoverAlignment.end => size.width - position.right - childSize.width + offset.dx, }; final minX = screenPadding.left; @@ -222,7 +230,7 @@ class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { } @override - bool shouldRelayout(_ActivityPopoverLayoutDelegate oldDelegate) { + bool shouldRelayout(_AnchoredPopoverLayoutDelegate oldDelegate) { return position != oldDelegate.position || alignment != oldDelegate.alignment || offset != oldDelegate.offset || diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 6b7e39fd75..fc0e2fe506 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -21,17 +21,23 @@ class FrostedScaffold extends StatelessWidget { /// Whether the body should resize when the on-screen keyboard appears. final bool? resizeToAvoidBottomInset; + /// Optional scaffold background, useful when a parent supplies a shared + /// surface behind this page. + final Color? backgroundColor; + const FrostedScaffold({ super.key, required this.appBar, required this.body, this.floatingActionButton, this.resizeToAvoidBottomInset, + this.backgroundColor, }); @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: backgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, floatingActionButton: floatingActionButton, body: Stack(children: [body, appBar]), diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart new file mode 100644 index 0000000000..1b77a2ccbf --- /dev/null +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +const mobileTabBarHeight = 56.0; +const mobileTabBarBottomGap = Grid.twelve; + +double mobileTabFooterBackdropHeight(BuildContext context) => + mobileTabBarHeight + + mobileTabBarBottomGap + + View.of(context).padding.bottom + + Grid.xl + + Grid.gutter; + +/// Shared fade behind the floating mobile tab bar. +class MobileTabFooterBackdrop extends StatelessWidget { + final double height; + final List stops; + final List opacities; + + const MobileTabFooterBackdrop({ + super.key, + required this.height, + this.stops = const [0, 0.5, 1], + this.opacities = const [0, 0.75, 1], + }) : assert(stops.length == opacities.length); + + @override + Widget build(BuildContext context) { + final surface = context.colors.surface; + return SizedBox( + height: height, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: stops, + colors: [ + for (final opacity in opacities) + surface.withValues(alpha: opacity), + ], + ), + ), + ), + ); + } +} From 39adc47df3c8873ba8a382f9bf36dcfa5d805c18 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 12:21:05 +0100 Subject: [PATCH 2/5] Fix mobile footer review findings Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 19 ++++++--------- .../widgets/mobile_tab_footer_backdrop.dart | 6 ++++- .../features/activity/activity_page_test.dart | 12 ++++++++++ .../mobile_tab_footer_backdrop_test.dart | 23 +++++++++++++++++++ 4 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index e0a176e957..db39850481 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -307,19 +307,14 @@ class ActivityPage extends HookConsumerWidget { ), ], ), - body: Stack( - children: [ - SafeArea( - top: false, - bottom: false, - child: Padding( - padding: EdgeInsets.only( - top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), - ), - child: body, - ), + body: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), ), - ], + child: body, + ), ), ); } diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index 1b77a2ccbf..154320073f 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -2,13 +2,17 @@ import 'package:flutter/material.dart'; import '../theme/theme.dart'; +/// Height of the floating mobile tab bar, excluding its bottom clearance. const mobileTabBarHeight = 56.0; + +/// Gap between the floating mobile tab bar and the bottom safe area. const mobileTabBarBottomGap = Grid.twelve; +/// Returns the shared footer backdrop height, including the logical safe area. double mobileTabFooterBackdropHeight(BuildContext context) => mobileTabBarHeight + mobileTabBarBottomGap + - View.of(context).padding.bottom + + MediaQuery.paddingOf(context).bottom + Grid.xl + Grid.gutter; diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 19b170b52a..a0293455f2 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -182,6 +182,18 @@ void main() { expect(find.byTooltip('Back'), findsNothing); }); + testWidgets('keeps bottom clearance for the floating tab bar', ( + tester, + ) async { + await tester.pumpWidget(await buildTestable()); + await tester.pumpAndSettle(); + + final safeAreas = tester.widgetList(find.byType(SafeArea)); + expect(safeAreas, hasLength(1)); + expect(safeAreas.single.top, isFalse); + expect(safeAreas.single.bottom, isTrue); + }); + testWidgets('shows error view with retry button', (tester) async { await tester.pumpWidget( await buildTestable(activityNotifier: _ErrorActivityNotifier.new), diff --git a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart new file mode 100644 index 0000000000..9e54177d61 --- /dev/null +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -0,0 +1,23 @@ +import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('uses the logical bottom safe-area inset', (tester) async { + double? height; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)), + child: Builder( + builder: (context) { + height = mobileTabFooterBackdropHeight(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(height, 170); + }); +} From b5c3606207d3920a6a5c2911092cede39540c536 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 13:00:42 +0100 Subject: [PATCH 3/5] Preserve mobile footer clearance Signed-off-by: kenny lopez --- .../features/channels/channels_page/body.dart | 5 +++- mobile/lib/features/search/search_page.dart | 10 ++++++-- .../widgets/mobile_tab_footer_backdrop.dart | 5 ++++ .../features/channels/channels_page_test.dart | 25 +++++++++++++++++++ .../features/search/search_page_test.dart | 8 ++++-- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 2e0a570f41..9f0b1dd4a7 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -189,7 +189,10 @@ class _SliverChannelsList extends HookConsumerWidget { } return SliverPadding( - padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + padding: EdgeInsets.only( + top: Grid.xxs, + bottom: MediaQuery.paddingOf(context).bottom, + ), sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index d76e8ff215..b608b65aef 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -346,7 +346,10 @@ class _SearchBody extends ConsumerWidget { return ListView( key: const Key('search-results-list'), padding: EdgeInsets.only( - bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom, + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), children: [ if (showChannels && state.channelResults.isNotEmpty) @@ -396,7 +399,10 @@ class _RecentSearches extends StatelessWidget { return ListView( key: const Key('recent-searches-list'), padding: EdgeInsets.only( - bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom, + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), children: [ Padding( diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index 154320073f..bd96967229 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -18,8 +18,13 @@ double mobileTabFooterBackdropHeight(BuildContext context) => /// Shared fade behind the floating mobile tab bar. class MobileTabFooterBackdrop extends StatelessWidget { + /// Vertical extent of the backdrop in logical pixels. final double height; + + /// Gradient stop positions, from the transparent top to the opaque bottom. final List stops; + + /// Surface-color alpha values paired with [stops]. final List opacities; const MobileTabFooterBackdrop({ diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 2a624cbde7..f718a98429 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -28,6 +28,7 @@ void main() { bool previewDirectory = false, double keyboardInset = 0, bool disableAnimations = false, + double bottomPadding = 0, Map communityIcons = const {}, ValueChanged? onCommunityIconLoad, TextScaler textScaler = TextScaler.noScaling, @@ -52,6 +53,7 @@ void main() { data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, textScaler: textScaler, + padding: EdgeInsets.only(bottom: bottomPadding), viewInsets: EdgeInsets.only(bottom: keyboardInset), ), child: child!, @@ -142,6 +144,29 @@ void main() { expect(sectionTitle.style?.fontWeight, FontWeight.w600); }); + testWidgets('keeps the last channel above the floating tab bar', ( + tester, + ) async { + const footerClearance = 102.0; + await tester.pumpWidget( + buildTestable( + bottomPadding: footerClearance, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final padding = tester.widget( + find.descendant( + of: find.byType(CustomScrollView), + matching: find.byType(SliverPadding), + ), + ); + expect((padding.padding as EdgeInsets).bottom, footerClearance); + }); + testWidgets('aligns the top, section, row, and skeleton label columns', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 52c2fb23ae..e4a576b91d 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -248,6 +248,7 @@ void main() { tester, ) async { const keyboardInset = 300.0; + const footerClearance = 102.0; await tester.pumpWidget( WidgetHelpers.testable( @@ -266,6 +267,7 @@ void main() { child: Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(bottom: footerClearance), viewInsets: const EdgeInsets.only(bottom: keyboardInset), ), child: const SearchPage(), @@ -282,13 +284,14 @@ void main() { ); final padding = recentSearches.padding! as EdgeInsets; - expect(padding.bottom, Grid.xl + keyboardInset); + expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); testWidgets('keeps search results scrollable above the keyboard', ( tester, ) async { const keyboardInset = 300.0; + const footerClearance = 102.0; final state = SearchState( query: 'general', channelResults: [ @@ -318,6 +321,7 @@ void main() { child: Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(bottom: footerClearance), viewInsets: const EdgeInsets.only(bottom: keyboardInset), ), child: const SearchPage(), @@ -332,7 +336,7 @@ void main() { ); final padding = results.padding! as EdgeInsets; - expect(padding.bottom, Grid.xl + keyboardInset); + expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); testWidgets('keeps no-results feedback above the keyboard', (tester) async { From 7af58b1e626e1d295c8d69dc834caa740dc33d66 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 13:38:16 +0100 Subject: [PATCH 4/5] Prevent mobile section name overflow Signed-off-by: kenny lopez --- .../channels/channels_page/sections.dart | 15 ++++--- .../features/channels/channels_page_test.dart | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index d8cafa3321..f9fe5453d7 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -152,14 +152,17 @@ class _CustomSectionHeader extends ConsumerWidget { ), ), const SizedBox(width: _kChannelLabelGap), - Text( - section.name, - style: contentListTitleTextStyle.copyWith( - color: sectionColor, - fontWeight: FontWeight.w600, + Expanded( + child: Text( + section.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: contentListTitleTextStyle.copyWith( + color: sectionColor, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), Builder( builder: (buttonContext) => IconButton( key: ValueKey('section-menu-${section.id}'), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index f718a98429..991db3b5cd 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -8,6 +8,8 @@ import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; @@ -167,6 +169,37 @@ void main() { expect((padding.padding as EdgeInsets).bottom, footerClearance); }); + testWidgets('truncates long custom section names beside the menu', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + const sectionName = 'A deliberately long custom section name for testing'; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection(id: 'section-1', name: sectionName, order: 0), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + final label = tester.widget(find.text(sectionName)); + expect(label.maxLines, 1); + expect(label.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); + testWidgets('aligns the top, section, row, and skeleton label columns', ( tester, ) async { @@ -1409,6 +1442,16 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { + _FakeChannelSectionsNotifier(this._store); + + final ChannelSectionStore _store; + + @override + ChannelSectionsState build() => + ChannelSectionsState(isReady: true, store: _store, version: 1); +} + class _FakeCommunityListNotifier extends CommunityListNotifier { _FakeCommunityListNotifier(this._communities); From 1cba148f0a7a64606a3568d35e6fee5b01935447 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 14:00:01 +0100 Subject: [PATCH 5/5] Document mobile popover APIs Signed-off-by: kenny lopez --- mobile/lib/shared/widgets/anchored_popover_menu.dart | 8 +++++++- mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 8530d07c27..46b50b6b00 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -10,7 +10,13 @@ const _popoverExitDuration = Duration(milliseconds: 110); const _popoverStartScale = 0.96; /// The horizontal edge a popover aligns to on its triggering control. -enum AnchoredPopoverAlignment { start, end } +enum AnchoredPopoverAlignment { + /// Aligns the popover's leading edge with the trigger's leading edge. + start, + + /// Aligns the popover's trailing edge with the trigger's trailing edge. + end, +} /// Shows an anchored, cross-platform popup menu with the Activity controls' /// sizing, motion, and safe-area placement. diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index bd96967229..687880acb1 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -27,6 +27,9 @@ class MobileTabFooterBackdrop extends StatelessWidget { /// Surface-color alpha values paired with [stops]. final List opacities; + /// Creates a footer backdrop with the required [height]. + /// + /// Override [stops] and [opacities] together to customize the gradient. const MobileTabFooterBackdrop({ super.key, required this.height,