feat(mobile): long-press a Solo chat to open its details sheet (#9163)

Long-pressing a team channel in the Messages list opens a bottom sheet
of channel actions. Long-pressing a Solo chat did nothing, so its
settings (star, mute, members, move, copy, leave/archive/delete) were
only reachable from inside the chat.

Now a long-press on a Solo chat row opens that chat's details screen as
a sheet, with the same presentation as the team channel sheet: drag
handle, close button, 640 pt max width, 70% max height. The content is
the existing channel details page rendered without its app bar
(`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full
page. Tapping a chat still opens it as before.

Solo chats are placed in Solo workspaces, not channel sections, so their
details (sheet and full page) no longer show "Move to section…". That
row wrote channel sections, which left the chat where it was. Moving a
Solo chat between workspaces stays on the chat header's workspace
control.

### Demo

Long-pressing a Solo chat in the Messages list (Solo filter) opens its
details sheet; Star toggles, the sheet scrolls to the lifecycle actions,
and the close button dismisses it. Synthetic chat named "QA demo".


https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920

### Validation

- Added test: long-pressing a Solo chat on the Me page opens the details
sheet with the chat's name, the sheet scrolls to its lifecycle actions,
and the channel-section move is absent.
- The full mobile test suite and `flutter analyze` pass.
- Full automated simulator run on the final code (iPhone 17 Pro, debug
build, signed-in Builderlab account, light and dark mode). In the Solo
long-press sheet: Star and Mute toggle and revert; Edit, a member's
profile and Add members open and close back to the sheet; Copy channel
name and Copy channel ID copy the right values and show a toast; the
sheet scrolls; the Leave, Archive and Delete confirmations appear and
were cancelled; the close button, scrim tap and drag down all dismiss
it; "Move to section…" is absent. Tapping the chat still opens it, and
its in-chat details page also omits the section move. Regression check:
the team channel long-press sheet and team details page are unchanged,
including a working "Move to section…". The recording is in Buzz rather
than here because it shows internal channel names. Limitation:
destructive actions were cancelled, not confirmed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-07 15:40:11 -08:00
committed by GitHub
parent 1a0dae77bf
commit e86d0e23a3
5 changed files with 423 additions and 300 deletions

View File

@@ -676,6 +676,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
personalChat: personalChat,
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();

View File

@@ -11,6 +11,7 @@ Future<bool?> showChannelDetailsPage({
required String? currentPubkey,
required void Function(BuildContext context, String pubkey) onMemberTap,
String? sectionId,
bool personalChat = false,
}) => Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) => CoveredRouteKeyboardInsets(
@@ -19,11 +20,39 @@ Future<bool?> showChannelDetailsPage({
currentPubkey: currentPubkey,
onMemberTap: onMemberTap,
sectionId: sectionId,
personalChat: personalChat,
),
),
),
);
/// Opens the channel settings page as a bottom sheet, presented like the
/// channel list's long-press actions sheet.
Future<bool?> showChannelDetailsSheet({
required BuildContext context,
required Channel channel,
required String? currentPubkey,
required void Function(BuildContext context, String pubkey) onMemberTap,
String? sectionId,
bool personalChat = false,
}) => showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
builder: (_) => ChannelDetailsPage(
channel: channel,
currentPubkey: currentPubkey,
onMemberTap: onMemberTap,
sectionId: sectionId,
personalChat: personalChat,
inSheet: true,
),
);
/// Combined channel identity, membership, organization, and lifecycle page.
class ChannelDetailsPage extends HookConsumerWidget {
const ChannelDetailsPage({
@@ -32,6 +61,8 @@ class ChannelDetailsPage extends HookConsumerWidget {
required this.currentPubkey,
required this.onMemberTap,
this.sectionId,
this.personalChat = false,
this.inSheet = false,
});
final Channel channel;
@@ -39,6 +70,14 @@ class ChannelDetailsPage extends HookConsumerWidget {
final void Function(BuildContext context, String pubkey) onMemberTap;
final String? sectionId;
/// Solo chats are organized into Solo workspaces, not channel sections, so
/// their details omit the channel-section move.
final bool personalChat;
/// Renders only the scrolling content, without the page's app bar, for a
/// bottom sheet that supplies its own drag handle.
final bool inSheet;
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedChannel = useState(channel);
@@ -270,6 +309,304 @@ class ChannelDetailsPage extends HookConsumerWidget {
final usesNativeIosGlassBackButton =
Navigator.canPop(context) &&
Theme.of(context).platform == TargetPlatform.iOS;
final content = ListView(
key: const ValueKey('channel-details-page-list'),
controller: scrollController,
shrinkWrap: inSheet,
padding: EdgeInsets.only(
top: inSheet ? 0 : frostedAppBarHeight(context) + Grid.xs,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xs,
),
children: [
_ChannelDetailsHero(
channel: resolvedChannel,
nameKey: heroNameKey,
displayName: displayName,
avatar: counterpart == null
? null
: AvatarImage(
key: const ValueKey('dm-details-avatar'),
imageUrl: counterpartProfile?.avatarUrl,
radius: 40,
isAgent: counterpartProfile?.isAgent ?? false,
backgroundColor: context.controlSurface,
fallback: Text(
counterpartProfile?.initial ??
dmAvatarInitial(
resolvedChannel,
currentPubkey: resolvedCurrentPubkey,
),
style: context.textTheme.headlineMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
Padding(
key: const ValueKey('channel-details-actions'),
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_channelDetailsSectionPadding,
Grid.gutter,
_channelDetailsSectionPadding,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-star-action'),
icon: null,
iconWidget: TablerStarIcon(
filled: isStarred,
color: isStarred
? context.colors.primary
: context.colors.onSurface,
),
label: isStarred ? 'Unstar' : 'Star',
onTap: toggleStar,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-mute-action'),
icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff,
iconColor: isMuted ? context.colors.primary : null,
label: isMuted ? 'Unmute' : 'Mute',
onTap: toggleMute,
),
),
if (!resolvedChannel.isDm) ...[
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-edit-action'),
icon: BuzzIcons.pencil,
label: 'Edit',
isEnabled: canEdit,
onTap: openManageChannel,
),
),
],
],
),
),
),
if (!isDirectDm)
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(
BuzzIcons.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: BuzzIcons.loaderCircle,
title: 'Loading members…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading members',
),
)
else if (membersAsync.hasError && members.isEmpty)
const AppListRow(
icon: BuzzIcons.triangleAlert,
title: 'Members unavailable',
)
else ...[
for (final member in previewMembers)
_ChannelMemberPreviewRow(
key: ValueKey('channel-details-member-${member.pubkey}'),
member: member,
currentPubkey: resolvedCurrentPubkey,
onMemberTap: onMemberTap,
displayName:
userCache[member.pubkey.toLowerCase()]?.displayName,
contextualLabel: memberLabels[member.pubkey.toLowerCase()],
avatarUrl:
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
),
if (memberCount > _channelMemberPreviewLimit)
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: [
if (!personalChat)
AppListRow(
icon: BuzzIcons.folderInput,
title: 'Move to section…',
trailing: const _ChannelDetailsChevron(),
onTap: () async {
await _showMoveSectionSheet(
context,
ref,
channel: resolvedChannel,
sectionId: currentSectionId,
);
},
),
AppListRow(
icon: BuzzIcons.copy,
title: 'Copy channel name',
onTap: () {
copyToClipboard(
context,
resolvedChannel.name,
message: 'Channel name copied',
);
},
),
AppListRow(
icon: BuzzIcons.hash,
title: 'Copy channel ID',
onTap: () {
copyToClipboard(
context,
resolvedChannel.id,
message: 'Channel ID copied',
);
},
),
],
),
if (resolvedChannel.isMember ||
canJoin ||
lifecycleCapabilitiesLoading ||
lifecycleCapabilitiesUnavailable ||
canArchive ||
canUnarchive ||
canDelete)
AppListCard(
key: const ValueKey('channel-details-lifecycle-card'),
verticalPadding: _channelDetailsSectionPadding,
children: [
if (canJoin)
AppListRow(
icon: BuzzIcons.logIn,
title: isJoining.value ? 'Joining channel…' : 'Join channel',
onTap: isJoining.value ? null : joinChannel,
),
if (resolvedChannel.isMember && !resolvedChannel.isArchived)
AppListRow(
icon: BuzzIcons.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: BuzzIcons.loaderCircle,
title: 'Loading channel actions…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading channel actions',
),
)
else if (lifecycleCapabilitiesUnavailable)
const AppListRow(
icon: BuzzIcons.triangleAlert,
title: 'Channel actions unavailable',
)
else ...[
if (canArchive)
AppListRow(
icon: BuzzIcons.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: BuzzIcons.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: BuzzIcons.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),
),
),
],
],
),
],
);
if (inSheet) return content;
return FrostedScaffold(
useUtilitySurfaceTheme: true,
appBar: FrostedAppBar(
@@ -314,306 +651,7 @@ class ChannelDetailsPage extends HookConsumerWidget {
),
),
),
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,
displayName: displayName,
avatar: counterpart == null
? null
: AvatarImage(
key: const ValueKey('dm-details-avatar'),
imageUrl: counterpartProfile?.avatarUrl,
radius: 40,
isAgent: counterpartProfile?.isAgent ?? false,
backgroundColor: context.controlSurface,
fallback: Text(
counterpartProfile?.initial ??
dmAvatarInitial(
resolvedChannel,
currentPubkey: resolvedCurrentPubkey,
),
style: context.textTheme.headlineMedium?.copyWith(
color: context.colors.onPrimaryContainer,
),
),
),
),
Padding(
key: const ValueKey('channel-details-actions'),
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
_channelDetailsSectionPadding,
Grid.gutter,
_channelDetailsSectionPadding,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-star-action'),
icon: null,
iconWidget: TablerStarIcon(
filled: isStarred,
color: isStarred
? context.colors.primary
: context.colors.onSurface,
),
label: isStarred ? 'Unstar' : 'Star',
onTap: toggleStar,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-mute-action'),
icon: isMuted ? BuzzIcons.bell : BuzzIcons.bellOff,
iconColor: isMuted ? context.colors.primary : null,
label: isMuted ? 'Unmute' : 'Mute',
onTap: toggleMute,
),
),
if (!resolvedChannel.isDm) ...[
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-edit-action'),
icon: BuzzIcons.pencil,
label: 'Edit',
isEnabled: canEdit,
onTap: openManageChannel,
),
),
],
],
),
),
),
if (!isDirectDm)
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(
BuzzIcons.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: BuzzIcons.loaderCircle,
title: 'Loading members…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading members',
),
)
else if (membersAsync.hasError && members.isEmpty)
const AppListRow(
icon: BuzzIcons.triangleAlert,
title: 'Members unavailable',
)
else ...[
for (final member in previewMembers)
_ChannelMemberPreviewRow(
key: ValueKey('channel-details-member-${member.pubkey}'),
member: member,
currentPubkey: resolvedCurrentPubkey,
onMemberTap: onMemberTap,
displayName:
userCache[member.pubkey.toLowerCase()]?.displayName,
contextualLabel:
memberLabels[member.pubkey.toLowerCase()],
avatarUrl:
userCache[member.pubkey.toLowerCase()]?.avatarUrl,
),
if (memberCount > _channelMemberPreviewLimit)
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: BuzzIcons.folderInput,
title: 'Move to section…',
trailing: const _ChannelDetailsChevron(),
onTap: () async {
await _showMoveSectionSheet(
context,
ref,
channel: resolvedChannel,
sectionId: currentSectionId,
);
},
),
AppListRow(
icon: BuzzIcons.copy,
title: 'Copy channel name',
onTap: () {
copyToClipboard(
context,
resolvedChannel.name,
message: 'Channel name copied',
);
},
),
AppListRow(
icon: BuzzIcons.hash,
title: 'Copy channel ID',
onTap: () {
copyToClipboard(
context,
resolvedChannel.id,
message: 'Channel ID copied',
);
},
),
],
),
if (resolvedChannel.isMember ||
canJoin ||
lifecycleCapabilitiesLoading ||
lifecycleCapabilitiesUnavailable ||
canArchive ||
canUnarchive ||
canDelete)
AppListCard(
key: const ValueKey('channel-details-lifecycle-card'),
verticalPadding: _channelDetailsSectionPadding,
children: [
if (canJoin)
AppListRow(
icon: BuzzIcons.logIn,
title: isJoining.value
? 'Joining channel…'
: 'Join channel',
onTap: isJoining.value ? null : joinChannel,
),
if (resolvedChannel.isMember && !resolvedChannel.isArchived)
AppListRow(
icon: BuzzIcons.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: BuzzIcons.loaderCircle,
title: 'Loading channel actions…',
trailing: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading channel actions',
),
)
else if (lifecycleCapabilitiesUnavailable)
const AppListRow(
icon: BuzzIcons.triangleAlert,
title: 'Channel actions unavailable',
)
else ...[
if (canArchive)
AppListRow(
icon: BuzzIcons.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: BuzzIcons.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: BuzzIcons.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),
),
),
],
],
),
],
),
body: content,
);
}
}

View File

@@ -35,7 +35,11 @@ import 'me_assistant_picker_sheet.dart';
import 'new_me_project.dart';
import 'me_workspace_picker.dart';
import 'me_recovery_dialog.dart';
import '../../profile/user_profile_sheet.dart';
import '../channel_actions_sheet.dart';
import '../channel_detail_page.dart';
import '../channel_identity_names_provider.dart';
import '../channel_member_profile_actions.dart';
import '../channel_management_provider.dart';
import '../channels_provider.dart';
@@ -179,6 +183,21 @@ class MePage extends HookConsumerWidget {
ChannelDetailPage(channel: channel, personalChat: true),
),
),
onLongPress: () => showChannelDetailsSheet(
context: context,
channel: channel,
currentPubkey: viewer,
onMemberTap: (context, pubkey) => showUserProfileSheet(
context,
pubkey,
names: channelIdentityNamesProvider(channel.id),
contextualActions: (_) => ChannelMemberProfileActions(
channel: channel,
pubkey: pubkey,
),
),
personalChat: true,
),
);
final inventorySlivers = <Widget>[
SliverPadding(

View File

@@ -20,12 +20,14 @@ class _MeChatRow extends ConsumerWidget {
required this.channelId,
required this.name,
required this.onTap,
required this.onLongPress,
required this.isUnread,
});
final String channelId;
final String name;
final bool isUnread;
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -35,11 +37,13 @@ class _MeChatRow extends ConsumerWidget {
return Semantics(
label: '${isUnread ? '$name, unread' : name}$status',
onTap: onTap,
onLongPress: onLongPress,
excludeSemantics: true,
button: true,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,

View File

@@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_groups.dart';
@@ -117,6 +118,66 @@ void main() {
expect(find.text('A place to work with your agents'), findsNothing);
});
testWidgets('long-pressing a chat opens its details as a sheet', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
defaultAgentProvider.overrideWith((ref) => Stream.value(null)),
myPubkeyProvider.overrideWithValue('owner'),
currentPubkeyProvider.overrideWith((ref) => 'owner'),
channelsProvider.overrideWith(LabeledChannels.new),
channelMembersProvider('loose').overrideWith(
(ref) async => [
ChannelMember(
pubkey: 'owner',
role: 'owner',
joinedAt: DateTime(2026),
),
],
),
agentOwnersProvider.overrideWithValue(
const AsyncValue.data(<String, String>{}),
),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups(groups: [], assignments: {})),
),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Unfiled labeled chat'));
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsOneWidget);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byKey(const ValueKey('channel-details-page-list')),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Unfiled labeled chat'),
),
findsOneWidget,
);
await tester.drag(
find.byKey(const ValueKey('channel-details-page-list')),
const Offset(0, -600),
);
await tester.pumpAndSettle();
expect(find.text('Leave channel'), findsOneWidget);
// Solo chats move between Solo workspaces, not channel sections.
expect(find.text('Move to section…'), findsNothing);
});
testWidgets('empty workspace stays centered behind the New chat keyboard', (
tester,
) async {