From 33f54de2dd27a8f6bce0d359183f5ebe5f2fa9ba Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 3 Oct 2026 10:06:05 +0100 Subject: [PATCH] feat(mobile): organize community and profile settings (#8056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Move Invite, Appearance, and Remove community into a **Community settings** section in the community-avatar sheet, above the existing switcher. Keep the sheet untitled and use circle-minus for removal. Personal Settings groups **Set status** and **Copy public key (npub)** above an untitled card containing **Display name**, **Profile description**, and **Edit photo**. Remove the leading icons from status, public key, and Send identity to desktop; keep the latter in its own untitled card. Remove the avatar status badge and top Edit action, and rename the Theme page to **Appearance**. Opening the presence chip triggers selection haptics; copying plays native iOS success feedback and confirms with “Public key (npub) copied.” ### Testing - Mobile formatting, analysis, and file-size checks passed. - 111 affected widget tests passed; all 48 settings tests passed again after the final labels and icon changes. - Final release build installed and launched on a physical iPhone in the separate Buzz Community preview, retaining its existing test account. - Full repository `just ci` was blocked by local disk exhaustion during Rust compilation. The full mobile suite passed (2,609 passed, four skipped), plus three push-unconfigured tests. The presence and copy feedback passed focused widget tests and analysis, including native iOS success-channel coverage. ### Snapshots Light and dark Flutter widget renders with fixture data. Images are GitHub attachments, not committed to the repository. The personal-settings snapshots precede the final “(npub)” label clarification. | Surface | Light | Dark | | --- | --- | --- | | Community sheet | Community sheet, light | Community sheet, dark | | Personal settings | Personal settings, light | Personal settings, dark | | Appearance | Appearance, light | Appearance, dark | --------- Signed-off-by: kenny lopez --- mobile/ios/Runner/AppDelegate.swift | 15 + mobile/lib/app.dart | 10 +- .../lib/features/channels/channels_page.dart | 19 +- .../channels/channels_page/community.dart | 292 ++++++++++++------ mobile/lib/features/home/home_page.dart | 10 + .../profile/settings_profile_header.dart | 280 +++++++---------- .../lib/features/settings/settings_page.dart | 133 +------- .../settings_page/community_section.dart | 41 --- .../settings_page/connection_section.dart | 161 ++++------ .../settings_page/profile_section.dart | 51 +++ .../settings_page/status_section.dart | 29 ++ .../theme_preview_sheet.dart | 2 +- mobile/lib/shared/success_haptic.dart | 17 + .../features/channels/channels_page_test.dart | 154 ++++++++- .../profile/settings_profile_header_test.dart | 108 ++++++- .../settings/connection_section_test.dart | 179 +++++++---- .../settings/notification_recovery_test.dart | 1 - .../settings/settings_page_loading_test.dart | 3 +- .../features/settings/settings_page_test.dart | 252 +++++++-------- .../push/push_unconfigured_build_test.dart | 1 - 20 files changed, 1002 insertions(+), 756 deletions(-) delete mode 100644 mobile/lib/features/settings/settings_page/community_section.dart create mode 100644 mobile/lib/features/settings/settings_page/profile_section.dart create mode 100644 mobile/lib/features/settings/settings_page/status_section.dart create mode 100644 mobile/lib/shared/success_haptic.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index bbd758ab8e..b10daf8274 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -29,6 +29,7 @@ import os.log endpointGrantStore: endpointGrantStore, keychainAccessGroup: pushKeychainAccessGroup ) + private var hapticsChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var ageSignalChannel: FlutterMethodChannel? @@ -75,6 +76,20 @@ import os.log apnsRegistrationBuffer.attach { [weak self] update in self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) } + hapticsChannel = FlutterMethodChannel( + name: "buzz/haptics", + binaryMessenger: messenger + ) + hapticsChannel?.setMethodCallHandler { call, result in + guard call.method == "success" else { + result(FlutterMethodNotImplemented) + return + } + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.success) + result(nil) + } qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 7a48123824..d6b7aa22cb 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -27,9 +27,12 @@ import 'features/channels/deep_link_dispatcher.dart'; import 'features/channels/voice_note_recording.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; +import 'features/profile/set_status_sheet.dart'; +import 'features/profile/user_status_provider.dart'; import 'features/profile/profile_edit_page.dart'; import 'features/profile/profile_text_editor.dart'; import 'features/settings/settings_page.dart'; +import 'features/settings/theme_picker_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; @@ -414,6 +417,8 @@ class App extends HookConsumerWidget { AuthStatus.authenticated => DeepLinkDispatcher( child: HomePage( settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => const CommunityInvitePage(), + communityAppearancePageBuilder: (_) => const ThemePickerPage(), hasUnreadInbox: hasUnreadInbox, ), ), @@ -438,9 +443,12 @@ class _SettingsPageContent extends ConsumerWidget { profileHeader: const SettingsProfileHeader(), profileEditPageBuilder: (_) => const ProfileEditPage(startInPhotoEditor: true), + onSetStatus: (context) => showSetStatusSheet( + context, + currentStatus: ref.read(userStatusProvider).asData?.value, + ), onEditDisplayName: showProfileDisplayNameEditor, onEditProfileDescription: showProfileDescriptionEditor, - invitePageBuilder: (_) => const CommunityInvitePage(), identityRecoveryPageBuilder: (_) => const PairingPage(addingCommunity: true, identityRecoveryOnly: true), ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5bee5e5bdc..24a3e19d12 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -13,13 +13,15 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/auth/auth.dart'; import '../../shared/community/community_icon_provider.dart'; +import '../../shared/community/community_membership_provider.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; -import '../../shared/widgets/buzz_titled_sheet_layout.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; @@ -162,6 +164,8 @@ _UnreadChannelState _computeUnreadChannelState({ class ChannelsPage extends HookConsumerWidget { const ChannelsPage({ required this.settingsPageBuilder, + this.communityInvitePageBuilder, + this.communityAppearancePageBuilder, required this.onSettingsTransitionProgress, this.tabReselection, super.key, @@ -169,6 +173,12 @@ class ChannelsPage extends HookConsumerWidget { final WidgetBuilder settingsPageBuilder; + /// Builds the invite destination opened from the community sheet. + final WidgetBuilder? communityInvitePageBuilder; + + /// Builds the appearance destination opened from the community sheet. + final WidgetBuilder? communityAppearancePageBuilder; + /// Reports Settings route progress so its foreground and Home's background /// render from the same timeline. final ValueChanged onSettingsTransitionProgress; @@ -309,8 +319,11 @@ class ChannelsPage extends HookConsumerWidget { showBuzzModalBottomSheet( context: context, showCloseButton: false, - showDragHandle: false, - builder: (_) => const _CommunitySwitcherSheet(), + showDragHandle: true, + builder: (_) => _CommunitySwitcherSheet( + invitePageBuilder: communityInvitePageBuilder, + appearancePageBuilder: communityAppearancePageBuilder, + ), ); } diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index f5a42ae213..85c708ee3d 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -1,118 +1,210 @@ part of '../channels_page.dart'; class _CommunitySwitcherSheet extends HookConsumerWidget { - const _CommunitySwitcherSheet(); + const _CommunitySwitcherSheet({ + this.invitePageBuilder, + this.appearancePageBuilder, + }); + + final WidgetBuilder? invitePageBuilder; + final WidgetBuilder? appearancePageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { final communitiesAsync = ref.watch(communityListProvider); final activeAsync = ref.watch(activeCommunityProvider); final isEditing = useState(false); + final role = ref.watch(currentCommunityRoleProvider); + final canInvite = role.hasError || canManageCommunityInvites(role.value); + + void openPage(WidgetBuilder builder) { + final navigator = Navigator.of(context, rootNavigator: true); + Navigator.of(context).pop(); + navigator.push(MaterialPageRoute(builder: builder)); + } return SafeArea( - child: BuzzTitledSheetLayout( + child: Column( + mainAxisSize: MainAxisSize.min, key: const Key('community-switcher-sheet'), - title: 'Switch Community', - titleKey: const Key('community-switcher-title'), - showDragHandle: true, - trailing: SizedBox( - key: const Key('community-switcher-edit'), - width: 56, - height: 44, - child: TextButton( - onPressed: () => isEditing.value = !isEditing.value, - style: TextButton.styleFrom( - minimumSize: const Size(56, 44), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text(isEditing.value ? 'Done' : 'Edit'), - ), - ), - child: communitiesAsync.when( - loading: () => const SizedBox( - height: 120, - child: Center( - child: BuzzLoadingIndicator( - size: 40, - semanticLabel: 'Loading communities', - ), - ), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Text('Error loading communities: $e'), - ), - data: (communities) { - final activeId = activeAsync.value?.id; - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - child: Material( - key: const Key('community-switcher-options'), - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.card), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for ( - var index = 0; - index < communities.length; - index++ - ) ...[ - if (index > 0) const _CommunitySwitcherDivider(), - _CommunitySwitcherTile( - community: communities[index], - isActive: communities[index].id == activeId, - isEditing: isEditing.value, - onTap: isEditing.value - ? null - : () async { - final community = communities[index]; - if (community.id != activeId) { - await ref - .read(communityListProvider.notifier) - .switchCommunity(community.id); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - onRemove: () => _confirmRemoveCommunity( - context, - ref, - communities[index], - closeSheetAfterRemoval: - communities[index].id == activeId, - ), - ), - ], - if (communities.isNotEmpty) - const _CommunitySwitcherDivider(), - _AddCommunityTile( - onTap: () { - final nav = Navigator.of( - context, - rootNavigator: true, - ); - ref.read(pairingProvider.notifier).reset(); - Navigator.of(context).pop(); - nav.push( - MaterialPageRoute( - builder: (_) => - const PairingPage(addingCommunity: true), - ), - ); - }, - ), - ], + children: [ + const SizedBox(height: Grid.xs), + Flexible( + child: communitiesAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading communities', ), ), ), - ); - }, - ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Text('Error loading communities: $e'), + ), + data: (communities) { + final activeId = activeAsync.value?.id; + final activeCommunity = communities + .where((community) => community.id == activeId) + .firstOrNull; + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (activeCommunity != null || + appearancePageBuilder != null || + (canInvite && invitePageBuilder != null)) + AppListCard( + label: 'Community settings', + children: [ + if (canInvite && invitePageBuilder != null) + AppListRow( + icon: LucideIcons.userPlus, + title: 'Invite', + trailing: const Icon( + LucideIcons.chevronRight, + size: 18, + ), + onTap: () => openPage(invitePageBuilder!), + ), + if (appearancePageBuilder != null) + AppListRow( + icon: LucideIcons.sunMoon, + title: 'Appearance', + trailing: const Icon( + LucideIcons.chevronRight, + size: 18, + ), + onTap: () => openPage(appearancePageBuilder!), + ), + if (activeCommunity != null) + AppListRow( + icon: LucideIcons.circleMinus, + title: 'Remove community', + titleColor: context.colors.error, + onTap: () => _confirmRemoveCommunity( + context, + ref, + activeCommunity, + closeSheetAfterRemoval: true, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Switch communities', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox( + key: const Key('community-switcher-edit'), + width: 56, + height: 44, + child: TextButton( + onPressed: () => + isEditing.value = !isEditing.value, + style: TextButton.styleFrom( + minimumSize: const Size(56, 44), + padding: EdgeInsets.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + child: Text(isEditing.value ? 'Done' : 'Edit'), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + ), + child: Material( + key: const Key('community-switcher-options'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for ( + var index = 0; + index < communities.length; + index++ + ) ...[ + if (index > 0) + const _CommunitySwitcherDivider(), + _CommunitySwitcherTile( + community: communities[index], + isActive: communities[index].id == activeId, + isEditing: isEditing.value, + onTap: isEditing.value + ? null + : () async { + final community = communities[index]; + if (community.id != activeId) { + await ref + .read( + communityListProvider + .notifier, + ) + .switchCommunity(community.id); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + onRemove: () => _confirmRemoveCommunity( + context, + ref, + communities[index], + closeSheetAfterRemoval: + communities[index].id == activeId, + ), + ), + ], + if (communities.isNotEmpty) + const _CommunitySwitcherDivider(), + _AddCommunityTile( + onTap: () { + final nav = Navigator.of( + context, + rootNavigator: true, + ); + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + nav.push( + MaterialPageRoute( + builder: (_) => const PairingPage( + addingCommunity: true, + ), + ), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], ), ); } diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 85c4debed7..6b4a42c1b4 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -17,11 +17,19 @@ import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { const HomePage({ required this.settingsPageBuilder, + this.communityInvitePageBuilder, + this.communityAppearancePageBuilder, required this.hasUnreadInbox, super.key, }); final WidgetBuilder settingsPageBuilder; + + /// Builds the invite destination opened from the community sheet. + final WidgetBuilder? communityInvitePageBuilder; + + /// Builds the appearance destination opened from the community sheet. + final WidgetBuilder? communityAppearancePageBuilder; final bool hasUnreadInbox; static const double _tabBarHeight = mobileTabBarHeight; @@ -90,6 +98,8 @@ class HomePage extends HookConsumerWidget { final pages = [ ChannelsPage( settingsPageBuilder: settingsPageBuilder, + communityInvitePageBuilder: communityInvitePageBuilder, + communityAppearancePageBuilder: communityAppearancePageBuilder, tabReselection: homeReselection, onSettingsTransitionProgress: (progress) { if (settingsTransitionProgress.value != progress) { diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 1852188ecf..d452c36cec 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -1,28 +1,25 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.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/animated_avatar.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/relay/media_image.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; -import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/progressive_animated_avatar.dart'; import 'profile_provider.dart'; import 'set_status_sheet.dart'; import 'user_status_provider.dart'; -/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred -/// avatar with a circular badge notched out of its bottom-right corner. Desktop -/// puts an edit-photo pencil in that badge; here it carries the status glyph and -/// opens the status sheet instead. The notch shape — including the fillets where -/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge]. +/// Profile avatar, display name, status, and presence shown above settings. class SettingsProfileHeader extends HookConsumerWidget { const SettingsProfileHeader({super.key}); @@ -33,6 +30,11 @@ class SettingsProfileHeader extends HookConsumerWidget { final profile = ref.watch(profileProvider).asData?.value; final status = ref.watch(userStatusProvider).asData?.value; final hasStatus = status != null && !status.isEmpty; + final palette = ref.watch(customEmojiListProvider); + final shortcode = normalizeShortcode(status?.emoji ?? ''); + final customEmoji = palette + .where((entry) => entry.shortcode == shortcode) + .firstOrNull; final presence = ref.watch(presenceProvider).value ?? 'offline'; final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); final animatedPosterUrl = animatedAvatar?.posterUrl; @@ -55,91 +57,93 @@ class SettingsProfileHeader extends HookConsumerWidget { padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve), child: Column( children: [ - MaskedAvatarBadge( - size: _avatarSize, - avatar: GestureDetector( - key: const ValueKey('settings-profile-avatar'), - onTap: animatedAvatar == null - ? null - : () => stoppedAnimationUrl.value = - stoppedAnimationUrl.value == animatedAvatar.animationUrl - ? null - : animatedAvatar.animationUrl, - child: ColoredBox( - key: const ValueKey('settings-profile-avatar-background'), - color: animatedAvatar == null - ? context.colors.primaryContainer - : Colors.transparent, - child: - animatedAvatar != null && - stoppedAnimationUrl.value != animatedAvatar.animationUrl - ? ProgressiveAnimatedAvatar( - key: ValueKey(animatedAvatar.animationUrl), - descriptor: animatedAvatar, - fallback: _AvatarFallback(initial: profile?.initial), - loadingImage: activeHandoff == null - ? null - : MemoryImage(activeHandoff.animation), - onAnimationReady: activeHandoff == null - ? null - : () => ref - .read(profileAvatarHandoffProvider.notifier) - .clear(activeHandoff.avatarUrl), - ) - : activeHandoff == null || animatedPosterUrl == null - ? AvatarImageContent( - imageUrl: avatarUrl, - fallback: _AvatarFallback(initial: profile?.initial), - ) - : Stack( - fit: StackFit.expand, - children: [ - Image( - image: MemoryImage(activeHandoff.poster), - fit: BoxFit.cover, - gaplessPlayback: true, - ), - Offstage( - offstage: true, - child: MediaImage( - key: ValueKey( - 'settings-profile-paused-handoff-${activeHandoff.avatarUrl}', - ), - url: animatedPosterUrl, + SizedBox.square( + dimension: _avatarSize, + child: ClipOval( + child: GestureDetector( + key: const ValueKey('settings-profile-avatar'), + onTap: animatedAvatar == null + ? null + : () => stoppedAnimationUrl.value = + stoppedAnimationUrl.value == + animatedAvatar.animationUrl + ? null + : animatedAvatar.animationUrl, + child: ColoredBox( + key: const ValueKey('settings-profile-avatar-background'), + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, + child: + animatedAvatar != null && + stoppedAnimationUrl.value != + animatedAvatar.animationUrl + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: profile?.initial), + loadingImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.animation), + onAnimationReady: activeHandoff == null + ? null + : () => ref + .read(profileAvatarHandoffProvider.notifier) + .clear(activeHandoff.avatarUrl), + ) + : activeHandoff == null || animatedPosterUrl == null + ? AvatarImageContent( + imageUrl: avatarUrl, + fallback: _AvatarFallback(initial: profile?.initial), + ) + : Stack( + fit: StackFit.expand, + children: [ + Image( + image: MemoryImage(activeHandoff.poster), fit: BoxFit.cover, - errorBuilder: (_, _, _) => - const SizedBox.shrink(), - frameBuilder: - ( - context, - child, - frame, - wasSynchronouslyLoaded, - ) { - if (wasSynchronouslyLoaded || - frame != null) { - WidgetsBinding.instance - .addPostFrameCallback((_) { - ref - .read( - profileAvatarHandoffProvider - .notifier, - ) - .clear(activeHandoff.avatarUrl); - }); - } - return child; - }, + gaplessPlayback: true, ), - ), - ], - ), + Offstage( + offstage: true, + child: MediaImage( + key: ValueKey( + 'settings-profile-paused-handoff-${activeHandoff.avatarUrl}', + ), + url: animatedPosterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const SizedBox.shrink(), + frameBuilder: + ( + context, + child, + frame, + wasSynchronouslyLoaded, + ) { + if (wasSynchronouslyLoaded || + frame != null) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + ref + .read( + profileAvatarHandoffProvider + .notifier, + ) + .clear( + activeHandoff.avatarUrl, + ); + }); + } + return child; + }, + ), + ), + ], + ), + ), ), ), - badge: _StatusBadge( - emoji: status?.emoji ?? '', - onTap: openStatusSheet, - ), ), const SizedBox(height: Grid.twelve), Text( @@ -147,8 +151,7 @@ class SettingsProfileHeader extends HookConsumerWidget { style: context.textTheme.titleMedium, textAlign: TextAlign.center, ), - // Keep the status text visible even when no emoji is set. NIP-38 - // permits text-only statuses, which the avatar badge cannot represent. + // Preserve the complete status below the name, including custom emoji. if (hasStatus) GestureDetector( onTap: openStatusSheet, @@ -159,8 +162,24 @@ class SettingsProfileHeader extends HookConsumerWidget { right: Grid.gutter, bottom: Grid.half, ), - child: Text( - status.text.isNotEmpty ? status.text : status.emoji, + child: Text.rich( + TextSpan( + children: [ + if (customEmoji != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: CustomEmojiImage( + shortcode: customEmoji.shortcode, + url: customEmoji.url, + ), + ) + else if (status.emoji.isNotEmpty) + TextSpan(text: status.emoji), + if (status.emoji.isNotEmpty && status.text.isNotEmpty) + const TextSpan(text: ' '), + if (status.text.isNotEmpty) TextSpan(text: status.text), + ], + ), style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -189,10 +208,12 @@ class _AvatarFallback extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - initial ?? '?', - style: context.textTheme.displaySmall?.copyWith( - color: context.colors.onPrimaryContainer, + return Center( + child: Text( + initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), ), ); } @@ -239,6 +260,7 @@ class _PresencePill extends StatelessWidget { key: const ValueKey('settings-presence-menu'), borderRadius: BorderRadius.circular(Radii.full), onTap: () async { + unawaited(HapticFeedback.selectionClick()); final selected = await showAnchoredPopover( context: buttonContext, width: 176, @@ -333,71 +355,3 @@ Color _presenceColor(BuildContext context, String presence) => 'away' => context.appColors.warning, _ => context.colors.outline, }; - -/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask -/// geometry rather than being set here. -class _StatusBadge extends StatelessWidget { - const _StatusBadge({required this.emoji, required this.onTap}); - - final String emoji; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: 'Set a status', - child: GestureDetector( - onTap: onTap, - child: DecoratedBox( - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Center(child: _StatusGlyph(emoji: emoji)), - ), - ), - ); - } -} - -/// The status emoji, resolving `:shortcode:` values against the community's -/// custom emoji. Falls back to the add-status icon when no status is set. -class _StatusGlyph extends ConsumerWidget { - const _StatusGlyph({required this.emoji}); - - final String emoji; - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (emoji.isEmpty) { - return Icon( - LucideIcons.smilePlus, - size: 20, - color: context.colors.onSurfaceVariant, - ); - } - - final shortcode = emoji.startsWith(':') && emoji.endsWith(':') - ? emoji.substring(1, emoji.length - 1).toLowerCase() - : null; - if (shortcode != null) { - for (final entry in ref.watch(customEmojiListProvider)) { - if (entry.shortcode == shortcode) { - return CustomEmojiImage( - shortcode: shortcode, - url: entry.url, - size: 22, - ); - } - } - return Icon( - LucideIcons.smile, - size: 20, - color: context.colors.onSurfaceVariant, - ); - } - - return Text(emoji, style: const TextStyle(fontSize: 20)); - } -} diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index fffecbf769..98a4ee56c5 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -11,7 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; -import '../../shared/community/community_membership_provider.dart'; +import '../../shared/success_haptic.dart'; import '../../shared/push/push_bridge.dart'; import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/relay/relay.dart'; @@ -23,27 +23,23 @@ import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; -import '../../shared/widgets/ios_glass_navigation_action.dart'; import '../../shared/widgets/immediate_page_route.dart'; -import '../../shared/widgets/modal_presentation.dart'; -import 'theme_picker_page.dart'; -part 'settings_page/community_section.dart'; +part 'settings_page/profile_section.dart'; +part 'settings_page/status_section.dart'; part 'settings_page/connection_section.dart'; part 'settings_page/notifications_section.dart'; Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); -enum _ProfileEditAction { displayName, description, photo } - class SettingsPage extends HookConsumerWidget { /// Creates the settings page. const SettingsPage({ super.key, required this.profileHeader, - required this.invitePageBuilder, required this.identityRecoveryPageBuilder, this.profileEditPageBuilder = _emptyProfileEditPage, + this.onSetStatus, this.onEditDisplayName, this.onEditProfileDescription, }); @@ -51,19 +47,19 @@ class SettingsPage extends HookConsumerWidget { /// Header widget displayed at the top of settings. final Widget profileHeader; - /// Builds the community-invite page pushed from the invite settings row. - final WidgetBuilder invitePageBuilder; - /// Builds the identity-recovery page pushed from the recovery settings row. final WidgetBuilder identityRecoveryPageBuilder; - /// Builds the current-user profile editor opened from the top action. + /// Builds the current-user profile editor opened from the Photo row. final WidgetBuilder profileEditPageBuilder; - /// Opens the display-name editor after the Edit Profile sheet closes. + /// Opens the current-user status editor. + final void Function(BuildContext context)? onSetStatus; + + /// Opens the display-name editor from the settings section. final Future Function(BuildContext context)? onEditDisplayName; - /// Opens the profile-description editor after the Edit Profile sheet closes. + /// Opens the profile-description editor from the settings section. final Future Function(BuildContext context)? onEditProfileDescription; @override @@ -75,75 +71,6 @@ class SettingsPage extends HookConsumerWidget { bottomHeight: Grid.xxs, ); - Future showEditProfileSheet() async { - final action = await showBuzzModalBottomSheet<_ProfileEditAction>( - context: context, - title: 'Edit profile', - builder: (sheetContext) => SafeArea( - top: false, - child: Padding( - key: const ValueKey('edit-profile-sheet-content'), - padding: const EdgeInsets.only(bottom: Grid.xs), - // AppListCard normally fills the height offered by a page section. - // Give it unbounded vertical space here so this compact action sheet - // hugs its three rows instead of filling the modal height cap. - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppListCard( - key: const ValueKey('edit-profile-options'), - dividerIndent: Grid.xs, - verticalPadding: 0, - children: [ - AppListRow( - key: const ValueKey('edit-profile-display-name'), - title: 'Display name', - trailing: const _RowChevron(), - onTap: () => Navigator.pop( - sheetContext, - _ProfileEditAction.displayName, - ), - ), - AppListRow( - key: const ValueKey('edit-profile-description'), - title: 'Profile description', - trailing: const _RowChevron(), - onTap: () => Navigator.pop( - sheetContext, - _ProfileEditAction.description, - ), - ), - AppListRow( - key: const ValueKey('edit-profile-photo'), - title: 'Photo', - trailing: const _RowChevron(), - onTap: () => - Navigator.pop(sheetContext, _ProfileEditAction.photo), - ), - ], - ), - ], - ), - ), - ), - ); - if (!context.mounted || action == null) return; - unawaited(HapticFeedback.selectionClick()); - switch (action) { - case _ProfileEditAction.displayName: - await onEditDisplayName?.call(context); - break; - case _ProfileEditAction.description: - await onEditProfileDescription?.call(context); - break; - case _ProfileEditAction.photo: - await Navigator.of( - context, - ).push(immediatePageRoute(builder: profileEditPageBuilder)); - break; - } - } - return FrostedScaffold( useUtilitySurfaceTheme: true, appBar: FrostedAppBar( @@ -174,38 +101,6 @@ class SettingsPage extends HookConsumerWidget { icon: const Icon(LucideIcons.x), ), ), - actions: [ - if (Theme.of(context).platform == TargetPlatform.iOS) - IosGlassNavigationAction( - key: const ValueKey('settings-edit-profile'), - label: 'Edit', - foregroundColor: navigationPrimaryForeground(context), - onPressed: () => unawaited(showEditProfileSheet()), - ) - else - Material( - key: const ValueKey('settings-edit-profile'), - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.full), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => unawaited(showEditProfileSheet()), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, - ), - child: Text( - 'Edit', - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], bottomHeight: Grid.xxs, bottom: const SizedBox.expand(), ), @@ -216,12 +111,16 @@ class SettingsPage extends HookConsumerWidget { padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), children: [ profileHeader, - _CommunitySection(invitePageBuilder: invitePageBuilder), + _StatusSection(onSetStatus: onSetStatus), + _ProfileSection( + profileEditPageBuilder: profileEditPageBuilder, + onEditDisplayName: onEditDisplayName, + onEditProfileDescription: onEditProfileDescription, + ), const _NotificationsSection(), _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, ), - const _RemoveCommunitySection(), ], ), ), diff --git a/mobile/lib/features/settings/settings_page/community_section.dart b/mobile/lib/features/settings/settings_page/community_section.dart deleted file mode 100644 index dc91c557cb..0000000000 --- a/mobile/lib/features/settings/settings_page/community_section.dart +++ /dev/null @@ -1,41 +0,0 @@ -part of '../settings_page.dart'; - -class _CommunitySection extends ConsumerWidget { - const _CommunitySection({required this.invitePageBuilder}); - - final WidgetBuilder invitePageBuilder; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final roleAsync = ref.watch(currentCommunityRoleProvider); - final canInvite = - roleAsync.hasError || canManageCommunityInvites(roleAsync.value); - final preference = ref.watch(communityThemeProvider); - - return AppListCard( - label: 'Community', - verticalPadding: Grid.twelve, - children: [ - if (canInvite) - AppListRow( - icon: LucideIcons.userPlus, - title: 'Invite to community', - trailing: const _RowChevron(), - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: invitePageBuilder)), - ), - AppListRow( - key: const ValueKey('community-theme-row'), - icon: LucideIcons.palette, - title: 'Theme', - value: themeSelectionLabel(preference.theme, preference.mode), - trailing: const _RowChevron(), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ThemePickerPage()), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 7e14fb559c..29d6b00cdd 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -12,58 +12,57 @@ class _ConnectionSection extends ConsumerWidget { final nsec = config.nsec; final community = authState?.community; + if (nsec == null || nsec.isEmpty || community == null) { + return const SizedBox.shrink(); + } + return AppListCard( - label: 'Connection', verticalPadding: Grid.twelve, children: [ - if (nsec != null && nsec.isNotEmpty && community != null) ...[ - _IdentityRow(nsec: nsec), - AppListRow( - icon: LucideIcons.scanQrCode, - title: 'Send identity to desktop', - subtitle: 'Scan a recovery code shown by Buzz Desktop', - trailing: const _RowChevron(), - onTap: () async { - final pairing = ref.read(pairingProvider.notifier); - final authorized = await pairing.authorizeIdentityExport( - community: community, - ); - if (!authorized) { - if (!context.mounted) return; - final message = ref.read(pairingProvider).errorMessage; - if (message != null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + AppListRow( + title: 'Send identity to desktop', + subtitle: 'Scan a recovery code shown by Buzz Desktop', + trailing: const _RowChevron(), + onTap: () async { + final pairing = ref.read(pairingProvider.notifier); + final authorized = await pairing.authorizeIdentityExport( + community: community, + ); + if (!authorized) { + if (!context.mounted) return; + final message = ref.read(pairingProvider).errorMessage; + if (message != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + return; + } + + try { + if (!context.mounted) return; + final resumed = await _waitForResumedFrame(); + if (!resumed) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Buzz did not return to the foreground. Try again.', + ), + ), + ); } return; } - - try { - if (!context.mounted) return; - final resumed = await _waitForResumedFrame(); - if (!resumed) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Buzz did not return to the foreground. Try again.', - ), - ), - ); - } - return; - } - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute(builder: identityRecoveryPageBuilder), - ); - } finally { - pairing.reset(); - } - }, - ), - ], + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute(builder: identityRecoveryPageBuilder), + ); + } finally { + pairing.reset(); + } + }, + ), ], ); } @@ -92,27 +91,6 @@ Future _waitForResumedFrame() async { return true; } -/// Destructive, so it gets a container of its own rather than sitting at the -/// bottom of the connection group. -class _RemoveCommunitySection extends ConsumerWidget { - const _RemoveCommunitySection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return AppListCard( - verticalPadding: Grid.twelve, - children: [ - AppListRow( - icon: LucideIcons.logOut, - title: 'Remove community', - titleColor: context.colors.error, - onTap: () => _confirmRemoveCommunity(context, ref), - ), - ], - ); - } -} - class _IdentityRow extends StatelessWidget { const _IdentityRow({required this.nsec}); @@ -132,8 +110,7 @@ class _IdentityRow extends StatelessWidget { label: 'Copy identity public key', value: npub ?? 'Identity unavailable', child: AppListRow( - icon: LucideIcons.key, - title: 'Identity (pubkey)', + title: 'Copy public key (npub)', trailing: Icon( LucideIcons.copy, size: 18, @@ -142,48 +119,14 @@ class _IdentityRow extends StatelessWidget { onTap: npub == null ? null : () async { - await copyToClipboard(context, npub, message: 'Pubkey copied'); + await copyToClipboard( + context, + npub, + message: 'Public key (npub) copied', + ); + await successHaptic(); }, ), ); } } - -void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { - showBuzzDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Remove Community'), - content: const Text( - 'This will disconnect this community. You will need ' - 'to scan a new pairing code to reconnect.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () async { - Navigator.of(ctx).pop(); // close dialog - try { - await ref.read(authProvider.notifier).signOut(); - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Could not remove community: $error')), - ); - return; - } - if (!context.mounted) return; - // Pop all pushed routes back to root so MaterialApp.home rebuilds - // to PairingPage when auth state changes. - Navigator.of(context).popUntil((route) => route.isFirst); - }, - style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), - child: const Text('Remove'), - ), - ], - ), - ); -} diff --git a/mobile/lib/features/settings/settings_page/profile_section.dart b/mobile/lib/features/settings/settings_page/profile_section.dart new file mode 100644 index 0000000000..8fa2a62df9 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/profile_section.dart @@ -0,0 +1,51 @@ +part of '../settings_page.dart'; + +class _ProfileSection extends StatelessWidget { + const _ProfileSection({ + required this.profileEditPageBuilder, + this.onEditDisplayName, + this.onEditProfileDescription, + }); + + final WidgetBuilder profileEditPageBuilder; + final Future Function(BuildContext context)? onEditDisplayName; + final Future Function(BuildContext context)? onEditProfileDescription; + + @override + Widget build(BuildContext context) => AppListCard( + key: const ValueKey('edit-profile-options'), + dividerIndent: Grid.xs, + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('edit-profile-display-name'), + title: 'Display name', + trailing: const _RowChevron(), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + unawaited(onEditDisplayName?.call(context)); + }, + ), + AppListRow( + key: const ValueKey('edit-profile-description'), + title: 'Profile description', + trailing: const _RowChevron(), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + unawaited(onEditProfileDescription?.call(context)); + }, + ), + AppListRow( + key: const ValueKey('edit-profile-photo'), + title: 'Edit photo', + trailing: const _RowChevron(), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + Navigator.of( + context, + ).push(immediatePageRoute(builder: profileEditPageBuilder)); + }, + ), + ], + ); +} diff --git a/mobile/lib/features/settings/settings_page/status_section.dart b/mobile/lib/features/settings/settings_page/status_section.dart new file mode 100644 index 0000000000..800efdf146 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/status_section.dart @@ -0,0 +1,29 @@ +part of '../settings_page.dart'; + +class _StatusSection extends ConsumerWidget { + const _StatusSection({this.onSetStatus}); + + final void Function(BuildContext context)? onSetStatus; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nsec = ref.watch(relayConfigProvider).nsec; + return AppListCard( + key: const ValueKey('status-identity-options'), + dividerIndent: Grid.xs, + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('settings-set-status'), + title: 'Set status', + trailing: const _RowChevron(), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onSetStatus?.call(context); + }, + ), + if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + ], + ); + } +} diff --git a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart index 70437c23c9..8522f99235 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart @@ -109,7 +109,7 @@ class _ThemePreviewExperience extends HookConsumerWidget { centerTitle: true, showBottomDivider: false, leading: _ThemePreviewCloseButton(onPressed: close), - title: const Text('Theme'), + title: const Text('Appearance'), actions: [_ThemePreviewSetButton(onPressed: applySelection)], ), body: Column( diff --git a/mobile/lib/shared/success_haptic.dart b/mobile/lib/shared/success_haptic.dart new file mode 100644 index 0000000000..204c95e6d2 --- /dev/null +++ b/mobile/lib/shared/success_haptic.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Plays native success feedback after an action completes. +Future successHaptic() async { + try { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { + await const MethodChannel('buzz/haptics').invokeMethod('success'); + } else { + await HapticFeedback.mediumImpact(); + } + } on PlatformException { + // Feedback availability must not affect the completed action. + } on MissingPluginException { + // Some embedders do not provide haptic feedback. + } +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 8e2b22bc75..a9a627c838 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -3,6 +3,11 @@ import 'dart:async'; import '../profile/presence_snapshot_test.dart' show PresenceTestRelay, presenceEvent; import 'dart:math'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:buzz/shared/widgets/buzz_sheet_header.dart'; +import 'package:flutter/rendering.dart'; +import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -37,9 +42,23 @@ import 'package:buzz/shared/widgets/skeleton.dart'; part 'channels_page_test/presence_tests.dart'; void main() { + setUpAll(() async { + if (Platform.environment.containsKey('COMMUNITY_SCREENSHOTS')) { + for (final font in { + 'Inter': 'assets/fonts/InterVariable.ttf', + 'packages/lucide_icons_flutter/Lucide': + 'packages/lucide_icons_flutter/assets/lucide.ttf', + }.entries) { + await (FontLoader( + font.key, + )..addFont(rootBundle.load(font.value))).load(); + } + } + }); Widget buildTestable({ required List overrides, bool previewDirectory = false, + Brightness brightness = Brightness.light, double keyboardInset = 0, bool disableAnimations = false, double bottomPadding = 0, @@ -65,10 +84,13 @@ void main() { dmDirectoryPreviewEnabledProvider.overrideWith( (ref) => previewDirectory, ), + appLifecycleProvider.overrideWith(() => _CommunitySettingsLifecycle()), ...overrides, ], child: MaterialApp( - theme: AppTheme.light(topSectionGradient: topSectionGradient), + theme: brightness == Brightness.dark + ? AppTheme.dark() + : AppTheme.light(topSectionGradient: topSectionGradient), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, @@ -82,6 +104,10 @@ void main() { children: [ ChannelsPage( settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => + const Scaffold(body: Text('Invite destination')), + communityAppearancePageBuilder: (_) => + const Scaffold(body: Text('Appearance destination')), onSettingsTransitionProgress: onSettingsTransitionProgress ?? (_) {}, tabReselection: tabReselection, @@ -1122,6 +1148,119 @@ void main() { expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); }); + for (final brightness in Brightness.values) { + testWidgets('community settings opens destinations in ${brightness.name}', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final community = Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ); + await tester.pumpWidget( + buildTestable( + brightness: brightness, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([community]), + ), + activeCommunityProvider.overrideWith((ref) async => community), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.admin), + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + expect(find.byType(BuzzSheetHeader), findsNothing); + expect(find.text('Community settings'), findsOneWidget); + expect(find.text('Invite'), findsOneWidget); + expect(find.text('Appearance'), findsOneWidget); + expect(find.text('Remove community'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Remove community')).dy, + greaterThan(tester.getTopLeft(find.text('Appearance')).dy), + ); + expect( + tester.getTopLeft(find.text('Remove community')).dy, + lessThan(tester.getTopLeft(find.text('Switch communities')).dy), + ); + await tester.tap(find.text('Remove community')); + await tester.pumpAndSettle(); + expect(find.text('Remove community?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Community settings'), findsOneWidget); + + expect( + tester.getTopLeft(find.text('Appearance')).dy, + lessThan(tester.getTopLeft(find.text('Switch communities')).dy), + ); + if (Platform.environment['COMMUNITY_SCREENSHOTS'] case final directory?) { + final boundary = tester + .element(find.byKey(const Key('community-switcher-sheet'))) + .findAncestorRenderObjectOfType()!; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File( + '$directory/community-${brightness.name}.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } + await tester.tap(find.text('Appearance')); + await tester.pumpAndSettle(); + expect(find.text('Appearance destination'), findsOneWidget); + expect(find.text('Community settings'), findsNothing); + Navigator.of(tester.element(find.text('Appearance destination'))).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Invite')); + await tester.pumpAndSettle(); + expect(find.text('Invite destination'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('community settings hides invite for members', (tester) async { + final community = Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2025), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + communityListProvider.overrideWith( + () => _FakeCommunityListNotifier([community]), + ), + activeCommunityProvider.overrideWith((ref) async => community), + currentCommunityRoleProvider.overrideWithValue( + const AsyncData(CommunityMemberRole.member), + ), + ], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Alpha')); + await tester.pumpAndSettle(); + expect(find.text('Invite'), findsNothing); + expect(find.text('Appearance'), findsOneWidget); + }); + testWidgets('community switcher separates selection from edit removal', ( tester, ) async { @@ -1167,7 +1306,7 @@ void main() { await tester.tap(find.text('Alpha')); await tester.pumpAndSettle(); - expect(find.text('Switch Community'), findsOneWidget); + expect(find.text('Community settings'), findsOneWidget); final options = find.byKey(const Key('community-switcher-options')); expect(options, findsOneWidget); final editButton = find.byKey(const Key('community-switcher-edit')); @@ -1175,7 +1314,7 @@ void main() { expect(find.byTooltip('Close sheet'), findsNothing); expect( tester.getCenter(editButton).dx, - greaterThan(tester.getCenter(find.text('Switch Community')).dx), + greaterThan(tester.getCenter(find.text('Community settings')).dx), ); expect(find.text('alpha.example.com'), findsOneWidget); expect(find.text('bravo.example.com'), findsOneWidget); @@ -1292,7 +1431,7 @@ void main() { await tester.pumpAndSettle(); expect(communityNotifier.removedIds, ['bravo']); - expect(find.text('Switch Community'), findsOneWidget); + expect(find.text('Community settings'), findsOneWidget); expect(find.text('Bravo'), findsNothing); expect(find.text('Done'), findsOneWidget); @@ -1367,7 +1506,7 @@ void main() { expect(tester.takeException(), isNull); expect( - tester.getSize(find.byKey(const Key('community-switcher-title'))).height, + tester.getSize(find.text('Community settings')).height, greaterThan(32), ); }); @@ -2826,3 +2965,8 @@ String _dmTileAvatarInitial(WidgetTester tester, String labelText) { ); return initial.data!; } + +class _CommunitySettingsLifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index 67682a86bc..f5a7dac352 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -7,16 +7,18 @@ import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_image.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; @@ -240,7 +242,7 @@ void main() { expect(container.read(profileAvatarHandoffProvider), isNull); }); - testWidgets('uses a bounded icon for an unresolved status shortcode', ( + testWidgets('keeps the avatar unbadged while showing the current status', ( tester, ) async { const missingShortcode = ':very_long_missing_custom_emoji:'; @@ -266,17 +268,88 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(Hero), findsNothing); - final badge = find.byType(MaskedAvatarBadge); + expect(find.byType(MaskedAvatarBadge), findsNothing); + expect(find.text('$missingShortcode Focusing'), findsOneWidget); expect( - find.descendant(of: badge, matching: find.text(missingShortcode)), - findsNothing, - ); - expect( - find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)), - findsOneWidget, + tester.getSize(find.byKey(const ValueKey('settings-profile-avatar'))), + const Size(128, 128), ); }); + for (final scenario in [ + (emoji: '🎯', text: 'Focusing', resolved: false), + (emoji: ':party_parrot:', text: '', resolved: true), + (emoji: ':party_parrot:', text: 'Celebrating', resolved: true), + (emoji: ':unknown:', text: '', resolved: false), + ]) { + testWidgets('preserves status ${scenario.emoji} ${scenario.text}', ( + tester, + ) async { + final client = http_testing.MockClient( + (_) async => http.Response.bytes(_transparentPng, 200), + ); + addTearDown(client.close); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith( + () => _FakePresenceNotifier('online'), + ), + userStatusProvider.overrideWith( + () => _FakeUserStatusNotifier( + UserStatus( + emoji: scenario.emoji, + text: scenario.text, + updatedAt: 1, + ), + ), + ), + customEmojiListProvider.overrideWithValue(const [ + CustomEmoji( + shortcode: 'party_parrot', + url: 'https://relay.example/parrot.png', + ), + ]), + mediaHttpClientProvider.overrideWithValue(client), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + if (scenario.resolved) { + final image = tester.widget( + find.byType(CustomEmojiImage), + ); + expect(image.shortcode, 'party_parrot'); + expect(image.url, 'https://relay.example/parrot.png'); + expect(find.text(':party_parrot:'), findsNothing); + if (scenario.text.isNotEmpty) { + expect(find.textContaining(scenario.text), findsOneWidget); + } + } else { + expect(find.byType(CustomEmojiImage), findsNothing); + expect( + find.text( + [ + scenario.emoji, + scenario.text, + ].where((s) => s.isNotEmpty).join(' '), + ), + findsOneWidget, + ); + } + expect(find.byType(MaskedAvatarBadge), findsNothing); + await tester.tap( + scenario.resolved + ? find.byType(CustomEmojiImage) + : find.textContaining(scenario.emoji), + ); + await tester.pumpAndSettle(); + expect(find.text('Set a status'), findsOneWidget); + }); + } + testWidgets( 'keeps text-only status visible beside a changeable presence pill', (tester) async { @@ -337,10 +410,27 @@ void main() { final presenceTarget = find.byKey( const ValueKey('settings-presence-target'), ); + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments); + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); final targetRect = tester.getRect(presenceTarget); await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1)); await tester.pump(); + expect(haptics, contains('HapticFeedbackType.selectionClick')); final scale = tester.widget( find.byKey(const ValueKey('activity-popover-scale')), ); diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index 2eb8304fe5..cfc6926bb3 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/auth/auth.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:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,75 +16,124 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets('shows a compact copyable identity row', (tester) async { - MethodCall? clipboardCall; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.setData') clipboardCall = call; + testWidgets( + 'shows a compact copyable identity row', + (tester) async { + var successFeedback = false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/haptics'), + (call) async { + expect(call.method, 'success'); + successFeedback = true; return null; - }); - addTearDown( - () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null), - ); - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/haptics'), + null, + ); + }); + MethodCall? clipboardCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clipboardCall = call; + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - relayConfigProvider.overrideWith(_RelayConfigNotifier.new), - authProvider.overrideWith(_AuthNotifier.new), - pairingProvider.overrideWith( - () => _PairingNotifier(Future.value(true)), + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_RelayConfigNotifier.new), + authProvider.overrideWith(_AuthNotifier.new), + pairingProvider.overrideWith( + () => _PairingNotifier(Future.value(true)), + ), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), - savedPrefsProvider.overrideWithValue(prefs), - ], - child: SettingsPage( - profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), - ), - ); - await tester.pump(); - await tester.ensureVisible(find.text('Identity (pubkey)')); - await tester.pumpAndSettle(); + ); + await tester.pump(); + await tester.ensureVisible(find.text('Copy public key (npub)')); + await tester.pumpAndSettle(); - final expectedPubkey = nostr.Keys( - '1111111111111111111111111111111111111111111111111111111111111111', - ).public; - // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the - // identity row, hardcoded so the codec itself stays under test. - const expectedNpub = - 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; - expect(find.text('Connected to'), findsNothing); - expect(find.text('https://relay.test'), findsNothing); - // Neither the raw hex key nor the full npub is rendered visually — the - // full npub is exposed through a11y and the clipboard only. - expect(find.text(expectedPubkey), findsNothing); - expect(find.text(expectedNpub), findsNothing); - // The identity row's a11y value carries the full npub (not raw hex). - expect( - find.ancestor( - of: find.text('Identity (pubkey)'), - matching: find.byWidgetPredicate( - (widget) => - widget is Semantics && widget.properties.value == expectedNpub, + final expectedPubkey = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).public; + // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the + // identity row, hardcoded so the codec itself stays under test. + const expectedNpub = + 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; + final statusCard = find.byKey(const ValueKey('status-identity-options')); + expect( + find.descendant(of: statusCard, matching: find.text('Set status')), + findsOneWidget, + ); + expect( + find.descendant( + of: statusCard, + matching: find.text('Copy public key (npub)'), ), - ), - findsOneWidget, - ); - final copy = tester.getRect(find.byIcon(LucideIcons.copy)); - final chevron = tester.getRect(find.byIcon(LucideIcons.chevronRight).first); - expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); + findsOneWidget, + ); + expect( + tester.getTopLeft(find.text('Copy public key (npub)')).dy, + lessThan(tester.getTopLeft(find.text('Display name')).dy), + ); + expect(find.text('Connection'), findsNothing); + final sendCard = find.ancestor( + of: find.text('Send identity to desktop'), + matching: find.byType(AppListCard), + ); + expect( + find.descendant( + of: sendCard, + matching: find.text('Copy public key (npub)'), + ), + findsNothing, + ); + expect(find.text('Connected to'), findsNothing); + expect(find.text('https://relay.test'), findsNothing); + // Neither the raw hex key nor the full npub is rendered visually — the + // full npub is exposed through a11y and the clipboard only. + expect(find.text(expectedPubkey), findsNothing); + expect(find.text(expectedNpub), findsNothing); + // The identity row's a11y value carries the full npub (not raw hex). + expect( + find.ancestor( + of: find.text('Copy public key (npub)'), + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.value == expectedNpub, + ), + ), + findsOneWidget, + ); + final copy = tester.getRect(find.byIcon(LucideIcons.copy)); + final chevron = tester.getRect( + find.byIcon(LucideIcons.chevronRight).first, + ); + expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); - await tester.tap(find.text('Identity (pubkey)')); - await tester.pump(); - expect(clipboardCall?.method, 'Clipboard.setData'); - expect(clipboardCall?.arguments, {'text': expectedNpub}); - expect(find.text('Pubkey copied'), findsOneWidget); - }); + await tester.tap(find.text('Copy public key (npub)')); + await tester.pump(); + expect(successFeedback, isTrue); + expect(clipboardCall?.method, 'Clipboard.setData'); + expect(clipboardCall?.arguments, {'text': expectedNpub}); + expect(find.text('Public key (npub) copied'), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); testWidgets('waits for a resumed frame before navigating after auth', ( tester, @@ -103,7 +153,6 @@ void main() { ], child: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const Scaffold(body: Text('Identity recovery')), ), @@ -141,7 +190,6 @@ void main() { ], child: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const Scaffold(body: Text('Identity recovery')), ), @@ -181,7 +229,6 @@ void main() { ], child: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const Scaffold(body: Text('Identity recovery')), ), @@ -224,7 +271,6 @@ void main() { ], child: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const Scaffold(body: Text('Identity recovery')), ), @@ -268,7 +314,6 @@ void main() { ], child: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const Scaffold(body: Text('Identity recovery')), ), diff --git a/mobile/test/features/settings/notification_recovery_test.dart b/mobile/test/features/settings/notification_recovery_test.dart index b04cae78d0..7f95680686 100644 --- a/mobile/test/features/settings/notification_recovery_test.dart +++ b/mobile/test/features/settings/notification_recovery_test.dart @@ -101,7 +101,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), diff --git a/mobile/test/features/settings/settings_page_loading_test.dart b/mobile/test/features/settings/settings_page_loading_test.dart index 866da15185..4e0c6ac204 100644 --- a/mobile/test/features/settings/settings_page_loading_test.dart +++ b/mobile/test/features/settings/settings_page_loading_test.dart @@ -45,13 +45,12 @@ void main() { ), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), ), ); - expect(find.text('Invite to community'), findsOneWidget); + expect(find.text('Invite to community'), findsNothing); expect(find.text('v0.16.0 (432)'), findsNothing); expect(find.byTooltip('Close settings'), findsOneWidget); diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index ad0075a994..6626ef0693 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,4 +1,8 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; @@ -64,13 +68,12 @@ void main() { ), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), ), ); - expect(find.text('Invite to community'), findsOneWidget); + expect(find.text('Invite to community'), findsNothing); await tester.pumpAndSettle(); expect( find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'), @@ -114,7 +117,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -167,7 +169,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -219,7 +220,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -269,7 +269,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -288,75 +287,102 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('opens profile edit choices and routes photo directly', ( - tester, - ) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + for (final brightness in Brightness.values) { + testWidgets( + 'shows status above profile editing and routes photo directly in ${brightness.name}', + (tester) async { + if (Platform.environment.containsKey('PROFILE_SCREENSHOTS')) { + await tester.runAsync(() async { + for (final font in { + 'Inter': 'assets/fonts/InterVariable.ttf', + 'packages/lucide_icons_flutter/Lucide': + 'packages/lucide_icons_flutter/assets/lucide.ttf', + }.entries) { + await (FontLoader( + font.key, + )..addFont(rootBundle.load(font.value))).load(); + } + }); + } + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: MaterialApp( - theme: AppTheme.light(), - home: SettingsPage( - profileHeader: const SizedBox.square(dimension: 128), - profileEditPageBuilder: (_) => - const Scaffold(body: Text('Profile editor destination')), - invitePageBuilder: (_) => const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + await tester.pumpWidget( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: brightness == Brightness.dark + ? AppTheme.dark() + : AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.square(dimension: 128), + profileEditPageBuilder: (_) => + const Scaffold(body: Text('Profile editor destination')), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), ), - ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - expect(find.text('Profile'), findsNothing); - await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); - await tester.pumpAndSettle(); - expect(find.text('Edit profile'), findsOneWidget); - expect(find.text('Display name'), findsOneWidget); - expect(find.text('Profile description'), findsOneWidget); - expect(find.text('Photo'), findsOneWidget); - final optionCard = find.descendant( - of: find.byKey(const ValueKey('edit-profile-options')), - matching: find.byType(Material), + expect(find.text('Profile'), findsNothing); + expect(find.text('Edit profile'), findsNothing); + expect(find.text('Display name'), findsOneWidget); + expect(find.text('Profile description'), findsOneWidget); + expect(find.text('Edit photo'), findsOneWidget); + final optionCard = find.descendant( + of: find.byKey(const ValueKey('edit-profile-options')), + matching: find.byType(Material), + ); + expect(tester.getSize(optionCard.first).height, greaterThan(150)); + for (final key in const [ + 'edit-profile-display-name', + 'edit-profile-description', + 'edit-profile-photo', + ]) { + expect( + tester + .widget(find.byKey(ValueKey(key))) + .verticalPadding, + Grid.xs, + ); + } + expect( + find.byKey(const ValueKey('settings-edit-profile')), + findsNothing, + ); + expect(find.byType(BottomSheet), findsNothing); + expect( + tester.widget(find.byType(AppListCard).first).key, + const ValueKey('status-identity-options'), + ); + if (Platform.environment['PROFILE_SCREENSHOTS'] case final directory?) { + final boundary = tester + .element(find.byType(SettingsPage)) + .findAncestorRenderObjectOfType()!; + await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + await Directory(directory).create(recursive: true); + await File( + '$directory/profile-${brightness.name}.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + image.dispose(); + }); + } + await tester.tap(find.byKey(const ValueKey('edit-profile-photo'))); + await tester.pumpAndSettle(); + expect(find.text('Profile editor destination'), findsOneWidget); + }, ); - expect(tester.getSize(optionCard.first).height, greaterThan(150)); - for (final key in const [ - 'edit-profile-display-name', - 'edit-profile-description', - 'edit-profile-photo', - ]) { - expect( - tester.widget(find.byKey(ValueKey(key))).verticalPadding, - Grid.xs, - ); - } - final sheetContent = tester.widget( - find.byKey(const ValueKey('edit-profile-sheet-content')), - ); - expect(sheetContent.padding, const EdgeInsets.only(bottom: Grid.xs)); - final sheetSafeArea = tester.widget( - find.ancestor( - of: find.byKey(const ValueKey('edit-profile-sheet-content')), - matching: find.byType(SafeArea), - ), - ); - expect(sheetSafeArea.top, isFalse); - expect(sheetSafeArea.bottom, isTrue); - final sheetRect = tester.getRect(find.byType(BottomSheet)); - final optionRect = tester.getRect( - find.byKey(const ValueKey('edit-profile-options')), - ); - expect(sheetRect.height, lessThan(340)); - expect(sheetRect.bottom - optionRect.bottom, Grid.xs); - await tester.tap(find.byKey(const ValueKey('edit-profile-photo'))); - await tester.pumpAndSettle(); - expect(find.text('Profile editor destination'), findsOneWidget); - }); + } - testWidgets('opens profile text editors after dismissing the choices', ( + testWidgets('opens profile text editors directly from settings', ( tester, ) async { SharedPreferences.setMockInitialValues({}); @@ -370,9 +396,9 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), + onSetStatus: (_) => opened.add('status'), onEditDisplayName: (_) async => opened.add('name'), onEditProfileDescription: (_) async => opened.add('description'), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -380,56 +406,20 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); - await tester.pumpAndSettle(); + expect( + tester.getTopLeft(find.text('Set status')).dy, + lessThan(tester.getTopLeft(find.text('Display name')).dy), + ); + await tester.tap(find.text('Set status')); + expect(opened, ['status']); await tester.tap(find.byKey(const ValueKey('edit-profile-display-name'))); await tester.pumpAndSettle(); - expect(opened, ['name']); + expect(opened, ['status', 'name']); expect(find.text('Edit profile'), findsNothing); - await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); - await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('edit-profile-description'))); await tester.pumpAndSettle(); - expect(opened, ['name', 'description']); - }); - - testWidgets('places Theme second in the Community section', (tester) async { - 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.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), - identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Theme'), findsOneWidget); - expect( - tester.getTopLeft(find.text('Theme')).dy, - greaterThan(tester.getTopLeft(find.text('Invite to community')).dy), - ); - expect(find.byKey(const ValueKey('community-theme-row')), findsOneWidget); - expect(find.text('Appearance'), findsNothing); - expect(find.text('Style · This community'), findsNothing); - - await tester.tap(find.byKey(const ValueKey('community-theme-row'))); - await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('theme-preview-pages')), findsOneWidget); + expect(opened, ['status', 'name', 'description']); }); testWidgets('uses the native glass close control on iOS', (tester) async { @@ -445,7 +435,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -458,19 +447,20 @@ void main() { (view) => (view.creationParams as Map?)?['icon'] == 'close', ); - final nativeEdit = nativeViews.singleWhere( - (view) => - (view.creationParams as Map?)?['label'] == 'Edit', - ); expect(nativeClose.viewType, 'buzz/navigation_glass'); expect(nativeClose.creationParams, containsPair('icon', 'close')); - expect(nativeEdit.viewType, 'buzz/navigation_glass'); - expect(nativeEdit.creationParams, containsPair('controlWidth', 56.0)); + expect( + nativeViews.where( + (view) => + (view.creationParams as Map?)?['label'] == 'Edit', + ), + isEmpty, + ); expect(find.byTooltip('Close settings'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); - testWidgets('shows community invite navigation to owners and admins', ( + testWidgets('keeps community controls out of personal settings', ( tester, ) async { SharedPreferences.setMockInitialValues({}); @@ -488,7 +478,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const Text('Invite destination'), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -496,17 +485,14 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Invite to community'), findsOneWidget); + expect(find.text('Invite to community'), findsNothing); expect( find.text('Add people directly or share an invite link'), findsNothing, ); - await tester.tap(find.text('Invite to community')); - await tester.pumpAndSettle(); - expect(find.text('Invite destination'), findsOneWidget); }); - testWidgets('keeps invite navigation available when role lookup fails', ( + testWidgets('keeps personal settings independent of community role errors', ( tester, ) async { SharedPreferences.setMockInitialValues({}); @@ -527,7 +513,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const Text('Invite destination'), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -535,10 +520,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Invite to community'), findsOneWidget); - await tester.tap(find.text('Invite to community')); - await tester.pumpAndSettle(); - expect(find.text('Invite destination'), findsOneWidget); + expect(find.text('Invite to community'), findsNothing); }); testWidgets('hides community invite navigation from plain members', ( @@ -559,7 +541,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const Text('Invite destination'), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), @@ -591,7 +572,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox(height: 100), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ), diff --git a/mobile/test/shared/push/push_unconfigured_build_test.dart b/mobile/test/shared/push/push_unconfigured_build_test.dart index 355fc58a6d..c5cf8161ad 100644 --- a/mobile/test/shared/push/push_unconfigured_build_test.dart +++ b/mobile/test/shared/push/push_unconfigured_build_test.dart @@ -99,7 +99,6 @@ void main() { theme: AppTheme.light(), home: SettingsPage( profileHeader: const SizedBox.shrink(), - invitePageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), ), ),