mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
17
mobile/lib/shared/success_haptic.dart
Normal file
17
mobile/lib/shared/success_haptic.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
|
||||
@@ -101,7 +101,6 @@ void main() {
|
||||
theme: AppTheme.light(),
|
||||
home: SettingsPage(
|
||||
profileHeader: const SizedBox.shrink(),
|
||||
invitePageBuilder: (_) => const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -99,7 +99,6 @@ void main() {
|
||||
theme: AppTheme.light(),
|
||||
home: SettingsPage(
|
||||
profileHeader: const SizedBox.shrink(),
|
||||
invitePageBuilder: (_) => const SizedBox.shrink(),
|
||||
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user