Unify mobile channel details (#6113)

## Summary

- combine channel identity, actions, member previews, and lifecycle
controls in one navigable mobile details page
- add compact member management and desktop-aligned channel editing
- smooth the profile entrance transition and standardize settings-group
spacing
- align Huddle lifecycle rows with regular message author and body
spacing

## Testing

- `just mobile-check`
- `just mobile-test` — 1,477 tests
- Pixel 10 debug build, install, and interaction review

## Snapshots

### Channel header

![Channel header with channel name and member
count](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--01-channel-header.png)

### Combined channel details

![Combined channel details with actions and member
preview](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--02-channel-details-members.png)

### Manage channel

![Manage channel editing
controls](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--03-manage-channel.png)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-19 17:48:09 +01:00
committed by GitHub
parent 08eb46ef3c
commit a567dfc2df
59 changed files with 3302 additions and 855 deletions

View File

@@ -27,8 +27,8 @@ import '../channels/dm_channel_labels.dart';
import '../channels/message_content.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'activity_provider.dart';
import 'compose_drafts_provider.dart';
import 'inbox_item.dart';

View File

@@ -0,0 +1,268 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import 'channel_management_provider.dart';
/// Searchable multi-select used to add people or agents to a channel.
class AddChannelMembersSheet extends HookConsumerWidget {
const AddChannelMembersSheet({
super.key,
required this.channelId,
required this.existingPubkeys,
});
final String channelId;
final Set<String> existingPubkeys;
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim().toLowerCase();
});
return timer.cancel;
}, [query.value]);
final normalizedQuery = debouncedQuery.value;
final directoryAsync = normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final normalizedExisting = existingPubkeys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final successfulPubkeys = useState<Set<String>>(<String>{});
final excludedPubkeys = {...normalizedExisting, ...successfulPubkeys.value};
final availableUsers =
directoryAsync.asData?.value
.where(
(user) => !excludedPubkeys.contains(user.pubkey.toLowerCase()),
)
.toList() ??
const <DirectoryUser>[];
void toggleUser(DirectoryUser user) {
if (isSubmitting.value) return;
final normalized = user.pubkey.toLowerCase();
selectedUsers.value = selectedPubkeys.contains(normalized)
? [
for (final selected in selectedUsers.value)
if (selected.pubkey.toLowerCase() != normalized) selected,
]
: [...selectedUsers.value, user];
submitError.value = null;
}
Future<void> addSelectedMembers() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) return;
final submittedUsers = List<DirectoryUser>.of(selectedUsers.value);
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(channelActionsProvider)
.addMembers(
channelId: channelId,
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
);
if (context.mounted) Navigator.of(context).pop(true);
} on AddMembersException catch (error) {
final failedPubkeys = error.failures.keys
.map((pubkey) => pubkey.toLowerCase())
.toSet();
successfulPubkeys.value = {
...successfulPubkeys.value,
for (final user in submittedUsers)
if (!failedPubkeys.contains(user.pubkey.toLowerCase()))
user.pubkey.toLowerCase(),
};
selectedUsers.value = [
for (final user in submittedUsers)
if (failedPubkeys.contains(user.pubkey.toLowerCase())) user,
];
submitError.value = error.message;
} catch (error) {
submitError.value = error.toString();
} finally {
isSubmitting.value = false;
}
}
return Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: Column(
children: [
TextField(
key: const ValueKey('add-channel-members-search'),
controller: queryController,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
decoration: const InputDecoration(
hintText: 'Search for people or agents',
prefixIcon: Icon(LucideIcons.search),
),
),
if (selectedUsers.value.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final user in selectedUsers.value)
InputChip(
key: ValueKey(
'add-channel-member-selected-${user.pubkey}',
),
label: Text(user.label),
onDeleted: isSubmitting.value
? null
: () => toggleUser(user),
),
],
),
),
],
const SizedBox(height: Grid.xxs),
Expanded(
child: directoryAsync.when(
data: (_) => availableUsers.isEmpty
? Center(
child: Text(
normalizedQuery.isEmpty
? 'Everyone available is already in this channel.'
: 'No matching people or agents.',
textAlign: TextAlign.center,
),
)
: ListView.builder(
key: const ValueKey('add-channel-members-results'),
itemCount: availableUsers.length,
itemBuilder: (context, index) {
final user = availableUsers[index];
final selected = selectedPubkeys.contains(
user.pubkey.toLowerCase(),
);
return Semantics(
key: ValueKey(
'add-channel-member-${user.pubkey}',
),
button: true,
selected: selected,
label: selected
? '${user.label}, selected'
: user.label,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor:
context.colors.primaryContainer,
fallback: Text(user.initial),
),
title: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
selected
? LucideIcons.circleCheck
: LucideIcons.plus,
color: selected
? context.colors.primary
: context.colors.onSurfaceVariant,
),
onTap: () => toggleUser(user),
),
);
},
),
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: 'Loading people and agents',
),
),
error: (error, _) => Center(
child: Text(
'Couldn’t load people or agents. Try again.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
),
),
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('add-channel-members-submit'),
onPressed: selectedUsers.value.isEmpty || isSubmitting.value
? null
: addSelectedMembers,
child: Text(
isSubmitting.value
? 'Adding…'
: selectedUsers.value.length == 1
? 'Add member'
: 'Add ${selectedUsers.value.length} members',
),
),
),
const SizedBox(height: Grid.xxs),
],
),
),
),
);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../shared/theme/theme.dart';
import '../../../shared/widgets/buzz_loading_indicator.dart';
import '../../profile/user_cache_provider.dart';
import '../../../shared/profile/user_cache_provider.dart';
import '../date_formatters.dart';
import 'observer_models.dart';
import 'observer_subscription.dart';

View File

@@ -141,16 +141,18 @@ class Channel {
);
Channel copyWith({
String? name,
String? description,
Object? lastMessageAt = _sentinel,
Object? archivedAt = _sentinel,
int? memberCount,
bool? isMember,
}) => Channel(
id: id,
name: name,
name: name ?? this.name,
channelType: channelType,
visibility: visibility,
description: description,
description: description ?? this.description,
topic: topic,
purpose: purpose,
createdBy: createdBy,

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -8,19 +9,30 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'add_members_sheet.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
part 'channel_details_page.dart';
/// Opens the mobile channel actions sheet and returns whether its parent page
/// should close after a successful lifecycle action.
Future<bool?> showChannelActionsSheet({
@@ -47,7 +59,7 @@ Future<bool?> showChannelActionsSheet({
/// Mobile action sheet for channel-level read, organization, and lifecycle
/// operations.
class ChannelActionsSheet extends ConsumerWidget {
class ChannelActionsSheet extends HookConsumerWidget {
const ChannelActionsSheet({
super.key,
required this.channel,
@@ -63,6 +75,8 @@ class ChannelActionsSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedChannel = useState(channel);
final currentChannel = displayedChannel.value;
final isMuted =
ref.watch(channelMutesProvider).store.channels[channel.id]?.muted ==
true;
@@ -207,7 +221,13 @@ class ChannelActionsSheet extends ConsumerWidget {
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(channel: channel),
builder: (_) => ManageChannelSheet(
channel: currentChannel,
canEditDetails:
canManageLifecycle && !currentChannel.isArchived,
onChannelUpdated: (updated) =>
displayedChannel.value = updated,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
@@ -222,7 +242,7 @@ class ChannelActionsSheet extends ConsumerWidget {
close();
copyToClipboard(
context,
channel.name,
currentChannel.name,
message: 'Channel name copied to clipboard',
);
},
@@ -250,7 +270,7 @@ class ChannelActionsSheet extends ConsumerWidget {
onTap: () => _confirmAndRun(
context,
ref,
title: 'Leave #${channel.name}?',
title: 'Leave #${currentChannel.name}?',
body: 'You’ll stop receiving messages from this channel.',
confirmLabel: 'Leave',
action: () => ref
@@ -281,7 +301,7 @@ class ChannelActionsSheet extends ConsumerWidget {
onTap: () => _confirmAndRun(
context,
ref,
title: 'Archive #${channel.name}?',
title: 'Archive #${currentChannel.name}?',
body: 'The channel will become read-only.',
confirmLabel: 'Archive',
action: () => ref
@@ -296,7 +316,7 @@ class ChannelActionsSheet extends ConsumerWidget {
onTap: () => _confirmAndRun(
context,
ref,
title: 'Unarchive #${channel.name}?',
title: 'Unarchive #${currentChannel.name}?',
body: 'The channel will become active again.',
confirmLabel: 'Unarchive',
action: () => ref
@@ -312,7 +332,7 @@ class ChannelActionsSheet extends ConsumerWidget {
onTap: () => _confirmAndRun(
context,
ref,
title: 'Delete #${channel.name}?',
title: 'Delete #${currentChannel.name}?',
body:
'This permanently deletes the channel and cannot be undone.',
confirmLabel: 'Delete',

View File

@@ -23,8 +23,8 @@ import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../forum/forum_posts_view.dart';
import 'android_ime_lift.dart';
import 'channel.dart';
@@ -259,9 +259,10 @@ class ChannelDetailPage extends HookConsumerWidget {
!resolvedChannel.isForum &&
isConnectionInProgress &&
!messagesNotifier.hasLoadedMessages;
final appBarTitleContentHeight = resolvedChannel.isDm
? _dmAppBarTitleContentHeight(context)
: 0.0;
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(
context,
isDm: resolvedChannel.isDm,
);
final readTimestamp = _channelReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
@@ -327,65 +328,53 @@ class ChannelDetailPage extends HookConsumerWidget {
channel: resolvedChannel,
currentPubkey: currentPubkey,
)
: Row(
children: [
SizedBox.square(
dimension: 22,
child: Center(
child: Icon(channelIcon(resolvedChannel), size: 18),
),
),
const SizedBox(width: Grid.half),
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
resolveDmChannelDisplayLabel(
resolvedChannel,
currentPubkey: currentPubkey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (resolvedChannel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: resolvedChannel),
],
],
),
),
],
),
actions: [
if (_showsMembersAction(resolvedChannel))
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showChannelActionsSheet(
context: context,
: _ChannelAppBarTitle(
channel: resolvedChannel,
isUnread: false,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Channel actions',
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
],
onTap: () async {
final shouldClose = await showChannelDetailsPage(
context: context,
channel: resolvedChannel,
currentPubkey: currentPubkey,
onMemberTap: showUserProfileSheet,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
),
actions: resolvedChannel.isDm
? [
if (_showsMembersAction(resolvedChannel))
_MembersButton(
channelId: resolvedChannel.id,
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showChannelActionsSheet(
context: context,
channel: resolvedChannel,
isUnread: false,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Channel actions',
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
]
: const [],
),
body: Stack(
fit: StackFit.expand,

View File

@@ -1,6 +1,7 @@
part of '../channel_detail_page.dart';
const _dmHeaderAvatarSize = 32.0;
const _channelHeaderAvatarSize = 40.0;
const _dmPresenceDotRatio = 8 / 14;
bool _showsMembersAction(Channel channel) {
@@ -18,16 +19,106 @@ double _scaledTextHeight(BuildContext context, TextStyle style) {
return scaledFontSize * (style.height ?? 1);
}
double _dmAppBarTitleContentHeight(BuildContext context) {
final titleStyle = context.textTheme.titleSmall;
final presenceStyle = context.textTheme.bodyMedium;
if (titleStyle == null || presenceStyle == null) {
return _dmHeaderAvatarSize;
double _twoLineAppBarTitleContentHeight(
BuildContext context, {
required bool isDm,
}) {
final titleStyle = isDm
? context.textTheme.titleSmall
: context.textTheme.titleMedium;
final subtitleStyle = isDm
? context.textTheme.bodyMedium
: context.textTheme.bodySmall;
final avatarSize = isDm ? _dmHeaderAvatarSize : _channelHeaderAvatarSize;
if (titleStyle == null || subtitleStyle == null) {
return avatarSize;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, presenceStyle);
return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize;
_scaledTextHeight(context, subtitleStyle);
return textHeight > avatarSize ? textHeight : avatarSize;
}
class _ChannelAppBarTitle extends ConsumerWidget {
const _ChannelAppBarTitle({required this.channel, required this.onTap});
final Channel channel;
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final memberCount = membersAsync.value?.length ?? channel.memberCount;
final memberLabel =
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
return Semantics(
button: true,
label: 'Open settings for ${channel.name}, $memberLabel',
child: Tooltip(
message: 'Open channel settings',
child: InkWell(
key: const ValueKey('channel-header-settings-trigger'),
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
child: Row(
children: [
Container(
key: const ValueKey('channel-header-avatar'),
width: _channelHeaderAvatarSize,
height: _channelHeaderAvatarSize,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
channelIcon(channel),
size: 20,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
channel.name,
key: const ValueKey('channel-header-name'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium,
),
),
if (channel.isEphemeral) ...[
const SizedBox(width: Grid.quarter),
_HeaderEphemeralBadge(channel: channel),
],
],
),
Text(
memberLabel,
key: const ValueKey('channel-header-member-count'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
);
}
}
class _MembersButton extends ConsumerWidget {

View File

@@ -378,28 +378,31 @@ class _MessageStyleSystemMessageContent extends StatelessWidget {
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Transform.translate(
offset: const Offset(0, -Grid.quarter),
child: Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MessageAuthorMeta(
displayName: resolveLabel(displayPubkey),
username: messageUsernameLabel(
userCache[displayPubkey.toLowerCase()],
),
timestamp: formatMessageTime(createdAt),
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
nameStyle: systemMessageHeadingTextStyle,
displayNameKey: ValueKey(
'system-message-author-$displayPubkey',
),
usernameKey: ValueKey(
'system-message-username-$displayPubkey',
),
timestampKey: ValueKey(
'system-message-timestamp-$displayPubkey',
Padding(
padding: const EdgeInsets.only(bottom: Grid.quarter),
child: MessageAuthorMeta(
displayName: resolveLabel(displayPubkey),
username: messageUsernameLabel(
userCache[displayPubkey.toLowerCase()],
),
timestamp: formatMessageTime(createdAt),
nameColor: context.colors.onSurface,
metadataColor: context.colors.onSurfaceVariant,
nameStyle: systemMessageHeadingTextStyle,
displayNameKey: ValueKey(
'system-message-author-$displayPubkey',
),
usernameKey: ValueKey(
'system-message-username-$displayPubkey',
),
timestampKey: ValueKey(
'system-message-timestamp-$displayPubkey',
),
),
),
Text.rich(

View File

@@ -0,0 +1,687 @@
part of 'channel_actions_sheet.dart';
const _channelMemberPreviewLimit = 5;
const _channelDetailsHeaderFrostScrollDistance = Grid.xxl;
const _channelDetailsHeaderFrostMaxBlurSigma = 20.0;
const _channelDetailsSectionPadding = Grid.twelve;
/// Opens the combined mobile channel settings page.
Future<bool?> showChannelDetailsPage({
required BuildContext context,
required Channel channel,
required String? currentPubkey,
required void Function(BuildContext context, String pubkey) onMemberTap,
String? sectionId,
}) => Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => ChannelDetailsPage(
channel: channel,
currentPubkey: currentPubkey,
onMemberTap: onMemberTap,
sectionId: sectionId,
),
),
);
/// Combined channel identity, membership, organization, and lifecycle page.
class ChannelDetailsPage extends HookConsumerWidget {
const ChannelDetailsPage({
super.key,
required this.channel,
required this.currentPubkey,
required this.onMemberTap,
this.sectionId,
});
final Channel channel;
final String? currentPubkey;
final void Function(BuildContext context, String pubkey) onMemberTap;
final String? sectionId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedChannel = useState(channel);
final resolvedChannel = displayedChannel.value;
final scrollController = useScrollController();
final heroNameKey = useMemoized(GlobalKey.new);
final collapsedTitleOffset = useRef<double?>(null);
final showCollapsedTitle = useState(false);
final headerFrostProgress = useState(0.0);
final isJoining = useState(false);
final isMuted =
ref
.watch(channelMutesProvider)
.store
.channels[resolvedChannel.id]
?.muted ==
true;
final isStarred =
ref
.watch(channelStarsProvider)
.store
.channels[resolvedChannel.id]
?.starred ==
true;
final membersAsync = ref.watch(channelMembersProvider(resolvedChannel.id));
final members = membersAsync.asData?.value ?? const <ChannelMember>[];
final agentOwnersAsync = ref.watch(agentOwnersProvider);
final sectionState = ref.watch(channelSectionsProvider);
final resolvedCurrentPubkey =
currentPubkey?.toLowerCase() ??
ref.watch(currentPubkeyProvider)?.toLowerCase();
final currentMember = members.cast<ChannelMember?>().firstWhere(
(member) => member?.pubkey.toLowerCase() == resolvedCurrentPubkey,
orElse: () => null,
);
final ownsOwnerAgent =
resolvedCurrentPubkey != null &&
members.any(
(member) =>
member.isOwner &&
agentOwnersAsync.value?[member.pubkey.toLowerCase()]
?.toLowerCase() ==
resolvedCurrentPubkey,
);
final canManageLifecycle =
currentMember?.isElevated == true || ownsOwnerAgent;
final canEdit = canManageLifecycle && !resolvedChannel.isArchived;
final canJoin =
resolvedChannel.visibility == 'open' &&
!resolvedChannel.isArchived &&
!resolvedChannel.isMember;
final canAddMembers =
membersAsync.hasValue &&
!membersAsync.isLoading &&
!membersAsync.hasError &&
!resolvedChannel.isArchived &&
resolvedChannel.canAddMembers(currentMember?.role);
final canArchive = !resolvedChannel.isArchived && canManageLifecycle;
final canUnarchive = resolvedChannel.isArchived && canManageLifecycle;
final canDelete =
!resolvedChannel.isArchived &&
(currentMember?.isOwner == true || ownsOwnerAgent);
final lifecycleCapabilitiesLoading =
membersAsync.isLoading || agentOwnersAsync.isLoading;
final lifecycleCapabilitiesUnavailable =
membersAsync.hasError || agentOwnersAsync.hasError;
final memberCount =
membersAsync.value?.length ?? resolvedChannel.memberCount;
final memberLabel =
'$memberCount ${memberCount == 1 ? 'member' : 'members'}';
final previewMembers = members.take(_channelMemberPreviewLimit).toList();
final userCache = ref.watch(userCacheProvider);
final currentSectionId = sectionState.isReady
? sectionState.store.assignments[resolvedChannel.id]
: sectionId;
final previewPubkeyKey = previewMembers
.map((member) => member.pubkey.toLowerCase())
.join('|');
useEffect(() {
if (previewMembers.isNotEmpty) {
ref
.read(userCacheProvider.notifier)
.preload(previewMembers.map((member) => member.pubkey).toList());
}
return null;
}, [previewPubkeyKey]);
useEffect(() {
void updateCollapsedTitle() {
final nextFrostProgress = !scrollController.hasClients
? 0.0
: (scrollController.offset /
_channelDetailsHeaderFrostScrollDistance)
.clamp(0.0, 1.0)
.toDouble();
if ((headerFrostProgress.value - nextFrostProgress).abs() > 0.001) {
headerFrostProgress.value = nextFrostProgress;
}
final threshold = collapsedTitleOffset.value;
if (threshold == null || !context.mounted) return;
final nextValue = scrollController.offset >= threshold;
if (showCollapsedTitle.value != nextValue) {
showCollapsedTitle.value = nextValue;
}
}
collapsedTitleOffset.value = null;
scrollController.addListener(updateCollapsedTitle);
WidgetsBinding.instance.addPostFrameCallback((_) {
final nameContext = heroNameKey.currentContext;
final renderBox = nameContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize || !context.mounted) {
return;
}
final nameBottom = renderBox
.localToGlobal(Offset(0, renderBox.size.height))
.dy;
collapsedTitleOffset.value =
scrollController.offset + nameBottom - frostedAppBarHeight(context);
updateCollapsedTitle();
});
return () => scrollController.removeListener(updateCollapsedTitle);
}, [scrollController, resolvedChannel.name]);
Future<void> openMembers() => showBuzzModalBottomSheet<void>(
context: context,
title: 'Members',
isScrollControlled: true,
showDragHandle: true,
builder: (_) => MembersSheet(
channel: resolvedChannel,
currentPubkey: resolvedCurrentPubkey,
),
);
Future<void> openAddMembers() async {
final mediaQuery = MediaQuery.of(context);
await showBuzzModalBottomSheet<bool>(
context: context,
title: 'Add members',
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight:
mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.xs,
),
builder: (_) => AddChannelMembersSheet(
channelId: resolvedChannel.id,
existingPubkeys: {
for (final member in members) member.pubkey.toLowerCase(),
},
),
);
}
Future<void> openManageChannel() async {
final shouldClose = await showBuzzModalBottomSheet<bool>(
context: context,
title: 'Manage channel',
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(
channel: resolvedChannel,
canEditDetails: canEdit,
onChannelUpdated: (updated) => displayedChannel.value = updated,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
}
}
void toggleStar() {
final notifier = ref.read(channelStarsProvider.notifier);
isStarred
? notifier.unstarChannel(resolvedChannel.id)
: notifier.starChannel(resolvedChannel.id);
}
void toggleMute() {
final notifier = ref.read(channelMutesProvider.notifier);
isMuted
? notifier.unmuteChannel(resolvedChannel.id)
: notifier.muteChannel(resolvedChannel.id);
}
Future<void> joinChannel() async {
if (isJoining.value) return;
isJoining.value = true;
try {
await ref.read(channelActionsProvider).joinChannel(resolvedChannel.id);
if (context.mounted) Navigator.of(context).pop(false);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Couldn’t join channel. Try again.')),
);
}
} finally {
isJoining.value = false;
}
}
final reducedMotion = MediaQuery.disableAnimationsOf(context);
return FrostedScaffold(
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
iconColor: context.colors.primary,
actions: const [SizedBox.square(dimension: 48)],
frosted: headerFrostProgress.value > 0,
frostedSurfaceOpacity: 0.5 * headerFrostProgress.value,
frostedBlurSigma:
_channelDetailsHeaderFrostMaxBlurSigma * headerFrostProgress.value,
showBottomDivider: headerFrostProgress.value > 0,
bottomDividerOpacity: 0.15 * headerFrostProgress.value,
title: AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 160),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: showCollapsedTitle.value
? Text(
resolvedChannel.name,
key: const ValueKey('channel-details-collapsed-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: const SizedBox(
key: ValueKey('channel-details-expanded-title-space'),
),
),
),
body: ListView(
key: const ValueKey('channel-details-page-list'),
controller: scrollController,
padding: EdgeInsets.only(
top: frostedAppBarHeight(context) + Grid.xs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs,
),
children: [
_ChannelDetailsHero(channel: resolvedChannel, nameKey: heroNameKey),
Padding(
key: const ValueKey('channel-details-actions'),
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_channelDetailsSectionPadding,
Grid.gutter,
_channelDetailsSectionPadding,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-star-action'),
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star channel',
onTap: toggleStar,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-mute-action'),
icon: isMuted ? LucideIcons.bell : LucideIcons.bellOff,
label: isMuted ? 'Unmute' : 'Mute channel',
onTap: toggleMute,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-edit-action'),
icon: LucideIcons.pencil,
label: 'Edit',
isEnabled: canEdit,
onTap: openManageChannel,
),
),
],
),
),
),
AppListCard(
key: const ValueKey('channel-details-members-card'),
label: memberLabel,
dividerIndent: Grid.xs + 40 + Grid.xs,
verticalPadding: _channelDetailsSectionPadding,
children: [
if (canAddMembers)
AppListRowRaw(
key: const ValueKey('channel-details-add-members-row'),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: context.colors.surfaceContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
color: context.colors.onSurfaceVariant,
),
),
title: Text(
'Add members',
style: context.textTheme.bodyLarge,
),
trailing: const _ChannelDetailsChevron(),
onTap: openAddMembers,
verticalPadding: Grid.xxs,
),
if (membersAsync.isLoading && members.isEmpty)
const AppListRow(
icon: LucideIcons.loaderCircle,
title: 'Loading members…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading members',
),
)
else if (membersAsync.hasError && members.isEmpty)
const AppListRow(
icon: LucideIcons.triangleAlert,
title: 'Members unavailable',
)
else ...[
for (final member in previewMembers)
_ChannelMemberPreviewRow(
key: ValueKey('channel-details-member-${member.pubkey}'),
member: member,
currentPubkey: resolvedCurrentPubkey,
onMemberTap: onMemberTap,
displayName:
userCache[member.pubkey.toLowerCase()]?.displayName,
avatarUrl:
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
),
AppListRowRaw(
key: const ValueKey('channel-details-members-row'),
leading: const SizedBox.square(dimension: 40),
title: Text('See all', style: context.textTheme.bodyLarge),
trailing: const _ChannelDetailsChevron(),
onTap: openMembers,
verticalPadding: Grid.xxs,
),
],
],
),
AppListCard(
key: const ValueKey('channel-details-channel-card'),
verticalPadding: _channelDetailsSectionPadding,
children: [
AppListRow(
icon: LucideIcons.folderInput,
title: 'Move to section…',
trailing: const _ChannelDetailsChevron(),
onTap: () async {
await _showMoveSectionSheet(
context,
ref,
channel: resolvedChannel,
sectionId: currentSectionId,
);
},
),
AppListRow(
icon: LucideIcons.copy,
title: 'Copy channel name',
onTap: () {
copyToClipboard(
context,
resolvedChannel.name,
message: 'Channel name copied to clipboard',
);
},
),
AppListRow(
icon: LucideIcons.hash,
title: 'Copy channel ID',
onTap: () {
copyToClipboard(
context,
resolvedChannel.id,
message: 'Channel ID copied to clipboard',
);
},
),
],
),
if (resolvedChannel.isMember ||
canJoin ||
lifecycleCapabilitiesLoading ||
lifecycleCapabilitiesUnavailable ||
canArchive ||
canUnarchive ||
canDelete)
AppListCard(
key: const ValueKey('channel-details-lifecycle-card'),
verticalPadding: _channelDetailsSectionPadding,
children: [
if (canJoin)
AppListRow(
icon: LucideIcons.logIn,
title: isJoining.value
? 'Joining channel…'
: 'Join channel',
onTap: isJoining.value ? null : joinChannel,
),
if (resolvedChannel.isMember && !resolvedChannel.isArchived)
AppListRow(
icon: LucideIcons.logOut,
title: 'Leave channel',
titleColor: context.colors.error,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Leave #${resolvedChannel.name}?',
body: 'You’ll stop receiving messages from this channel.',
confirmLabel: 'Leave',
action: () => ref
.read(channelActionsProvider)
.leaveChannel(resolvedChannel.id),
),
),
if (lifecycleCapabilitiesLoading)
const AppListRow(
icon: LucideIcons.loaderCircle,
title: 'Loading channel actions…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading channel actions',
),
)
else if (lifecycleCapabilitiesUnavailable)
const AppListRow(
icon: LucideIcons.triangleAlert,
title: 'Channel actions unavailable',
)
else ...[
if (canArchive)
AppListRow(
icon: LucideIcons.archive,
title: 'Archive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Archive #${resolvedChannel.name}?',
body: 'The channel will become read-only.',
confirmLabel: 'Archive',
action: () => ref
.read(channelActionsProvider)
.archiveChannel(resolvedChannel.id),
),
),
if (canUnarchive)
AppListRow(
icon: LucideIcons.archiveRestore,
title: 'Unarchive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Unarchive #${resolvedChannel.name}?',
body: 'The channel will become active again.',
confirmLabel: 'Unarchive',
action: () => ref
.read(channelActionsProvider)
.unarchiveChannel(resolvedChannel.id),
),
),
if (canDelete)
AppListRow(
icon: LucideIcons.trash2,
title: 'Delete channel',
titleColor: context.colors.error,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Delete #${resolvedChannel.name}?',
body:
'This permanently deletes the channel and cannot be undone.',
confirmLabel: 'Delete',
action: () => ref
.read(channelActionsProvider)
.deleteChannel(resolvedChannel.id),
),
),
],
],
),
],
),
);
}
}
class _ChannelDetailsHero extends StatelessWidget {
const _ChannelDetailsHero({required this.channel, required this.nameKey});
final Channel channel;
final GlobalKey nameKey;
@override
Widget build(BuildContext context) {
final description = channel.description.trim();
return Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xs,
Grid.gutter,
_channelDetailsSectionPadding,
),
child: Column(
children: [
Container(
key: const ValueKey('channel-details-avatar'),
width: 80,
height: 80,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
_channelDetailsIcon(channel),
size: 32,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(height: Grid.xxs),
KeyedSubtree(
key: const ValueKey('channel-details-name'),
child: Text(
channel.name,
key: nameKey,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (description.isNotEmpty) ...[
const SizedBox(height: Grid.half),
Text(
description,
key: const ValueKey('channel-details-description'),
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
);
}
}
class _ChannelMemberPreviewRow extends StatelessWidget {
const _ChannelMemberPreviewRow({
super.key,
required this.member,
required this.currentPubkey,
required this.onMemberTap,
required this.displayName,
required this.avatarUrl,
});
final ChannelMember member;
final String? currentPubkey;
final void Function(BuildContext context, String pubkey) onMemberTap;
final String? displayName;
final String? avatarUrl;
@override
Widget build(BuildContext context) {
final isSelf = member.pubkey.toLowerCase() == currentPubkey?.toLowerCase();
final label = isSelf
? 'You'
: displayName?.trim().isNotEmpty == true
? displayName!.trim()
: member.labelFor(currentPubkey);
final roleLabel = _channelMemberRoleLabel(member.role);
final titleStyle = context.textTheme.bodyLarge;
final roleStyle = context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
);
return AppListRowRaw(
leading: AvatarImage(
imageUrl: avatarUrl,
radius: 20,
backgroundColor: context.colors.primaryContainer,
fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()),
),
title: Text.rich(
TextSpan(
style: titleStyle,
children: [
TextSpan(text: label),
TextSpan(text: ' · $roleLabel', style: roleStyle),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const _ChannelDetailsChevron(),
onTap: () => onMemberTap(context, member.pubkey),
verticalPadding: Grid.xxs,
);
}
}
String _channelMemberRoleLabel(String role) {
if (role == 'bot') return 'Agent';
if (role.isEmpty) return 'Member';
return '${role[0].toUpperCase()}${role.substring(1)}';
}
IconData _channelDetailsIcon(Channel channel) {
if (channel.isPrivate) return LucideIcons.lock;
if (channel.isForum) return LucideIcons.messageSquareText;
return LucideIcons.hash;
}
class _ChannelDetailsChevron extends StatelessWidget {
const _ChannelDetailsChevron();
@override
Widget build(BuildContext context) => Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
);
}

View File

@@ -0,0 +1,400 @@
part of 'channel_management_provider.dart';
/// Builds the relay tags for setting the archived state of [channelId].
List<List<String>> buildSetChannelArchivedTags(
String channelId, {
required bool archived,
}) => [
['h', channelId],
['archived', archived.toString()],
];
/// Builds the relay tags for deleting [channelId].
List<List<String>> buildDeleteChannelTags(String channelId) => [
['h', channelId],
];
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
final SignedEventRelay _signedEventRelay;
final String? _currentPubkey;
final bool Function()? _isCommunityValid;
ChannelActions({
required Ref ref,
required RelaySessionNotifier session,
required SignedEventRelay signedEventRelay,
required String? currentPubkey,
bool Function()? isCommunityValid,
}) : _ref = ref,
_session = session,
_signedEventRelay = signedEventRelay,
_currentPubkey = currentPubkey,
_isCommunityValid = isCommunityValid;
Future<Channel> createChannel({
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) async {
final channelId = _newUuidV4();
final tags = buildCreateChannelTags(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
);
_ensureCommunityValid();
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
_ensureCommunityValid();
return _refreshChannelsAndRead(channelId);
}
/// Open (or create) a DM channel with the given pubkeys.
///
/// This submits a kind:41010 command event; the relay responds with an OK
/// message whose content carries `response:{...}` containing the new
/// `channel_id`.
Future<Channel> openDm({required List<String> pubkeys}) async {
_ensureCommunityValid();
final result = await _signedEventRelay.submit(
kind: 41010,
content: '',
tags: pubkeys.map((pk) => ['p', pk]).toList(),
);
_ensureCommunityValid();
final response = parseCommandResponse(result.content);
final channelId = response?['channel_id'] as String?;
if (channelId == null || channelId.isEmpty) {
throw Exception('Relay did not return a DM channel id');
}
return _refreshChannelsAndRead(channelId);
}
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
}) async {
final normalizedRole = role.trim();
if (normalizedRole.isEmpty) {
throw ArgumentError.value(role, 'role', 'must not be empty');
}
final normalizedPubkeys = {
for (final pubkey in pubkeys)
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
};
_ensureCommunityValid();
// Per-pubkey failures are collected rather than thrown on the spot: one
// relay rejection must not skip the remaining adds or the invalidation
// below, which would leave the members list stale for the adds that landed.
final failures = <String, String>{};
for (final pubkey in normalizedPubkeys) {
// Outside the catch: a community switch mid-loop must abort the whole
// add, not be recorded as this pubkey's rejection.
_ensureCommunityValid();
try {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey],
['role', normalizedRole],
],
);
} catch (error) {
failures[pubkey] = _relayErrorMessage(error);
}
}
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
if (failures.isNotEmpty) {
throw AddMembersException(failures);
}
}
void _ensureCommunityValid() {
if (_isCommunityValid?.call() == false) {
throw StateError(
'Channel action cancelled because the active community changed',
);
}
}
Future<void> joinChannel(String channelId) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9021,
content: '',
tags: [
['h', channelId],
],
);
_ensureCommunityValid();
await _refreshChannelState(channelId);
}
Future<void> leaveChannel(String channelId) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9022,
content: '',
tags: [
['h', channelId],
],
);
_ensureCommunityValid();
await _refreshChannelState(channelId);
}
/// Updates the user-editable channel metadata and refreshes cached details.
Future<void> updateChannel({
required String channelId,
String? name,
String? description,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildUpdateChannelTags(
channelId: channelId,
name: name,
description: description,
),
);
_ensureCommunityValid();
await _refreshChannelState(channelId);
}
/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);
/// Unarchives the channel and refreshes its cached state.
Future<void> unarchiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: false);
Future<void> _setChannelArchived(
String channelId, {
required bool archived,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildSetChannelArchivedTags(channelId, archived: archived),
);
_ensureCommunityValid();
await _refreshChannelState(channelId);
}
/// Deletes the channel and refreshes its cached state.
Future<void> deleteChannel(String channelId) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9008,
content: '',
tags: buildDeleteChannelTags(channelId),
);
_ensureCommunityValid();
await _refreshChannelState(channelId);
}
Future<void> setCanvas({
required String channelId,
required String content,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 40100,
content: content,
tags: [
['h', channelId],
],
);
_ensureCommunityValid();
_ref.invalidate(channelCanvasProvider(channelId));
}
/// User search via NIP-50 over kind:0 profile events.
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final events = await _session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: limit),
]);
return directoryUsersFromProfileEvents(events)
.where(
(user) =>
_currentPubkey == null ||
user.pubkey.toLowerCase() != _currentPubkey,
)
.toList();
}
Future<Channel> _refreshChannelsAndRead(String channelId) async {
_ensureCommunityValid();
await _ref.read(channelsProvider.notifier).refresh();
_ensureCommunityValid();
final channels = await _ref.read(channelsProvider.future);
_ensureCommunityValid();
return channels.firstWhere(
(channel) => channel.id == channelId,
orElse: () =>
throw Exception('Channel was created but is not visible yet'),
);
}
Future<void> _refreshChannelState(String channelId) async {
_ensureCommunityValid();
await _ref.read(channelsProvider.notifier).refresh();
_ensureCommunityValid();
_ref.invalidate(channelDetailsProvider(channelId));
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
_ref.invalidate(channelCanvasProvider(channelId));
}
String _newUuidV4() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20, 32)}';
}
Future<void> changeMemberRole({
required String channelId,
required String pubkey,
required String role,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
['role', role],
],
);
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> removeMember({
required String channelId,
required String pubkey,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: 9001,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
],
);
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> addReaction(String eventId, String emoji) async {
final shortcode = normalizeShortcode(emoji);
final emojiUrl = reactionEmojiUrl(
emoji,
_ref.read(customEmojiListProvider),
);
await _signedEventRelay.submit(
kind: EventKind.reaction,
content: emoji,
tags: [
['e', eventId],
if (shortcode != null && emojiUrl != null)
['emoji', shortcode, emojiUrl],
],
);
}
Future<void> removeReaction(String reactionEventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', reactionEventId],
],
);
}
Future<void> editMessage({
required String channelId,
required String eventId,
required String content,
List<List<String>> mediaTags = const [],
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: EventKind.streamMessageEdit,
content: content,
tags: [
['h', channelId],
['e', eventId],
...mediaTags,
],
);
_ensureCommunityValid();
}
Future<void> deleteMessage({
required String channelId,
required String eventId,
}) async {
_ensureCommunityValid();
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
);
_ensureCommunityValid();
}
static final Random _random = Random.secure();
}
final channelActionsProvider = Provider<ChannelActions>((ref) {
final relayConfig = ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final session = ref.read(relaySessionProvider.notifier);
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: relayConfig.nsec,
),
currentPubkey: currentPubkey,
isCommunityValid: () {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == relayConfig.baseUrl &&
currentConfig.nsec == relayConfig.nsec;
},
);
});

View File

@@ -11,8 +11,13 @@ import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import 'channel.dart';
import 'channel_metadata_updates.dart';
import 'channels_provider.dart';
export 'channel_metadata_updates.dart';
part 'channel_management_actions.dart';
String _relayErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');
@@ -614,355 +619,3 @@ List<List<String>> buildCreateChannelTags({
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
];
}
/// Builds the relay tags for setting the archived state of [channelId].
List<List<String>> buildSetChannelArchivedTags(
String channelId, {
required bool archived,
}) => [
['h', channelId],
['archived', archived.toString()],
];
/// Builds the relay tags for deleting [channelId].
List<List<String>> buildDeleteChannelTags(String channelId) => [
['h', channelId],
];
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
final SignedEventRelay _signedEventRelay;
final String? _currentPubkey;
final bool Function()? _isCommunityValid;
ChannelActions({
required Ref ref,
required RelaySessionNotifier session,
required SignedEventRelay signedEventRelay,
required String? currentPubkey,
bool Function()? isCommunityValid,
}) : _ref = ref,
_session = session,
_signedEventRelay = signedEventRelay,
_currentPubkey = currentPubkey,
_isCommunityValid = isCommunityValid;
Future<Channel> createChannel({
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) async {
final channelId = _newUuidV4();
final tags = buildCreateChannelTags(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
);
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId);
}
/// Open (or create) a DM channel with the given pubkeys.
///
/// This submits a kind:41010 command event; the relay responds with an OK
/// message whose content carries `response:{...}` containing the new
/// `channel_id`.
Future<Channel> openDm({required List<String> pubkeys}) async {
final result = await _signedEventRelay.submit(
kind: 41010,
content: '',
tags: pubkeys.map((pk) => ['p', pk]).toList(),
);
final response = parseCommandResponse(result.content);
final channelId = response?['channel_id'] as String?;
if (channelId == null || channelId.isEmpty) {
throw Exception('Relay did not return a DM channel id');
}
return _refreshChannelsAndRead(channelId);
}
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
}) async {
final normalizedRole = role.trim();
if (normalizedRole.isEmpty) {
throw ArgumentError.value(role, 'role', 'must not be empty');
}
final normalizedPubkeys = {
for (final pubkey in pubkeys)
if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(),
};
_ensureCommunityValid();
// Per-pubkey failures are collected rather than thrown on the spot: one
// relay rejection must not skip the remaining adds or the invalidation
// below, which would leave the members list stale for the adds that landed.
final failures = <String, String>{};
for (final pubkey in normalizedPubkeys) {
// Outside the catch: a community switch mid-loop must abort the whole
// add, not be recorded as this pubkey's rejection.
_ensureCommunityValid();
try {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey],
['role', normalizedRole],
],
);
} catch (error) {
failures[pubkey] = _relayErrorMessage(error);
}
}
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
if (failures.isNotEmpty) {
throw AddMembersException(failures);
}
}
void _ensureCommunityValid() {
if (_isCommunityValid?.call() == false) {
throw StateError(
'Channel action cancelled because the active community changed',
);
}
}
Future<void> joinChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9021,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
Future<void> leaveChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9022,
content: '',
tags: [
['h', channelId],
],
);
await _refreshChannelState(channelId);
}
/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);
/// Unarchives the channel and refreshes its cached state.
Future<void> unarchiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: false);
Future<void> _setChannelArchived(
String channelId, {
required bool archived,
}) async {
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildSetChannelArchivedTags(channelId, archived: archived),
);
await _refreshChannelState(channelId);
}
/// Deletes the channel and refreshes its cached state.
Future<void> deleteChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9008,
content: '',
tags: buildDeleteChannelTags(channelId),
);
await _refreshChannelState(channelId);
}
Future<void> setCanvas({
required String channelId,
required String content,
}) async {
await _signedEventRelay.submit(
kind: 40100,
content: content,
tags: [
['h', channelId],
],
);
_ref.invalidate(channelCanvasProvider(channelId));
}
/// User search via NIP-50 over kind:0 profile events.
Future<List<DirectoryUser>> searchUsers(String query, {int limit = 8}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final events = await _session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: limit),
]);
return directoryUsersFromProfileEvents(events)
.where(
(user) =>
_currentPubkey == null ||
user.pubkey.toLowerCase() != _currentPubkey,
)
.toList();
}
Future<Channel> _refreshChannelsAndRead(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
final channels = await _ref.read(channelsProvider.future);
return channels.firstWhere(
(channel) => channel.id == channelId,
orElse: () =>
throw Exception('Channel was created but is not visible yet'),
);
}
Future<void> _refreshChannelState(String channelId) async {
await _ref.read(channelsProvider.notifier).refresh();
_ref.invalidate(channelDetailsProvider(channelId));
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
_ref.invalidate(channelCanvasProvider(channelId));
}
String _newUuidV4() {
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20, 32)}';
}
Future<void> changeMemberRole({
required String channelId,
required String pubkey,
required String role,
}) async {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
['role', role],
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> removeMember({
required String channelId,
required String pubkey,
}) async {
await _signedEventRelay.submit(
kind: 9001,
content: '',
tags: [
['h', channelId],
['p', pubkey.toLowerCase()],
],
);
_ref.invalidate(channelMembersProvider(channelId));
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
Future<void> addReaction(String eventId, String emoji) async {
final shortcode = normalizeShortcode(emoji);
final emojiUrl = reactionEmojiUrl(
emoji,
_ref.read(customEmojiListProvider),
);
await _signedEventRelay.submit(
kind: EventKind.reaction,
content: emoji,
tags: [
['e', eventId],
if (shortcode != null && emojiUrl != null)
['emoji', shortcode, emojiUrl],
],
);
}
Future<void> removeReaction(String reactionEventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', reactionEventId],
],
);
}
Future<void> editMessage({
required String channelId,
required String eventId,
required String content,
List<List<String>> mediaTags = const [],
}) async {
await _signedEventRelay.submit(
kind: EventKind.streamMessageEdit,
content: content,
tags: [
['h', channelId],
['e', eventId],
...mediaTags,
],
);
}
Future<void> deleteMessage({
required String channelId,
required String eventId,
}) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: buildDeleteMessageTags(channelId: channelId, eventId: eventId),
);
}
static final Random _random = Random.secure();
}
final channelActionsProvider = Provider<ChannelActions>((ref) {
final relayConfig = ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(currentPubkeyProvider);
final session = ref.read(relaySessionProvider.notifier);
return ChannelActions(
ref: ref,
session: session,
signedEventRelay: SignedEventRelay(
session: session,
nsec: relayConfig.nsec,
),
currentPubkey: currentPubkey,
isCommunityValid: () {
final currentConfig = ref.read(relayConfigProvider);
return currentConfig.baseUrl == relayConfig.baseUrl &&
currentConfig.nsec == relayConfig.nsec;
},
);
});

View File

@@ -0,0 +1,19 @@
/// Builds the kind:9002 tags used to update channel name and description.
List<List<String>> buildUpdateChannelTags({
required String channelId,
String? name,
String? description,
}) {
if (name == null && description == null) {
throw ArgumentError('At least one channel field must be provided');
}
final canonicalName = name?.trim().replaceFirst(RegExp(r'^#+'), '').trim();
if (canonicalName != null && canonicalName.isEmpty) {
throw ArgumentError.value(name, 'name', 'must not be empty');
}
return [
['h', channelId],
if (canonicalName != null) ['name', canonicalName],
if (description != null) ['about', description.trim()],
];
}

View File

@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'channel_typing_provider.dart';
import 'small_avatar.dart';

View File

@@ -30,7 +30,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../profile/profile_avatar.dart';
import '../profile/profile_provider.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
import '../pairing/pairing_provider.dart';
import 'channel.dart';
@@ -166,7 +166,8 @@ class ChannelsPage extends HookConsumerWidget {
final WidgetBuilder settingsPageBuilder;
/// Reports the Settings route's raw animation progress from 0 to 1.
/// Reports Settings route progress so its foreground and Home's background
/// render from the same timeline.
final ValueChanged<double> onSettingsTransitionProgress;
/// Notifies this page when its already-selected tab is tapped again.
@@ -346,12 +347,11 @@ class ChannelsPage extends HookConsumerWidget {
size: _kTopSectionAvatarSize,
onTap: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).push(
_SettingsPageRoute(
builder: settingsPageBuilder,
onTransitionProgress: onSettingsTransitionProgress,
),
final route = _SettingsPageRoute(
builder: settingsPageBuilder,
onTransitionProgress: onSettingsTransitionProgress,
);
Navigator.of(context).push(route);
},
),
),
@@ -389,23 +389,34 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
builder(context),
transitionsBuilder: _buildSettingsTransition,
opaque: false,
transitionDuration: const Duration(milliseconds: 190),
allowSnapshotting: false,
transitionDuration: const Duration(milliseconds: 220),
reverseTransitionDuration: const Duration(milliseconds: 190),
);
final ValueChanged<double> onTransitionProgress;
Animation<double>? _progressAnimation;
bool _hasStartedForwardTransition = false;
@override
void install() {
super.install();
_progressAnimation = animation?..addListener(_reportProgress);
_reportProgress();
}
void _reportProgress() {
onTransitionProgress(_progressAnimation?.value ?? 0);
final progressAnimation = _progressAnimation;
if (progressAnimation == null) return;
// ProxyAnimation briefly exposes the previous completed value while the
// route installs its new controller. Ignore that handoff notification and
// begin reporting only once the route is genuinely moving forward.
if (!_hasStartedForwardTransition) {
if (progressAnimation.status != AnimationStatus.forward) return;
_hasStartedForwardTransition = true;
}
onTransitionProgress(progressAnimation.value);
}
@override
@@ -422,18 +433,22 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
) {
if (MediaQuery.disableAnimationsOf(context)) return child;
final incoming = CurvedAnimation(
final motion = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
// Keep the complete page on one timeline. A gentler forward ease keeps
// the entrance visible without letting scale finish ahead of opacity;
// the existing reverse curve preserves the exit motion.
curve: Curves.easeOutQuad,
reverseCurve: Curves.easeOutCubic,
);
return FadeTransition(
key: const ValueKey('settings-transition-opacity'),
opacity: _SettingsOpacityAnimation(incoming),
opacity: motion,
child: RepaintBoundary(
key: const ValueKey('settings-transition-layer'),
child: ScaleTransition(
scale: Tween<double>(begin: 1.04, end: 1).animate(incoming),
key: const ValueKey('settings-transition-scale'),
scale: Tween<double>(begin: 1.04, end: 1).animate(motion),
alignment: Alignment.center,
child: child,
),
@@ -441,22 +456,3 @@ class _SettingsPageRoute extends PageRouteBuilder<void> {
);
}
}
/// Keeps Settings already composed on entry while retaining a complete exit
/// fade. Reading the parent live also keeps opacity synchronized with scale on
/// the route's first frame.
class _SettingsOpacityAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
_SettingsOpacityAnimation(this.parent);
@override
final Animation<double> parent;
@override
double get value {
final progress = parent.value;
return parent.status == AnimationStatus.reverse
? progress
: 0.8 + (0.2 * progress);
}
}

View File

@@ -26,8 +26,8 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/deeplink/deep_link.dart';

View File

@@ -1,27 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
class ManageChannelSheet extends HookConsumerWidget {
final Channel channel;
const ManageChannelSheet({
super.key,
required this.channel,
this.canEditDetails = true,
this.onChannelUpdated,
});
const ManageChannelSheet({super.key, required this.channel});
final Channel channel;
final bool canEditDetails;
final ValueChanged<Channel>? onChannelUpdated;
@override
Widget build(BuildContext context, WidgetRef ref) {
final canvasAsync = ref.watch(channelCanvasProvider(channel.id));
final nameController = useTextEditingController(text: channel.name);
final descriptionController = useTextEditingController(
text: channel.description,
);
useListenable(nameController);
useListenable(descriptionController);
final canvasController = useTextEditingController();
final isEditingCanvas = useState(false);
final isSavingCanvas = useState(false);
final isBusy = useState(false);
final isSavingDetails = useState(false);
final actionError = useState<String?>(null);
final canvasController = useTextEditingController();
useEffect(() {
final canvas = canvasAsync.asData?.value;
@@ -31,53 +42,47 @@ class ManageChannelSheet extends HookConsumerWidget {
return null;
}, [canvasAsync.asData?.value.content, isEditingCanvas.value]);
final mutesState = ref.watch(channelMutesProvider);
final isMuted = mutesState.store.channels[channel.id]?.muted == true;
final canJoin =
channel.visibility == 'open' &&
!channel.isArchived &&
!channel.isMember &&
!channel.isDm;
final canLeave = channel.isMember && !channel.isArchived && !channel.isDm;
final canonicalName = nameController.text
.trim()
.replaceFirst(RegExp(r'^#+'), '')
.trim();
final description = descriptionController.text.trim();
final nameDirty = canonicalName != channel.name.trim();
final descriptionDirty = description != channel.description.trim();
final canSaveDetails =
canEditDetails &&
canonicalName.isNotEmpty &&
(nameDirty || descriptionDirty) &&
!isSavingDetails.value;
final canEditCanvas = channel.isMember && !channel.isArchived;
Future<void> joinChannel() async {
if (isBusy.value) return;
isBusy.value = true;
Future<void> saveDetails() async {
if (!canSaveDetails) return;
isSavingDetails.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).joinChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(false);
}
await ref
.read(channelActionsProvider)
.updateChannel(
channelId: channel.id,
name: nameDirty ? canonicalName : null,
description: descriptionDirty ? description : null,
);
final updated = channel.copyWith(
name: canonicalName,
description: description,
);
onChannelUpdated?.call(updated);
if (context.mounted) Navigator.of(context).pop(false);
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
}
}
Future<void> leaveChannel() async {
if (isBusy.value) return;
isBusy.value = true;
actionError.value = null;
try {
await ref.read(channelActionsProvider).leaveChannel(channel.id);
if (context.mounted) {
Navigator.of(context).pop(true);
}
} catch (error) {
actionError.value = error.toString();
} finally {
isBusy.value = false;
isSavingDetails.value = false;
}
}
Future<void> saveCanvas() async {
if (isSavingCanvas.value) {
return;
}
if (isSavingCanvas.value) return;
isSavingCanvas.value = true;
actionError.value = null;
try {
@@ -87,9 +92,7 @@ class ManageChannelSheet extends HookConsumerWidget {
channelId: channel.id,
content: canvasController.text.trim(),
);
if (context.mounted) {
isEditingCanvas.value = false;
}
if (context.mounted) isEditingCanvas.value = false;
} catch (error) {
actionError.value = error.toString();
} finally {
@@ -109,99 +112,59 @@ class ManageChannelSheet extends HookConsumerWidget {
child: ListView(
shrinkWrap: true,
children: [
Text(
'Basic management for ${channel.name}.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
_ManageChannelTextField(
fieldKey: const ValueKey('manage-channel-name'),
outlineKey: const ValueKey('manage-channel-name-outline'),
controller: nameController,
enabled: canEditDetails && !isSavingDetails.value,
hintText: 'Channel name',
textInputAction: TextInputAction.next,
),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.xs),
const SizedBox(height: Grid.xs),
_ManageChannelTextField(
fieldKey: const ValueKey('manage-channel-description'),
outlineKey: const ValueKey('manage-channel-description-outline'),
controller: descriptionController,
enabled: canEditDetails && !isSavingDetails.value,
hintText: 'Description',
minLines: 2,
maxLines: 4,
textInputAction: TextInputAction.newline,
),
if (canonicalName.isEmpty) ...[
const SizedBox(height: Grid.half),
Text(
error,
'Channel name is required.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
SwitchListTile(
title: const Text('Mute channel'),
subtitle: const Text('Suppress notifications and unread badges'),
secondary: const Icon(LucideIcons.bellOff),
contentPadding: EdgeInsets.zero,
value: isMuted,
onChanged: (value) {
if (value) {
ref
.read(channelMutesProvider.notifier)
.muteChannel(channel.id);
} else {
ref
.read(channelMutesProvider.notifier)
.unmuteChannel(channel.id);
}
},
),
if (canJoin || canLeave) ...[
const SizedBox(height: Grid.xs),
Wrap(
spacing: Grid.xxs,
children: [
if (canJoin)
FilledButton.tonal(
onPressed: isBusy.value ? null : joinChannel,
child: Text(
isBusy.value ? 'Joining\u2026' : 'Join channel',
),
),
if (canLeave)
OutlinedButton(
onPressed: isBusy.value ? null : leaveChannel,
child: Text(
isBusy.value ? 'Leaving\u2026' : 'Leave channel',
),
),
],
Align(
alignment: Alignment.centerRight,
child: FilledButton(
key: const ValueKey('manage-channel-save-details'),
onPressed: canSaveDetails ? saveDetails : null,
child: Text(isSavingDetails.value ? 'Saving…' : 'Save changes'),
),
],
),
const SizedBox(height: Grid.sm),
Text('Context', style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Description',
value: channel.description,
emptyLabel: 'No description set',
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Topic',
value: channel.topic,
emptyLabel: 'No topic set',
),
const SizedBox(height: Grid.xxs),
_ContextCard(
label: 'Purpose',
value: channel.purpose,
emptyLabel: 'No purpose set',
),
if (!channel.isDm) ...[
const SizedBox(height: Grid.sm),
Text('Canvas', style: context.textTheme.labelLarge),
const SizedBox(height: Grid.xxs),
canvasAsync.when(
_ManageChannelSection(
label: 'Canvas',
child: canvasAsync.when(
data: (canvas) {
if (isEditingCanvas.value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('manage-channel-canvas'),
controller: canvasController,
maxLines: 8,
minLines: 6,
decoration: const InputDecoration(
hintText:
'Write your canvas content in Markdown\u2026',
hintText: 'Write your canvas content in Markdown…',
),
),
const SizedBox(height: Grid.xxs),
@@ -225,7 +188,7 @@ class ManageChannelSheet extends HookConsumerWidget {
: saveCanvas,
child: Text(
isSavingCanvas.value
? 'Saving\u2026'
? 'Saving…'
: 'Save canvas',
),
),
@@ -236,22 +199,14 @@ class ManageChannelSheet extends HookConsumerWidget {
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Text(
canvas.content?.trim().isNotEmpty == true
? canvas.content!
: 'No canvas set for this channel.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
Text(
canvas.content?.trim().isNotEmpty == true
? canvas.content!
: 'No canvas set for this channel.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.xxs),
@@ -274,16 +229,25 @@ class ManageChannelSheet extends HookConsumerWidget {
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 40,
semanticLabel: 'Loading channel details',
semanticLabel: 'Loading channel canvas',
),
),
error: (error, _) => Text(
error.toString(),
'Couldn’t load the channel canvas.',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
@@ -292,45 +256,94 @@ class ManageChannelSheet extends HookConsumerWidget {
}
}
class _ContextCard extends StatelessWidget {
final String label;
final String? value;
final String emptyLabel;
const _ContextCard({
required this.label,
required this.value,
required this.emptyLabel,
class _ManageChannelTextField extends StatelessWidget {
const _ManageChannelTextField({
required this.fieldKey,
required this.outlineKey,
required this.controller,
required this.enabled,
required this.hintText,
required this.textInputAction,
this.minLines = 1,
this.maxLines = 1,
});
final Key fieldKey;
final Key outlineKey;
final TextEditingController controller;
final bool enabled;
final String hintText;
final TextInputAction textInputAction;
final int minLines;
final int maxLines;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Grid.xs),
return DecoratedBox(
key: outlineKey,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.8),
),
borderRadius: BorderRadius.circular(Radii.card),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel,
style: context.textTheme.bodyMedium?.copyWith(
child: Semantics(
label: hintText,
textField: true,
child: TextField(
key: fieldKey,
controller: controller,
enabled: enabled,
minLines: minLines,
maxLines: maxLines,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: hintText,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
),
],
textInputAction: textInputAction,
),
),
);
}
}
class _ManageChannelSection extends StatelessWidget {
const _ManageChannelSection({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
child: Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Material(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Padding(padding: const EdgeInsets.all(Grid.xs), child: child),
),
],
);
}

View File

@@ -7,8 +7,8 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../profile/user_status.dart';
import '../profile/user_status_cache_provider.dart';
import 'agent_activity/agent_activity_sheet.dart';
@@ -184,6 +184,7 @@ class _SectionLabel extends StatelessWidget {
const _changeableRoles = ['admin', 'member', 'guest'];
String _roleLabel(String role) {
if (role == 'bot') return 'Agent';
if (role.isEmpty) return 'Member';
return '${role[0].toUpperCase()}${role.substring(1)}';
}

View File

@@ -1,5 +1,5 @@
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../profile/user_profile.dart';
import '../../../shared/profile/user_profile.dart';
import '../channel_management_provider.dart';
import 'mention_ranking.dart';

View File

@@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../shared/crypto/nip_oa.dart';
import '../../../shared/mentions/agent_identity_provider.dart';
import '../../../shared/relay/relay.dart';
import '../../profile/user_cache_provider.dart';
import '../../profile/user_profile.dart';
import '../../../shared/profile/user_cache_provider.dart';
import '../../../shared/profile/user_profile.dart';
import '../channel.dart';
import '../channel_management_provider.dart';
import '../channels_provider.dart';

View File

@@ -11,8 +11,8 @@ import '../../shared/emoji/emoji_burst.dart';
import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/emoji/positive_emoji.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'channel_management_provider.dart';
import 'emoji_picker.dart';
import 'recent_emoji_provider.dart';

View File

@@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../channels/channel_management_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'channel.dart';
import 'channel_messages_provider.dart';
import 'message_mention_pubkeys.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_profile.dart';
/// 20px circle avatar used in thread summary rows and other compact lists.
class SmallAvatar extends StatelessWidget {

View File

@@ -13,8 +13,8 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'android_ime_lift.dart';
import 'channel_link_navigation.dart';
import 'channel_messages_provider.dart';

View File

@@ -9,9 +9,9 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_profile.dart';
import 'forum_models.dart';
/// Card displaying a forum post preview in the posts list.

View File

@@ -15,8 +15,8 @@ import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/compose_bar.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../profile/user_profile_sheet.dart';
import 'forum_models.dart';
import 'forum_provider.dart';

View File

@@ -126,6 +126,10 @@ class HomePage extends HookConsumerWidget {
),
),
ValueListenableBuilder<double>(
key: const ValueKey('home-settings-transition-progress'),
// Keep one stable listenable for Home. Swapping in the route's
// animation can briefly rebuild with its completed value before the
// new controller starts, which makes the background scale twice.
valueListenable: settingsTransitionProgress,
child: Scaffold(
backgroundColor: Colors.transparent,
@@ -219,7 +223,10 @@ class HomePage extends HookConsumerWidget {
: Curves.easeOutCubic.transform(progress);
return Opacity(
key: const ValueKey('home-settings-transition-opacity'),
opacity: 1 - curvedProgress,
// Settings supplies the crossfade. Keeping Home opaque beneath
// it prevents the bare backdrop showing through two partially
// transparent layers.
opacity: 1,
child: Transform.scale(
key: const ValueKey('home-settings-transition-scale'),
scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress),

View File

@@ -6,7 +6,7 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'user_profile.dart';
import '../../shared/profile/user_profile.dart';
/// Matches desktop's sidebar profile card, whose avatar is 32px.
const _defaultAvatarSize = 32.0;

View File

@@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import 'user_profile.dart';
import '../../shared/profile/user_profile.dart';
/// The current user's profile (kind:0 metadata) loaded over the relay
/// WebSocket. Returns null when no nsec is configured or when the user has

View File

@@ -45,7 +45,8 @@ class SettingsProfileHeader extends HookConsumerWidget {
showSetStatusSheet(context, currentStatus: status);
return Padding(
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
key: const ValueKey('settings-profile-header'),
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.twelve),
child: Column(
children: [
MaskedAvatarBadge(

View File

@@ -19,7 +19,7 @@ import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import 'presence_cache_provider.dart';
import 'user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'user_status_cache_provider.dart';
/// Show a user profile bottom sheet for the given [pubkey].

View File

@@ -5,7 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'note_card.dart';
import 'pulse_models.dart';

View File

@@ -9,7 +9,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../channels/message_content.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'note_card.dart';
import 'pulse_actions.dart';
import 'pulse_models.dart';

View File

@@ -10,7 +10,7 @@ import '../../shared/widgets/avatar_image.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'compose_note_page.dart';
import 'pulse_actions.dart';

View File

@@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../profile/user_cache_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import 'pulse_models.dart';
final globalNotesProvider = FutureProvider<List<UserNote>>((ref) async {

View File

@@ -23,8 +23,8 @@ import '../channels/message_content.dart';
import '../channels/date_formatters.dart';
import '../forum/forum_thread_page.dart';
import '../profile/profile_provider.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'recent_searches_provider.dart';
import 'search_provider.dart';

View File

@@ -25,6 +25,7 @@ class _AppearanceSection extends ConsumerWidget {
return AppListCard(
label: 'Style · This community',
verticalPadding: Grid.twelve,
children: [
AppListRow(
icon: LucideIcons.sunMoon,

View File

@@ -14,6 +14,7 @@ class _CommunitySection extends ConsumerWidget {
return AppListCard(
label: 'Community',
verticalPadding: Grid.twelve,
children: [
AppListRow(
icon: LucideIcons.userPlus,

View File

@@ -14,6 +14,7 @@ class _ConnectionSection extends ConsumerWidget {
return AppListCard(
label: 'Connection',
verticalPadding: Grid.twelve,
children: [
AppListRow(
icon: LucideIcons.server,
@@ -104,6 +105,7 @@ class _RemoveCommunitySection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppListCard(
verticalPadding: Grid.twelve,
children: [
AppListRow(
icon: LucideIcons.logOut,

View File

@@ -2,8 +2,8 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/relay/relay.dart';
import '../crypto/nip_oa.dart';
import '../relay/relay.dart';
import 'user_profile.dart';
/// In-memory cache of user profiles, fetched in batches from the relay.

View File

@@ -129,6 +129,7 @@ class AppListRowRaw extends StatelessWidget {
this.subtitle,
this.trailing,
this.onTap,
this.verticalPadding = _rowVerticalPadding,
});
final Widget leading;
@@ -136,13 +137,14 @@ class AppListRowRaw extends StatelessWidget {
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final double verticalPadding;
@override
Widget build(BuildContext context) {
final row = Padding(
padding: EdgeInsets.symmetric(
horizontal: AppListInset.of(context),
vertical: _rowVerticalPadding,
vertical: verticalPadding,
),
child: Row(
children: [

View File

@@ -11,6 +11,7 @@ class AppListCard extends StatelessWidget {
super.key,
this.label,
this.dividerIndent,
this.verticalPadding = Grid.xxs,
required this.children,
});
@@ -22,6 +23,12 @@ class AppListCard extends StatelessWidget {
/// separators align with their row content on both sides.
final double? dividerIndent;
/// Outer padding above and below this section.
///
/// Adjacent cards contribute this padding from both sides. For example,
/// passing [Grid.twelve] creates a 24dp rhythm between grouped surfaces.
final double verticalPadding;
final List<Widget> children;
static const _inset = Grid.xs;
@@ -53,11 +60,11 @@ class AppListCard extends StatelessWidget {
}
return Padding(
padding: const EdgeInsets.fromLTRB(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
verticalPadding,
Grid.gutter,
Grid.xxs,
verticalPadding,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -40,42 +40,52 @@ class BuzzActionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final canTap = isEnabled && !isLoading;
return GestureDetector(
onTap: canTap
? () {
unawaited(HapticFeedback.lightImpact());
onTap();
}
: null,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: isEnabled ? 1 : 0.5,
child: Container(
width: double.infinity,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: loadingSemanticLabel ?? label,
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
void handleTap() {
unawaited(HapticFeedback.lightImpact());
onTap();
}
return Semantics(
button: true,
enabled: canTap,
label: isLoading ? loadingSemanticLabel ?? label : label,
onTap: canTap ? handleTap : null,
excludeSemantics: true,
child: GestureDetector(
onTap: canTap ? handleTap : null,
excludeFromSemantics: true,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: isEnabled ? 1 : 0.5,
child: Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)),
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: loadingSemanticLabel ?? label,
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
textAlign: TextAlign.center,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
),
],
],
),
),
),
),

View File

@@ -12,8 +12,8 @@ import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';

View File

@@ -3,6 +3,9 @@ import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_actions_sheet.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
import 'package:buzz/features/channels/manage_channel_sheet.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
@@ -12,7 +15,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
const _currentPubkey = 'me';
Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
Channel _channel({
String type = 'stream',
bool isArchived = false,
bool isMember = true,
}) => Channel(
id: 'channel-id',
name: type == 'dm' ? 'Alice' : 'general',
channelType: type,
@@ -21,7 +28,7 @@ Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
createdBy: 'owner',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
isMember: isMember,
archivedAt: isArchived ? DateTime(2025, 1, 2) : null,
);
@@ -58,6 +65,19 @@ Widget _modalApp({
overrides: [
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
channelSectionsProvider.overrideWith(
() => _FakeChannelSectionsNotifier(const ChannelSectionStore()),
),
agentOwnersProvider.overrideWithValue(
const AsyncValue.data(<String, String>{}),
),
channelCanvasProvider(channel.id).overrideWith(
(ref) async => const ChannelCanvas(
content: null,
updatedAt: null,
authorPubkey: null,
),
),
channelActionsProvider.overrideWith(createChannelActions),
],
child: MaterialApp(
@@ -284,6 +304,7 @@ void main() {
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
expect(find.text('Move to section…'), findsOneWidget);
expect(find.text('Leave channel'), findsOneWidget);
});
@@ -302,29 +323,163 @@ void main() {
expect(find.text('Channel actions unavailable'), findsOneWidget);
});
testWidgets(
'manage leave closes both nested sheets without popping the page',
(tester) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
testWidgets('Manage contains editing and canvas, not mute or leave', (
tester,
) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
final manageSheet = find.byType(ManageChannelSheet);
expect(manageSheet, findsOneWidget);
expect(
find.descendant(
of: manageSheet,
matching: find.byKey(const ValueKey('manage-channel-name')),
),
findsOneWidget,
);
expect(
find.descendant(of: manageSheet, matching: find.text('Canvas')),
findsOneWidget,
);
expect(
find.descendant(of: manageSheet, matching: find.text('Mute channel')),
findsNothing,
);
expect(
find.descendant(of: manageSheet, matching: find.text('Leave channel')),
findsNothing,
);
expect(
find.descendant(of: manageSheet, matching: find.text('Topic')),
findsNothing,
);
expect(
find.descendant(of: manageSheet, matching: find.text('Purpose')),
findsNothing,
);
});
testWidgets('Manage refreshes metadata after saving from actions', (
tester,
) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
createChannelActions: (ref) => _FakeChannelActions(
ref,
onUpdateChannel: (channelId, name, description) async {},
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('manage-channel-name')),
'renamed',
);
await tester.tap(find.byKey(const ValueKey('manage-channel-save-details')));
await tester.pumpAndSettle();
expect(find.byType(ChannelActionsSheet), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
},
);
expect(find.widgetWithText(ListTile, 'Manage channel'), findsOneWidget);
await tester.tap(find.widgetWithText(ListTile, 'Manage channel'));
await tester.pumpAndSettle();
final nameField = tester.widget<TextField>(
find.byKey(const ValueKey('manage-channel-name')),
);
expect(nameField.controller?.text, 'renamed');
});
testWidgets('non-member cannot edit canvas from Manage channel', (
tester,
) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(isMember: false),
loadMembers: () async => const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
final editCanvas = find.widgetWithText(FilledButton, 'Create canvas');
expect(editCanvas, findsOneWidget);
expect(tester.widget<FilledButton>(editCanvas).onPressed, isNull);
});
testWidgets('regular members can move channels but cannot edit metadata', (
tester,
) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'member',
joinedAt: DateTime(2025),
),
],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
expect(find.text('Move to section…'), findsOneWidget);
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
final nameField = find.byKey(const ValueKey('manage-channel-name'));
final descriptionField = find.byKey(
const ValueKey('manage-channel-description'),
);
expect(tester.widget<TextField>(nameField).enabled, isFalse);
expect(tester.widget<TextField>(descriptionField).enabled, isFalse);
expect(
tester
.widget<FilledButton>(
find.byKey(const ValueKey('manage-channel-save-details')),
)
.onPressed,
isNull,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Create canvas'),
)
.onPressed,
isNotNull,
);
});
testWidgets('DM omits quick actions, then shows mute and copy rows', (
tester,
@@ -366,8 +521,25 @@ void main() {
});
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
_FakeChannelSectionsNotifier(this._store);
final ChannelSectionStore _store;
@override
ChannelSectionsState build() =>
ChannelSectionsState(isReady: true, store: _store, version: 1);
}
class _FakeChannelActions extends ChannelActions {
_FakeChannelActions(Ref ref)
final Future<void> Function(
String channelId,
String? name,
String? description,
)?
onUpdateChannel;
_FakeChannelActions(Ref ref, {this.onUpdateChannel})
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
@@ -378,6 +550,15 @@ class _FakeChannelActions extends ChannelActions {
currentPubkey: _currentPubkey,
);
@override
Future<void> updateChannel({
required String channelId,
String? name,
String? description,
}) async {
await onUpdateChannel?.call(channelId, name, description);
}
String? unarchivedChannelId;
@override

File diff suppressed because it is too large Load Diff

View File

@@ -201,6 +201,28 @@ void main() {
});
group('build channel lifecycle tags', () {
test('channel edits match desktop kind 9002 tags', () {
expect(
buildUpdateChannelTags(
channelId: 'channel-id',
name: ' ###general ',
description: ' Team updates ',
),
[
['h', 'channel-id'],
['name', 'general'],
['about', 'Team updates'],
],
);
});
test('channel edits reject a hash-only name', () {
expect(
() => buildUpdateChannelTags(channelId: 'channel-id', name: ' ### '),
throwsArgumentError,
);
});
test('archive matches kind 9002 tags', () {
expect(buildSetChannelArchivedTags('channel-id', archived: true), [
['h', 'channel-id'],
@@ -222,6 +244,49 @@ void main() {
});
});
test(
'stale channel actions cannot publish after a community switch',
() async {
final session = RelaySessionNotifier();
final container = ProviderContainer(
retry: (_, _) => null,
overrides: [
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final staleActions = container.read(channelActionsProvider);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://other-community.example', nsec: null);
final operations = <Future<void> Function()>[
() =>
staleActions.updateChannel(channelId: _channelId, name: 'renamed'),
() => staleActions.archiveChannel(_channelId),
() => staleActions.unarchiveChannel(_channelId),
() => staleActions.deleteChannel(_channelId),
() => staleActions.setCanvas(channelId: _channelId, content: 'canvas'),
() => staleActions.changeMemberRole(
channelId: _channelId,
pubkey: _memberPubkey,
role: 'admin',
),
() => staleActions.removeMember(
channelId: _channelId,
pubkey: _memberPubkey,
),
() => staleActions.leaveChannel(_channelId),
];
for (final operation in operations) {
await expectLater(operation(), throwsA(isA<StateError>()));
}
},
);
group('channelMembersProvider', () {
test('waits for the relay connection before fetching members', () async {
final session = _ConnectionAwareRelaySession();
@@ -447,6 +512,17 @@ const _channelId = '11111111-1111-4111-8111-111111111111';
const _memberPubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://first-community.example', nsec: null);
@override
void update({required String baseUrl, String? nsec}) {
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
}
}
class _ConnectionAwareRelaySession extends RelaySessionNotifier {
int historyQueryCount = 0;

View File

@@ -18,7 +18,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
@@ -744,9 +744,10 @@ void main() {
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
expect(route, isNot(isA<MaterialPageRoute<void>>()));
expect(route?.opaque, isFalse);
expect(route?.allowSnapshotting, isFalse);
});
testWidgets('reports Settings progress in both directions', (tester) async {
testWidgets('reports monotonic Settings route progress', (tester) async {
final progress = <double>[];
await tester.pumpWidget(
buildTestable(
@@ -759,23 +760,31 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pump();
expect(progress, [0]);
await tester.pump(const Duration(milliseconds: 16));
expect(progress.last, inExclusiveRange(0, 1));
await tester.pump(const Duration(milliseconds: 95));
expect(progress.last, inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(progress.any((value) => value > 0 && value < 1), isTrue);
expect(progress.last, 1);
for (var index = 1; index < progress.length; index++) {
expect(progress[index], greaterThanOrEqualTo(progress[index - 1]));
}
final reverseStart = progress.length;
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pump();
final reverseStart = progress.length;
await tester.pump(const Duration(milliseconds: 95));
expect(progress.last, inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(
progress.skip(reverseStart).any((value) => value > 0 && value < 1),
isTrue,
);
expect(progress.last, 0);
for (var index = reverseStart + 1; index < progress.length; index++) {
expect(progress[index], lessThanOrEqualTo(progress[index - 1]));
}
});
testWidgets('paints Settings content with its surface from the first frame', (
tester,
) async {
testWidgets('scales and fully fades Settings into view', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
@@ -792,7 +801,12 @@ void main() {
const ValueKey('settings-transition-opacity'),
skipOffstage: false,
);
final scaleTransition = find.byKey(
const ValueKey('settings-transition-scale'),
skipOffstage: false,
);
expect(transition, findsOneWidget);
expect(scaleTransition, findsOneWidget);
expect(
find.descendant(
of: transition,
@@ -803,24 +817,44 @@ void main() {
),
findsOneWidget,
);
expect(tester.widget<FadeTransition>(transition).opacity.value, 0.8);
expect(tester.widget<FadeTransition>(transition).opacity.value, 0);
await tester.pump(const Duration(milliseconds: 95));
final forwardOpacity = tester
.widget<FadeTransition>(transition)
.opacity
.value;
final forwardScale = tester
.widget<ScaleTransition>(scaleTransition)
.scale
.value;
final forwardScaleProgress = (1.04 - forwardScale) / 0.04;
expect(
tester.widget<FadeTransition>(transition).opacity.value,
inExclusiveRange(0.8, 1),
forwardOpacity,
closeTo(Curves.easeOutQuad.transform(95 / 220), 0.02),
);
expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001));
await tester.pumpAndSettle();
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 95));
final reverseOpacity = tester
.widget<FadeTransition>(transition)
.opacity
.value;
final reverseScale = tester
.widget<ScaleTransition>(scaleTransition)
.scale
.value;
final reverseScaleProgress = (1.04 - reverseScale) / 0.04;
expect(
tester.widget<FadeTransition>(transition).opacity.value,
reverseOpacity,
inExclusiveRange(0, 1),
reason: 'The complete Settings layer still fades out on exit.',
);
expect(reverseScaleProgress, closeTo(reverseOpacity, 0.001));
});
testWidgets('gives feedback for the profile and community controls', (

View File

@@ -1,7 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
final userPubkey = 'a' * 64;

View File

@@ -3,8 +3,8 @@ import 'package:buzz/features/channels/timeline_message.dart';
import 'package:buzz/shared/emoji/emoji_burst.dart';
import 'package:buzz/shared/emoji/emoji_data.dart';
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

View File

@@ -8,8 +8,8 @@ import 'package:buzz/features/forum/forum_posts_view.dart';
import 'package:buzz/features/forum/forum_provider.dart';
import 'package:buzz/features/forum/forum_thread_page.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -1,5 +1,6 @@
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -111,6 +112,90 @@ void main() {
);
});
testWidgets('keeps Home opaque beneath the Settings transition', (
tester,
) async {
await tester.pumpWidget(await buildHome());
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pump();
await tester.pump(const Duration(milliseconds: 95));
double homeOpacity() => tester
.widget<Opacity>(
find.byKey(const ValueKey('home-settings-transition-opacity')),
)
.opacity;
expect(homeOpacity(), 1);
await tester.pumpAndSettle();
Navigator.of(
tester.element(
find.byKey(
const ValueKey('settings-transition-opacity'),
skipOffstage: false,
),
),
).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 95));
expect(homeOpacity(), 1);
});
testWidgets('uses one monotonic route animation for Settings and Home', (
tester,
) async {
await tester.pumpWidget(await buildHome());
await tester.pumpAndSettle();
double homeScale() => tester
.widget<Transform>(
find.byKey(const ValueKey('home-settings-transition-scale')),
)
.transform
.storage[0];
await tester.tap(find.byType(ProfileAvatar));
await tester.pump();
final settingsTransition = find.byKey(
const ValueKey('settings-transition-opacity'),
skipOffstage: false,
);
final settingsRoute = ModalRoute.of(tester.element(settingsTransition));
final entranceScales = <double>[homeScale()];
final routeValues = <double>[settingsRoute!.animation!.value];
for (var frame = 0; frame < 15; frame++) {
await tester.pump(const Duration(milliseconds: 16));
entranceScales.add(homeScale());
routeValues.add(settingsRoute.animation!.value);
}
expect(entranceScales.first, closeTo(1, 0.000001));
final reversalFrames = <int>[];
for (var frame = 1; frame < entranceScales.length; frame++) {
if (entranceScales[frame] > entranceScales[frame - 1] + 0.000001) {
reversalFrames.add(frame);
}
}
expect(
reversalFrames,
isEmpty,
reason:
'Home must scale down in one direction on entrance. '
'scales=$entranceScales route=$routeValues',
);
expect(entranceScales, everyElement(inInclusiveRange(0.97, 1)));
expect(entranceScales.last, closeTo(0.97, 0.001));
await tester.pumpAndSettle();
Navigator.of(tester.element(settingsTransition)).pop();
await tester.pumpAndSettle();
});
testWidgets('gives selection haptics only when the tab changes', (
tester,
) async {

View File

@@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';

View File

@@ -1,5 +1,5 @@
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/widgets.dart';

View File

@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/settings_profile_header.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
@@ -20,6 +20,26 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('reserves half of the 24dp settings section gap', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
final header = tester.widget<Padding>(
find.byKey(const ValueKey('settings-profile-header')),
);
expect((header.padding as EdgeInsets).bottom, Grid.twelve);
});
testWidgets('shows the poster until the animated avatar is ready', (
tester,
) async {

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/pulse/compose_note_page.dart';
import 'package:buzz/features/pulse/pulse_models.dart';
import 'package:buzz/shared/theme/theme.dart';

View File

@@ -1,5 +1,5 @@
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/pulse/note_card.dart';
import 'package:buzz/features/pulse/pulse_models.dart';
import 'package:buzz/shared/theme/theme.dart';

View File

@@ -4,8 +4,8 @@ import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/search/recent_searches_provider.dart';
import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';

View File

@@ -1,6 +1,7 @@
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/community/community_membership_provider.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_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -106,4 +107,41 @@ void main() {
expect(find.text('Invite to community'), findsNothing);
});
testWidgets('uses a 24dp rhythm between profile settings groups', (
tester,
) async {
tester.view.physicalSize = const Size(390, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
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(height: 100),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
final sections = tester.widgetList<AppListCard>(find.byType(AppListCard));
expect(sections, isNotEmpty);
expect(
sections.every((section) => section.verticalPadding == Grid.twelve),
isTrue,
);
});
}