diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 55b6c48c2f8..a2f5669e45e 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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 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, diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index b0e06b192f2..752b43ff2d6 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -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, ), - ), - ), - child: Icon( - channelIcon(channel), - size: 20, - color: context.colors.primary, + if (channel.isEphemeral) ...[ + const SizedBox(width: Grid.quarter), + _HeaderEphemeralBadge(channel: channel), + ], + ], ), - ), - 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( - 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, - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index f049ae0db2b..b893f01fb0f 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -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, diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 34dc6418ece..0ba9493876b 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -357,8 +357,27 @@ Future showReactionDetailSheet({ return; } if (!context.mounted) return; + if (defaultTargetPlatform == TargetPlatform.android) { + await showModalBottomSheet( + 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( context: context, + title: 'Reactions', isScrollControlled: true, showDragHandle: true, builder: (sheetContext) => _ReactionDetailSheet( @@ -369,6 +388,132 @@ Future showReactionDetailSheet({ ); } +class _AndroidReactionDetailSheet extends HookConsumerWidget { + const _AndroidReactionDetailSheet({ + required this.channelId, + required this.reactions, + required this.initialEmoji, + }); + + final String channelId; + final List 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( + 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 reactions; @@ -382,89 +527,75 @@ class _ReactionDetailSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedEmoji = useState(initialEmoji); + final selectedEmoji = useState(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( + 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; - }, + Padding( + 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, ), - ], - ), + ), + ], ), ), - - // 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, - ), - ), - ], - ), ), - // 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, ); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9d32c0d103c..bb7f42ee4b4 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -832,6 +832,7 @@ class ThreadDetailPage extends HookConsumerWidget { return FrostedScaffold( resizeToAvoidBottomInset: !usesFixedAndroidImeViewport, appBar: FrostedAppBar( + alwaysFrosted: true, nativeViewSuppressed: messageActionBackdropActive, nativeTitle: 'Thread', leading: usesNativeIosGlassBackButton diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 674ca6abb58..78bb54c87c2 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -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); diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 7c900917b36..98996f0cf72 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -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() != + 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 diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7916e0cd269..5d954bc4fe7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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( - 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(avatarFinder); - expect(tester.getSize(avatarFinder), const Size.square(32)); - expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot); - expect(avatar.badge, isNotNull); - expect( - tester - .widget( - 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( 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(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(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( - find.descendant( - of: avatarFinder, - matching: find.byType(ClipRRect), - ), - ) - .borderRadius, - BorderRadius.circular(9.6), - ); - expect( - tester - .widget( - 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( - 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(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( 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( + 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( - tester - .widget(find.byKey(const ValueKey('channel-header-name'))) - .style - ?.fontSize, - AppTheme.light().textTheme.titleSmall?.fontSize, - ); - expect( - tester - .widget(find.byKey(const ValueKey('channel-header-name'))) - .style - ?.fontWeight, - FontWeight.w600, - ); - final channelHeaderAvatar = tester.widget( - 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( - 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( - find.byKey(const ValueKey('channel-header-member-count')), - ) - .style - ?.fontSize, - AppTheme.light().textTheme.bodySmall?.fontSize, - ); + expect(find.byKey(const ValueKey('channel-header-avatar')), findsNothing); expect( tester - .widget( - 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( - 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. diff --git a/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart b/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart index ddead61a911..6a1c3815d9e 100644 --- a/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart @@ -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 { null: 'Unknown', 'online': 'Online', @@ -51,10 +51,11 @@ void presenceTests() { entry.value, ); expect( - tester.widget(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); diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 1915cd15c86..1670268a060 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -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); }); }); diff --git a/mobile/test/shared/widgets/frosted_app_bar_test.dart b/mobile/test/shared/widgets/frosted_app_bar_test.dart index 8f1d5ffca9b..90eb669f269 100644 --- a/mobile/test/shared/widgets/frosted_app_bar_test.dart +++ b/mobile/test/shared/widgets/frosted_app_bar_test.dart @@ -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( + 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( + 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( + find.byKey(const ValueKey('frosted-app-bar-background')), + ) + .decoration, + initial, + ); + } + }, + ); + testWidgets('title row and reported height grow with accessible text', ( tester, ) async { diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index 2c11bee9759..998febfdec5 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -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 {