import 'dart:async'; import 'package:flutter/material.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/l10n/l10n.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/sheet_divider.dart'; import 'channel.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 '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; /// Opens the mobile channel actions sheet and returns whether its parent page /// should close after a successful lifecycle action. Future showChannelActionsSheet({ required BuildContext context, required Channel channel, required bool isUnread, VoidCallback? onMarkRead, String? sectionId, }) => showBuzzModalBottomSheet( 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 ConsumerWidget { 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 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>.data([]) : ref.watch(channelMembersProvider(channel.id)); final agentOwnersAsync = channel.isDm ? const AsyncValue>.data({}) : ref.watch(agentOwnersProvider); final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); final currentMember = membersAsync.value?.cast().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: Text(context.l10n.moveToSection), 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 ? context.l10n.unmuteChannel : context.l10n.muteChannel, ), 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: Text(context.l10n.manageChannel), onTap: () async { final shouldClose = await showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, constraints: BoxConstraints( maxWidth: 640, maxHeight: MediaQuery.sizeOf(context).height * 0.9, ), builder: (_) => ManageChannelSheet(channel: channel), ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(true); } }, ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(LucideIcons.copy), title: Text(context.l10n.copyChannelName), onTap: () { close(); copyToClipboard( context, channel.name, message: context.l10n.channelNameCopied, ); }, ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(LucideIcons.hash), title: Text(context.l10n.copyChannelId), onTap: () { close(); copyToClipboard( context, channel.id, message: context.l10n.channelIdCopied, ); }, ), if (!channel.isDm) ...[ const SheetDivider(), if (channel.isMember && !channel.isArchived) _ActionTile( icon: LucideIcons.logOut, label: context.l10n.leaveChannelLabel, destructive: true, onTap: () => _confirmAndRun( context, ref, title: context.l10n.leaveChannel(channel.name), body: context.l10n.leaveChannelBody, confirmLabel: context.l10n.leave, action: () => ref .read(channelActionsProvider) .leaveChannel(channel.id), ), ), if (lifecycleCapabilitiesLoading) ListTile( enabled: false, leading: BuzzLoadingIndicator( size: 20, semanticLabel: context.l10n.loadingChannelActions, ), title: Text(context.l10n.loadingChannelActions), ) else if (lifecycleCapabilitiesUnavailable) ListTile( enabled: false, leading: Icon(LucideIcons.triangleAlert), title: Text(context.l10n.channelActionsUnavailable), ) else ...[ if (canArchive) _ActionTile( icon: LucideIcons.archive, label: context.l10n.archiveChannelLabel, onTap: () => _confirmAndRun( context, ref, title: context.l10n.archiveChannel(channel.name), body: context.l10n.archiveChannelBody, confirmLabel: context.l10n.archive, action: () => ref .read(channelActionsProvider) .archiveChannel(channel.id), ), ), if (canUnarchive) _ActionTile( icon: LucideIcons.archiveRestore, label: context.l10n.unarchiveChannelLabel, onTap: () => _confirmAndRun( context, ref, title: context.l10n.unarchiveChannel(channel.name), body: context.l10n.unarchiveChannelBody, confirmLabel: context.l10n.unarchive, action: () => ref .read(channelActionsProvider) .unarchiveChannel(channel.id), ), ), if (canDelete) _ActionTile( icon: LucideIcons.trash2, label: context.l10n.deleteChannelLabel, destructive: true, onTap: () => _confirmAndRun( context, ref, title: context.l10n.deleteChannel(channel.name), body: context.l10n.deleteChannelBody, confirmLabel: context.l10n.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 ? context.l10n.unstar : context.l10n.star, onTap: onToggleStar, ), ), const SizedBox(width: Grid.twelve), Expanded( child: _ChannelQuickAction( icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, label: isUnread ? context.l10n.markRead : context.l10n.markUnread, 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 _confirmAndRun( BuildContext sheetContext, WidgetRef ref, { required String title, required String body, required String confirmLabel, required Future Function() action, }) async { final pageContext = Navigator.of(sheetContext, rootNavigator: true).context; final confirmed = await showBuzzDialog( context: pageContext, builder: (dialogContext) => AlertDialog( title: Text(title), content: Text(body), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(dialogContext.l10n.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( pageContext.l10n.actionFailed(confirmLabel), ), ), ); } } Future _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( 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: Text(context.l10n.newSection), 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: Text(context.l10n.removeFromSection), onTap: () { Navigator.of(sheetContext).pop(); ref .read(channelSectionsProvider.notifier) .unassignChannel(channel.id); }, ), ], ), ), ), ), ); } Future _showSectionNameDialog(BuildContext context) async { final controller = TextEditingController(); final result = await showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.newSectionTitle), content: TextField(controller: controller, autofocus: true), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text(context.l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), child: Text(context.l10n.create), ), ], ), ); controller.dispose(); return result; }