mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
Unify mobile channel details
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
245
mobile/lib/features/channels/add_members_sheet.dart
Normal file
245
mobile/lib/features/channels/add_members_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
670
mobile/lib/features/channels/channel_details_page.dart
Normal file
670
mobile/lib/features/channels/channel_details_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
19
mobile/lib/features/channels/channel_metadata_updates.dart
Normal file
19
mobile/lib/features/channels/channel_metadata_updates.dart
Normal 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()],
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -25,6 +25,7 @@ class _AppearanceSection extends ConsumerWidget {
|
||||
|
||||
return AppListCard(
|
||||
label: 'Style · This community',
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.sunMoon,
|
||||
|
||||
@@ -14,6 +14,7 @@ class _CommunitySection extends ConsumerWidget {
|
||||
|
||||
return AppListCard(
|
||||
label: 'Community',
|
||||
verticalPadding: Grid.twelve,
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.userPlus,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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', (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user