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,
|
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
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
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(
|
||||||
|
|||||||
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'
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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')),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user