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, endpointGrantStore: endpointGrantStore,
keychainAccessGroup: pushKeychainAccessGroup keychainAccessGroup: pushKeychainAccessGroup
) )
private var hapticsChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var ageSignalChannel: FlutterMethodChannel? private var ageSignalChannel: FlutterMethodChannel?
@@ -75,6 +76,20 @@ import os.log
apnsRegistrationBuffer.attach { [weak self] update in apnsRegistrationBuffer.attach { [weak self] update in
self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) 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( qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner", name: "buzz/qr_scanner",
binaryMessenger: messenger binaryMessenger: messenger

View File

@@ -27,9 +27,12 @@ import 'features/channels/deep_link_dispatcher.dart';
import 'features/channels/voice_note_recording.dart'; import 'features/channels/voice_note_recording.dart';
import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/user_status_cache_provider.dart';
import 'features/profile/settings_profile_header.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_edit_page.dart';
import 'features/profile/profile_text_editor.dart'; import 'features/profile/profile_text_editor.dart';
import 'features/settings/settings_page.dart'; import 'features/settings/settings_page.dart';
import 'features/settings/theme_picker_page.dart';
import 'shared/auth/auth.dart'; import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.dart'; import 'shared/emoji/emoji_burst.dart';
@@ -414,6 +417,8 @@ class App extends HookConsumerWidget {
AuthStatus.authenticated => DeepLinkDispatcher( AuthStatus.authenticated => DeepLinkDispatcher(
child: HomePage( child: HomePage(
settingsPageBuilder: _buildSettingsPage, settingsPageBuilder: _buildSettingsPage,
communityInvitePageBuilder: (_) => const CommunityInvitePage(),
communityAppearancePageBuilder: (_) => const ThemePickerPage(),
hasUnreadInbox: hasUnreadInbox, hasUnreadInbox: hasUnreadInbox,
), ),
), ),
@@ -438,9 +443,12 @@ class _SettingsPageContent extends ConsumerWidget {
profileHeader: const SettingsProfileHeader(), profileHeader: const SettingsProfileHeader(),
profileEditPageBuilder: (_) => profileEditPageBuilder: (_) =>
const ProfileEditPage(startInPhotoEditor: true), const ProfileEditPage(startInPhotoEditor: true),
onSetStatus: (context) => showSetStatusSheet(
context,
currentStatus: ref.read(userStatusProvider).asData?.value,
),
onEditDisplayName: showProfileDisplayNameEditor, onEditDisplayName: showProfileDisplayNameEditor,
onEditProfileDescription: showProfileDescriptionEditor, onEditProfileDescription: showProfileDescriptionEditor,
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true), 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/auth/auth.dart';
import '../../shared/community/community_icon_provider.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/relay/relay.dart';
import '../../shared/theme/theme.dart'; import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_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_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/modal_presentation.dart';
@@ -162,6 +164,8 @@ _UnreadChannelState _computeUnreadChannelState({
class ChannelsPage extends HookConsumerWidget { class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({ const ChannelsPage({
required this.settingsPageBuilder, required this.settingsPageBuilder,
this.communityInvitePageBuilder,
this.communityAppearancePageBuilder,
required this.onSettingsTransitionProgress, required this.onSettingsTransitionProgress,
this.tabReselection, this.tabReselection,
super.key, super.key,
@@ -169,6 +173,12 @@ class ChannelsPage extends HookConsumerWidget {
final WidgetBuilder settingsPageBuilder; 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 /// Reports Settings route progress so its foreground and Home's background
/// render from the same timeline. /// render from the same timeline.
final ValueChanged<double> onSettingsTransitionProgress; final ValueChanged<double> onSettingsTransitionProgress;
@@ -309,8 +319,11 @@ class ChannelsPage extends HookConsumerWidget {
showBuzzModalBottomSheet<void>( showBuzzModalBottomSheet<void>(
context: context, context: context,
showCloseButton: false, showCloseButton: false,
showDragHandle: false, showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(), builder: (_) => _CommunitySwitcherSheet(
invitePageBuilder: communityInvitePageBuilder,
appearancePageBuilder: communityAppearancePageBuilder,
),
); );
} }

View File

@@ -1,118 +1,210 @@
part of '../channels_page.dart'; part of '../channels_page.dart';
class _CommunitySwitcherSheet extends HookConsumerWidget { class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet(); const _CommunitySwitcherSheet({
this.invitePageBuilder,
this.appearancePageBuilder,
});
final WidgetBuilder? invitePageBuilder;
final WidgetBuilder? appearancePageBuilder;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider); final communitiesAsync = ref.watch(communityListProvider);
final activeAsync = ref.watch(activeCommunityProvider); final activeAsync = ref.watch(activeCommunityProvider);
final isEditing = useState(false); 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( return SafeArea(
child: BuzzTitledSheetLayout( child: Column(
mainAxisSize: MainAxisSize.min,
key: const Key('community-switcher-sheet'), key: const Key('community-switcher-sheet'),
title: 'Switch Community', children: [
titleKey: const Key('community-switcher-title'), const SizedBox(height: Grid.xs),
showDragHandle: true, Flexible(
trailing: SizedBox( child: communitiesAsync.when(
key: const Key('community-switcher-edit'), loading: () => const SizedBox(
width: 56, height: 120,
height: 44, child: Center(
child: TextButton( child: BuzzLoadingIndicator(
onPressed: () => isEditing.value = !isEditing.value, size: 40,
style: TextButton.styleFrom( semanticLabel: 'Loading communities',
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),
),
);
},
),
],
), ),
), ),
), ),
); 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 { class HomePage extends HookConsumerWidget {
const HomePage({ const HomePage({
required this.settingsPageBuilder, required this.settingsPageBuilder,
this.communityInvitePageBuilder,
this.communityAppearancePageBuilder,
required this.hasUnreadInbox, required this.hasUnreadInbox,
super.key, super.key,
}); });
final WidgetBuilder settingsPageBuilder; 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; final bool hasUnreadInbox;
static const double _tabBarHeight = mobileTabBarHeight; static const double _tabBarHeight = mobileTabBarHeight;
@@ -90,6 +98,8 @@ class HomePage extends HookConsumerWidget {
final pages = [ final pages = [
ChannelsPage( ChannelsPage(
settingsPageBuilder: settingsPageBuilder, settingsPageBuilder: settingsPageBuilder,
communityInvitePageBuilder: communityInvitePageBuilder,
communityAppearancePageBuilder: communityAppearancePageBuilder,
tabReselection: homeReselection, tabReselection: homeReselection,
onSettingsTransitionProgress: (progress) { onSettingsTransitionProgress: (progress) {
if (settingsTransitionProgress.value != progress) { if (settingsTransitionProgress.value != progress) {

View File

@@ -1,28 +1,25 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/animated_avatar.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_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/relay/media_image.dart'; import '../../shared/relay/media_image.dart';
import '../../shared/theme/theme.dart'; import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import '../../shared/widgets/progressive_animated_avatar.dart'; import '../../shared/widgets/progressive_animated_avatar.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'set_status_sheet.dart'; import 'set_status_sheet.dart';
import 'user_status_provider.dart'; import 'user_status_provider.dart';
/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred /// Profile avatar, display name, status, and presence shown above settings.
/// 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].
class SettingsProfileHeader extends HookConsumerWidget { class SettingsProfileHeader extends HookConsumerWidget {
const SettingsProfileHeader({super.key}); const SettingsProfileHeader({super.key});
@@ -33,6 +30,11 @@ class SettingsProfileHeader extends HookConsumerWidget {
final profile = ref.watch(profileProvider).asData?.value; final profile = ref.watch(profileProvider).asData?.value;
final status = ref.watch(userStatusProvider).asData?.value; final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty; 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 presence = ref.watch(presenceProvider).value ?? 'offline';
final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl);
final animatedPosterUrl = animatedAvatar?.posterUrl; final animatedPosterUrl = animatedAvatar?.posterUrl;
@@ -55,91 +57,93 @@ class SettingsProfileHeader extends HookConsumerWidget {
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve), padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve),
child: Column( child: Column(
children: [ children: [
MaskedAvatarBadge( SizedBox.square(
size: _avatarSize, dimension: _avatarSize,
avatar: GestureDetector( child: ClipOval(
key: const ValueKey('settings-profile-avatar'), child: GestureDetector(
onTap: animatedAvatar == null key: const ValueKey('settings-profile-avatar'),
? null onTap: animatedAvatar == null
: () => stoppedAnimationUrl.value = ? null
stoppedAnimationUrl.value == animatedAvatar.animationUrl : () => stoppedAnimationUrl.value =
? null stoppedAnimationUrl.value ==
: animatedAvatar.animationUrl, animatedAvatar.animationUrl
child: ColoredBox( ? null
key: const ValueKey('settings-profile-avatar-background'), : animatedAvatar.animationUrl,
color: animatedAvatar == null child: ColoredBox(
? context.colors.primaryContainer key: const ValueKey('settings-profile-avatar-background'),
: Colors.transparent, color: animatedAvatar == null
child: ? context.colors.primaryContainer
animatedAvatar != null && : Colors.transparent,
stoppedAnimationUrl.value != animatedAvatar.animationUrl child:
? ProgressiveAnimatedAvatar( animatedAvatar != null &&
key: ValueKey(animatedAvatar.animationUrl), stoppedAnimationUrl.value !=
descriptor: animatedAvatar, animatedAvatar.animationUrl
fallback: _AvatarFallback(initial: profile?.initial), ? ProgressiveAnimatedAvatar(
loadingImage: activeHandoff == null key: ValueKey(animatedAvatar.animationUrl),
? null descriptor: animatedAvatar,
: MemoryImage(activeHandoff.animation), fallback: _AvatarFallback(initial: profile?.initial),
onAnimationReady: activeHandoff == null loadingImage: activeHandoff == null
? null ? null
: () => ref : MemoryImage(activeHandoff.animation),
.read(profileAvatarHandoffProvider.notifier) onAnimationReady: activeHandoff == null
.clear(activeHandoff.avatarUrl), ? null
) : () => ref
: activeHandoff == null || animatedPosterUrl == null .read(profileAvatarHandoffProvider.notifier)
? AvatarImageContent( .clear(activeHandoff.avatarUrl),
imageUrl: avatarUrl, )
fallback: _AvatarFallback(initial: profile?.initial), : activeHandoff == null || animatedPosterUrl == null
) ? AvatarImageContent(
: Stack( imageUrl: avatarUrl,
fit: StackFit.expand, fallback: _AvatarFallback(initial: profile?.initial),
children: [ )
Image( : Stack(
image: MemoryImage(activeHandoff.poster), fit: StackFit.expand,
fit: BoxFit.cover, children: [
gaplessPlayback: true, Image(
), image: MemoryImage(activeHandoff.poster),
Offstage(
offstage: true,
child: MediaImage(
key: ValueKey(
'settings-profile-paused-handoff-${activeHandoff.avatarUrl}',
),
url: animatedPosterUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => gaplessPlayback: true,
const SizedBox.shrink(),
frameBuilder:
(
context,
child,
frame,
wasSynchronouslyLoaded,
) {
if (wasSynchronouslyLoaded ||
frame != null) {
WidgetsBinding.instance
.addPostFrameCallback((_) {
ref
.read(
profileAvatarHandoffProvider
.notifier,
)
.clear(activeHandoff.avatarUrl);
});
}
return child;
},
), ),
), 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), const SizedBox(height: Grid.twelve),
Text( Text(
@@ -147,8 +151,7 @@ class SettingsProfileHeader extends HookConsumerWidget {
style: context.textTheme.titleMedium, style: context.textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
// Keep the status text visible even when no emoji is set. NIP-38 // Preserve the complete status below the name, including custom emoji.
// permits text-only statuses, which the avatar badge cannot represent.
if (hasStatus) if (hasStatus)
GestureDetector( GestureDetector(
onTap: openStatusSheet, onTap: openStatusSheet,
@@ -159,8 +162,24 @@ class SettingsProfileHeader extends HookConsumerWidget {
right: Grid.gutter, right: Grid.gutter,
bottom: Grid.half, bottom: Grid.half,
), ),
child: Text( child: Text.rich(
status.text.isNotEmpty ? status.text : status.emoji, 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( style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant, color: context.colors.onSurfaceVariant,
), ),
@@ -189,10 +208,12 @@ class _AvatarFallback extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Text( return Center(
initial ?? '?', child: Text(
style: context.textTheme.displaySmall?.copyWith( initial ?? '?',
color: context.colors.onPrimaryContainer, style: context.textTheme.displaySmall?.copyWith(
color: context.colors.onPrimaryContainer,
),
), ),
); );
} }
@@ -239,6 +260,7 @@ class _PresencePill extends StatelessWidget {
key: const ValueKey('settings-presence-menu'), key: const ValueKey('settings-presence-menu'),
borderRadius: BorderRadius.circular(Radii.full), borderRadius: BorderRadius.circular(Radii.full),
onTap: () async { onTap: () async {
unawaited(HapticFeedback.selectionClick());
final selected = await showAnchoredPopover<String>( final selected = await showAnchoredPopover<String>(
context: buttonContext, context: buttonContext,
width: 176, width: 176,
@@ -333,71 +355,3 @@ Color _presenceColor(BuildContext context, String presence) =>
'away' => context.appColors.warning, 'away' => context.appColors.warning,
_ => context.colors.outline, _ => 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/auth/auth.dart';
import '../../shared/clipboard_utils.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_bridge.dart';
import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_relay_capability_provider.dart';
import '../../shared/relay/relay.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_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_button.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/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/connection_section.dart';
part 'settings_page/notifications_section.dart'; part 'settings_page/notifications_section.dart';
Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink();
enum _ProfileEditAction { displayName, description, photo }
class SettingsPage extends HookConsumerWidget { class SettingsPage extends HookConsumerWidget {
/// Creates the settings page. /// Creates the settings page.
const SettingsPage({ const SettingsPage({
super.key, super.key,
required this.profileHeader, required this.profileHeader,
required this.invitePageBuilder,
required this.identityRecoveryPageBuilder, required this.identityRecoveryPageBuilder,
this.profileEditPageBuilder = _emptyProfileEditPage, this.profileEditPageBuilder = _emptyProfileEditPage,
this.onSetStatus,
this.onEditDisplayName, this.onEditDisplayName,
this.onEditProfileDescription, this.onEditProfileDescription,
}); });
@@ -51,19 +47,19 @@ class SettingsPage extends HookConsumerWidget {
/// Header widget displayed at the top of settings. /// Header widget displayed at the top of settings.
final Widget profileHeader; 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. /// Builds the identity-recovery page pushed from the recovery settings row.
final WidgetBuilder identityRecoveryPageBuilder; 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; 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; 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; final Future<void> Function(BuildContext context)? onEditProfileDescription;
@override @override
@@ -75,75 +71,6 @@ class SettingsPage extends HookConsumerWidget {
bottomHeight: Grid.xxs, 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( return FrostedScaffold(
useUtilitySurfaceTheme: true, useUtilitySurfaceTheme: true,
appBar: FrostedAppBar( appBar: FrostedAppBar(
@@ -174,38 +101,6 @@ class SettingsPage extends HookConsumerWidget {
icon: const Icon(LucideIcons.x), 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, bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(), bottom: const SizedBox.expand(),
), ),
@@ -216,12 +111,16 @@ class SettingsPage extends HookConsumerWidget {
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
children: [ children: [
profileHeader, profileHeader,
_CommunitySection(invitePageBuilder: invitePageBuilder), _StatusSection(onSetStatus: onSetStatus),
_ProfileSection(
profileEditPageBuilder: profileEditPageBuilder,
onEditDisplayName: onEditDisplayName,
onEditProfileDescription: onEditProfileDescription,
),
const _NotificationsSection(), const _NotificationsSection(),
_ConnectionSection( _ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder, 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 nsec = config.nsec;
final community = authState?.community; final community = authState?.community;
if (nsec == null || nsec.isEmpty || community == null) {
return const SizedBox.shrink();
}
return AppListCard( return AppListCard(
label: 'Connection',
verticalPadding: Grid.twelve, verticalPadding: Grid.twelve,
children: [ children: [
if (nsec != null && nsec.isNotEmpty && community != null) ...[ AppListRow(
_IdentityRow(nsec: nsec), title: 'Send identity to desktop',
AppListRow( subtitle: 'Scan a recovery code shown by Buzz Desktop',
icon: LucideIcons.scanQrCode, trailing: const _RowChevron(),
title: 'Send identity to desktop', onTap: () async {
subtitle: 'Scan a recovery code shown by Buzz Desktop', final pairing = ref.read(pairingProvider.notifier);
trailing: const _RowChevron(), final authorized = await pairing.authorizeIdentityExport(
onTap: () async { community: community,
final pairing = ref.read(pairingProvider.notifier); );
final authorized = await pairing.authorizeIdentityExport( if (!authorized) {
community: community, if (!context.mounted) return;
); final message = ref.read(pairingProvider).errorMessage;
if (!authorized) { if (message != null) {
if (!context.mounted) return; ScaffoldMessenger.of(
final message = ref.read(pairingProvider).errorMessage; context,
if (message != null) { ).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of( }
context, return;
).showSnackBar(SnackBar(content: Text(message))); }
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; return;
} }
if (!context.mounted) return;
try { await Navigator.of(context).push(
if (!context.mounted) return; MaterialPageRoute<void>(builder: identityRecoveryPageBuilder),
final resumed = await _waitForResumedFrame(); );
if (!resumed) { } finally {
if (context.mounted) { pairing.reset();
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();
}
},
),
],
], ],
); );
} }
@@ -92,27 +91,6 @@ Future<bool> _waitForResumedFrame() async {
return true; 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 { class _IdentityRow extends StatelessWidget {
const _IdentityRow({required this.nsec}); const _IdentityRow({required this.nsec});
@@ -132,8 +110,7 @@ class _IdentityRow extends StatelessWidget {
label: 'Copy identity public key', label: 'Copy identity public key',
value: npub ?? 'Identity unavailable', value: npub ?? 'Identity unavailable',
child: AppListRow( child: AppListRow(
icon: LucideIcons.key, title: 'Copy public key (npub)',
title: 'Identity (pubkey)',
trailing: Icon( trailing: Icon(
LucideIcons.copy, LucideIcons.copy,
size: 18, size: 18,
@@ -142,48 +119,14 @@ class _IdentityRow extends StatelessWidget {
onTap: npub == null onTap: npub == null
? null ? null
: () async { : () 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, centerTitle: true,
showBottomDivider: false, showBottomDivider: false,
leading: _ThemePreviewCloseButton(onPressed: close), leading: _ThemePreviewCloseButton(onPressed: close),
title: const Text('Theme'), title: const Text('Appearance'),
actions: [_ThemePreviewSetButton(onPressed: applySelection)], actions: [_ThemePreviewSetButton(onPressed: applySelection)],
), ),
body: Column( 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' import '../profile/presence_snapshot_test.dart'
show PresenceTestRelay, presenceEvent; show PresenceTestRelay, presenceEvent;
import 'dart:math'; 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -37,9 +42,23 @@ import 'package:buzz/shared/widgets/skeleton.dart';
part 'channels_page_test/presence_tests.dart'; part 'channels_page_test/presence_tests.dart';
void main() { 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({ Widget buildTestable({
required List<Override> overrides, required List<Override> overrides,
bool previewDirectory = false, bool previewDirectory = false,
Brightness brightness = Brightness.light,
double keyboardInset = 0, double keyboardInset = 0,
bool disableAnimations = false, bool disableAnimations = false,
double bottomPadding = 0, double bottomPadding = 0,
@@ -65,10 +84,13 @@ void main() {
dmDirectoryPreviewEnabledProvider.overrideWith( dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory, (ref) => previewDirectory,
), ),
appLifecycleProvider.overrideWith(() => _CommunitySettingsLifecycle()),
...overrides, ...overrides,
], ],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(topSectionGradient: topSectionGradient), theme: brightness == Brightness.dark
? AppTheme.dark()
: AppTheme.light(topSectionGradient: topSectionGradient),
builder: (context, child) => MediaQuery( builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations, disableAnimations: disableAnimations,
@@ -82,6 +104,10 @@ void main() {
children: [ children: [
ChannelsPage( ChannelsPage(
settingsPageBuilder: _buildSettingsPage, settingsPageBuilder: _buildSettingsPage,
communityInvitePageBuilder: (_) =>
const Scaffold(body: Text('Invite destination')),
communityAppearancePageBuilder: (_) =>
const Scaffold(body: Text('Appearance destination')),
onSettingsTransitionProgress: onSettingsTransitionProgress:
onSettingsTransitionProgress ?? (_) {}, onSettingsTransitionProgress ?? (_) {},
tabReselection: tabReselection, tabReselection: tabReselection,
@@ -1122,6 +1148,119 @@ void main() {
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); 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', ( testWidgets('community switcher separates selection from edit removal', (
tester, tester,
) async { ) async {
@@ -1167,7 +1306,7 @@ void main() {
await tester.tap(find.text('Alpha')); await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Switch Community'), findsOneWidget); expect(find.text('Community settings'), findsOneWidget);
final options = find.byKey(const Key('community-switcher-options')); final options = find.byKey(const Key('community-switcher-options'));
expect(options, findsOneWidget); expect(options, findsOneWidget);
final editButton = find.byKey(const Key('community-switcher-edit')); final editButton = find.byKey(const Key('community-switcher-edit'));
@@ -1175,7 +1314,7 @@ void main() {
expect(find.byTooltip('Close sheet'), findsNothing); expect(find.byTooltip('Close sheet'), findsNothing);
expect( expect(
tester.getCenter(editButton).dx, 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('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget); expect(find.text('bravo.example.com'), findsOneWidget);
@@ -1292,7 +1431,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(communityNotifier.removedIds, ['bravo']); expect(communityNotifier.removedIds, ['bravo']);
expect(find.text('Switch Community'), findsOneWidget); expect(find.text('Community settings'), findsOneWidget);
expect(find.text('Bravo'), findsNothing); expect(find.text('Bravo'), findsNothing);
expect(find.text('Done'), findsOneWidget); expect(find.text('Done'), findsOneWidget);
@@ -1367,7 +1506,7 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect( expect(
tester.getSize(find.byKey(const Key('community-switcher-title'))).height, tester.getSize(find.text('Community settings')).height,
greaterThan(32), greaterThan(32),
); );
}); });
@@ -2826,3 +2965,8 @@ String _dmTileAvatarInitial(WidgetTester tester, String labelText) {
); );
return initial.data!; 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.dart';
import 'package:buzz/features/profile/user_status_provider.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_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_auth.dart';
import 'package:buzz/shared/relay/media_image.dart'; import 'package:buzz/shared/relay/media_image.dart';
import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing; import 'package:http/testing.dart' as http_testing;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart'; import '../../helpers/widget_helpers.dart';
@@ -240,7 +242,7 @@ void main() {
expect(container.read(profileAvatarHandoffProvider), isNull); 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, tester,
) async { ) async {
const missingShortcode = ':very_long_missing_custom_emoji:'; const missingShortcode = ':very_long_missing_custom_emoji:';
@@ -266,17 +268,88 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing); expect(find.byType(Hero), findsNothing);
final badge = find.byType(MaskedAvatarBadge); expect(find.byType(MaskedAvatarBadge), findsNothing);
expect(find.text('$missingShortcode Focusing'), findsOneWidget);
expect( expect(
find.descendant(of: badge, matching: find.text(missingShortcode)), tester.getSize(find.byKey(const ValueKey('settings-profile-avatar'))),
findsNothing, const Size(128, 128),
);
expect(
find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)),
findsOneWidget,
); );
}); });
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( testWidgets(
'keeps text-only status visible beside a changeable presence pill', 'keeps text-only status visible beside a changeable presence pill',
(tester) async { (tester) async {
@@ -337,10 +410,27 @@ void main() {
final presenceTarget = find.byKey( final presenceTarget = find.byKey(
const ValueKey('settings-presence-target'), 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); final targetRect = tester.getRect(presenceTarget);
await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1)); await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1));
await tester.pump(); await tester.pump();
expect(haptics, contains('HapticFeedbackType.selectionClick'));
final scale = tester.widget<ScaleTransition>( final scale = tester.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')), 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/auth/auth.dart';
import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -15,75 +16,124 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart'; import '../../helpers/widget_helpers.dart';
void main() { void main() {
testWidgets('shows a compact copyable identity row', (tester) async { testWidgets(
MethodCall? clipboardCall; 'shows a compact copyable identity row',
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger (tester) async {
.setMockMethodCallHandler(SystemChannels.platform, (call) async { var successFeedback = false;
if (call.method == 'Clipboard.setData') clipboardCall = call; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('buzz/haptics'),
(call) async {
expect(call.method, 'success');
successFeedback = true;
return null; return null;
}); },
addTearDown( );
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger addTearDown(() {
.setMockMethodCallHandler(SystemChannels.platform, null), tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
); const MethodChannel('buzz/haptics'),
SharedPreferences.setMockInitialValues({}); null,
final prefs = await SharedPreferences.getInstance(); );
});
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( await tester.pumpWidget(
WidgetHelpers.testable( WidgetHelpers.testable(
overrides: [ overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new), relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new), authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith( pairingProvider.overrideWith(
() => _PairingNotifier(Future<bool>.value(true)), () => _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.pump(); await tester.ensureVisible(find.text('Copy public key (npub)'));
await tester.ensureVisible(find.text('Identity (pubkey)')); await tester.pumpAndSettle();
await tester.pumpAndSettle();
final expectedPubkey = nostr.Keys( final expectedPubkey = nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111', '1111111111111111111111111111111111111111111111111111111111111111',
).public; ).public;
// Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the // Keys('1'×64).public ↔ this npub — the NIP-19 canonical vector for the
// identity row, hardcoded so the codec itself stays under test. // identity row, hardcoded so the codec itself stays under test.
const expectedNpub = const expectedNpub =
'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9'; 'npub1fu64hh9hes90w2808n8tjc2ajp5yhddjef0ctx4s7zmsgp6cwx4qgy4eg9';
expect(find.text('Connected to'), findsNothing); final statusCard = find.byKey(const ValueKey('status-identity-options'));
expect(find.text('https://relay.test'), findsNothing); expect(
// Neither the raw hex key nor the full npub is rendered visually — the find.descendant(of: statusCard, matching: find.text('Set status')),
// full npub is exposed through a11y and the clipboard only. findsOneWidget,
expect(find.text(expectedPubkey), findsNothing); );
expect(find.text(expectedNpub), findsNothing); expect(
// The identity row's a11y value carries the full npub (not raw hex). find.descendant(
expect( of: statusCard,
find.ancestor( matching: find.text('Copy public key (npub)'),
of: find.text('Identity (pubkey)'),
matching: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.value == expectedNpub,
), ),
), findsOneWidget,
findsOneWidget, );
); expect(
final copy = tester.getRect(find.byIcon(LucideIcons.copy)); tester.getTopLeft(find.text('Copy public key (npub)')).dy,
final chevron = tester.getRect(find.byIcon(LucideIcons.chevronRight).first); lessThan(tester.getTopLeft(find.text('Display name')).dy),
expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); );
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.tap(find.text('Copy public key (npub)'));
await tester.pump(); await tester.pump();
expect(clipboardCall?.method, 'Clipboard.setData'); expect(successFeedback, isTrue);
expect(clipboardCall?.arguments, {'text': expectedNpub}); expect(clipboardCall?.method, 'Clipboard.setData');
expect(find.text('Pubkey copied'), findsOneWidget); 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', ( testWidgets('waits for a resumed frame before navigating after auth', (
tester, tester,
@@ -103,7 +153,6 @@ void main() {
], ],
child: SettingsPage( child: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')), const Scaffold(body: Text('Identity recovery')),
), ),
@@ -141,7 +190,6 @@ void main() {
], ],
child: SettingsPage( child: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')), const Scaffold(body: Text('Identity recovery')),
), ),
@@ -181,7 +229,6 @@ void main() {
], ],
child: SettingsPage( child: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')), const Scaffold(body: Text('Identity recovery')),
), ),
@@ -224,7 +271,6 @@ void main() {
], ],
child: SettingsPage( child: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')), const Scaffold(body: Text('Identity recovery')),
), ),
@@ -268,7 +314,6 @@ void main() {
], ],
child: SettingsPage( child: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')), const Scaffold(body: Text('Identity recovery')),
), ),

View File

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

View File

@@ -45,13 +45,12 @@ void main() {
), ),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => 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.text('v0.16.0 (432)'), findsNothing);
expect(find.byTooltip('Close settings'), findsOneWidget); expect(find.byTooltip('Close settings'), findsOneWidget);

View File

@@ -1,4 +1,8 @@
import 'dart:async'; 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/features/settings/settings_page.dart';
import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/community/community_membership_provider.dart';
@@ -64,13 +68,12 @@ void main() {
), ),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
), ),
); );
expect(find.text('Invite to community'), findsOneWidget); expect(find.text('Invite to community'), findsNothing);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'), find.text(buildNumber.isEmpty ? 'v0.16.0' : 'v0.16.0 ($buildNumber)'),
@@ -114,7 +117,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -167,7 +169,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -219,7 +220,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -269,7 +269,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -288,75 +287,102 @@ void main() {
debugDefaultTargetPlatformOverride = null; debugDefaultTargetPlatformOverride = null;
}); });
testWidgets('opens profile edit choices and routes photo directly', ( for (final brightness in Brightness.values) {
tester, testWidgets(
) async { 'shows status above profile editing and routes photo directly in ${brightness.name}',
SharedPreferences.setMockInitialValues({}); (tester) async {
final prefs = await SharedPreferences.getInstance(); 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( await tester.pumpWidget(
ProviderScope( ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)], overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: brightness == Brightness.dark
home: SettingsPage( ? AppTheme.dark()
profileHeader: const SizedBox.square(dimension: 128), : AppTheme.light(),
profileEditPageBuilder: (_) => home: SettingsPage(
const Scaffold(body: Text('Profile editor destination')), profileHeader: const SizedBox.square(dimension: 128),
invitePageBuilder: (_) => const SizedBox.shrink(), profileEditPageBuilder: (_) =>
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), const Scaffold(body: Text('Profile editor destination')),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
), ),
), );
), await tester.pumpAndSettle();
);
await tester.pumpAndSettle();
expect(find.text('Profile'), findsNothing); expect(find.text('Profile'), findsNothing);
await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); expect(find.text('Edit profile'), findsNothing);
await tester.pumpAndSettle(); expect(find.text('Display name'), findsOneWidget);
expect(find.text('Edit profile'), findsOneWidget); expect(find.text('Profile description'), findsOneWidget);
expect(find.text('Display name'), findsOneWidget); expect(find.text('Edit photo'), findsOneWidget);
expect(find.text('Profile description'), findsOneWidget); final optionCard = find.descendant(
expect(find.text('Photo'), findsOneWidget); of: find.byKey(const ValueKey('edit-profile-options')),
final optionCard = find.descendant( matching: find.byType(Material),
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, tester,
) async { ) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -370,9 +396,9 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
onSetStatus: (_) => opened.add('status'),
onEditDisplayName: (_) async => opened.add('name'), onEditDisplayName: (_) async => opened.add('name'),
onEditProfileDescription: (_) async => opened.add('description'), onEditProfileDescription: (_) async => opened.add('description'),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -380,56 +406,20 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); expect(
await tester.pumpAndSettle(); 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.tap(find.byKey(const ValueKey('edit-profile-display-name')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(opened, ['name']); expect(opened, ['status', 'name']);
expect(find.text('Edit profile'), findsNothing); 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.tap(find.byKey(const ValueKey('edit-profile-description')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(opened, ['name', 'description']); expect(opened, ['status', '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);
}); });
testWidgets('uses the native glass close control on iOS', (tester) async { testWidgets('uses the native glass close control on iOS', (tester) async {
@@ -445,7 +435,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -458,19 +447,20 @@ void main() {
(view) => (view) =>
(view.creationParams as Map<Object?, Object?>?)?['icon'] == 'close', (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.viewType, 'buzz/navigation_glass');
expect(nativeClose.creationParams, containsPair('icon', 'close')); expect(nativeClose.creationParams, containsPair('icon', 'close'));
expect(nativeEdit.viewType, 'buzz/navigation_glass'); expect(
expect(nativeEdit.creationParams, containsPair('controlWidth', 56.0)); nativeViews.where(
(view) =>
(view.creationParams as Map<Object?, Object?>?)?['label'] == 'Edit',
),
isEmpty,
);
expect(find.byTooltip('Close settings'), findsOneWidget); expect(find.byTooltip('Close settings'), findsOneWidget);
debugDefaultTargetPlatformOverride = null; debugDefaultTargetPlatformOverride = null;
}); });
testWidgets('shows community invite navigation to owners and admins', ( testWidgets('keeps community controls out of personal settings', (
tester, tester,
) async { ) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -488,7 +478,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -496,17 +485,14 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget); expect(find.text('Invite to community'), findsNothing);
expect( expect(
find.text('Add people directly or share an invite link'), find.text('Add people directly or share an invite link'),
findsNothing, 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, tester,
) async { ) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -527,7 +513,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -535,10 +520,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget); expect(find.text('Invite to community'), findsNothing);
await tester.tap(find.text('Invite to community'));
await tester.pumpAndSettle();
expect(find.text('Invite destination'), findsOneWidget);
}); });
testWidgets('hides community invite navigation from plain members', ( testWidgets('hides community invite navigation from plain members', (
@@ -559,7 +541,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox.shrink(), profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),
@@ -591,7 +572,6 @@ void main() {
theme: AppTheme.light(), theme: AppTheme.light(),
home: SettingsPage( home: SettingsPage(
profileHeader: const SizedBox(height: 100), profileHeader: const SizedBox(height: 100),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
), ),
), ),

View File

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