feat(mobile): organize community and profile settings (#8056)

## 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 | <img
src="https://github.com/user-attachments/assets/d52a192e-bd40-4263-ac2d-05d589c7ebf2"
width="280" alt="Community sheet, light"> | <img
src="https://github.com/user-attachments/assets/8756ebe9-b6d4-4920-8fb7-1cb7ee8ef778"
width="280" alt="Community sheet, dark"> |
| Personal settings | <img
src="https://github.com/user-attachments/assets/962b98bd-e4cf-4cd1-8948-69b349390ceb"
width="280" alt="Personal settings, light"> | <img
src="https://github.com/user-attachments/assets/223bb0a8-e10e-43b1-83ea-e0955af4627a"
width="280" alt="Personal settings, dark"> |
| Appearance | <img
src="https://github.com/user-attachments/assets/334ba797-55cb-47ce-b677-0befafb7659b"
width="280" alt="Appearance, light"> | <img
src="https://github.com/user-attachments/assets/79b62503-d476-4dbc-8c75-8cee0ee37e98"
width="280" alt="Appearance, dark"> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-03 10:06:05 +01:00
committed by GitHub
parent 8af2d91f37
commit 33f54de2dd
20 changed files with 1002 additions and 756 deletions

View File

@@ -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

View File

@@ -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),
);

View File

@@ -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<double> onSettingsTransitionProgress;
@@ -309,8 +319,11 @@ class ChannelsPage extends HookConsumerWidget {
showBuzzModalBottomSheet<void>(
context: context,
showCloseButton: false,
showDragHandle: false,
builder: (_) => const _CommunitySwitcherSheet(),
showDragHandle: true,
builder: (_) => _CommunitySwitcherSheet(
invitePageBuilder: communityInvitePageBuilder,
appearancePageBuilder: communityAppearancePageBuilder,
),
);
}

View File

@@ -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<void>(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<void>(
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<void>(
builder: (_) => const PairingPage(
addingCommunity: true,
),
),
);
},
),
],
),
),
),
],
),
);
},
),
),
],
),
);
}

View File

@@ -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) {

View File

@@ -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<String>(
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));
}
}

View File

@@ -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<void> 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<void> Function(BuildContext context)? onEditProfileDescription;
@override
@@ -75,75 +71,6 @@ class SettingsPage extends HookConsumerWidget {
bottomHeight: Grid.xxs,
);
Future<void> 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<void>(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(),
],
),
),

View File

@@ -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<void>(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<void>(builder: (_) => const ThemePickerPage()),
),
),
],
);
}
}

View File

@@ -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<void>(builder: identityRecoveryPageBuilder),
);
} finally {
pairing.reset();
}
},
),
],
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: identityRecoveryPageBuilder),
);
} finally {
pairing.reset();
}
},
),
],
);
}
@@ -92,27 +91,6 @@ Future<bool> _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<void>(
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'),
),
],
),
);
}

View File

@@ -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<void> Function(BuildContext context)? onEditDisplayName;
final Future<void> 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<void>(builder: profileEditPageBuilder));
},
),
],
);
}

View File

@@ -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),
],
);
}
}

View File

@@ -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(

View File

@@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Plays native success feedback after an action completes.
Future<void> successHaptic() async {
try {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
await const MethodChannel('buzz/haptics').invokeMethod<void>('success');
} else {
await HapticFeedback.mediumImpact();
}
} on PlatformException {
// Feedback availability must not affect the completed action.
} on MissingPluginException {
// Some embedders do not provide haptic feedback.
}
}

View File

@@ -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<Override> 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<RenderRepaintBoundary>()!;
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;
}

View File

@@ -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<CustomEmojiImage>(
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 = <Object?>[];
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<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
);

View File

@@ -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<bool>.value(true)),
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(
() => _PairingNotifier(Future<bool>.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')),
),

View File

@@ -101,7 +101,6 @@ void main() {
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),

View File

@@ -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);

View File

@@ -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<AppListRow>(find.byKey(ValueKey(key)))
.verticalPadding,
Grid.xs,
);
}
expect(
find.byKey(const ValueKey('settings-edit-profile')),
findsNothing,
);
expect(find.byType(BottomSheet), findsNothing);
expect(
tester.widget<AppListCard>(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<RenderRepaintBoundary>()!;
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<AppListRow>(find.byKey(ValueKey(key))).verticalPadding,
Grid.xs,
);
}
final sheetContent = tester.widget<Padding>(
find.byKey(const ValueKey('edit-profile-sheet-content')),
);
expect(sheetContent.padding, const EdgeInsets.only(bottom: Grid.xs));
final sheetSafeArea = tester.widget<SafeArea>(
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?>(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<Object?, Object?>?)?['icon'] == 'close',
);
final nativeEdit = nativeViews.singleWhere(
(view) =>
(view.creationParams as Map<Object?, Object?>?)?['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<Object?, Object?>?)?['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(),
),
),

View File

@@ -99,7 +99,6 @@ void main() {
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),