Unify mobile channel details

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 11:10:10 +01:00
parent f956e6fe06
commit 8373bcc5cf
25 changed files with 2169 additions and 387 deletions

View File

@@ -0,0 +1,245 @@
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 availableUsers =
directoryAsync.asData?.value
.where(
(user) => !normalizedExisting.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;
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(channelActionsProvider)
.addMembers(
channelId: channelId,
pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(),
);
if (context.mounted) Navigator.of(context).pop(true);
} on AddMembersException catch (error) {
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 ListTile(
key: ValueKey(
'add-channel-member-${user.pubkey}',
),
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

@@ -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,31 @@ 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 '../profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.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({
@@ -161,7 +174,7 @@ class ChannelActionsSheet extends ConsumerWidget {
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
if (!channel.isDm && canManageLifecycle)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.folderInput),

View File

@@ -243,9 +243,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,
@@ -311,65 +312,52 @@ 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,
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

@@ -0,0 +1,670 @@
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,
String? sectionId,
}) => Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => ChannelDetailsPage(
channel: channel,
currentPubkey: currentPubkey,
sectionId: sectionId,
),
),
);
/// Combined channel identity, membership, organization, and lifecycle page.
class ChannelDetailsPage extends HookConsumerWidget {
const ChannelDetailsPage({
super.key,
required this.channel,
required this.currentPubkey,
this.sectionId,
});
final Channel channel;
final String? currentPubkey;
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 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 =
!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 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,
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: Row(
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,
displayName:
userCache[member.pubkey.toLowerCase()]?.displayName,
avatarUrl:
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
),
if (members.length > previewMembers.length)
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: sectionId,
);
},
),
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.displayName,
required this.avatarUrl,
});
final ChannelMember member;
final String? currentPubkey;
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: () => showUserProfileSheet(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

@@ -11,8 +11,11 @@ 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';
String _relayErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');
@@ -761,6 +764,24 @@ class ChannelActions {
await _refreshChannelState(channelId);
}
/// Updates the user-editable channel metadata and refreshes cached details.
Future<void> updateChannel({
required String channelId,
String? name,
String? description,
}) async {
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildUpdateChannelTags(
channelId: channelId,
name: name,
description: description,
),
);
await _refreshChannelState(channelId);
}
/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);

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

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

@@ -1,27 +1,36 @@
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.onChannelUpdated,
});
const ManageChannelSheet({super.key, required this.channel});
final Channel channel;
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 +40,46 @@ 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 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 =
canonicalName.isNotEmpty &&
(nameDirty || descriptionDirty) &&
!isSavingDetails.value;
final canEditCanvas = !channel.isArchived;
final canJoin =
channel.visibility == 'open' &&
!channel.isArchived &&
!channel.isMember &&
!channel.isDm;
final canLeave = channel.isMember && !channel.isArchived && !channel.isDm;
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 +89,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 +109,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: !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: !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 +185,7 @@ class ManageChannelSheet extends HookConsumerWidget {
: saveCanvas,
child: Text(
isSavingCanvas.value
? 'Saving\u2026'
? 'Saving…'
: 'Save canvas',
),
),
@@ -236,22 +196,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 +226,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 +253,90 @@ 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,
),
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,
),
const SizedBox(height: Grid.half),
Text(
value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel,
style: context.textTheme.bodyMedium?.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

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

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

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

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

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

@@ -3,6 +3,7 @@ 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/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';
@@ -302,29 +303,53 @@ 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),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
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();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
expect(find.byType(ChannelActionsSheet), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
},
);
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('DM omits quick actions, then shows mute and copy rows', (
tester,

View File

@@ -36,6 +36,8 @@ import 'package:buzz/features/profile/user_profile_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';
import 'package:buzz/shared/widgets/app_list_card.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart';
@@ -180,6 +182,7 @@ Widget _buildTestable({
_FakeChannelsNotifier? channelsNotifier,
List<NavigatorObserver> navigatorObservers = const [],
Future<List<ChannelMember>> Function()? loadMembers,
List<DirectoryUser>? directoryUsers,
ChannelActions Function(Ref ref)? createChannelActions,
ReadStateNotifier? readStateNotifier,
_FakeMessagesNotifier? messagesNotifier,
@@ -228,6 +231,9 @@ Widget _buildTestable({
channelBotPubkeysProvider(
_channelId,
).overrideWith((ref) async => const <String>{}),
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
if (directoryUsers != null)
relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
if (readStateNotifier != null)
@@ -809,7 +815,96 @@ void main() {
);
});
testWidgets('members sheet shows roles and manage controls for owners', (
testWidgets(
'channel details combines people and agents in one member list',
(tester) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Self',
),
ChannelMember(
pubkey: 'alice',
role: 'member',
joinedAt: DateTime(2025),
displayName: 'Alice',
),
ChannelMember(
pubkey: 'agent',
role: 'bot',
joinedAt: DateTime(2025),
displayName: 'Agent',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.text('3 members'), findsOneWidget);
expect(find.text('You · Owner', findRichText: true), findsOneWidget);
expect(find.text('Alice · Member', findRichText: true), findsOneWidget);
expect(find.text('Agent · Agent', findRichText: true), findsOneWidget);
expect(find.text('Member'), findsNothing);
expect(find.text('Owner'), findsNothing);
expect(find.text('People · 2'), findsNothing);
expect(find.text('Agents · 1'), findsNothing);
expect(find.text('PEOPLE — 2'), findsNothing);
expect(find.text('BOTS — 1'), findsNothing);
final aliceRow = find.byKey(
const ValueKey('channel-details-member-alice'),
);
final aliceText = tester.widget<Text>(
find.descendant(
of: aliceRow,
matching: find.byWidgetPredicate(
(widget) =>
widget is Text &&
widget.textSpan?.toPlainText() == 'Alice · Member',
),
),
);
final aliceSpans = (aliceText.textSpan! as TextSpan).children!;
expect(
aliceSpans.last.style?.fontSize,
AppTheme.light().textTheme.bodySmall?.fontSize,
);
expect(
tester
.widget<AvatarImage>(
find.descendant(
of: aliceRow,
matching: find.byType(AvatarImage),
),
)
.radius,
20,
);
expect(
find.descendant(
of: aliceRow,
matching: find.byIcon(LucideIcons.chevronRight),
),
findsOneWidget,
);
expect(tester.getSize(aliceRow).height, 40 + (Grid.xxs * 2));
await tester.tap(aliceRow);
await tester.pumpAndSettle();
expect(find.byType(UserProfileSheet), findsOneWidget);
},
);
testWidgets('one-member channel shows Add first without a See all row', (
tester,
) async {
await tester.pumpWidget(
@@ -822,38 +917,131 @@ void main() {
joinedAt: DateTime(2025),
displayName: 'Self',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
final addRow = find.byKey(
const ValueKey('channel-details-add-members-row'),
);
final memberRow = find.byKey(
const ValueKey('channel-details-member-self'),
);
expect(addRow, findsOneWidget);
expect(memberRow, findsOneWidget);
expect(find.text('See all'), findsNothing);
expect(
tester.widget<Text>(find.text('Add members')).style,
Theme.of(tester.element(addRow)).textTheme.bodyLarge,
);
expect(
tester.getTopLeft(addRow).dy,
lessThan(tester.getTopLeft(memberRow).dy),
);
});
testWidgets('previews five members before an icon-free See all row', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'alice',
role: 'member',
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Alice',
),
for (var index = 0; index < 5; index++)
ChannelMember(
pubkey: 'member-$index',
role: 'member',
joinedAt: DateTime(2025),
),
ChannelMember(
pubkey: 'agent',
role: 'bot',
joinedAt: DateTime(2025),
displayName: 'Agent',
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('View members'));
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Member'), findsOneWidget);
expect(find.text('Owner'), findsOneWidget);
expect(find.text('People · 2'), findsOneWidget);
expect(find.text('Agents · 1'), findsOneWidget);
expect(find.text('PEOPLE — 2'), findsNothing);
expect(find.text('BOTS — 1'), findsNothing);
final previews = find.byWidgetPredicate((widget) {
final key = widget.key;
return key is ValueKey<String> &&
key.value.startsWith('channel-details-member-');
});
expect(previews, findsNWidgets(5));
expect(find.text('See all'), findsOneWidget);
final seeAllRow = find.byKey(
const ValueKey('channel-details-members-row'),
);
expect(seeAllRow, findsOneWidget);
expect(
tester.widget<Text>(find.text('See all')).style,
Theme.of(tester.element(seeAllRow)).textTheme.bodyLarge,
);
expect(
find.descendant(of: seeAllRow, matching: find.text('7 members')),
findsNothing,
);
expect(
find.descendant(
of: seeAllRow,
matching: find.byIcon(LucideIcons.users),
),
findsNothing,
);
final firstMemberRow = previews.first;
final firstMemberTitle = find.descendant(
of: firstMemberRow,
matching: find.byWidgetPredicate(
(widget) => widget is Text && widget.textSpan != null,
),
);
expect(
tester.getTopLeft(find.text('See all')).dx,
closeTo(tester.getTopLeft(firstMemberTitle).dx, 0.1),
);
expect(tester.getSize(seeAllRow).height, 40 + (Grid.xxs * 2));
await tester.ensureVisible(seeAllRow);
await tester.pumpAndSettle();
await tester.tap(seeAllRow);
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const ValueKey('members-sheet-list')),
const Offset(0, -500),
);
await tester.pumpAndSettle();
expect(find.text('Agent'), findsOneWidget);
expect(find.text('Bot'), findsNothing);
});
testWidgets('members sheet has no divider and pads a one-member list', (
testWidgets('Add members keeps its close control below the safe top', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.viewPadding = const FakeViewPadding(top: 47, bottom: 34);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewPadding);
addTearDown(tester.view.resetViewInsets);
await tester.pumpWidget(
_buildTestable(
messages: const [],
@@ -862,48 +1050,75 @@ void main() {
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
displayName: 'Self',
),
],
directoryUsers: const [
DirectoryUser(pubkey: 'alice', displayName: 'Alice'),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('View members'));
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-details-add-members-row')),
);
await tester.pumpAndSettle();
final contentPadding = find.byKey(
const ValueKey('members-sheet-content-padding'),
final sheet = find.byType(BottomSheet).last;
final closeButton = find.byTooltip('Close sheet');
expect(closeButton, findsOneWidget);
expect(tester.getTopLeft(sheet).dy, greaterThanOrEqualTo(47 + Grid.xs));
expect(tester.getRect(closeButton).bottom, lessThan(844 - 300));
});
testWidgets('Add members submits selected directory users', (tester) async {
List<String>? addedPubkeys;
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
),
],
directoryUsers: const [
DirectoryUser(pubkey: 'alice', displayName: 'Alice'),
],
createChannelActions: (ref) => _FakeChannelActions(
ref,
onAddMembers: (_, pubkeys) async => addedPubkeys = pubkeys,
),
),
);
expect(contentPadding, findsOneWidget);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-details-add-members-row')),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('add-channel-member-alice')));
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('add-channel-members-submit')),
);
await tester.pumpAndSettle();
expect(addedPubkeys, ['alice']);
expect(
find.descendant(of: contentPadding, matching: find.byType(Divider)),
find.byKey(const ValueKey('add-channel-members-search')),
findsNothing,
);
expect(
(tester.widget<Padding>(contentPadding).padding as EdgeInsets).bottom,
0,
);
final viewport = tester.widget<ConstrainedBox>(
find.byKey(const ValueKey('members-sheet-viewport')),
);
expect(viewport.constraints.maxHeight, 400 + Grid.md);
expect(
(tester
.widget<ListView>(
find.byKey(const ValueKey('members-sheet-list')),
)
.padding!
as EdgeInsets)
.bottom,
Grid.md,
);
expect(
find.byKey(const ValueKey('buzz-sheet-surface-margin')),
findsNothing,
);
expect(find.text('People · 1'), findsOneWidget);
expect(tester.widget<Text>(find.text('People · 1')).style?.fontSize, 14);
});
testWidgets('hides composer for archived channels', (tester) async {
@@ -999,14 +1214,14 @@ void main() {
);
expect(find.text('Message…'), findsNothing);
await tester.tap(find.byTooltip('Channel actions'));
await tester.pumpAndSettle();
await tester.drag(
find.byType(SingleChildScrollView).last,
const Offset(0, -300),
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.drag(
find.byKey(const ValueKey('channel-details-page-list')),
const Offset(0, -300),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Join channel'));
await tester.pumpAndSettle();
@@ -1016,10 +1231,11 @@ void main() {
find.text('Join this channel from Manage to participate.'),
findsNothing,
);
expect(find.text('Message #general'), findsOneWidget);
});
testWidgets('detail-header manage leave closes the detail page', (
testWidgets('Leave lives on the detail page instead of Manage', (
tester,
) async {
await tester.pumpWidget(
@@ -1030,14 +1246,24 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Channel actions'));
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.drag(
find.byKey(const ValueKey('channel-details-page-list')),
const Offset(0, -300),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Leave'));
await tester.pumpAndSettle();
expect(find.byTooltip('Channel actions'), findsNothing);
expect(
find.byKey(const ValueKey('channel-header-settings-trigger')),
findsNothing,
);
});
testWidgets('keeps manage sheet dismissible with a long canvas', (
@@ -1051,6 +1277,13 @@ void main() {
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
),
],
canvasContent: List.generate(
80,
(index) => 'Canvas line $index',
@@ -1059,18 +1292,17 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Channel actions'));
await tester.pumpAndSettle();
await tester.drag(
find.byType(SingleChildScrollView).last,
const Offset(0, -300),
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.tap(
find.byKey(const ValueKey('channel-details-edit-action')),
);
await tester.pumpAndSettle();
final sheet = find.byType(BottomSheet).last;
expect(find.byType(BottomSheet), findsNWidgets(2));
expect(find.byType(BottomSheet), findsOneWidget);
expect(tester.getSize(sheet).height, lessThanOrEqualTo(720));
final sheetTop = tester.getTopLeft(sheet).dy;
@@ -1080,7 +1312,88 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Manage channel'), findsOneWidget);
expect(find.text('Manage channel'), findsNothing);
});
testWidgets('Edit updates name and description without legacy fields', (
tester,
) async {
String? updatedName;
String? updatedDescription;
await tester.pumpWidget(
_buildTestable(
messages: const [],
members: [
ChannelMember(
pubkey: 'self',
role: 'owner',
joinedAt: DateTime(2025),
),
],
createChannelActions: (ref) => _FakeChannelActions(
ref,
onUpdateChannel: (_, name, description) async {
updatedName = name;
updatedDescription = description;
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-details-edit-action')),
);
await tester.pumpAndSettle();
expect(find.text('Mute channel'), findsOneWidget);
expect(find.text('Leave channel'), findsNothing);
expect(find.text('Topic'), findsNothing);
expect(find.text('Purpose'), findsNothing);
expect(find.text('Canvas'), findsOneWidget);
final nameField = tester.widget<TextField>(
find.byKey(const ValueKey('manage-channel-name')),
);
final descriptionField = tester.widget<TextField>(
find.byKey(const ValueKey('manage-channel-description')),
);
expect(nameField.decoration?.labelText, isNull);
expect(nameField.decoration?.hintText, 'Channel name');
expect(nameField.decoration?.border, InputBorder.none);
expect(descriptionField.decoration?.labelText, isNull);
expect(descriptionField.decoration?.hintText, 'Description');
expect(descriptionField.decoration?.border, InputBorder.none);
final nameOutline = tester.getRect(
find.byKey(const ValueKey('manage-channel-name-outline')),
);
final descriptionOutline = tester.getRect(
find.byKey(const ValueKey('manage-channel-description-outline')),
);
expect(descriptionOutline.top - nameOutline.bottom, Grid.xs);
await tester.enterText(
find.byKey(const ValueKey('manage-channel-name')),
' #renamed ',
);
await tester.enterText(
find.byKey(const ValueKey('manage-channel-description')),
'A new description',
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('manage-channel-save-details')),
);
await tester.pumpAndSettle();
expect(updatedName, 'renamed');
expect(updatedDescription, 'A new description');
expect(find.text('renamed'), findsOneWidget);
expect(find.text('A new description'), findsOneWidget);
});
testWidgets('shows empty state when no messages', (tester) async {
@@ -1704,7 +2017,13 @@ void main() {
final unreadRect = tester.getRect(unreadButton);
expect(
unreadRect.top,
frostedAppBarHeight(tester.element(unreadButton)) + Grid.xs,
frostedAppBarHeight(
tester.element(unreadButton),
titleContentHeight: tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar).first)
.titleContentHeight,
) +
Grid.xs,
);
expect(find.text('Latest'), findsNothing);
@@ -4152,13 +4471,149 @@ void main() {
});
group('App bar', () {
testWidgets('shows channel name with hash icon', (tester) async {
await tester.pumpWidget(_buildTestable(messages: []));
testWidgets('shows a tappable channel name and collective member count', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [],
members: List.generate(
5,
(index) => ChannelMember(
pubkey: 'member-$index',
role: 'member',
joinedAt: DateTime(2025),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('5 members'), findsOneWidget);
// The hash icon appears in the app bar and in the compose bar toolbar.
expect(find.byIcon(LucideIcons.hash), findsAtLeastNWidgets(1));
expect(
tester.getSize(find.byKey(const ValueKey('channel-header-avatar'))),
const Size.square(40),
);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('channel-header-name')))
.style
?.fontSize,
AppTheme.light().textTheme.titleMedium?.fontSize,
);
expect(
tester
.widget<Text>(
find.byKey(const ValueKey('channel-header-member-count')),
)
.style
?.fontSize,
AppTheme.light().textTheme.bodySmall?.fontSize,
);
expect(find.byTooltip('View members'), findsNothing);
expect(find.byTooltip('Channel actions'), findsNothing);
await tester.tap(
find.byKey(const ValueKey('channel-header-settings-trigger')),
);
await tester.pumpAndSettle();
expect(find.text('Channel settings'), findsNothing);
expect(
find.byKey(const ValueKey('channel-details-collapsed-title')),
findsNothing,
);
expect(
find.byKey(const ValueKey('channel-details-avatar')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('channel-details-name')),
findsOneWidget,
);
expect(find.text('General discussion'), findsOneWidget);
expect(find.text('5 members'), findsOneWidget);
expect(find.text('Preferences'), findsNothing);
expect(find.text('Star channel'), findsOneWidget);
expect(find.text('Mute channel'), findsOneWidget);
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Actions'), findsNothing);
expect(find.byTooltip('Back'), findsOneWidget);
var detailsAppBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
expect(detailsAppBar.frosted, isFalse);
expect(detailsAppBar.frostedSurfaceOpacity, 0);
expect(detailsAppBar.frostedBlurSigma, 0);
expect(detailsAppBar.showBottomDivider, isFalse);
final descriptionBottom = tester
.getRect(find.byKey(const ValueKey('channel-details-description')))
.bottom;
final firstActionTop = tester
.getRect(find.byKey(const ValueKey('channel-details-star-action')))
.top;
expect(firstActionTop - descriptionBottom, closeTo(Grid.sm, 0.5));
final firstActionBottom = tester
.getRect(find.byKey(const ValueKey('channel-details-star-action')))
.bottom;
final membersLabelTop = tester.getRect(find.text('5 members')).top;
expect(membersLabelTop - firstActionBottom, closeTo(Grid.sm, 0.5));
expect(
tester
.widget<AppListCard>(
find.byKey(const ValueKey('channel-details-members-card')),
)
.verticalPadding,
Grid.twelve,
);
expect(find.text('Channel'), findsNothing);
await tester.drag(
find.byKey(const ValueKey('channel-details-page-list')),
const Offset(0, -300),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-details-collapsed-title')),
findsOneWidget,
);
final collapsedTitle = find.byKey(
const ValueKey('channel-details-collapsed-title'),
);
expect(
tester.getCenter(collapsedTitle).dx,
closeTo(tester.getCenter(find.byType(FrostedAppBar).last).dx, 0.5),
);
expect(find.text('Channel'), findsNothing);
detailsAppBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
expect(detailsAppBar.frosted, isTrue);
expect(detailsAppBar.frostedSurfaceOpacity, 0.5);
expect(detailsAppBar.frostedBlurSigma, 20);
expect(detailsAppBar.showBottomDivider, isTrue);
expect(detailsAppBar.bottomDividerOpacity, 0.15);
expect(
tester
.widget<AppListCard>(
find.byKey(const ValueKey('channel-details-channel-card')),
)
.verticalPadding,
Grid.twelve,
);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('channel-header-settings-trigger')),
findsOneWidget,
);
});
testWidgets('shows lock icon for private channel', (tester) async {
@@ -7268,27 +7723,57 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
class _FakeChannelActions extends ChannelActions {
final Future<void> Function(String channelId)? onJoinChannel;
final Future<void> Function(String channelId, List<String> pubkeys)?
onAddMembers;
final Future<void> Function(
String channelId,
String? name,
String? description,
)?
onUpdateChannel;
_FakeChannelActions(Ref ref, {this.onJoinChannel})
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
_FakeChannelActions(
Ref ref, {
this.onJoinChannel,
this.onAddMembers,
this.onUpdateChannel,
}) : super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
@override
Future<void> joinChannel(String channelId) async {
await onJoinChannel?.call(channelId);
}
@override
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
}) async {
await onAddMembers?.call(channelId, pubkeys);
}
@override
Future<void> leaveChannel(String channelId) async {
return;
}
@override
Future<void> updateChannel({
required String channelId,
String? name,
String? description,
}) async {
await onUpdateChannel?.call(channelId, name, description);
}
}
class _RecordingRelaySocket extends RelaySocket {

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'],

View File

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

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