Polish mobile navigation and menus (#3486)

## Summary
- Add a shared footer fade behind the floating tabs on Home, Activity,
and Search.
- Use a shared anchored popover for Activity filters and section
actions, with working section move controls.
- Polish message grouping/press states and remove the initial Search
back button.
<img width="630" height="1368" alt="Screenshot 2026-07-29 at 08 49 37"
src="https://github.com/user-attachments/assets/9e787adf-0bb3-49c6-8224-5819e8cfb1ad"
/>

### Testing
- `flutter analyze`
- `flutter test`
- Release build installed and checked on a connected iPhone

### Screenshots
A real-device Activity baseline showing the original solid footer is
attached in a PR comment. The updated review build was checked on the
connected iPhone.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-29 16:10:51 +01:00
committed by GitHub
parent 6438dedf83
commit 4555899ab2
16 changed files with 621 additions and 380 deletions
@@ -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,
@@ -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<InboxFilter>(
final selected = await showAnchoredPopover<InboxFilter>(
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<String>(
final selected = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: _ActivityPopoverAlignment.end,
alignment: AnchoredPopoverAlignment.end,
color: context.colors.surface,
elevation: 4,
shadowColor: context.colors.shadow.withValues(alpha: 0.18),
@@ -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}',
),
),
),
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<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
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}',
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: currentChannelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
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),
),
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<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
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,
channelId: channelId,
currentChannelId: currentChannelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
],
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
),
],
],
),
),
),
),
@@ -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
@@ -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)
@@ -152,56 +152,72 @@ 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(),
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<String>(
context: context,
position: position,
items: [
const PopupMenuItem(value: 'rename', child: Text('Rename')),
PopupMenuItem(
value: 'move_up',
enabled: !isFirst,
child: const Text('Move Up'),
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<String>(
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),
),
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();
}
},
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),
@@ -502,168 +502,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}',
),
),
),
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<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
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}',
),
),
);
},
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,
ref: ref,
message: message,
channelId: channelId,
imageUrl: imageUrl,
canManageMessage: canManageMessage,
onDeleted: () {
if (viewerContext.mounted) {
Navigator.of(viewerContext).maybePop();
}
},
),
if (message.edited) ...[
const SizedBox(width: Grid.half),
Text(
'(edited)',
style: context.textTheme.labelSmall
?.copyWith(
color:
context.colors.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
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),
),
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<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: channelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
),
);
},
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,
channelId: targetChannelId,
currentChannelId: channelId,
);
},
onMentionTap: (pubkey) =>
showUserProfileSheet(context, pubkey),
),
],
if (message.reactions.isNotEmpty)
ReactionRow(
reactions: message.reactions,
onToggle: (emoji) =>
toggleReaction(ref, message, emoji),
),
],
),
),
),
),
],
],
),
),
),
),
+13 -2
View File
@@ -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,
+10 -2
View File
@@ -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,
@@ -344,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)
@@ -394,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(
@@ -1,16 +1,30 @@
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<T?> _showActivityPopover<T>({
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 {
/// 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.
Future<T?> showAnchoredPopover<T>({
required BuildContext context,
required List<PopupMenuEntry<T>> items,
required double width,
required _ActivityPopoverAlignment alignment,
required AnchoredPopoverAlignment alignment,
required Color color,
required ShapeBorder shape,
required double elevation,
@@ -36,7 +50,7 @@ Future<T?> _showActivityPopover<T>({
final mediaQuery = MediaQuery.of(context);
return navigator.push<T>(
_ActivityPopoverRoute<T>(
_AnchoredPopoverRoute<T>(
position: RelativeRect.fromRect(triggerRect, overlayRect),
items: items,
width: width,
@@ -61,11 +75,11 @@ Future<T?> _showActivityPopover<T>({
);
}
class _ActivityPopoverRoute<T> extends PopupRoute<T> {
class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
final RelativeRect position;
final List<PopupMenuEntry<T>> items;
final double width;
final _ActivityPopoverAlignment alignment;
final AnchoredPopoverAlignment alignment;
final Offset offset;
final Color color;
final ShapeBorder shape;
@@ -78,7 +92,7 @@ class _ActivityPopoverRoute<T> extends PopupRoute<T> {
final bool reducedMotion;
final String _barrierLabel;
_ActivityPopoverRoute({
_AnchoredPopoverRoute({
required this.position,
required this.items,
required this.width,
@@ -107,11 +121,11 @@ class _ActivityPopoverRoute<T> extends PopupRoute<T> {
@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 +137,16 @@ class _ActivityPopoverRoute<T> extends PopupRoute<T> {
CurveTween(curve: Curves.easeOutCubic),
);
final scaleAnimation = Tween<double>(
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 +188,13 @@ class _ActivityPopoverRoute<T> extends PopupRoute<T> {
}
}
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 +215,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 +236,7 @@ class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate {
}
@override
bool shouldRelayout(_ActivityPopoverLayoutDelegate oldDelegate) {
bool shouldRelayout(_AnchoredPopoverLayoutDelegate oldDelegate) {
return position != oldDelegate.position ||
alignment != oldDelegate.alignment ||
offset != oldDelegate.offset ||
@@ -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]),
@@ -0,0 +1,61 @@
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 +
MediaQuery.paddingOf(context).bottom +
Grid.xl +
Grid.gutter;
/// 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<double> stops;
/// Surface-color alpha values paired with [stops].
final List<double> 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,
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),
],
),
),
),
);
}
}
@@ -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<SafeArea>(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),
@@ -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';
@@ -28,6 +30,7 @@ void main() {
bool previewDirectory = false,
double keyboardInset = 0,
bool disableAnimations = false,
double bottomPadding = 0,
Map<String, String?> communityIcons = const {},
ValueChanged<String>? onCommunityIconLoad,
TextScaler textScaler = TextScaler.noScaling,
@@ -52,6 +55,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 +146,60 @@ 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<SliverPadding>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(SliverPadding),
),
);
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<Text>(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 {
@@ -1384,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);
@@ -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 {
@@ -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);
});
}