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