From 8373bcc5cf65ecdcbb3e1c389c337ea698135372 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 11:10:10 +0100 Subject: [PATCH] Unify mobile channel details Signed-off-by: kenny lopez --- .../features/channels/add_members_sheet.dart | 245 +++++++ mobile/lib/features/channels/channel.dart | 6 +- .../channels/channel_actions_sheet.dart | 15 +- .../channels/channel_detail_page.dart | 110 ++- .../channels/channel_detail_page/app_bar.dart | 105 ++- .../channels/channel_details_page.dart | 670 ++++++++++++++++++ .../channels/channel_management_provider.dart | 21 + .../channels/channel_metadata_updates.dart | 19 + .../lib/features/channels/channels_page.dart | 60 +- .../channels/manage_channel_sheet.dart | 342 ++++----- .../lib/features/channels/members_sheet.dart | 1 + mobile/lib/features/home/home_page.dart | 9 +- .../profile/settings_profile_header.dart | 3 +- .../settings_page/appearance_section.dart | 1 + .../settings_page/community_section.dart | 1 + .../settings_page/connection_section.dart | 2 + mobile/lib/shared/widgets/app_list.dart | 4 +- mobile/lib/shared/widgets/app_list_card.dart | 13 +- .../channels/channel_actions_sheet_test.dart | 67 +- .../channels/channel_detail_page_test.dart | 635 +++++++++++++++-- .../channel_management_provider_test.dart | 22 + .../features/channels/channels_page_test.dart | 62 +- mobile/test/features/home/home_page_test.dart | 85 +++ .../profile/settings_profile_header_test.dart | 20 + .../features/settings/settings_page_test.dart | 38 + 25 files changed, 2169 insertions(+), 387 deletions(-) create mode 100644 mobile/lib/features/channels/add_members_sheet.dart create mode 100644 mobile/lib/features/channels/channel_details_page.dart create mode 100644 mobile/lib/features/channels/channel_metadata_updates.dart diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart new file mode 100644 index 0000000000..a710c21ac7 --- /dev/null +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -0,0 +1,245 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import 'channel_management_provider.dart'; + +/// Searchable multi-select used to add people or agents to a channel. +class AddChannelMembersSheet extends HookConsumerWidget { + const AddChannelMembersSheet({ + super.key, + required this.channelId, + required this.existingPubkeys, + }); + + final String channelId; + final Set existingPubkeys; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queryController = useTextEditingController(); + final query = useState(''); + final debouncedQuery = useState(''); + final selectedUsers = useState>([]); + final isSubmitting = useState(false); + final submitError = useState(null); + + useEffect(() { + final timer = Timer(const Duration(milliseconds: 250), () { + debouncedQuery.value = query.value.trim().toLowerCase(); + }); + return timer.cancel; + }, [query.value]); + + final normalizedQuery = debouncedQuery.value; + final directoryAsync = normalizedQuery.isEmpty + ? ref.watch(relayDirectoryUsersProvider) + : ref.watch(relayDirectorySearchProvider(normalizedQuery)); + final normalizedExisting = existingPubkeys + .map((pubkey) => pubkey.toLowerCase()) + .toSet(); + final selectedPubkeys = selectedUsers.value + .map((user) => user.pubkey.toLowerCase()) + .toSet(); + final availableUsers = + directoryAsync.asData?.value + .where( + (user) => !normalizedExisting.contains(user.pubkey.toLowerCase()), + ) + .toList() ?? + const []; + + void toggleUser(DirectoryUser user) { + if (isSubmitting.value) return; + final normalized = user.pubkey.toLowerCase(); + selectedUsers.value = selectedPubkeys.contains(normalized) + ? [ + for (final selected in selectedUsers.value) + if (selected.pubkey.toLowerCase() != normalized) selected, + ] + : [...selectedUsers.value, user]; + submitError.value = null; + } + + Future addSelectedMembers() async { + if (selectedUsers.value.isEmpty || isSubmitting.value) return; + isSubmitting.value = true; + submitError.value = null; + try { + await ref + .read(channelActionsProvider) + .addMembers( + channelId: channelId, + pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(), + ); + if (context.mounted) Navigator.of(context).pop(true); + } on AddMembersException catch (error) { + submitError.value = error.message; + } catch (error) { + submitError.value = error.toString(); + } finally { + isSubmitting.value = false; + } + } + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom, + ), + child: SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.72, + ), + child: Column( + children: [ + TextField( + key: const ValueKey('add-channel-members-search'), + controller: queryController, + autofocus: true, + autocorrect: false, + enableSuggestions: false, + enabled: !isSubmitting.value, + onChanged: (value) => query.value = value, + decoration: const InputDecoration( + hintText: 'Search for people or agents', + prefixIcon: Icon(LucideIcons.search), + ), + ), + if (selectedUsers.value.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: Grid.half, + runSpacing: Grid.half, + children: [ + for (final user in selectedUsers.value) + InputChip( + key: ValueKey( + 'add-channel-member-selected-${user.pubkey}', + ), + label: Text(user.label), + onDeleted: isSubmitting.value + ? null + : () => toggleUser(user), + ), + ], + ), + ), + ], + const SizedBox(height: Grid.xxs), + Expanded( + child: directoryAsync.when( + data: (_) => availableUsers.isEmpty + ? Center( + child: Text( + normalizedQuery.isEmpty + ? 'Everyone available is already in this channel.' + : 'No matching people or agents.', + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + key: const ValueKey('add-channel-members-results'), + itemCount: availableUsers.length, + itemBuilder: (context, index) { + final user = availableUsers[index]; + final selected = selectedPubkeys.contains( + user.pubkey.toLowerCase(), + ); + return ListTile( + key: ValueKey( + 'add-channel-member-${user.pubkey}', + ), + contentPadding: EdgeInsets.zero, + leading: AvatarImage( + imageUrl: user.avatarUrl, + radius: 20, + backgroundColor: + context.colors.primaryContainer, + fallback: Text(user.initial), + ), + title: Text( + user.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + user.secondaryLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Icon( + selected + ? LucideIcons.circleCheck + : LucideIcons.plus, + color: selected + ? context.colors.primary + : context.colors.onSurfaceVariant, + ), + onTap: () => toggleUser(user), + ); + }, + ), + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading people and agents', + ), + ), + error: (error, _) => Center( + child: Text( + 'Couldn’t load people or agents. Try again.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + if (submitError.value case final error?) ...[ + const SizedBox(height: Grid.xxs), + Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xxs), + SizedBox( + width: double.infinity, + child: FilledButton( + key: const ValueKey('add-channel-members-submit'), + onPressed: selectedUsers.value.isEmpty || isSubmitting.value + ? null + : addSelectedMembers, + child: Text( + isSubmitting.value + ? 'Adding…' + : selectedUsers.value.length == 1 + ? 'Add member' + : 'Add ${selectedUsers.value.length} members', + ), + ), + ), + const SizedBox(height: Grid.xxs), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 30b3f2b48f..7f0eb97d36 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -141,16 +141,18 @@ class Channel { ); Channel copyWith({ + String? name, + String? description, Object? lastMessageAt = _sentinel, Object? archivedAt = _sentinel, int? memberCount, bool? isMember, }) => Channel( id: id, - name: name, + name: name ?? this.name, channelType: channelType, visibility: visibility, - description: description, + description: description ?? this.description, topic: topic, purpose: purpose, createdBy: createdBy, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 99dc2ebf47..c1073bcb02 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -8,19 +9,31 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_action_tile.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/sheet_divider.dart'; import 'channel.dart'; +import 'add_members_sheet.dart'; import 'channel_management_provider.dart'; import 'channel_mutes/channel_mutes_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_stars/channel_stars_provider.dart'; import 'channels_provider.dart'; import 'manage_channel_sheet.dart'; +import 'members_sheet.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile_sheet.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; +part 'channel_details_page.dart'; + /// Opens the mobile channel actions sheet and returns whether its parent page /// should close after a successful lifecycle action. Future showChannelActionsSheet({ @@ -161,7 +174,7 @@ class ChannelActionsSheet extends ConsumerWidget { ), const SizedBox(height: Grid.xs), ], - if (!channel.isDm) + if (!channel.isDm && canManageLifecycle) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(LucideIcons.folderInput), diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8cef1a78ff..0ee58a3085 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -243,9 +243,10 @@ class ChannelDetailPage extends HookConsumerWidget { !resolvedChannel.isForum && isConnectionInProgress && !messagesNotifier.hasLoadedMessages; - final appBarTitleContentHeight = resolvedChannel.isDm - ? _dmAppBarTitleContentHeight(context) - : 0.0; + final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight( + context, + isDm: resolvedChannel.isDm, + ); final readTimestamp = _channelReadTimestamp( channel: resolvedChannel, messagesState: messagesState, @@ -311,65 +312,52 @@ class ChannelDetailPage extends HookConsumerWidget { channel: resolvedChannel, currentPubkey: currentPubkey, ) - : Row( - children: [ - SizedBox.square( - dimension: 22, - child: Center( - child: Icon(channelIcon(resolvedChannel), size: 18), - ), - ), - const SizedBox(width: Grid.half), - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - resolveDmChannelDisplayLabel( - resolvedChannel, - currentPubkey: currentPubkey, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (resolvedChannel.isEphemeral) ...[ - const SizedBox(width: Grid.quarter), - _HeaderEphemeralBadge(channel: resolvedChannel), - ], - ], - ), - ), - ], - ), - actions: [ - if (_showsMembersAction(resolvedChannel)) - _MembersButton( - channelId: resolvedChannel.id, - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), - IconButton( - color: context.colors.primary, - onPressed: () async { - final shouldClose = await showChannelActionsSheet( - context: context, + : _ChannelAppBarTitle( 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), - ), - ], + onTap: () async { + final shouldClose = await showChannelDetailsPage( + context: context, + channel: resolvedChannel, + currentPubkey: currentPubkey, + sectionId: ref + .read(channelSectionsProvider) + .store + .assignments[resolvedChannel.id], + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + actions: resolvedChannel.isDm + ? [ + 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), + ), + ] + : const [], ), 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 b3e1271e98..bccf8679a7 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -1,6 +1,7 @@ part of '../channel_detail_page.dart'; const _dmHeaderAvatarSize = 32.0; +const _channelHeaderAvatarSize = 40.0; const _dmPresenceDotRatio = 8 / 14; bool _showsMembersAction(Channel channel) { @@ -18,16 +19,106 @@ double _scaledTextHeight(BuildContext context, TextStyle style) { return scaledFontSize * (style.height ?? 1); } -double _dmAppBarTitleContentHeight(BuildContext context) { - final titleStyle = context.textTheme.titleSmall; - final presenceStyle = context.textTheme.bodyMedium; - if (titleStyle == null || presenceStyle == null) { - return _dmHeaderAvatarSize; +double _twoLineAppBarTitleContentHeight( + BuildContext context, { + required bool isDm, +}) { + final titleStyle = isDm + ? context.textTheme.titleSmall + : context.textTheme.titleMedium; + 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, presenceStyle); - return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize; + _scaledTextHeight(context, subtitleStyle); + return textHeight > avatarSize ? textHeight : avatarSize; +} + +class _ChannelAppBarTitle extends ConsumerWidget { + const _ChannelAppBarTitle({required this.channel, required this.onTap}); + + final Channel channel; + 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'}'; + + return Semantics( + button: true, + label: 'Open settings for ${channel.name}, $memberLabel', + child: Tooltip( + message: 'Open channel settings', + child: InkWell( + key: const ValueKey('channel-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.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + channelIcon(channel), + size: 20, + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + 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.titleMedium, + ), + ), + if (channel.isEphemeral) ...[ + const SizedBox(width: Grid.quarter), + _HeaderEphemeralBadge(channel: channel), + ], + ], + ), + Text( + memberLabel, + key: const ValueKey('channel-header-member-count'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } } class _MembersButton extends ConsumerWidget { diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart new file mode 100644 index 0000000000..54d038ea2f --- /dev/null +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -0,0 +1,670 @@ +part of 'channel_actions_sheet.dart'; + +const _channelMemberPreviewLimit = 5; +const _channelDetailsHeaderFrostScrollDistance = Grid.xxl; +const _channelDetailsHeaderFrostMaxBlurSigma = 20.0; +const _channelDetailsSectionPadding = Grid.twelve; + +/// Opens the combined mobile channel settings page. +Future showChannelDetailsPage({ + required BuildContext context, + required Channel channel, + required String? currentPubkey, + String? sectionId, +}) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailsPage( + channel: channel, + currentPubkey: currentPubkey, + sectionId: sectionId, + ), + ), +); + +/// Combined channel identity, membership, organization, and lifecycle page. +class ChannelDetailsPage extends HookConsumerWidget { + const ChannelDetailsPage({ + super.key, + required this.channel, + required this.currentPubkey, + this.sectionId, + }); + + final Channel channel; + final String? currentPubkey; + final String? sectionId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final displayedChannel = useState(channel); + final resolvedChannel = displayedChannel.value; + final scrollController = useScrollController(); + final heroNameKey = useMemoized(GlobalKey.new); + final collapsedTitleOffset = useRef(null); + final showCollapsedTitle = useState(false); + final headerFrostProgress = useState(0.0); + final isJoining = useState(false); + final isMuted = + ref + .watch(channelMutesProvider) + .store + .channels[resolvedChannel.id] + ?.muted == + true; + final isStarred = + ref + .watch(channelStarsProvider) + .store + .channels[resolvedChannel.id] + ?.starred == + true; + final membersAsync = ref.watch(channelMembersProvider(resolvedChannel.id)); + final members = membersAsync.asData?.value ?? const []; + final agentOwnersAsync = ref.watch(agentOwnersProvider); + final resolvedCurrentPubkey = + currentPubkey?.toLowerCase() ?? + ref.watch(currentPubkeyProvider)?.toLowerCase(); + final currentMember = members.cast().firstWhere( + (member) => member?.pubkey.toLowerCase() == resolvedCurrentPubkey, + orElse: () => null, + ); + final ownsOwnerAgent = + resolvedCurrentPubkey != null && + members.any( + (member) => + member.isOwner && + agentOwnersAsync.value?[member.pubkey.toLowerCase()] + ?.toLowerCase() == + resolvedCurrentPubkey, + ); + final canManageLifecycle = + currentMember?.isElevated == true || ownsOwnerAgent; + final canEdit = canManageLifecycle && !resolvedChannel.isArchived; + final canJoin = + resolvedChannel.visibility == 'open' && + !resolvedChannel.isArchived && + !resolvedChannel.isMember; + final canAddMembers = + !resolvedChannel.isArchived && + resolvedChannel.canAddMembers(currentMember?.role); + final canArchive = !resolvedChannel.isArchived && canManageLifecycle; + final canUnarchive = resolvedChannel.isArchived && canManageLifecycle; + final canDelete = + !resolvedChannel.isArchived && + (currentMember?.isOwner == true || ownsOwnerAgent); + final lifecycleCapabilitiesLoading = + membersAsync.isLoading || agentOwnersAsync.isLoading; + final lifecycleCapabilitiesUnavailable = + membersAsync.hasError || agentOwnersAsync.hasError; + final memberCount = + membersAsync.value?.length ?? resolvedChannel.memberCount; + final memberLabel = + '$memberCount ${memberCount == 1 ? 'member' : 'members'}'; + final previewMembers = members.take(_channelMemberPreviewLimit).toList(); + final userCache = ref.watch(userCacheProvider); + final previewPubkeyKey = previewMembers + .map((member) => member.pubkey.toLowerCase()) + .join('|'); + + useEffect(() { + if (previewMembers.isNotEmpty) { + ref + .read(userCacheProvider.notifier) + .preload(previewMembers.map((member) => member.pubkey).toList()); + } + return null; + }, [previewPubkeyKey]); + + useEffect(() { + void updateCollapsedTitle() { + final nextFrostProgress = !scrollController.hasClients + ? 0.0 + : (scrollController.offset / + _channelDetailsHeaderFrostScrollDistance) + .clamp(0.0, 1.0) + .toDouble(); + if ((headerFrostProgress.value - nextFrostProgress).abs() > 0.001) { + headerFrostProgress.value = nextFrostProgress; + } + + final threshold = collapsedTitleOffset.value; + if (threshold == null || !context.mounted) return; + final nextValue = scrollController.offset >= threshold; + if (showCollapsedTitle.value != nextValue) { + showCollapsedTitle.value = nextValue; + } + } + + collapsedTitleOffset.value = null; + scrollController.addListener(updateCollapsedTitle); + WidgetsBinding.instance.addPostFrameCallback((_) { + final nameContext = heroNameKey.currentContext; + final renderBox = nameContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize || !context.mounted) { + return; + } + final nameBottom = renderBox + .localToGlobal(Offset(0, renderBox.size.height)) + .dy; + collapsedTitleOffset.value = + scrollController.offset + nameBottom - frostedAppBarHeight(context); + updateCollapsedTitle(); + }); + return () => scrollController.removeListener(updateCollapsedTitle); + }, [scrollController, resolvedChannel.name]); + + Future openMembers() => showBuzzModalBottomSheet( + context: context, + title: 'Members', + isScrollControlled: true, + showDragHandle: true, + builder: (_) => MembersSheet( + channel: resolvedChannel, + currentPubkey: resolvedCurrentPubkey, + ), + ); + + Future openAddMembers() async { + final mediaQuery = MediaQuery.of(context); + await showBuzzModalBottomSheet( + context: context, + title: 'Add members', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: + mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs, + ), + builder: (_) => AddChannelMembersSheet( + channelId: resolvedChannel.id, + existingPubkeys: { + for (final member in members) member.pubkey.toLowerCase(), + }, + ), + ); + } + + Future openManageChannel() async { + final shouldClose = await showBuzzModalBottomSheet( + context: context, + title: 'Manage channel', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => ManageChannelSheet( + channel: resolvedChannel, + onChannelUpdated: (updated) => displayedChannel.value = updated, + ), + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(true); + } + } + + void toggleStar() { + final notifier = ref.read(channelStarsProvider.notifier); + isStarred + ? notifier.unstarChannel(resolvedChannel.id) + : notifier.starChannel(resolvedChannel.id); + } + + void toggleMute() { + final notifier = ref.read(channelMutesProvider.notifier); + isMuted + ? notifier.unmuteChannel(resolvedChannel.id) + : notifier.muteChannel(resolvedChannel.id); + } + + Future joinChannel() async { + if (isJoining.value) return; + isJoining.value = true; + try { + await ref.read(channelActionsProvider).joinChannel(resolvedChannel.id); + if (context.mounted) Navigator.of(context).pop(false); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Couldn’t join channel. Try again.')), + ); + } + } finally { + isJoining.value = false; + } + } + + final reducedMotion = MediaQuery.disableAnimationsOf(context); + return FrostedScaffold( + backgroundColor: context.colors.surface, + appBar: FrostedAppBar( + iconColor: context.colors.primary, + actions: const [SizedBox.square(dimension: 48)], + frosted: headerFrostProgress.value > 0, + frostedSurfaceOpacity: 0.5 * headerFrostProgress.value, + frostedBlurSigma: + _channelDetailsHeaderFrostMaxBlurSigma * headerFrostProgress.value, + showBottomDivider: headerFrostProgress.value > 0, + bottomDividerOpacity: 0.15 * headerFrostProgress.value, + title: AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: showCollapsedTitle.value + ? Text( + resolvedChannel.name, + key: const ValueKey('channel-details-collapsed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox( + key: ValueKey('channel-details-expanded-title-space'), + ), + ), + ), + body: ListView( + key: const ValueKey('channel-details-page-list'), + controller: scrollController, + padding: EdgeInsets.only( + top: frostedAppBarHeight(context) + Grid.xs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs, + ), + children: [ + _ChannelDetailsHero(channel: resolvedChannel, nameKey: heroNameKey), + Padding( + key: const ValueKey('channel-details-actions'), + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + _channelDetailsSectionPadding, + Grid.gutter, + _channelDetailsSectionPadding, + ), + child: Row( + children: [ + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-star-action'), + icon: isStarred ? LucideIcons.starOff : LucideIcons.star, + label: isStarred ? 'Unstar' : 'Star channel', + onTap: toggleStar, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-mute-action'), + icon: isMuted ? LucideIcons.bell : LucideIcons.bellOff, + label: isMuted ? 'Unmute' : 'Mute channel', + onTap: toggleMute, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: BuzzActionTile( + key: const ValueKey('channel-details-edit-action'), + icon: LucideIcons.pencil, + label: 'Edit', + isEnabled: canEdit, + onTap: openManageChannel, + ), + ), + ], + ), + ), + AppListCard( + key: const ValueKey('channel-details-members-card'), + label: memberLabel, + dividerIndent: Grid.xs + 40 + Grid.xs, + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canAddMembers) + AppListRowRaw( + key: const ValueKey('channel-details-add-members-row'), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colors.surfaceContainer, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.plus, + color: context.colors.onSurfaceVariant, + ), + ), + title: Text( + 'Add members', + style: context.textTheme.bodyLarge, + ), + trailing: const _ChannelDetailsChevron(), + onTap: openAddMembers, + verticalPadding: Grid.xxs, + ), + if (membersAsync.isLoading && members.isEmpty) + const AppListRow( + icon: LucideIcons.loaderCircle, + title: 'Loading members…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading members', + ), + ) + else if (membersAsync.hasError && members.isEmpty) + const AppListRow( + icon: LucideIcons.triangleAlert, + title: 'Members unavailable', + ) + else ...[ + for (final member in previewMembers) + _ChannelMemberPreviewRow( + key: ValueKey('channel-details-member-${member.pubkey}'), + member: member, + currentPubkey: resolvedCurrentPubkey, + displayName: + userCache[member.pubkey.toLowerCase()]?.displayName, + avatarUrl: + userCache[member.pubkey.toLowerCase()]?.avatarUrl, + ), + if (members.length > previewMembers.length) + AppListRowRaw( + key: const ValueKey('channel-details-members-row'), + leading: const SizedBox.square(dimension: 40), + title: Text('See all', style: context.textTheme.bodyLarge), + trailing: const _ChannelDetailsChevron(), + onTap: openMembers, + verticalPadding: Grid.xxs, + ), + ], + ], + ), + AppListCard( + key: const ValueKey('channel-details-channel-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + AppListRow( + icon: LucideIcons.folderInput, + title: 'Move to section…', + trailing: const _ChannelDetailsChevron(), + onTap: () async { + await _showMoveSectionSheet( + context, + ref, + channel: resolvedChannel, + sectionId: sectionId, + ); + }, + ), + AppListRow( + icon: LucideIcons.copy, + title: 'Copy channel name', + onTap: () { + copyToClipboard( + context, + resolvedChannel.name, + message: 'Channel name copied to clipboard', + ); + }, + ), + AppListRow( + icon: LucideIcons.hash, + title: 'Copy channel ID', + onTap: () { + copyToClipboard( + context, + resolvedChannel.id, + message: 'Channel ID copied to clipboard', + ); + }, + ), + ], + ), + if (resolvedChannel.isMember || + canJoin || + lifecycleCapabilitiesLoading || + lifecycleCapabilitiesUnavailable || + canArchive || + canUnarchive || + canDelete) + AppListCard( + key: const ValueKey('channel-details-lifecycle-card'), + verticalPadding: _channelDetailsSectionPadding, + children: [ + if (canJoin) + AppListRow( + icon: LucideIcons.logIn, + title: isJoining.value + ? 'Joining channel…' + : 'Join channel', + onTap: isJoining.value ? null : joinChannel, + ), + if (resolvedChannel.isMember && !resolvedChannel.isArchived) + AppListRow( + icon: LucideIcons.logOut, + title: 'Leave channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Leave #${resolvedChannel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', + action: () => ref + .read(channelActionsProvider) + .leaveChannel(resolvedChannel.id), + ), + ), + if (lifecycleCapabilitiesLoading) + const AppListRow( + icon: LucideIcons.loaderCircle, + title: 'Loading channel actions…', + trailing: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + ) + else if (lifecycleCapabilitiesUnavailable) + const AppListRow( + icon: LucideIcons.triangleAlert, + title: 'Channel actions unavailable', + ) + else ...[ + if (canArchive) + AppListRow( + icon: LucideIcons.archive, + title: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${resolvedChannel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(resolvedChannel.id), + ), + ), + if (canUnarchive) + AppListRow( + icon: LucideIcons.archiveRestore, + title: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${resolvedChannel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(resolvedChannel.id), + ), + ), + if (canDelete) + AppListRow( + icon: LucideIcons.trash2, + title: 'Delete channel', + titleColor: context.colors.error, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${resolvedChannel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(resolvedChannel.id), + ), + ), + ], + ], + ), + ], + ), + ); + } +} + +class _ChannelDetailsHero extends StatelessWidget { + const _ChannelDetailsHero({required this.channel, required this.nameKey}); + + final Channel channel; + final GlobalKey nameKey; + + @override + Widget build(BuildContext context) { + final description = channel.description.trim(); + return Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xs, + Grid.gutter, + _channelDetailsSectionPadding, + ), + child: Column( + children: [ + Container( + key: const ValueKey('channel-details-avatar'), + width: 80, + height: 80, + decoration: BoxDecoration( + color: context.colors.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _channelDetailsIcon(channel), + size: 32, + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(height: Grid.xxs), + KeyedSubtree( + key: const ValueKey('channel-details-name'), + child: Text( + channel.name, + key: nameKey, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + Text( + description, + key: const ValueKey('channel-details-description'), + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _ChannelMemberPreviewRow extends StatelessWidget { + const _ChannelMemberPreviewRow({ + super.key, + required this.member, + required this.currentPubkey, + required this.displayName, + required this.avatarUrl, + }); + + final ChannelMember member; + final String? currentPubkey; + final String? displayName; + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase(); + final label = isSelf + ? 'You' + : displayName?.trim().isNotEmpty == true + ? displayName!.trim() + : member.labelFor(currentPubkey); + final roleLabel = _channelMemberRoleLabel(member.role); + final titleStyle = context.textTheme.bodyLarge; + final roleStyle = context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ); + + return AppListRowRaw( + leading: AvatarImage( + imageUrl: avatarUrl, + radius: 20, + backgroundColor: context.colors.primaryContainer, + fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()), + ), + title: Text.rich( + TextSpan( + style: titleStyle, + children: [ + TextSpan(text: label), + TextSpan(text: ' · $roleLabel', style: roleStyle), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const _ChannelDetailsChevron(), + onTap: () => showUserProfileSheet(context, member.pubkey), + verticalPadding: Grid.xxs, + ); + } +} + +String _channelMemberRoleLabel(String role) { + if (role == 'bot') return 'Agent'; + if (role.isEmpty) return 'Member'; + return '${role[0].toUpperCase()}${role.substring(1)}'; +} + +IconData _channelDetailsIcon(Channel channel) { + if (channel.isPrivate) return LucideIcons.lock; + if (channel.isForum) return LucideIcons.messageSquareText; + return LucideIcons.hash; +} + +class _ChannelDetailsChevron extends StatelessWidget { + const _ChannelDetailsChevron(); + + @override + Widget build(BuildContext context) => Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); +} diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index e33ba30c5d..66097da2c0 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -11,8 +11,11 @@ import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; +import 'channel_metadata_updates.dart'; import 'channels_provider.dart'; +export 'channel_metadata_updates.dart'; + String _relayErrorMessage(Object error) => error.toString().replaceFirst('Exception: ', ''); @@ -761,6 +764,24 @@ class ChannelActions { await _refreshChannelState(channelId); } + /// Updates the user-editable channel metadata and refreshes cached details. + Future updateChannel({ + required String channelId, + String? name, + String? description, + }) async { + await _signedEventRelay.submit( + kind: 9002, + content: '', + tags: buildUpdateChannelTags( + channelId: channelId, + name: name, + description: description, + ), + ); + await _refreshChannelState(channelId); + } + /// Archives the channel and refreshes its cached state. Future archiveChannel(String channelId) => _setChannelArchived(channelId, archived: true); diff --git a/mobile/lib/features/channels/channel_metadata_updates.dart b/mobile/lib/features/channels/channel_metadata_updates.dart new file mode 100644 index 0000000000..1c80ee4e7f --- /dev/null +++ b/mobile/lib/features/channels/channel_metadata_updates.dart @@ -0,0 +1,19 @@ +/// Builds the kind:9002 tags used to update channel name and description. +List> buildUpdateChannelTags({ + required String channelId, + String? name, + String? description, +}) { + if (name == null && description == null) { + throw ArgumentError('At least one channel field must be provided'); + } + final canonicalName = name?.trim().replaceFirst(RegExp(r'^#+'), '').trim(); + if (canonicalName != null && canonicalName.isEmpty) { + throw ArgumentError.value(name, 'name', 'must not be empty'); + } + return [ + ['h', channelId], + if (canonicalName != null) ['name', canonicalName], + if (description != null) ['about', description.trim()], + ]; +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c77ef278ec..43029052ca 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -166,7 +166,8 @@ class ChannelsPage extends HookConsumerWidget { final WidgetBuilder settingsPageBuilder; - /// Reports the Settings route's raw animation progress from 0 to 1. + /// Reports Settings route progress so its foreground and Home's background + /// render from the same timeline. final ValueChanged onSettingsTransitionProgress; /// Notifies this page when its already-selected tab is tapped again. @@ -346,12 +347,11 @@ class ChannelsPage extends HookConsumerWidget { size: _kTopSectionAvatarSize, onTap: () { unawaited(HapticFeedback.lightImpact()); - Navigator.of(context).push( - _SettingsPageRoute( - builder: settingsPageBuilder, - onTransitionProgress: onSettingsTransitionProgress, - ), + final route = _SettingsPageRoute( + builder: settingsPageBuilder, + onTransitionProgress: onSettingsTransitionProgress, ); + Navigator.of(context).push(route); }, ), ), @@ -389,23 +389,34 @@ class _SettingsPageRoute extends PageRouteBuilder { builder(context), transitionsBuilder: _buildSettingsTransition, opaque: false, - transitionDuration: const Duration(milliseconds: 190), + allowSnapshotting: false, + transitionDuration: const Duration(milliseconds: 220), reverseTransitionDuration: const Duration(milliseconds: 190), ); final ValueChanged onTransitionProgress; Animation? _progressAnimation; + bool _hasStartedForwardTransition = false; @override void install() { super.install(); _progressAnimation = animation?..addListener(_reportProgress); - _reportProgress(); } void _reportProgress() { - onTransitionProgress(_progressAnimation?.value ?? 0); + final progressAnimation = _progressAnimation; + if (progressAnimation == null) return; + + // ProxyAnimation briefly exposes the previous completed value while the + // route installs its new controller. Ignore that handoff notification and + // begin reporting only once the route is genuinely moving forward. + if (!_hasStartedForwardTransition) { + if (progressAnimation.status != AnimationStatus.forward) return; + _hasStartedForwardTransition = true; + } + onTransitionProgress(progressAnimation.value); } @override @@ -422,18 +433,22 @@ class _SettingsPageRoute extends PageRouteBuilder { ) { if (MediaQuery.disableAnimationsOf(context)) return child; - final incoming = CurvedAnimation( + final motion = CurvedAnimation( parent: animation, - curve: Curves.easeOutCubic, + // Keep the complete page on one timeline. A gentler forward ease keeps + // the entrance visible without letting scale finish ahead of opacity; + // the existing reverse curve preserves the exit motion. + curve: Curves.easeOutQuad, reverseCurve: Curves.easeOutCubic, ); return FadeTransition( key: const ValueKey('settings-transition-opacity'), - opacity: _SettingsOpacityAnimation(incoming), + opacity: motion, child: RepaintBoundary( key: const ValueKey('settings-transition-layer'), child: ScaleTransition( - scale: Tween(begin: 1.04, end: 1).animate(incoming), + key: const ValueKey('settings-transition-scale'), + scale: Tween(begin: 1.04, end: 1).animate(motion), alignment: Alignment.center, child: child, ), @@ -441,22 +456,3 @@ class _SettingsPageRoute extends PageRouteBuilder { ); } } - -/// Keeps Settings already composed on entry while retaining a complete exit -/// fade. Reading the parent live also keeps opacity synchronized with scale on -/// the route's first frame. -class _SettingsOpacityAnimation extends Animation - with AnimationWithParentMixin { - _SettingsOpacityAnimation(this.parent); - - @override - final Animation parent; - - @override - double get value { - final progress = parent.value; - return parent.status == AnimationStatus.reverse - ? progress - : 0.8 + (0.2 * progress); - } -} diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 3f0d612745..983eb9139e 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -1,27 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; -import 'channel_mutes/channel_mutes_provider.dart'; class ManageChannelSheet extends HookConsumerWidget { - final Channel channel; + const ManageChannelSheet({ + super.key, + required this.channel, + this.onChannelUpdated, + }); - const ManageChannelSheet({super.key, required this.channel}); + final Channel channel; + final ValueChanged? onChannelUpdated; @override Widget build(BuildContext context, WidgetRef ref) { final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); + final nameController = useTextEditingController(text: channel.name); + final descriptionController = useTextEditingController( + text: channel.description, + ); + useListenable(nameController); + useListenable(descriptionController); + final canvasController = useTextEditingController(); final isEditingCanvas = useState(false); final isSavingCanvas = useState(false); - final isBusy = useState(false); + final isSavingDetails = useState(false); final actionError = useState(null); - final canvasController = useTextEditingController(); useEffect(() { final canvas = canvasAsync.asData?.value; @@ -31,53 +40,46 @@ class ManageChannelSheet extends HookConsumerWidget { return null; }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); - final mutesState = ref.watch(channelMutesProvider); - final isMuted = mutesState.store.channels[channel.id]?.muted == true; + final canonicalName = nameController.text + .trim() + .replaceFirst(RegExp(r'^#+'), '') + .trim(); + final description = descriptionController.text.trim(); + final nameDirty = canonicalName != channel.name.trim(); + final descriptionDirty = description != channel.description.trim(); + final canSaveDetails = + canonicalName.isNotEmpty && + (nameDirty || descriptionDirty) && + !isSavingDetails.value; + final canEditCanvas = !channel.isArchived; - final canJoin = - channel.visibility == 'open' && - !channel.isArchived && - !channel.isMember && - !channel.isDm; - final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; - final canEditCanvas = channel.isMember && !channel.isArchived; - - Future joinChannel() async { - if (isBusy.value) return; - isBusy.value = true; + Future saveDetails() async { + if (!canSaveDetails) return; + isSavingDetails.value = true; actionError.value = null; try { - await ref.read(channelActionsProvider).joinChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(false); - } + await ref + .read(channelActionsProvider) + .updateChannel( + channelId: channel.id, + name: nameDirty ? canonicalName : null, + description: descriptionDirty ? description : null, + ); + final updated = channel.copyWith( + name: canonicalName, + description: description, + ); + onChannelUpdated?.call(updated); + if (context.mounted) Navigator.of(context).pop(false); } catch (error) { actionError.value = error.toString(); } finally { - isBusy.value = false; - } - } - - Future leaveChannel() async { - if (isBusy.value) return; - isBusy.value = true; - actionError.value = null; - try { - await ref.read(channelActionsProvider).leaveChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(true); - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isBusy.value = false; + isSavingDetails.value = false; } } Future saveCanvas() async { - if (isSavingCanvas.value) { - return; - } + if (isSavingCanvas.value) return; isSavingCanvas.value = true; actionError.value = null; try { @@ -87,9 +89,7 @@ class ManageChannelSheet extends HookConsumerWidget { channelId: channel.id, content: canvasController.text.trim(), ); - if (context.mounted) { - isEditingCanvas.value = false; - } + if (context.mounted) isEditingCanvas.value = false; } catch (error) { actionError.value = error.toString(); } finally { @@ -109,99 +109,59 @@ class ManageChannelSheet extends HookConsumerWidget { child: ListView( shrinkWrap: true, children: [ - Text( - 'Basic management for ${channel.name}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), + _ManageChannelTextField( + fieldKey: const ValueKey('manage-channel-name'), + outlineKey: const ValueKey('manage-channel-name-outline'), + controller: nameController, + enabled: !isSavingDetails.value, + hintText: 'Channel name', + textInputAction: TextInputAction.next, ), - if (actionError.value case final error?) ...[ - const SizedBox(height: Grid.xs), + const SizedBox(height: Grid.xs), + _ManageChannelTextField( + fieldKey: const ValueKey('manage-channel-description'), + outlineKey: const ValueKey('manage-channel-description-outline'), + controller: descriptionController, + enabled: !isSavingDetails.value, + hintText: 'Description', + minLines: 2, + maxLines: 4, + textInputAction: TextInputAction.newline, + ), + if (canonicalName.isEmpty) ...[ + const SizedBox(height: Grid.half), Text( - error, + 'Channel name is required.', style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), ], const SizedBox(height: Grid.xs), - SwitchListTile( - title: const Text('Mute channel'), - subtitle: const Text('Suppress notifications and unread badges'), - secondary: const Icon(LucideIcons.bellOff), - contentPadding: EdgeInsets.zero, - value: isMuted, - onChanged: (value) { - if (value) { - ref - .read(channelMutesProvider.notifier) - .muteChannel(channel.id); - } else { - ref - .read(channelMutesProvider.notifier) - .unmuteChannel(channel.id); - } - }, - ), - if (canJoin || canLeave) ...[ - const SizedBox(height: Grid.xs), - Wrap( - spacing: Grid.xxs, - children: [ - if (canJoin) - FilledButton.tonal( - onPressed: isBusy.value ? null : joinChannel, - child: Text( - isBusy.value ? 'Joining\u2026' : 'Join channel', - ), - ), - if (canLeave) - OutlinedButton( - onPressed: isBusy.value ? null : leaveChannel, - child: Text( - isBusy.value ? 'Leaving\u2026' : 'Leave channel', - ), - ), - ], + Align( + alignment: Alignment.centerRight, + child: FilledButton( + key: const ValueKey('manage-channel-save-details'), + onPressed: canSaveDetails ? saveDetails : null, + child: Text(isSavingDetails.value ? 'Saving…' : 'Save changes'), ), - ], + ), const SizedBox(height: Grid.sm), - Text('Context', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Description', - value: channel.description, - emptyLabel: 'No description set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Topic', - value: channel.topic, - emptyLabel: 'No topic set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Purpose', - value: channel.purpose, - emptyLabel: 'No purpose set', - ), - if (!channel.isDm) ...[ - const SizedBox(height: Grid.sm), - Text('Canvas', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - canvasAsync.when( + _ManageChannelSection( + label: 'Canvas', + child: canvasAsync.when( data: (canvas) { if (isEditingCanvas.value) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( + key: const ValueKey('manage-channel-canvas'), controller: canvasController, maxLines: 8, minLines: 6, decoration: const InputDecoration( - hintText: - 'Write your canvas content in Markdown\u2026', + hintText: 'Write your canvas content in Markdown…', ), ), const SizedBox(height: Grid.xxs), @@ -225,7 +185,7 @@ class ManageChannelSheet extends HookConsumerWidget { : saveCanvas, child: Text( isSavingCanvas.value - ? 'Saving\u2026' + ? 'Saving…' : 'Save canvas', ), ), @@ -236,22 +196,14 @@ class ManageChannelSheet extends HookConsumerWidget { } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Text( - canvas.content?.trim().isNotEmpty == true - ? canvas.content! - : 'No canvas set for this channel.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), + Text( + canvas.content?.trim().isNotEmpty == true + ? canvas.content! + : 'No canvas set for this channel.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), ), const SizedBox(height: Grid.xxs), @@ -274,16 +226,25 @@ class ManageChannelSheet extends HookConsumerWidget { loading: () => const Center( child: BuzzLoadingIndicator( size: 40, - semanticLabel: 'Loading channel details', + semanticLabel: 'Loading channel canvas', ), ), error: (error, _) => Text( - error.toString(), + 'Couldn’t load the channel canvas.', style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), ), + ), + if (actionError.value case final error?) ...[ + const SizedBox(height: Grid.xxs), + Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), ], ], ), @@ -292,45 +253,90 @@ class ManageChannelSheet extends HookConsumerWidget { } } -class _ContextCard extends StatelessWidget { - final String label; - final String? value; - final String emptyLabel; - - const _ContextCard({ - required this.label, - required this.value, - required this.emptyLabel, +class _ManageChannelTextField extends StatelessWidget { + const _ManageChannelTextField({ + required this.fieldKey, + required this.outlineKey, + required this.controller, + required this.enabled, + required this.hintText, + required this.textInputAction, + this.minLines = 1, + this.maxLines = 1, }); + final Key fieldKey; + final Key outlineKey; + final TextEditingController controller; + final bool enabled; + final String hintText; + final TextInputAction textInputAction; + final int minLines; + final int maxLines; + @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), + return DecoratedBox( + key: outlineKey, decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.8), + ), + borderRadius: BorderRadius.circular(Radii.card), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), + child: TextField( + key: fieldKey, + controller: controller, + enabled: enabled, + minLines: minLines, + maxLines: maxLines, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hintText, + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, ), - const SizedBox(height: Grid.half), - Text( - value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.twelve, ), - ], + ), + textInputAction: textInputAction, ), ); } } + +class _ManageChannelSection extends StatelessWidget { + const _ManageChannelSection({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs), + child: Text( + label, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + clipBehavior: Clip.antiAlias, + child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child), + ), + ], + ); +} diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 54215ccd75..15432484eb 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -184,6 +184,7 @@ class _SectionLabel extends StatelessWidget { const _changeableRoles = ['admin', 'member', 'guest']; String _roleLabel(String role) { + if (role == 'bot') return 'Agent'; if (role.isEmpty) return 'Member'; return '${role[0].toUpperCase()}${role.substring(1)}'; } diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 1026a51a01..85c4debed7 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -126,6 +126,10 @@ class HomePage extends HookConsumerWidget { ), ), ValueListenableBuilder( + key: const ValueKey('home-settings-transition-progress'), + // Keep one stable listenable for Home. Swapping in the route's + // animation can briefly rebuild with its completed value before the + // new controller starts, which makes the background scale twice. valueListenable: settingsTransitionProgress, child: Scaffold( backgroundColor: Colors.transparent, @@ -219,7 +223,10 @@ class HomePage extends HookConsumerWidget { : Curves.easeOutCubic.transform(progress); return Opacity( key: const ValueKey('home-settings-transition-opacity'), - opacity: 1 - curvedProgress, + // Settings supplies the crossfade. Keeping Home opaque beneath + // it prevents the bare backdrop showing through two partially + // transparent layers. + opacity: 1, child: Transform.scale( key: const ValueKey('home-settings-transition-scale'), scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress), diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 59f424cb7a..ac2df53ef4 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -45,7 +45,8 @@ class SettingsProfileHeader extends HookConsumerWidget { showSetStatusSheet(context, currentStatus: status); return Padding( - padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm), + key: const ValueKey('settings-profile-header'), + padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve), child: Column( children: [ MaskedAvatarBadge( diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart index a5c3a5142d..7624a3cead 100644 --- a/mobile/lib/features/settings/settings_page/appearance_section.dart +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -25,6 +25,7 @@ class _AppearanceSection extends ConsumerWidget { return AppListCard( label: 'Style · This community', + verticalPadding: Grid.twelve, children: [ AppListRow( icon: LucideIcons.sunMoon, diff --git a/mobile/lib/features/settings/settings_page/community_section.dart b/mobile/lib/features/settings/settings_page/community_section.dart index b6a8c1b927..3e2b38c97c 100644 --- a/mobile/lib/features/settings/settings_page/community_section.dart +++ b/mobile/lib/features/settings/settings_page/community_section.dart @@ -14,6 +14,7 @@ class _CommunitySection extends ConsumerWidget { return AppListCard( label: 'Community', + verticalPadding: Grid.twelve, children: [ AppListRow( icon: LucideIcons.userPlus, diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 15cecdd4c6..8ab89f9e96 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -14,6 +14,7 @@ class _ConnectionSection extends ConsumerWidget { return AppListCard( label: 'Connection', + verticalPadding: Grid.twelve, children: [ AppListRow( icon: LucideIcons.server, @@ -104,6 +105,7 @@ class _RemoveCommunitySection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return AppListCard( + verticalPadding: Grid.twelve, children: [ AppListRow( icon: LucideIcons.logOut, diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 393b1d70ba..787e66d577 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -129,6 +129,7 @@ class AppListRowRaw extends StatelessWidget { this.subtitle, this.trailing, this.onTap, + this.verticalPadding = _rowVerticalPadding, }); final Widget leading; @@ -136,13 +137,14 @@ class AppListRowRaw extends StatelessWidget { final Widget? subtitle; final Widget? trailing; final VoidCallback? onTap; + final double verticalPadding; @override Widget build(BuildContext context) { final row = Padding( padding: EdgeInsets.symmetric( horizontal: AppListInset.of(context), - vertical: _rowVerticalPadding, + vertical: verticalPadding, ), child: Row( children: [ diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart index 00815e14b9..b14c653476 100644 --- a/mobile/lib/shared/widgets/app_list_card.dart +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -11,6 +11,7 @@ class AppListCard extends StatelessWidget { super.key, this.label, this.dividerIndent, + this.verticalPadding = Grid.xxs, required this.children, }); @@ -22,6 +23,12 @@ class AppListCard extends StatelessWidget { /// separators align with their row content on both sides. final double? dividerIndent; + /// Outer padding above and below this section. + /// + /// Adjacent cards contribute this padding from both sides. For example, + /// passing [Grid.twelve] creates a 24dp rhythm between grouped surfaces. + final double verticalPadding; + final List children; static const _inset = Grid.xs; @@ -53,11 +60,11 @@ class AppListCard extends StatelessWidget { } return Padding( - padding: const EdgeInsets.fromLTRB( + padding: EdgeInsets.fromLTRB( Grid.gutter, - Grid.xxs, + verticalPadding, Grid.gutter, - Grid.xxs, + verticalPadding, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/test/features/channels/channel_actions_sheet_test.dart b/mobile/test/features/channels/channel_actions_sheet_test.dart index e38a40d9a5..c2565c35ae 100644 --- a/mobile/test/features/channels/channel_actions_sheet_test.dart +++ b/mobile/test/features/channels/channel_actions_sheet_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_actions_sheet.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/manage_channel_sheet.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -302,29 +303,53 @@ void main() { expect(find.text('Channel actions unavailable'), findsOneWidget); }); - testWidgets( - 'manage leave closes both nested sheets without popping the page', - (tester) async { - await tester.pumpWidget( - _modalApp( - channel: _channel(), - loadMembers: () async => const [], - createChannelActions: (ref) => _FakeChannelActions(ref), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open actions')); - await tester.pumpAndSettle(); + testWidgets('Manage contains editing and canvas, not mute or leave', ( + tester, + ) async { + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => const [], + createChannelActions: (ref) => _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); - await tester.tap(find.text('Manage channel')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Leave channel').last); - await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); - expect(find.byType(ChannelActionsSheet), findsNothing); - expect(find.byType(Scaffold), findsOneWidget); - }, - ); + final manageSheet = find.byType(ManageChannelSheet); + expect(manageSheet, findsOneWidget); + expect( + find.descendant( + of: manageSheet, + matching: find.byKey(const ValueKey('manage-channel-name')), + ), + findsOneWidget, + ); + expect( + find.descendant(of: manageSheet, matching: find.text('Canvas')), + findsOneWidget, + ); + expect( + find.descendant(of: manageSheet, matching: find.text('Mute channel')), + findsNothing, + ); + expect( + find.descendant(of: manageSheet, matching: find.text('Leave channel')), + findsNothing, + ); + expect( + find.descendant(of: manageSheet, matching: find.text('Topic')), + findsNothing, + ); + expect( + find.descendant(of: manageSheet, matching: find.text('Purpose')), + findsNothing, + ); + }); testWidgets('DM omits quick actions, then shows mute and copy rows', ( tester, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e385452b..f8e493ccaa 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -36,6 +36,8 @@ import 'package:buzz/features/profile/user_profile_sheet.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list_card.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/frosted_scaffold.dart'; import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; @@ -180,6 +182,7 @@ Widget _buildTestable({ _FakeChannelsNotifier? channelsNotifier, List navigatorObservers = const [], Future> Function()? loadMembers, + List? directoryUsers, ChannelActions Function(Ref ref)? createChannelActions, ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, @@ -228,6 +231,9 @@ Widget _buildTestable({ channelBotPubkeysProvider( _channelId, ).overrideWith((ref) async => const {}), + agentOwnersProvider.overrideWith((ref) async => const {}), + if (directoryUsers != null) + relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), if (createChannelActions != null) channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) @@ -809,7 +815,96 @@ void main() { ); }); - testWidgets('members sheet shows roles and manage controls for owners', ( + testWidgets( + 'channel details combines people and agents in one member list', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + displayName: 'Self', + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + displayName: 'Alice', + ), + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + displayName: 'Agent', + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + expect(find.text('3 members'), findsOneWidget); + expect(find.text('You · Owner', findRichText: true), findsOneWidget); + expect(find.text('Alice · Member', findRichText: true), findsOneWidget); + expect(find.text('Agent · Agent', findRichText: true), findsOneWidget); + expect(find.text('Member'), findsNothing); + expect(find.text('Owner'), findsNothing); + expect(find.text('People · 2'), findsNothing); + expect(find.text('Agents · 1'), findsNothing); + expect(find.text('PEOPLE — 2'), findsNothing); + expect(find.text('BOTS — 1'), findsNothing); + + final aliceRow = find.byKey( + const ValueKey('channel-details-member-alice'), + ); + final aliceText = tester.widget( + find.descendant( + of: aliceRow, + matching: find.byWidgetPredicate( + (widget) => + widget is Text && + widget.textSpan?.toPlainText() == 'Alice · Member', + ), + ), + ); + final aliceSpans = (aliceText.textSpan! as TextSpan).children!; + expect( + aliceSpans.last.style?.fontSize, + AppTheme.light().textTheme.bodySmall?.fontSize, + ); + expect( + tester + .widget( + find.descendant( + of: aliceRow, + matching: find.byType(AvatarImage), + ), + ) + .radius, + 20, + ); + expect( + find.descendant( + of: aliceRow, + matching: find.byIcon(LucideIcons.chevronRight), + ), + findsOneWidget, + ); + expect(tester.getSize(aliceRow).height, 40 + (Grid.xxs * 2)); + + await tester.tap(aliceRow); + await tester.pumpAndSettle(); + expect(find.byType(UserProfileSheet), findsOneWidget); + }, + ); + + testWidgets('one-member channel shows Add first without a See all row', ( tester, ) async { await tester.pumpWidget( @@ -822,38 +917,131 @@ void main() { joinedAt: DateTime(2025), displayName: 'Self', ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + final addRow = find.byKey( + const ValueKey('channel-details-add-members-row'), + ); + final memberRow = find.byKey( + const ValueKey('channel-details-member-self'), + ); + expect(addRow, findsOneWidget); + expect(memberRow, findsOneWidget); + expect(find.text('See all'), findsNothing); + expect( + tester.widget(find.text('Add members')).style, + Theme.of(tester.element(addRow)).textTheme.bodyLarge, + ); + expect( + tester.getTopLeft(addRow).dy, + lessThan(tester.getTopLeft(memberRow).dy), + ); + }); + + testWidgets('previews five members before an icon-free See all row', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ ChannelMember( - pubkey: 'alice', - role: 'member', + pubkey: 'self', + role: 'owner', joinedAt: DateTime(2025), - displayName: 'Alice', ), + for (var index = 0; index < 5; index++) + ChannelMember( + pubkey: 'member-$index', + role: 'member', + joinedAt: DateTime(2025), + ), ChannelMember( pubkey: 'agent', role: 'bot', joinedAt: DateTime(2025), - displayName: 'Agent', ), ], ), ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('View members')); + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); await tester.pumpAndSettle(); - expect(find.text('Alice'), findsOneWidget); - expect(find.text('Member'), findsOneWidget); - expect(find.text('Owner'), findsOneWidget); - expect(find.text('People · 2'), findsOneWidget); - expect(find.text('Agents · 1'), findsOneWidget); - expect(find.text('PEOPLE — 2'), findsNothing); - expect(find.text('BOTS — 1'), findsNothing); + final previews = find.byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && + key.value.startsWith('channel-details-member-'); + }); + expect(previews, findsNWidgets(5)); + expect(find.text('See all'), findsOneWidget); + final seeAllRow = find.byKey( + const ValueKey('channel-details-members-row'), + ); + expect(seeAllRow, findsOneWidget); + expect( + tester.widget(find.text('See all')).style, + Theme.of(tester.element(seeAllRow)).textTheme.bodyLarge, + ); + expect( + find.descendant(of: seeAllRow, matching: find.text('7 members')), + findsNothing, + ); + expect( + find.descendant( + of: seeAllRow, + matching: find.byIcon(LucideIcons.users), + ), + findsNothing, + ); + final firstMemberRow = previews.first; + final firstMemberTitle = find.descendant( + of: firstMemberRow, + matching: find.byWidgetPredicate( + (widget) => widget is Text && widget.textSpan != null, + ), + ); + expect( + tester.getTopLeft(find.text('See all')).dx, + closeTo(tester.getTopLeft(firstMemberTitle).dx, 0.1), + ); + expect(tester.getSize(seeAllRow).height, 40 + (Grid.xxs * 2)); + + await tester.ensureVisible(seeAllRow); + await tester.pumpAndSettle(); + await tester.tap(seeAllRow); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('members-sheet-list')), + const Offset(0, -500), + ); + await tester.pumpAndSettle(); + expect(find.text('Agent'), findsOneWidget); + expect(find.text('Bot'), findsNothing); }); - testWidgets('members sheet has no divider and pads a one-member list', ( + testWidgets('Add members keeps its close control below the safe top', ( tester, ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(top: 47, bottom: 34); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewPadding); + addTearDown(tester.view.resetViewInsets); + await tester.pumpWidget( _buildTestable( messages: const [], @@ -862,48 +1050,75 @@ void main() { pubkey: 'self', role: 'owner', joinedAt: DateTime(2025), - displayName: 'Self', ), ], + directoryUsers: const [ + DirectoryUser(pubkey: 'alice', displayName: 'Alice'), + ], ), ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('View members')); + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('channel-details-add-members-row')), + ); await tester.pumpAndSettle(); - final contentPadding = find.byKey( - const ValueKey('members-sheet-content-padding'), + final sheet = find.byType(BottomSheet).last; + final closeButton = find.byTooltip('Close sheet'); + expect(closeButton, findsOneWidget); + expect(tester.getTopLeft(sheet).dy, greaterThanOrEqualTo(47 + Grid.xs)); + expect(tester.getRect(closeButton).bottom, lessThan(844 - 300)); + }); + + testWidgets('Add members submits selected directory users', (tester) async { + List? addedPubkeys; + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + directoryUsers: const [ + DirectoryUser(pubkey: 'alice', displayName: 'Alice'), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onAddMembers: (_, pubkeys) async => addedPubkeys = pubkeys, + ), + ), ); - expect(contentPadding, findsOneWidget); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('channel-details-add-members-row')), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('add-channel-member-alice'))); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('add-channel-members-submit')), + ); + await tester.pumpAndSettle(); + + expect(addedPubkeys, ['alice']); expect( - find.descendant(of: contentPadding, matching: find.byType(Divider)), + find.byKey(const ValueKey('add-channel-members-search')), findsNothing, ); - expect( - (tester.widget(contentPadding).padding as EdgeInsets).bottom, - 0, - ); - final viewport = tester.widget( - find.byKey(const ValueKey('members-sheet-viewport')), - ); - expect(viewport.constraints.maxHeight, 400 + Grid.md); - expect( - (tester - .widget( - find.byKey(const ValueKey('members-sheet-list')), - ) - .padding! - as EdgeInsets) - .bottom, - Grid.md, - ); - expect( - find.byKey(const ValueKey('buzz-sheet-surface-margin')), - findsNothing, - ); - expect(find.text('People · 1'), findsOneWidget); - expect(tester.widget(find.text('People · 1')).style?.fontSize, 14); }); testWidgets('hides composer for archived channels', (tester) async { @@ -999,14 +1214,14 @@ void main() { ); expect(find.text('Message…'), findsNothing); - await tester.tap(find.byTooltip('Channel actions')); - await tester.pumpAndSettle(); - await tester.drag( - find.byType(SingleChildScrollView).last, - const Offset(0, -300), + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Manage channel').last); + await tester.drag( + find.byKey(const ValueKey('channel-details-page-list')), + const Offset(0, -300), + ); await tester.pumpAndSettle(); await tester.tap(find.text('Join channel')); await tester.pumpAndSettle(); @@ -1016,10 +1231,11 @@ void main() { find.text('Join this channel from Manage to participate.'), findsNothing, ); + expect(find.text('Message #general'), findsOneWidget); }); - testWidgets('detail-header manage leave closes the detail page', ( + testWidgets('Leave lives on the detail page instead of Manage', ( tester, ) async { await tester.pumpWidget( @@ -1030,14 +1246,24 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Channel actions')); + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); await tester.pumpAndSettle(); - await tester.tap(find.text('Manage channel').last); + await tester.drag( + find.byKey(const ValueKey('channel-details-page-list')), + const Offset(0, -300), + ); await tester.pumpAndSettle(); await tester.tap(find.text('Leave channel').last); await tester.pumpAndSettle(); + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); - expect(find.byTooltip('Channel actions'), findsNothing); + expect( + find.byKey(const ValueKey('channel-header-settings-trigger')), + findsNothing, + ); }); testWidgets('keeps manage sheet dismissible with a long canvas', ( @@ -1051,6 +1277,13 @@ void main() { await tester.pumpWidget( _buildTestable( messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], canvasContent: List.generate( 80, (index) => 'Canvas line $index', @@ -1059,18 +1292,17 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Channel actions')); - await tester.pumpAndSettle(); - await tester.drag( - find.byType(SingleChildScrollView).last, - const Offset(0, -300), + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Manage channel').last); + await tester.tap( + find.byKey(const ValueKey('channel-details-edit-action')), + ); await tester.pumpAndSettle(); final sheet = find.byType(BottomSheet).last; - expect(find.byType(BottomSheet), findsNWidgets(2)); + expect(find.byType(BottomSheet), findsOneWidget); expect(tester.getSize(sheet).height, lessThanOrEqualTo(720)); final sheetTop = tester.getTopLeft(sheet).dy; @@ -1080,7 +1312,88 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Manage channel'), findsOneWidget); + expect(find.text('Manage channel'), findsNothing); + }); + + testWidgets('Edit updates name and description without legacy fields', ( + tester, + ) async { + String? updatedName; + String? updatedDescription; + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => _FakeChannelActions( + ref, + onUpdateChannel: (_, name, description) async { + updatedName = name; + updatedDescription = description; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('channel-details-edit-action')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Mute channel'), findsOneWidget); + expect(find.text('Leave channel'), findsNothing); + expect(find.text('Topic'), findsNothing); + expect(find.text('Purpose'), findsNothing); + expect(find.text('Canvas'), findsOneWidget); + + final nameField = tester.widget( + find.byKey(const ValueKey('manage-channel-name')), + ); + final descriptionField = tester.widget( + find.byKey(const ValueKey('manage-channel-description')), + ); + expect(nameField.decoration?.labelText, isNull); + expect(nameField.decoration?.hintText, 'Channel name'); + expect(nameField.decoration?.border, InputBorder.none); + expect(descriptionField.decoration?.labelText, isNull); + expect(descriptionField.decoration?.hintText, 'Description'); + expect(descriptionField.decoration?.border, InputBorder.none); + final nameOutline = tester.getRect( + find.byKey(const ValueKey('manage-channel-name-outline')), + ); + final descriptionOutline = tester.getRect( + find.byKey(const ValueKey('manage-channel-description-outline')), + ); + expect(descriptionOutline.top - nameOutline.bottom, Grid.xs); + + await tester.enterText( + find.byKey(const ValueKey('manage-channel-name')), + ' #renamed ', + ); + await tester.enterText( + find.byKey(const ValueKey('manage-channel-description')), + 'A new description', + ); + await tester.pump(); + await tester.tap( + find.byKey(const ValueKey('manage-channel-save-details')), + ); + await tester.pumpAndSettle(); + + expect(updatedName, 'renamed'); + expect(updatedDescription, 'A new description'); + expect(find.text('renamed'), findsOneWidget); + expect(find.text('A new description'), findsOneWidget); }); testWidgets('shows empty state when no messages', (tester) async { @@ -1704,7 +2017,13 @@ void main() { final unreadRect = tester.getRect(unreadButton); expect( unreadRect.top, - frostedAppBarHeight(tester.element(unreadButton)) + Grid.xs, + frostedAppBarHeight( + tester.element(unreadButton), + titleContentHeight: tester + .widget(find.byType(FrostedAppBar).first) + .titleContentHeight, + ) + + Grid.xs, ); expect(find.text('Latest'), findsNothing); @@ -4152,13 +4471,149 @@ void main() { }); group('App bar', () { - testWidgets('shows channel name with hash icon', (tester) async { - await tester.pumpWidget(_buildTestable(messages: [])); + testWidgets('shows a tappable channel name and collective member count', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + members: List.generate( + 5, + (index) => ChannelMember( + pubkey: 'member-$index', + role: 'member', + joinedAt: DateTime(2025), + ), + ), + ), + ); await tester.pumpAndSettle(); 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), + ); + expect( + tester + .widget(find.byKey(const ValueKey('channel-header-name'))) + .style + ?.fontSize, + AppTheme.light().textTheme.titleMedium?.fontSize, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('channel-header-member-count')), + ) + .style + ?.fontSize, + AppTheme.light().textTheme.bodySmall?.fontSize, + ); + expect(find.byTooltip('View members'), findsNothing); + expect(find.byTooltip('Channel actions'), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('channel-header-settings-trigger')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Channel settings'), findsNothing); + expect( + find.byKey(const ValueKey('channel-details-collapsed-title')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('channel-details-avatar')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-details-name')), + findsOneWidget, + ); + expect(find.text('General discussion'), findsOneWidget); + expect(find.text('5 members'), findsOneWidget); + expect(find.text('Preferences'), findsNothing); + expect(find.text('Star channel'), findsOneWidget); + expect(find.text('Mute channel'), findsOneWidget); + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Actions'), findsNothing); + expect(find.byTooltip('Back'), findsOneWidget); + + var detailsAppBar = tester.widget( + find.byType(FrostedAppBar).last, + ); + expect(detailsAppBar.frosted, isFalse); + expect(detailsAppBar.frostedSurfaceOpacity, 0); + expect(detailsAppBar.frostedBlurSigma, 0); + expect(detailsAppBar.showBottomDivider, isFalse); + + final descriptionBottom = tester + .getRect(find.byKey(const ValueKey('channel-details-description'))) + .bottom; + final firstActionTop = tester + .getRect(find.byKey(const ValueKey('channel-details-star-action'))) + .top; + expect(firstActionTop - descriptionBottom, closeTo(Grid.sm, 0.5)); + + final firstActionBottom = tester + .getRect(find.byKey(const ValueKey('channel-details-star-action'))) + .bottom; + final membersLabelTop = tester.getRect(find.text('5 members')).top; + expect(membersLabelTop - firstActionBottom, closeTo(Grid.sm, 0.5)); + expect( + tester + .widget( + find.byKey(const ValueKey('channel-details-members-card')), + ) + .verticalPadding, + Grid.twelve, + ); + expect(find.text('Channel'), findsNothing); + + await tester.drag( + find.byKey(const ValueKey('channel-details-page-list')), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('channel-details-collapsed-title')), + findsOneWidget, + ); + final collapsedTitle = find.byKey( + const ValueKey('channel-details-collapsed-title'), + ); + expect( + tester.getCenter(collapsedTitle).dx, + closeTo(tester.getCenter(find.byType(FrostedAppBar).last).dx, 0.5), + ); + expect(find.text('Channel'), findsNothing); + detailsAppBar = tester.widget( + find.byType(FrostedAppBar).last, + ); + expect(detailsAppBar.frosted, isTrue); + expect(detailsAppBar.frostedSurfaceOpacity, 0.5); + expect(detailsAppBar.frostedBlurSigma, 20); + expect(detailsAppBar.showBottomDivider, isTrue); + expect(detailsAppBar.bottomDividerOpacity, 0.15); + expect( + tester + .widget( + find.byKey(const ValueKey('channel-details-channel-card')), + ) + .verticalPadding, + Grid.twelve, + ); + + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('channel-header-settings-trigger')), + findsOneWidget, + ); }); testWidgets('shows lock icon for private channel', (tester) async { @@ -7268,27 +7723,57 @@ class _FakeChannelsNotifier extends ChannelsNotifier { class _FakeChannelActions extends ChannelActions { final Future Function(String channelId)? onJoinChannel; + final Future Function(String channelId, List pubkeys)? + onAddMembers; + final Future Function( + String channelId, + String? name, + String? description, + )? + onUpdateChannel; - _FakeChannelActions(Ref ref, {this.onJoinChannel}) - : super( - ref: ref, - session: ref.read(relaySessionProvider.notifier), - signedEventRelay: SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: null, - ), - currentPubkey: 'self', - ); + _FakeChannelActions( + Ref ref, { + this.onJoinChannel, + this.onAddMembers, + this.onUpdateChannel, + }) : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); @override Future joinChannel(String channelId) async { await onJoinChannel?.call(channelId); } + @override + Future addMembers({ + required String channelId, + required List pubkeys, + String role = 'member', + }) async { + await onAddMembers?.call(channelId, pubkeys); + } + @override Future leaveChannel(String channelId) async { return; } + + @override + Future updateChannel({ + required String channelId, + String? name, + String? description, + }) async { + await onUpdateChannel?.call(channelId, name, description); + } } class _RecordingRelaySocket extends RelaySocket { diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 619a011f8f..71dc8abcaf 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -201,6 +201,28 @@ void main() { }); group('build channel lifecycle tags', () { + test('channel edits match desktop kind 9002 tags', () { + expect( + buildUpdateChannelTags( + channelId: 'channel-id', + name: ' ###general ', + description: ' Team updates ', + ), + [ + ['h', 'channel-id'], + ['name', 'general'], + ['about', 'Team updates'], + ], + ); + }); + + test('channel edits reject a hash-only name', () { + expect( + () => buildUpdateChannelTags(channelId: 'channel-id', name: ' ### '), + throwsArgumentError, + ); + }); + test('archive matches kind 9002 tags', () { expect(buildSetChannelArchivedTags('channel-id', archived: true), [ ['h', 'channel-id'], diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index b0387b54f8..cc6aabbd51 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -744,9 +744,10 @@ void main() { final route = ModalRoute.of(tester.element(find.text('Injected settings'))); expect(route, isNot(isA>())); expect(route?.opaque, isFalse); + expect(route?.allowSnapshotting, isFalse); }); - testWidgets('reports Settings progress in both directions', (tester) async { + testWidgets('reports monotonic Settings route progress', (tester) async { final progress = []; await tester.pumpWidget( buildTestable( @@ -759,23 +760,31 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byType(ProfileAvatar)); + await tester.pump(); + expect(progress, [0]); + await tester.pump(const Duration(milliseconds: 16)); + expect(progress.last, inExclusiveRange(0, 1)); + await tester.pump(const Duration(milliseconds: 95)); + expect(progress.last, inExclusiveRange(0, 1)); await tester.pumpAndSettle(); - expect(progress.any((value) => value > 0 && value < 1), isTrue); expect(progress.last, 1); + for (var index = 1; index < progress.length; index++) { + expect(progress[index], greaterThanOrEqualTo(progress[index - 1])); + } - final reverseStart = progress.length; Navigator.of(tester.element(find.text('Injected settings'))).pop(); + await tester.pump(); + final reverseStart = progress.length; + await tester.pump(const Duration(milliseconds: 95)); + expect(progress.last, inExclusiveRange(0, 1)); await tester.pumpAndSettle(); - expect( - progress.skip(reverseStart).any((value) => value > 0 && value < 1), - isTrue, - ); expect(progress.last, 0); + for (var index = reverseStart + 1; index < progress.length; index++) { + expect(progress[index], lessThanOrEqualTo(progress[index - 1])); + } }); - testWidgets('paints Settings content with its surface from the first frame', ( - tester, - ) async { + testWidgets('scales and fully fades Settings into view', (tester) async { await tester.pumpWidget( buildTestable( overrides: [ @@ -792,7 +801,12 @@ void main() { const ValueKey('settings-transition-opacity'), skipOffstage: false, ); + final scaleTransition = find.byKey( + const ValueKey('settings-transition-scale'), + skipOffstage: false, + ); expect(transition, findsOneWidget); + expect(scaleTransition, findsOneWidget); expect( find.descendant( of: transition, @@ -803,24 +817,44 @@ void main() { ), findsOneWidget, ); - expect(tester.widget(transition).opacity.value, 0.8); + expect(tester.widget(transition).opacity.value, 0); await tester.pump(const Duration(milliseconds: 95)); + final forwardOpacity = tester + .widget(transition) + .opacity + .value; + final forwardScale = tester + .widget(scaleTransition) + .scale + .value; + final forwardScaleProgress = (1.04 - forwardScale) / 0.04; expect( - tester.widget(transition).opacity.value, - inExclusiveRange(0.8, 1), + forwardOpacity, + closeTo(Curves.easeOutQuad.transform(95 / 220), 0.02), ); + expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001)); await tester.pumpAndSettle(); expect(tester.widget(transition).opacity.value, 1); Navigator.of(tester.element(find.text('Injected settings'))).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 95)); + final reverseOpacity = tester + .widget(transition) + .opacity + .value; + final reverseScale = tester + .widget(scaleTransition) + .scale + .value; + final reverseScaleProgress = (1.04 - reverseScale) / 0.04; expect( - tester.widget(transition).opacity.value, + reverseOpacity, inExclusiveRange(0, 1), reason: 'The complete Settings layer still fades out on exit.', ); + expect(reverseScaleProgress, closeTo(reverseOpacity, 0.001)); }); testWidgets('gives feedback for the profile and community controls', ( diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 3a54332c98..fc0aaa966b 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -1,5 +1,6 @@ import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/features/channels/channels_page.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -111,6 +112,90 @@ void main() { ); }); + testWidgets('keeps Home opaque beneath the Settings transition', ( + tester, + ) async { + await tester.pumpWidget(await buildHome()); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 95)); + + double homeOpacity() => tester + .widget( + find.byKey(const ValueKey('home-settings-transition-opacity')), + ) + .opacity; + + expect(homeOpacity(), 1); + + await tester.pumpAndSettle(); + Navigator.of( + tester.element( + find.byKey( + const ValueKey('settings-transition-opacity'), + skipOffstage: false, + ), + ), + ).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 95)); + + expect(homeOpacity(), 1); + }); + + testWidgets('uses one monotonic route animation for Settings and Home', ( + tester, + ) async { + await tester.pumpWidget(await buildHome()); + await tester.pumpAndSettle(); + + double homeScale() => tester + .widget( + find.byKey(const ValueKey('home-settings-transition-scale')), + ) + .transform + .storage[0]; + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pump(); + + final settingsTransition = find.byKey( + const ValueKey('settings-transition-opacity'), + skipOffstage: false, + ); + final settingsRoute = ModalRoute.of(tester.element(settingsTransition)); + + final entranceScales = [homeScale()]; + final routeValues = [settingsRoute!.animation!.value]; + for (var frame = 0; frame < 15; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + entranceScales.add(homeScale()); + routeValues.add(settingsRoute.animation!.value); + } + expect(entranceScales.first, closeTo(1, 0.000001)); + final reversalFrames = []; + for (var frame = 1; frame < entranceScales.length; frame++) { + if (entranceScales[frame] > entranceScales[frame - 1] + 0.000001) { + reversalFrames.add(frame); + } + } + expect( + reversalFrames, + isEmpty, + reason: + 'Home must scale down in one direction on entrance. ' + 'scales=$entranceScales route=$routeValues', + ); + expect(entranceScales, everyElement(inInclusiveRange(0.97, 1))); + expect(entranceScales.last, closeTo(0.97, 0.001)); + + await tester.pumpAndSettle(); + Navigator.of(tester.element(settingsTransition)).pop(); + await tester.pumpAndSettle(); + }); + testWidgets('gives selection haptics only when the tab changes', ( tester, ) async { diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index ca10c4726c..b746c5e36d 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -20,6 +20,26 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('reserves half of the 24dp settings section gap', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + final header = tester.widget( + find.byKey(const ValueKey('settings-profile-header')), + ); + expect((header.padding as EdgeInsets).bottom, Grid.twelve); + }); + testWidgets('shows the poster until the animated avatar is ready', ( tester, ) async { diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 64245ea721..15e79b0f77 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -106,4 +107,41 @@ void main() { expect(find.text('Invite to community'), findsNothing); }); + + testWidgets('uses a 24dp rhythm between profile settings groups', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox(height: 100), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final sections = tester.widgetList(find.byType(AppListCard)); + expect(sections, isNotEmpty); + expect( + sections.every((section) => section.verticalPadding == Grid.twelve), + isTrue, + ); + }); }