fix(mobile): align Android conversation headers and reactions (#8082)

## Summary
Align Android channel and DM headers with iOS: centered, tappable titles
with presence or member counts and no avatars. Keep conversation
navigation frosted through keyboard and scroll changes, and run the
typing shimmer left to right.

Add an Android reaction details sheet with All/emoji tabs, swipe
navigation, and compact rows aligned to the tab gutters. Native iOS
reaction presentation is unchanged.

### Related issue
None found.

### Testing
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed.
- Android debug build installed and launched on Pixel 10.
- Full `just ci` could not complete: disk space exhausted while fetching
an unrelated Rust dependency.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-04 16:49:12 +01:00
committed by GitHub
parent 75a4efd0ca
commit 5f2d801b8f
12 changed files with 712 additions and 655 deletions

View File

@@ -11,7 +11,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/animated_avatar.dart';
import '../../shared/emoji/emoji_burst.dart';
import '../../shared/huddle/huddle.dart';
import '../../shared/identity_names/identity_names.dart';
@@ -28,10 +27,7 @@ import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/flapping_bee.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/load_error_view.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
@@ -44,7 +40,6 @@ import 'channel_actions_sheet.dart';
import 'channel_identity_names_provider.dart';
import 'channel_member_profile_actions.dart';
import 'channel_link_navigation.dart';
import 'agent_activity/working_bots_provider.dart';
import 'channel_management_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_messages_provider.dart';
@@ -65,7 +60,6 @@ import 'jump_to_latest_switcher.dart';
import 'local_message_send_animation_provider.dart';
import 'local_message_send_transition.dart';
import 'mobile_huddle_controller.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_action_backdrop_state.dart';
import 'message_long_press_region.dart';
@@ -520,13 +514,8 @@ class ChannelDetailPage extends HookConsumerWidget {
!resolvedChannel.isForum &&
isConnectionInProgress &&
!messagesNotifier.hasLoadedMessages;
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(
context,
isDm: resolvedChannel.isDm,
);
final usesNativeIosGlassBackButton =
Navigator.canPop(context) &&
Theme.of(context).platform == TargetPlatform.iOS;
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(context);
final readTimestamp = _channelReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
@@ -574,15 +563,14 @@ class ChannelDetailPage extends HookConsumerWidget {
});
}, [channel.id, readState.isReady, readTimestamp]);
final nativeDm =
resolvedChannel.isDm && defaultTargetPlatform == TargetPlatform.iOS
final dmHeader = resolvedChannel.isDm
? _watchDmHeader(ref, resolvedChannel, currentPubkey)
: null;
final nativeMembers = ref.watch(channelMembersProvider(resolvedChannel.id));
final nativeMemberCount =
nativeMembers.value?.length ?? resolvedChannel.memberCount;
final nativeMemberLabel =
'$nativeMemberCount ${nativeMemberCount == 1 ? 'member' : 'members'}';
final headerMembers = ref.watch(channelMembersProvider(resolvedChannel.id));
final headerMemberCount =
headerMembers.value?.length ?? resolvedChannel.memberCount;
final headerMemberLabel =
'$headerMemberCount ${headerMemberCount == 1 ? 'member' : 'members'}';
Future<void> openChannelDetails() async {
final shouldClose = await showChannelDetailsPage(
context: context,
@@ -611,21 +599,22 @@ class ChannelDetailPage extends HookConsumerWidget {
resizeToAvoidBottomInset:
!usesFixedAndroidImeViewport || resolvedChannel.isForum,
appBar: FrostedAppBar(
alwaysFrosted: true,
nativeViewSuppressed: messageActionBackdropActive,
nativeEphemeralLabel: ephemeralChannelDisplay(
resolvedChannel,
)?.tooltipLabel,
nativeTitle:
nativeDm?.label ??
dmHeader?.label ??
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
nativeSubtitle: isOneToOneDm
? nativeDm?.presenceLabel
: nativeMemberLabel,
? dmHeader?.presenceLabel
: headerMemberLabel,
nativeTitlePresenceColor: switch (isOneToOneDm
? nativeDm?.presence
? dmHeader?.presence
: null) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
@@ -643,68 +632,21 @@ class ChannelDetailPage extends HookConsumerWidget {
iconColor: context.colors.primary,
titleContentHeight: appBarTitleContentHeight,
titleStyle: channelTitleTextStyle,
title: Padding(
padding: EdgeInsets.only(
left: usesNativeIosGlassBackButton
? iosGlassChannelHeaderTitleSpacing
: 0,
),
child: resolvedChannel.isDm
? _DmAppBarTitle(
channel: resolvedChannel,
currentPubkey: currentPubkey,
)
: _ChannelAppBarTitle(
channel: resolvedChannel,
onTap: openChannelDetails,
),
centerTitle: true,
title: _ConversationAppBarTitle(
channel: resolvedChannel,
label: dmHeader?.label ?? resolvedChannel.name,
subtitle: isOneToOneDm ? dmHeader!.presenceLabel : headerMemberLabel,
presence: isOneToOneDm ? dmHeader?.presence : null,
onTap: openChannelDetails,
),
actions: resolvedChannel.isDm
? [
if (showsHuddleAction)
_HuddleButton(
channel: resolvedChannel,
events: [
...messagesState.value ?? const [],
...huddleLifecycle,
],
),
if (_showsMembersAction(resolvedChannel))
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showChannelActionsSheet(
context: context,
channel: resolvedChannel,
isUnread: false,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Channel actions',
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
]
: [
if (showsComposer)
_HuddleButton(
channel: resolvedChannel,
events: [
...messagesState.value ?? const [],
...huddleLifecycle,
],
),
],
actions: [
if (resolvedChannel.isDm ? showsHuddleAction : showsComposer)
_HuddleButton(
channel: resolvedChannel,
events: [...messagesState.value ?? const [], ...huddleLifecycle],
),
],
),
body: Stack(
fit: StackFit.expand,

View File

@@ -1,123 +1,116 @@
part of '../channel_detail_page.dart';
const _dmHeaderAvatarSize = 32.0;
const _channelHeaderAvatarSize = 40.0;
const _dmPresenceDotRatio = 8 / 14;
bool _showsMembersAction(Channel channel) {
if (!channel.isDm) return true;
final participants = channel.participantPubkeys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
return participants.length != 2;
}
double _scaledTextHeight(BuildContext context, TextStyle style) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 0);
return scaledFontSize * (style.height ?? 1);
return MediaQuery.textScalerOf(context).scale(style.fontSize ?? 0) *
(style.height ?? 1);
}
double _twoLineAppBarTitleContentHeight(
BuildContext context, {
required bool isDm,
}) {
double _twoLineAppBarTitleContentHeight(BuildContext context) {
final titleStyle = context.textTheme.titleSmall;
final subtitleStyle = isDm
? context.textTheme.bodyMedium
: context.textTheme.bodySmall;
final avatarSize = isDm ? _dmHeaderAvatarSize : _channelHeaderAvatarSize;
if (titleStyle == null || subtitleStyle == null) {
return avatarSize;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, subtitleStyle);
return textHeight > avatarSize ? textHeight : avatarSize;
final subtitleStyle = context.textTheme.bodySmall;
if (titleStyle == null || subtitleStyle == null) return 40;
return max(
40,
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, subtitleStyle),
);
}
class _ChannelAppBarTitle extends ConsumerWidget {
const _ChannelAppBarTitle({required this.channel, required this.onTap});
class _ConversationAppBarTitle extends StatelessWidget {
const _ConversationAppBarTitle({
required this.channel,
required this.label,
required this.subtitle,
required this.presence,
required this.onTap,
});
final Channel channel;
final String label;
final String subtitle;
final String? presence;
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final memberCount = membersAsync.value?.length ?? channel.memberCount;
final memberLabel =
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
Widget build(BuildContext context) {
final prefix = channel.isDm ? 'dm' : 'channel';
return Semantics(
button: true,
label: 'Open settings for ${channel.name}, $memberLabel',
child: Tooltip(
message: 'Open channel settings',
message: channel.isDm
? 'Open conversation details'
: 'Open channel settings',
child: InkWell(
key: const ValueKey('channel-header-settings-trigger'),
key: ValueKey('$prefix-header-settings-trigger'),
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Row(
children: [
Container(
key: const ValueKey('channel-header-avatar'),
width: _channelHeaderAvatarSize,
height: _channelHeaderAvatarSize,
decoration: BoxDecoration(
color: context.colors.surface,
shape: BoxShape.circle,
border: Border.fromBorderSide(
BorderSide(
color: context.colors.inverseSurface.withValues(
alpha: 0.07,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Column(
key: ValueKey('$prefix-header-text-stack'),
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!channel.isDm && channel.visibility == 'private') ...[
Icon(
LucideIcons.lock,
size: 14,
color: context.colors.onSurfaceVariant,
semanticLabel: 'Private channel',
),
const SizedBox(width: Grid.quarter),
],
Flexible(
child: Text(
label,
key: ValueKey('$prefix-header-name'),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
strokeAlign: BorderSide.strokeAlignOutside,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
child: Icon(
channelIcon(channel),
size: 20,
color: context.colors.primary,
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: ConstrainedBox(
key: const ValueKey('channel-header-text-stack'),
constraints: const BoxConstraints(
minHeight: _channelHeaderAvatarSize,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
channel.name,
key: const ValueKey('channel-header-name'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (presence != null) ...[
ExcludeSemantics(
child: Container(
key: const ValueKey('dm-header-presence-dot'),
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
),
Text(
memberLabel,
key: const ValueKey('channel-header-member-count'),
const SizedBox(width: Grid.quarter),
],
Flexible(
child: Text(
subtitle,
key: ValueKey(
channel.isDm
? 'dm-header-presence'
: 'channel-header-member-count',
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
@@ -126,11 +119,11 @@ class _ChannelAppBarTitle extends ConsumerWidget {
),
),
),
],
),
),
],
),
),
],
],
),
),
),
),
@@ -138,71 +131,12 @@ class _ChannelAppBarTitle extends ConsumerWidget {
}
}
class _MembersButton extends ConsumerWidget {
final String channelId;
final Channel channel;
final String? currentPubkey;
const _MembersButton({
required this.channelId,
required this.channel,
required this.currentPubkey,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasWorkingBot = ref
.watch(workingBotPubkeysProvider(channelId))
.isNotEmpty;
return IconButton(
color: context.colors.primary,
onPressed: () {
showBuzzModalBottomSheet<void>(
context: context,
title: 'Members',
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
MembersSheet(channel: channel, currentPubkey: currentPubkey),
);
},
tooltip: 'View members',
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(LucideIcons.users, size: 22),
if (hasWorkingBot)
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: context.appColors.success,
shape: BoxShape.circle,
border: Border.all(color: context.colors.surface, width: 1.5),
),
),
),
],
),
);
}
}
// Both renderers subscribe to the same counterpart identity and presence.
({
String label,
String? pubkey,
String? avatarUrl,
String initial,
bool isAgent,
String? presence,
String presenceLabel,
})
_watchDmHeader(WidgetRef ref, Channel channel, String? currentPubkey) {
({String label, String? presence, String presenceLabel}) _watchDmHeader(
WidgetRef ref,
Channel channel,
String? currentPubkey,
) {
final normalizedCurrent = currentPubkey?.toLowerCase();
String? otherPubkey;
@@ -231,22 +165,6 @@ _watchDmHeader(WidgetRef ref, Channel channel, String? currentPubkey) {
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
}
final avatarUrl = profile?.avatarUrl;
final isAgent =
(otherPubkey != null &&
ref
.watch(agentMentionPubkeysProvider(channel.id))
.contains(otherPubkey)) ||
profile?.ownerPubkey != null;
// Keyed to the hex public key when the participant is unnamed and the
// profile isn't cached — the compact-npub participant label would
// otherwise render `N` for every unnamed DM counterpart. Selection skips
// the current user like the header label does, so the initial always
// identifies the same counterpart the label names.
final initial =
profile?.initial ??
dmAvatarInitial(channel, currentPubkey: currentPubkey);
final presenceLabel = switch (presence) {
'online' => 'Online',
'away' => 'Away',
@@ -264,111 +182,7 @@ _watchDmHeader(WidgetRef ref, Channel channel, String? currentPubkey) {
),
),
),
pubkey: otherPubkey,
avatarUrl: avatarUrl,
initial: initial,
isAgent: isAgent,
presence: presence,
presenceLabel: presenceLabel,
);
}
class _DmAppBarTitle extends ConsumerWidget {
final Channel channel;
final String? currentPubkey;
const _DmAppBarTitle({required this.channel, required this.currentPubkey});
@override
Widget build(BuildContext context, WidgetRef ref) {
final identity = _watchDmHeader(ref, channel, currentPubkey);
final avatarUrl = identity.avatarUrl;
final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl);
final initial = identity.initial;
final isAgent = identity.isAgent;
final presence = identity.presence;
final presenceLabel = identity.presenceLabel;
return Row(
children: [
MaskedAvatarBadge(
key: const ValueKey('dm-header-avatar'),
size: _dmHeaderAvatarSize,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipRRect(
borderRadius: BorderRadius.circular(
isAgent ? _dmHeaderAvatarSize * 0.3 : _dmHeaderAvatarSize / 2,
),
child: ColoredBox(
color: animatedAvatar == null
? context.colors.primaryContainer
: Colors.transparent,
child: AvatarImageContent(
imageUrl: animatedAvatar?.posterUrl ?? avatarUrl,
fallback: Text(
initial,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
),
badge: presence == null
? null
: Center(
child: FractionallySizedBox(
widthFactor: _dmPresenceDotRatio,
heightFactor: _dmPresenceDotRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
},
shape: BoxShape.circle,
),
),
),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
identity.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
key: const ValueKey('dm-header-name'),
style: context.textTheme.titleSmall,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
presenceLabel,
key: const ValueKey('dm-header-presence'),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
);
}
}

View File

@@ -143,7 +143,7 @@ class _TypingTextShimmer extends HookWidget {
animation: animation,
child: label,
builder: (context, child) {
final center = 1.5 - (animation.value * 3);
final center = -1.5 + (animation.value * 3);
return ShaderMask(
key: const ValueKey('channel-typing-shimmer'),
blendMode: BlendMode.srcIn,

View File

@@ -357,8 +357,27 @@ Future<void> showReactionDetailSheet({
return;
}
if (!context.mounted) return;
if (defaultTargetPlatform == TargetPlatform.android) {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
backgroundColor: context.colors.surfaceContainerHighest,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(Radii.dialog)),
),
builder: (_) => _AndroidReactionDetailSheet(
channelId: channelId,
reactions: reactions,
initialEmoji: initialEmoji,
),
);
return;
}
showBuzzModalBottomSheet<void>(
context: context,
title: 'Reactions',
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _ReactionDetailSheet(
@@ -369,6 +388,132 @@ Future<void> showReactionDetailSheet({
);
}
class _AndroidReactionDetailSheet extends HookConsumerWidget {
const _AndroidReactionDetailSheet({
required this.channelId,
required this.reactions,
required this.initialEmoji,
});
final String channelId;
final List<TimelineReaction> reactions;
final String initialEmoji;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(userCacheProvider);
final names = ref.watch(channelIdentityNamesProvider(channelId));
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
final pubkeys = reactions
.expand((reaction) => reaction.userPubkeys)
.toSet()
.toList();
useEffect(() {
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
}, [pubkeys.join(',')]);
final total = reactions.fold<int>(
0,
(sum, reaction) => sum + reaction.count,
);
final maxHeight = MediaQuery.sizeOf(context).height * 0.65;
final desiredHeight =
48.0 + pubkeys.length * 56.0 + MediaQuery.viewPaddingOf(context).bottom;
return DefaultTabController(
length: reactions.length + 1,
initialIndex: reactions.indexWhere((r) => r.emoji == initialEmoji) + 1,
child: SizedBox(
key: const ValueKey('reaction-details-sheet'),
height: desiredHeight.clamp(
maxHeight < 340 ? maxHeight : 340,
maxHeight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
isScrollable: true,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
tabAlignment: TabAlignment.start,
dividerHeight: 0,
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
indicator: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(Radii.full),
),
labelColor: context.colors.onSurface,
unselectedLabelColor: context.colors.onSurfaceVariant,
tabs: [
Tab(
key: const ValueKey('reaction-filter-all'),
text: 'All $total',
),
for (final reaction in reactions)
Tab(
key: ValueKey('reaction-filter-${reaction.emoji}'),
child: Tooltip(
message: dataset.displayName(reaction.emoji),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionEmoji(reaction: reaction, size: 20),
const SizedBox(width: Grid.quarter),
Text('${reaction.count}'),
],
),
),
),
],
),
Expanded(
child: TabBarView(
children: [
for (var tab = 0; tab <= reactions.length; tab++)
Builder(
builder: (context) {
final filtered = tab == 0
? reactions
: [reactions[tab - 1]];
final rows = [
for (final reaction in filtered)
for (final pubkey in reaction.userPubkeys.toSet())
(pubkey: pubkey, reaction: reaction),
];
return ListView.builder(
key: PageStorageKey('reaction-page-$tab'),
padding: EdgeInsets.only(
bottom: MediaQuery.viewPaddingOf(context).bottom,
),
itemCount: rows.length,
itemBuilder: (_, index) {
final row = rows[index];
return _ReactorTile(
profile: cache[row.pubkey.toLowerCase()],
pubkey: row.pubkey,
displayName: names.labelFor(row.pubkey),
reaction: row.reaction,
reactionLabel: dataset.displayName(
row.reaction.emoji,
),
compact: true,
);
},
);
},
),
],
),
),
],
),
),
);
}
}
class _ReactionDetailSheet extends HookConsumerWidget {
final String channelId;
final List<TimelineReaction> reactions;
@@ -382,89 +527,75 @@ class _ReactionDetailSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedEmoji = useState(initialEmoji);
final selectedEmoji = useState<String?>(initialEmoji);
final userCache = ref.watch(userCacheProvider);
final identityNames = ref.watch(channelIdentityNamesProvider(channelId));
final currentReaction = reactions.firstWhere(
(r) => r.emoji == selectedEmoji.value,
orElse: () => reactions.first,
);
// Preload profiles for reactors.
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
final rows = [
for (final reaction in reactions)
if (selectedEmoji.value == null ||
reaction.emoji == selectedEmoji.value)
for (final pubkey in reaction.userPubkeys.toSet())
(pubkey: pubkey, reaction: reaction),
];
final pubkeys = rows.map((row) => row.pubkey).toSet().toList();
useEffect(() {
if (currentReaction.userPubkeys.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(currentReaction.userPubkeys);
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
}, [currentReaction.userPubkeys]);
}, [pubkeys.join(',')]);
final total = reactions.fold<int>(
0,
(sum, reaction) => sum + reaction.count,
);
return ConstrainedBox(
key: const ValueKey('reaction-details-sheet'),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Emoji filter chips (if multiple reaction types).
if (reactions.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final reaction in reactions)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionEmoji(reaction: reaction, size: 16),
const SizedBox(width: Grid.quarter),
Text('${reaction.count}'),
],
),
selected: reaction.emoji == selectedEmoji.value,
onSelected: (_) {
selectedEmoji.value = reaction.emoji;
},
),
),
],
),
),
),
// Header: emoji + shortcode.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.half,
),
child: Row(
children: [
_ReactionEmoji(reaction: currentReaction, size: 32),
const SizedBox(width: Grid.half),
Text(
// Resolved from the shared emoji-mart dataset, so this name
// matches desktop's `emojiDisplayName` for the whole set
// rather than the 28 glyphs a hardcoded map used to cover.
ref
.watch(emojiDatasetOrEmptyProvider)
.displayName(currentReaction.emoji),
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurfaceVariant,
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
key: const ValueKey('reaction-filter-all'),
label: Text('All $total'),
selected: selectedEmoji.value == null,
onSelected: (_) => selectedEmoji.value = null,
),
),
),
],
for (final reaction in reactions)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ChoiceChip(
key: ValueKey('reaction-filter-${reaction.emoji}'),
tooltip: dataset.displayName(reaction.emoji),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ReactionEmoji(reaction: reaction, size: 20),
const SizedBox(width: Grid.quarter),
Text('${reaction.count}'),
],
),
selected: reaction.emoji == selectedEmoji.value,
onSelected: (_) => selectedEmoji.value = reaction.emoji,
),
),
],
),
),
),
// Reactor list.
Flexible(
child: ListView.builder(
shrinkWrap: true,
@@ -474,18 +605,19 @@ class _ReactionDetailSheet extends HookConsumerWidget {
top: Grid.xxs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.half,
),
itemCount: currentReaction.userPubkeys.length,
itemCount: rows.length,
itemBuilder: (context, index) {
final pubkey = currentReaction.userPubkeys[index];
final profile = userCache[pubkey.toLowerCase()];
final row = rows[index];
return AppListCardItem(
index: index,
itemCount: currentReaction.userPubkeys.length,
itemCount: rows.length,
dividerIndent: Grid.xs + 40 + Grid.xs,
child: _ReactorTile(
profile: profile,
pubkey: pubkey,
displayName: identityNames.labelFor(pubkey),
profile: userCache[row.pubkey.toLowerCase()],
pubkey: row.pubkey,
displayName: identityNames.labelFor(row.pubkey),
reaction: row.reaction,
reactionLabel: dataset.displayName(row.reaction.emoji),
),
);
},
@@ -501,11 +633,17 @@ class _ReactorTile extends StatelessWidget {
final UserProfile? profile;
final String pubkey;
final String displayName;
final TimelineReaction reaction;
final String reactionLabel;
final bool compact;
const _ReactorTile({
required this.profile,
required this.pubkey,
required this.displayName,
required this.reaction,
required this.reactionLabel,
this.compact = false,
});
@override
@@ -513,7 +651,19 @@ class _ReactorTile extends StatelessWidget {
final about = profile?.about;
return ListTile(
key: ValueKey('reactor-$pubkey-${reaction.emoji}'),
minTileHeight: compact ? 52 : null,
contentPadding: compact
? const EdgeInsets.symmetric(horizontal: Grid.gutter)
: null,
trailing: Semantics(
label: reactionLabel,
child: ExcludeSemantics(
child: _ReactionEmoji(reaction: reaction, size: 26),
),
),
leading: _ReactorAvatar(
radius: compact ? 18 : 20,
avatarUrl: profile?.avatarUrl,
initial:
profile?.initial ??
@@ -527,7 +677,7 @@ class _ReactorTile extends StatelessWidget {
),
overflow: TextOverflow.ellipsis,
),
subtitle: about != null && about.isNotEmpty
subtitle: !compact && about != null && about.isNotEmpty
? Text(
about,
maxLines: 1,
@@ -546,18 +696,20 @@ class _ReactorAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;
final bool isAgent;
final double radius;
const _ReactorAvatar({
required this.avatarUrl,
required this.initial,
required this.isAgent,
required this.radius,
});
@override
Widget build(BuildContext context) {
return AvatarImage(
imageUrl: avatarUrl,
radius: 20,
radius: radius,
fallback: Text(initial),
isAgent: isAgent,
);

View File

@@ -832,6 +832,7 @@ class ThreadDetailPage extends HookConsumerWidget {
return FrostedScaffold(
resizeToAvoidBottomInset: !usesFixedAndroidImeViewport,
appBar: FrostedAppBar(
alwaysFrosted: true,
nativeViewSuppressed: messageActionBackdropActive,
nativeTitle: 'Thread',
leading: usesNativeIosGlassBackButton

View File

@@ -144,6 +144,10 @@ class FrostedAppBar extends StatelessWidget {
/// surrounding scroll scope reports content beneath the controls.
final bool frosted;
/// Keeps the Flutter backdrop and divider stable regardless of scroll state.
/// Conversation headers use this while their timeline and composer resize.
final bool alwaysFrosted;
/// Opacity of the frosted surface above the blurred backdrop.
final double frostedSurfaceOpacity;
@@ -216,6 +220,7 @@ class FrostedAppBar extends StatelessWidget {
this.iconColor,
this.gradient,
this.frosted = true,
this.alwaysFrosted = false,
this.frostedSurfaceOpacity = 0.5,
this.frostedBlurSigma = 20,
this.showBottomDivider = true,
@@ -325,7 +330,8 @@ class FrostedAppBar extends StatelessWidget {
}
final topPadding = MediaQuery.paddingOf(context).top;
final scrollUnder = FrostedScrollUnderScope.maybeOf(context);
final isScrolledUnder = scrollUnder?.isScrolledUnder ?? false;
final isScrolledUnder =
alwaysFrosted || (scrollUnder?.isScrolledUnder ?? false);
final paintsFrost = frosted && isScrolledUnder;
final paintsBottomDivider = showBottomDivider && isScrolledUnder;
final canPop = Navigator.canPop(context);

View File

@@ -132,17 +132,26 @@ class FrostedScaffold extends HookWidget {
onNotification: (notification) {
final ScrollMetrics metrics;
final int depth;
final BuildContext? sourceContext;
if (notification is ScrollNotification) {
metrics = notification.metrics;
depth = notification.depth;
sourceContext = notification.context;
} else if (notification is ScrollMetricsNotification) {
// Initial layout and content-size changes matter even before a drag.
metrics = notification.metrics;
depth = notification.depth;
sourceContext = notification.context;
} else {
return false;
}
if (depth != 0 || metrics.axis != Axis.vertical) return false;
// EditableText has its own depth-zero scrollable. Composer edits and
// caret scrolling must not replace the page's scroll-under state.
if (sourceContext?.findAncestorWidgetOfExactType<EditableText>() !=
null) {
return false;
}
// Reversed chats start at zero at the newest message. Older content
// behind the top bar is measured by extentAfter, not scroll pixels.
final topContentDepth = metrics.axisDirection == AxisDirection.up

View File

@@ -70,9 +70,7 @@ import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/flapping_bee.dart';
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:buzz/shared/widgets/lucide_star_icon.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -707,12 +705,8 @@ void main() {
label,
);
expect(
tester
.widget<MaskedAvatarBadge>(
find.byKey(const ValueKey('dm-header-avatar')),
)
.badge,
label == 'Unknown' ? isNull : isNotNull,
find.byKey(const ValueKey('dm-header-avatar')),
findsNothing,
);
}
if (label != 'Offline') expect(find.text('Offline'), findsNothing);
@@ -756,7 +750,7 @@ void main() {
});
}
testWidgets('uses the shared 32px masked presence avatar in DM headers', (
testWidgets('centers a tappable DM header without an avatar', (
tester,
) async {
final dmChannel = Channel(
@@ -785,26 +779,7 @@ void main() {
);
await tester.pumpAndSettle();
final avatarFinder = find.byKey(const ValueKey('dm-header-avatar'));
final avatar = tester.widget<MaskedAvatarBadge>(avatarFinder);
expect(tester.getSize(avatarFinder), const Size.square(32));
expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot);
expect(avatar.badge, isNotNull);
expect(
tester
.widget<ClipRRect>(
find.descendant(
of: avatarFinder,
matching: find.byType(ClipRRect),
),
)
.borderRadius,
BorderRadius.circular(16),
);
expect(
find.descendant(of: avatarFinder, matching: find.byType(ClipPath)),
findsOneWidget,
);
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
final name = tester.widget<Text>(
find.byKey(const ValueKey('dm-header-name')),
);
@@ -812,21 +787,27 @@ void main() {
find.byKey(const ValueKey('dm-header-presence')),
);
expect(name.style?.fontSize, 16);
expect(name.style?.fontWeight, FontWeight.w500);
expect(name.style?.fontWeight, FontWeight.w600);
expect(presence.style?.fontSize, 14);
expect(presence.style?.fontWeight, FontWeight.w400);
// Named counterpart: the avatar initial comes from the authored name.
expect(_dmHeaderAvatarInitial(tester), 'A');
expect(
tester.getCenter(find.byKey(const ValueKey('dm-header-text-stack'))).dx,
moreOrLessEquals(
tester.view.physicalSize.width / tester.view.devicePixelRatio / 2,
),
);
await tester.tap(
find.byKey(const ValueKey('dm-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.byType(ChannelDetailsPage), findsOneWidget);
Navigator.of(tester.element(find.byType(ChannelDetailsPage))).pop();
await tester.pumpAndSettle();
expect(find.byTooltip('View members'), findsNothing);
expect(find.byTooltip('Start Huddle'), findsOneWidget);
});
testWidgets('keys unnamed DM header avatars to the hex participant key', (
tester,
) async {
// A valid unnamed counterpart: the compact-npub label would render `N`
// for every unnamed DM, so the header avatar stays keyed to the hex
// public key instead.
testWidgets('labels unnamed DMs with the participant key', (tester) async {
const a11ce =
'a11ce00000000000000000000000000000000000000000000000000000000000';
final dmChannel = Channel(
@@ -852,19 +833,10 @@ void main() {
tester.widget<Text>(find.byKey(const ValueKey('dm-header-name'))).data,
shortPubkey(a11ce),
);
// The named counterpart in the test above keeps its authored initial
// ('A' from 'Alice'); this unnamed one gets the hex-key-derived 'A',
// not the `N` its npub label starts with.
expect(_dmHeaderAvatarInitial(tester), 'A');
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
});
testWidgets('keys DM header fallback avatars to the non-self counterpart', (
tester,
) async {
// Member order does not guarantee the counterpart is listed first:
// the current user (self, from the fake profile) comes FIRST, so an
// avatar keyed to the first participant would render the current
// user's initial while the header label names the counterpart.
testWidgets('labels DMs with the non-self counterpart', (tester) async {
const b0b =
'b0b0000000000000000000000000000000000000000000000000000000000000';
final dmChannel = Channel(
@@ -886,17 +858,14 @@ void main() {
);
await tester.pumpAndSettle();
// Label and avatar agree on the counterpart's key: the compact npub
// names the unnamed counterpart, and the avatar initial is keyed to
// that same hex key — never the current user's `S`.
expect(
tester.widget<Text>(find.byKey(const ValueKey('dm-header-name'))).data,
shortPubkey(b0b),
);
expect(_dmHeaderAvatarInitial(tester), 'B');
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
});
testWidgets('uses a fallback squircle for bot-role DM participants', (
testWidgets('omits the header avatar for bot-role DM participants', (
tester,
) async {
final dmChannel = Channel(
@@ -923,33 +892,7 @@ void main() {
);
await tester.pumpAndSettle();
final avatarFinder = find.byKey(const ValueKey('dm-header-avatar'));
expect(
tester
.widget<ClipRRect>(
find.descendant(
of: avatarFinder,
matching: find.byType(ClipRRect),
),
)
.borderRadius,
BorderRadius.circular(9.6),
);
expect(
tester
.widget<AvatarImageContent>(
find.descendant(
of: avatarFinder,
matching: find.byType(AvatarImageContent),
),
)
.imageUrl,
isNull,
);
expect(
find.descendant(of: avatarFinder, matching: find.byType(ClipPath)),
findsOneWidget,
);
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
});
testWidgets('hides the Huddle action in a one-to-one agent DM', (
@@ -984,18 +927,7 @@ void main() {
);
await tester.pumpAndSettle();
final avatarFinder = find.byKey(const ValueKey('dm-header-avatar'));
expect(
tester
.widget<ClipRRect>(
find.descendant(
of: avatarFinder,
matching: find.byType(ClipRRect),
),
)
.borderRadius,
BorderRadius.circular(9.6),
);
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing);
expect(find.byTooltip('Start Huddle'), findsNothing);
});
@@ -1862,7 +1794,9 @@ void main() {
},
);
testWidgets('keeps the Members action for group DMs', (tester) async {
testWidgets('opens group DM members from the centered title', (
tester,
) async {
final dmChannel = Channel(
id: _channelId,
name: 'DM',
@@ -1881,6 +1815,15 @@ void main() {
_buildTestable(
messages: const [],
channel: dmChannel,
members: ['self', 'alice', 'bob']
.map(
(pubkey) => ChannelMember(
pubkey: pubkey,
role: 'member',
joinedAt: DateTime(2025),
),
)
.toList(),
knownAgentPubkeys: const {'alice'},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
@@ -1890,8 +1833,24 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byTooltip('View members'), findsOneWidget);
expect(find.byTooltip('View members'), findsNothing);
expect(find.byTooltip('Start Huddle'), findsOneWidget);
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('dm-header-presence')))
.data,
'3 members',
);
await tester.tap(
find.byKey(const ValueKey('dm-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.byType(ChannelDetailsPage), findsOneWidget);
expect(
find.byKey(const ValueKey('channel-details-members-card')),
findsOneWidget,
);
});
testWidgets(
@@ -10577,35 +10536,94 @@ void main() {
return;
}
final backRect = platform == TargetPlatform.iOS
? tester.getRect(
find.byKey(const ValueKey('channel-ios-glass-back')),
)
: tester.getRect(find.byTooltip('Back'));
final avatarRect = tester.getRect(
find.byKey(const ValueKey('channel-header-avatar')),
);
final titleSpacing = avatarRect.left - backRect.right;
final title = tester.renderObject<RenderParagraph>(
find.byKey(const ValueKey('channel-header-name')),
);
final titleDidExceedMaxLines = title.didExceedMaxLines;
debugDefaultTargetPlatformOverride = previousPlatform;
expect(
titleSpacing,
moreOrLessEquals(
platform == TargetPlatform.iOS
? iosGlassChannelHeaderTitleSpacing
: 0,
),
tester
.getCenter(
find.byKey(const ValueKey('channel-header-text-stack')),
)
.dx,
moreOrLessEquals(160),
);
debugDefaultTargetPlatformOverride = previousPlatform;
expect(titleDidExceedMaxLines, isTrue);
expect(tester.takeException(), isNull);
},
);
}
for (final isDm in [false, true]) {
testWidgets('Android centered title fits large text dm=$isDm', (
tester,
) async {
tester.view.physicalSize = const Size(320, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final channel = isDm
? Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Self', 'A very long counterpart name'],
participantPubkeys: const ['self', 'alice'],
isMember: true,
)
: _testChannel.copyWith(name: 'a-very-long-channel-name');
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: channel,
textScaler: const TextScaler.linear(2),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
),
child: const Text('Open conversation'),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open conversation'));
await tester.pumpAndSettle();
final prefix = isDm ? 'dm' : 'channel';
final title = tester.getRect(
find.byKey(ValueKey('$prefix-header-text-stack')),
);
expect(title.center.dx, moreOrLessEquals(160));
expect(
title.left,
greaterThanOrEqualTo(tester.getRect(find.byTooltip('Back')).right),
);
expect(
title.right,
lessThanOrEqualTo(
tester.getRect(find.byTooltip('Start Huddle')).left,
),
);
expect(tester.takeException(), isNull);
await tester.tap(
find.byKey(ValueKey('$prefix-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.byType(ChannelDetailsPage), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
testWidgets('shows a tappable channel name and collective member count', (
tester,
) async {
@@ -10626,90 +10644,14 @@ void main() {
expect(find.text('general'), findsOneWidget);
expect(find.text('5 members'), findsOneWidget);
// The hash icon appears in the app bar and in the compose bar toolbar.
expect(find.byIcon(LucideIcons.hash), findsAtLeastNWidgets(1));
expect(
tester.getSize(find.byKey(const ValueKey('channel-header-avatar'))),
const Size.square(40),
);
final channelHeaderAvatarRect = tester.getRect(
find.byKey(const ValueKey('channel-header-avatar')),
);
final channelHeaderTextStackRect = tester.getRect(
find.byKey(const ValueKey('channel-header-text-stack')),
);
expect(channelHeaderTextStackRect.height, 40);
expect(
channelHeaderTextStackRect.center.dy,
moreOrLessEquals(channelHeaderAvatarRect.center.dy),
);
expect(find.byKey(const ValueKey('channel-header-avatar')), findsNothing);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('channel-header-name')))
.style
?.fontSize,
AppTheme.light().textTheme.titleSmall?.fontSize,
);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('channel-header-name')))
.style
?.fontWeight,
FontWeight.w600,
);
final channelHeaderAvatar = tester.widget<Container>(
find.byKey(const ValueKey('channel-header-avatar')),
);
expect(
(channelHeaderAvatar.decoration as BoxDecoration).color,
AppTheme.light().colorScheme.surface,
);
final channelHeaderAvatarBorder =
(channelHeaderAvatar.decoration as BoxDecoration).border! as Border;
expect(
channelHeaderAvatarBorder.top.color,
AppTheme.light().colorScheme.inverseSurface.withValues(alpha: 0.07),
);
expect(channelHeaderAvatarBorder.top.width, 1);
expect(
channelHeaderAvatarBorder.top.strokeAlign,
BorderSide.strokeAlignOutside,
);
expect(
tester
.widget<Icon>(
find.descendant(
of: find.byKey(const ValueKey('channel-header-avatar')),
matching: find.byIcon(LucideIcons.hash),
),
)
.color,
AppTheme.light().colorScheme.primary,
);
expect(
tester.getRect(find.byKey(const ValueKey('channel-header-name'))).left -
tester
.getRect(find.byKey(const ValueKey('channel-header-avatar')))
.right,
moreOrLessEquals(Grid.twelve),
);
expect(
tester
.widget<Text>(
find.byKey(const ValueKey('channel-header-member-count')),
)
.style
?.fontSize,
AppTheme.light().textTheme.bodySmall?.fontSize,
);
expect(
tester
.widget<Text>(
find.byKey(const ValueKey('channel-header-member-count')),
)
.style
?.color,
AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.65),
.getCenter(find.byKey(const ValueKey('channel-header-text-stack')))
.dx,
moreOrLessEquals(
tester.view.physicalSize.width / tester.view.devicePixelRatio / 2,
),
);
expect(find.byTooltip('View members'), findsNothing);
expect(find.byTooltip('Channel actions'), findsNothing);
@@ -16482,16 +16424,6 @@ class _TestNavigatorObserver extends NavigatorObserver {
}
}
/// Avatar fallback initial in the DM header — asserts at the production
/// seam (the masked `dm-header-avatar` badge), not the label helper.
String _dmHeaderAvatarInitial(WidgetTester tester) {
final avatar = find.byKey(const ValueKey('dm-header-avatar'));
final initial = tester.widget<Text>(
find.descendant(of: avatar, matching: find.byType(Text)),
);
return initial.data!;
}
/// Avatar fallback initial in the channel-details member preview row keyed
/// to [pubkey] — asserts at the production seam (the rendered
/// `_ChannelMemberPreviewRow`), not the label helper.

View File

@@ -34,8 +34,8 @@ void presenceTests() {
),
);
await tester.pumpAndSettle();
final avatar = find.byKey(const ValueKey('dm-header-avatar'));
final bounds = tester.getRect(avatar);
final header = find.byKey(const ValueKey('dm-header-text-stack'));
final center = tester.getCenter(header).dx;
for (final entry in <String?, String>{
null: 'Unknown',
'online': 'Online',
@@ -51,10 +51,11 @@ void presenceTests() {
entry.value,
);
expect(
tester.widget<MaskedAvatarBadge>(avatar).badge,
entry.key == null ? isNull : isNotNull,
find.byKey(const ValueKey('dm-header-presence-dot')),
entry.key == null ? findsNothing : findsOneWidget,
);
expect(tester.getRect(avatar), bounds);
expect(find.byKey(const ValueKey('dm-header-avatar')), findsNothing);
expect(tester.getCenter(header).dx, center);
await _capturePresence(tester, 'dm-${entry.value}');
}
cache.setPresence(null);

View File

@@ -350,6 +350,86 @@ void main() {
},
);
testWidgets(
'Android reaction counts open attribution without toggling or opening message actions',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
var toggles = 0;
var messageHolds = 0;
await _pumpRow(
tester,
reactions: [
_reaction(reactedByCurrentUser: true),
_reaction(emoji: _eyes),
],
onToggle: (_) => toggles++,
onMessageLongPress: () => messageHolds++,
);
final pill = find.byKey(const ValueKey('reaction-pill-$_eyes'));
await tester.longPress(
find.descendant(of: pill, matching: find.text('1')),
);
await tester.pumpAndSettle();
expect(find.text('Reactions'), findsNothing);
expect(find.text('All 2'), findsOneWidget);
final controller = DefaultTabController.of(
tester.element(find.byType(TabBar)),
);
expect(controller.index, 2);
expect(
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
findsOneWidget,
);
expect(
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
findsNothing,
);
await tester.tap(find.byKey(const ValueKey('reaction-filter-all')));
await tester.pumpAndSettle();
expect(controller.index, 0);
expect(
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
findsOneWidget,
);
expect(
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('reaction-filter-$_fire')));
await tester.pumpAndSettle();
expect(controller.index, 1);
expect(
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
findsNothing,
);
expect(
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
findsOneWidget,
);
await tester.drag(find.byType(TabBarView), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(controller.index, 2);
expect(
find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(),
findsOneWidget,
);
expect(
find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(),
findsNothing,
);
expect(toggles, 0);
expect(messageHolds, 0);
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('reaction-details-sheet')),
findsNothing,
);
debugDefaultTargetPlatformOverride = null;
},
);
group('ReactionRow', () {
testWidgets('shows the count even at one, matching desktop', (
tester,
@@ -452,7 +532,9 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text(':fire:'), findsOneWidget);
expect(find.byTooltip(':fire:'), findsOneWidget);
expect(find.byType(TabBar), findsOneWidget);
expect(find.text('All 1'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
});

View File

@@ -1,5 +1,6 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scroll_under_scope.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
import 'package:flutter/material.dart';
@@ -9,6 +10,54 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets(
'conversation backdrop stays mounted across scroll and keyboard changes',
(tester) async {
final scrolled = ValueNotifier(false);
addTearDown(scrolled.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: ValueListenableBuilder<bool>(
valueListenable: scrolled,
builder: (_, value, _) => FrostedScrollUnderScope(
isScrolledUnder: value,
child: const Stack(
children: [
FrostedAppBar(
title: Text('Conversation'),
alwaysFrosted: true,
),
],
),
),
),
),
);
final backdrop = tester.element(find.byType(BackdropFilter));
final initial = tester
.widget<Container>(
find.byKey(const ValueKey('frosted-app-bar-background')),
)
.decoration;
addTearDown(tester.view.reset);
for (final under in [true, false, true, false]) {
scrolled.value = under;
tester.view.viewInsets = FakeViewPadding(bottom: under ? 300 : 0);
await tester.pump();
expect(tester.element(find.byType(BackdropFilter)), same(backdrop));
expect(
tester
.widget<Container>(
find.byKey(const ValueKey('frosted-app-bar-background')),
)
.decoration,
initial,
);
}
},
);
testWidgets('title row and reported height grow with accessible text', (
tester,
) async {

View File

@@ -67,6 +67,75 @@ void main() {
);
});
for (final reverse in [false, true]) {
testWidgets(
'composer scrolling preserves timeline frost reverse=$reverse',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final timeline = ScrollController(
initialScrollOffset: reverse ? 0 : 120,
);
addTearDown(timeline.dispose);
final composer = TextEditingController();
addTearDown(composer.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Conversation')),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: reverse,
controller: timeline,
itemCount: 40,
itemBuilder: (_, index) =>
SizedBox(height: 48, child: Text('Message $index')),
),
),
SizedBox(
height: 100,
child: TextField(
controller: composer,
minLines: 1,
maxLines: 3,
),
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.tap(find.byType(TextField));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.enterText(
find.byType(TextField),
'One\nTwo\nThree\nFour\nFive',
);
await tester.pumpAndSettle();
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.enterText(find.byType(TextField), '');
await tester.pumpAndSettle();
expect(find.byType(BackdropFilter), findsOneWidget);
expect(_appBarBorder(tester)?.bottom.color.a, greaterThan(0));
timeline.jumpTo(reverse ? timeline.position.maxScrollExtent : 0);
await tester.pumpAndSettle();
expect(find.byType(BackdropFilter), findsNothing);
await tester.enterText(
find.byType(TextField),
'One\nTwo\nThree\nFour\nFive',
);
await tester.pumpAndSettle();
expect(find.byType(BackdropFilter), findsNothing);
},
);
}
testWidgets(
'page frost and divider appear only while content scrolls under header',
(tester) async {