Files
buzz/mobile/lib/features/channels/channel_actions_sheet.dart
klopez4212 27b51144f3 Polish mobile bottom sheets and profile cards (#4911)
## Summary

- standardize mobile sheets with shared spacing, close controls, action
tiles, haptics, and motion
- add uniform native concentric corners on iOS 26+ while preserving the
Android sheet shape
- refresh profile actions/status and normalize membership and huddle
timeline spacing

## Validation

- `just mobile-check`
- `just mobile-test` (1,165 tests)
- signed iPhone Release build and device install
- Android debug build and Pixel 10 install

## Snapshots

### Channel actions

![Channel action sheet on
Pixel](https://raw.githubusercontent.com/block/buzz/3babe5d8e339a1e7ad69de3b07e17eab17fe3f9d/pr-4911--pixel-channel-actions.png)

### Profile card

![Profile card sheet on
Pixel](https://raw.githubusercontent.com/block/buzz/3babe5d8e339a1e7ad69de3b07e17eab17fe3f9d/pr-4911--pixel-profile-card.png)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-05 20:22:37 +01:00

590 lines
20 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/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/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<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 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<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,
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: const Text('Copy channel name'),
onTap: () {
close();
copyToClipboard(
context,
channel.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 #${channel.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 #${channel.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 #${channel.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 #${channel.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;
}