Files
buzz/mobile/lib/features/channels/channel_actions_sheet.dart
klopez4212 aeb741fd31 Polish mobile channel navigation and message sends (#6488)
## Summary
- align iOS back controls and affected titles across channel details,
settings, and pairing
- make channel star and mute actions reflect their state immediately
- animate locally sent channel, thread, and DM messages from behind the
composer with a 300ms ease-out

## Testing
- `just mobile-check`
- full mobile test suite (1,573 tests)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-21 19:13:16 +01:00

613 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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/ios_glass_navigation_button.dart';
import '../../shared/widgets/lucide_star_icon.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'add_members_sheet.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
part 'channel_details_page.dart';
/// Opens the mobile channel actions sheet and returns whether its parent page
/// should close after a successful lifecycle action.
Future<bool?> showChannelActionsSheet({
required BuildContext context,
required Channel channel,
required bool isUnread,
VoidCallback? onMarkRead,
String? sectionId,
}) => showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
builder: (_) => ChannelActionsSheet(
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
),
);
/// Mobile action sheet for channel-level read, organization, and lifecycle
/// operations.
class ChannelActionsSheet extends HookConsumerWidget {
const ChannelActionsSheet({
super.key,
required this.channel,
required this.isUnread,
this.onMarkRead,
this.sectionId,
});
final Channel channel;
final bool isUnread;
final VoidCallback? onMarkRead;
final String? sectionId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final displayedChannel = useState(channel);
final currentChannel = displayedChannel.value;
final isMuted =
ref.watch(channelMutesProvider).store.channels[channel.id]?.muted ==
true;
final isStarred =
!channel.isDm &&
ref.watch(channelStarsProvider).store.channels[channel.id]?.starred ==
true;
final membersAsync = channel.isDm
? const AsyncValue<List<ChannelMember>>.data([])
: ref.watch(channelMembersProvider(channel.id));
final agentOwnersAsync = channel.isDm
? const AsyncValue<Map<String, String>>.data({})
: ref.watch(agentOwnersProvider);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final currentMember = membersAsync.value?.cast<ChannelMember?>().firstWhere(
(member) => member?.pubkey.toLowerCase() == currentPubkey,
orElse: () => null,
);
final ownsOwnerAgent =
currentPubkey != null &&
membersAsync.value?.any(
(member) =>
member.isOwner &&
agentOwnersAsync.value?[member.pubkey.toLowerCase()]
?.toLowerCase() ==
currentPubkey,
) ==
true;
final canManageLifecycle =
currentMember?.isElevated == true || ownsOwnerAgent;
final canArchive = !channel.isArchived && canManageLifecycle;
final canUnarchive = channel.isArchived && canManageLifecycle;
final canDelete =
!channel.isArchived &&
(currentMember?.isOwner == true || ownsOwnerAgent);
final lifecycleCapabilitiesLoading =
membersAsync.isLoading || agentOwnersAsync.isLoading;
final lifecycleCapabilitiesUnavailable =
membersAsync.hasError || agentOwnersAsync.hasError;
void close() => Navigator.of(context).pop();
return SafeArea(
top: false,
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!channel.isDm) ...[
_ChannelQuickActionsRow(
isStarred: isStarred,
isUnread: isUnread,
onToggleStar: () {
close();
final notifier = ref.read(channelStarsProvider.notifier);
isStarred
? notifier.unstarChannel(channel.id)
: notifier.starChannel(channel.id);
},
onToggleRead: () {
close();
final timestamp = dateTimeToUnixSeconds(
channel.lastMessageAt,
);
if (isUnread) {
onMarkRead?.call();
if (timestamp != null) {
ref
.read(readStateProvider.notifier)
.markContextRead(
channel.id,
timestamp,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(
channel.id,
timestamp,
);
}
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id, channelId: channel.id);
}
},
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.folderInput),
title: const Text('Move to section…'),
onTap: () async {
final pageContext = Navigator.of(
context,
rootNavigator: true,
).context;
close();
await _showMoveSectionSheet(
pageContext,
ref,
channel: channel,
sectionId: sectionId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff),
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
onTap: () {
close();
final notifier = ref.read(channelMutesProvider.notifier);
isMuted
? notifier.unmuteChannel(channel.id)
: notifier.muteChannel(channel.id);
},
),
if (!channel.isDm)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.settings),
title: const Text('Manage channel'),
onTap: () 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: currentChannel,
canEditDetails:
canManageLifecycle && !currentChannel.isArchived,
onChannelUpdated: (updated) =>
displayedChannel.value = updated,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: const Text('Copy channel name'),
onTap: () {
close();
copyToClipboard(
context,
currentChannel.name,
message: 'Channel name copied to clipboard',
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.hash),
title: const Text('Copy channel ID'),
onTap: () {
close();
copyToClipboard(
context,
channel.id,
message: 'Channel ID copied to clipboard',
);
},
),
if (!channel.isDm) ...[
const SheetDivider(),
if (channel.isMember && !channel.isArchived)
_ActionTile(
icon: LucideIcons.logOut,
label: 'Leave channel',
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Leave #${currentChannel.name}?',
body: 'You’ll stop receiving messages from this channel.',
confirmLabel: 'Leave',
action: () => ref
.read(channelActionsProvider)
.leaveChannel(channel.id),
),
),
if (lifecycleCapabilitiesLoading)
const ListTile(
enabled: false,
leading: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading channel actions',
),
title: Text('Loading channel actions…'),
)
else if (lifecycleCapabilitiesUnavailable)
const ListTile(
enabled: false,
leading: Icon(LucideIcons.triangleAlert),
title: Text('Channel actions unavailable'),
)
else ...[
if (canArchive)
_ActionTile(
icon: LucideIcons.archive,
label: 'Archive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Archive #${currentChannel.name}?',
body: 'The channel will become read-only.',
confirmLabel: 'Archive',
action: () => ref
.read(channelActionsProvider)
.archiveChannel(channel.id),
),
),
if (canUnarchive)
_ActionTile(
icon: LucideIcons.archiveRestore,
label: 'Unarchive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Unarchive #${currentChannel.name}?',
body: 'The channel will become active again.',
confirmLabel: 'Unarchive',
action: () => ref
.read(channelActionsProvider)
.unarchiveChannel(channel.id),
),
),
if (canDelete)
_ActionTile(
icon: LucideIcons.trash2,
label: 'Delete channel',
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Delete #${currentChannel.name}?',
body:
'This permanently deletes the channel and cannot be undone.',
confirmLabel: 'Delete',
action: () => ref
.read(channelActionsProvider)
.deleteChannel(channel.id),
),
),
],
],
],
),
),
),
);
}
}
class _ChannelQuickActionsRow extends StatelessWidget {
const _ChannelQuickActionsRow({
required this.isStarred,
required this.isUnread,
required this.onToggleStar,
required this.onToggleRead,
});
final bool isStarred;
final bool isUnread;
final VoidCallback onToggleStar;
final VoidCallback onToggleRead;
@override
Widget build(BuildContext context) => Row(
children: [
Expanded(
child: _ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star',
onTap: onToggleStar,
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: _ChannelQuickAction(
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
label: isUnread ? 'Mark Read' : 'Mark Unread',
onTap: onToggleRead,
),
),
],
);
}
class _ChannelQuickAction extends StatelessWidget {
const _ChannelQuickAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
);
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: destructive ? context.colors.error : null),
title: Text(
label,
style: destructive ? TextStyle(color: context.colors.error) : null,
),
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
);
}
Future<void> _confirmAndRun(
BuildContext sheetContext,
WidgetRef ref, {
required String title,
required String body,
required String confirmLabel,
required Future<void> Function() action,
}) async {
final pageContext = Navigator.of(sheetContext, rootNavigator: true).context;
final confirmed = await showBuzzDialog<bool>(
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(confirmLabel),
),
],
),
);
if (confirmed != true) return;
try {
await action();
if (pageContext.mounted) Navigator.of(pageContext).pop(true);
} catch (error) {
if (!pageContext.mounted) return;
ScaffoldMessenger.of(pageContext).showSnackBar(
SnackBar(
content: Text(
'Couldn’t ${confirmLabel.toLowerCase()} channel. Try again.',
),
),
);
}
}
Future<void> _showMoveSectionSheet(
BuildContext context,
WidgetRef ref, {
required Channel channel,
required String? sectionId,
}) async {
final sections = [...ref.read(channelSectionsProvider).store.sections]
..sort((a, b) => a.order.compareTo(b.order));
await showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
ListTile(
leading: const Icon(LucideIcons.folder),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
},
),
ListTile(
leading: const Icon(LucideIcons.folderPlus),
title: const Text('New section…'),
onTap: () async {
Navigator.of(sheetContext).pop();
final name = await _showSectionNameDialog(context);
if (name == null || name.isEmpty) return;
final notifier = ref.read(channelSectionsProvider.notifier);
notifier.createSection(name);
final created = ref
.read(channelSectionsProvider)
.store
.sections
.where((section) => section.name == name.trim())
.lastOrNull;
if (created != null) {
notifier.assignChannel(channel.id, created.id);
}
},
),
if (sectionId != null)
ListTile(
leading: const Icon(LucideIcons.folderMinus),
title: const Text('Remove from section'),
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.unassignChannel(channel.id);
},
),
],
),
),
),
),
);
}
Future<String?> _showSectionNameDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showBuzzDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('New Section'),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.of(dialogContext).pop(controller.text.trim()),
child: const Text('Create'),
),
],
),
);
controller.dispose();
return result;
}