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>
This commit is contained in:
klopez4212
2026-08-05 20:22:37 +01:00
committed by GitHub
parent f2ce575b62
commit 27b51144f3
37 changed files with 2040 additions and 900 deletions
@@ -13,6 +13,7 @@
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */; };
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -57,6 +58,7 @@
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = "<group>"; };
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -179,6 +181,7 @@
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -413,6 +416,7 @@
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+25
View File
@@ -8,6 +8,7 @@ import UserNotifications
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
override func application(
@@ -63,6 +64,30 @@ import UserNotifications
)
}
if let concentricSheetRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzConcentricSheetSurface"
) {
concentricSheetRegistrar.register(
ConcentricSheetSurfaceFactory(),
withId: "buzz/concentric_sheet_surface"
)
concentricSheetSurfaceChannel = FlutterMethodChannel(
name: "buzz/concentric_sheet_surface",
binaryMessenger: messenger
)
concentricSheetSurfaceChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
if #available(iOS 26.0, *) {
result(true)
} else {
result(false)
}
}
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
@@ -0,0 +1,54 @@
import Flutter
import UIKit
final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory {
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
ConcentricSheetSurfacePlatformView(frame: frame, arguments: args)
}
}
final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
private let surfaceView: UIView
init(frame: CGRect, arguments args: Any?) {
let arguments = args as? [String: Any]
let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF
let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24
surfaceView = UIView(frame: frame)
surfaceView.isOpaque = true
surfaceView.backgroundColor = Self.color(from: colorValue)
surfaceView.clipsToBounds = true
surfaceView.layer.cornerCurve = .continuous
if #available(iOS 26.0, *) {
surfaceView.cornerConfiguration = .uniformCorners(
radius: .containerConcentric(minimum: minimumRadius)
)
} else {
surfaceView.layer.cornerRadius = minimumRadius
}
super.init()
}
func view() -> UIView {
surfaceView
}
private static func color(from value: UInt32) -> UIColor {
let alpha = CGFloat((value >> 24) & 0xFF) / 255
let red = CGFloat((value >> 16) & 0xFF) / 255
let green = CGFloat((value >> 8) & 0xFF) / 255
let blue = CGFloat(value & 0xFF) / 255
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
@@ -18,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channels_provider.dart';
@@ -340,32 +340,42 @@ class _InboxRow extends HookConsumerWidget {
}
void _showRowActions(BuildContext context) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
size: 20,
),
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
onTap: () {
Navigator.of(sheetContext).pop();
isDone ? onMarkUnread() : onMarkRead();
},
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: IconTheme.merge(
data: const IconThemeData(size: 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isDone ? LucideIcons.mail : LucideIcons.mailOpen,
),
title: Text(isDone ? 'Mark unread' : 'Mark as read'),
onTap: () {
Navigator.of(sheetContext).pop();
isDone ? onMarkUnread() : onMarkRead();
},
),
ListTile(
leading: const Icon(LucideIcons.externalLink),
title: const Text('Open conversation'),
onTap: () {
Navigator.of(sheetContext).pop();
onTap();
},
),
],
),
ListTile(
leading: const Icon(LucideIcons.externalLink, size: 20),
title: const Text('Open conversation'),
onTap: () {
Navigator.of(sheetContext).pop();
onTap();
},
),
],
),
),
),
);
@@ -1,4 +1,7 @@
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';
@@ -6,6 +9,7 @@ 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';
@@ -25,7 +29,7 @@ Future<bool?> showChannelActionsSheet({
required bool isUnread,
VoidCallback? onMarkRead,
String? sectionId,
}) => showModalBottomSheet<bool>(
}) => showBuzzModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -103,215 +107,223 @@ class ChannelActionsSheet extends ConsumerWidget {
return SafeArea(
top: false,
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) {
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)
.markContextRead(
channel.id,
timestamp,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(
channel.id,
timestamp,
);
.markContextUnread(channel.id, channelId: channel.id);
}
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id, channelId: channel.id);
}
},
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
ListTile(
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(
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(
leading: const Icon(LucideIcons.settings),
title: const Text('Manage channel'),
onTap: () async {
final shouldClose = await showModalBottomSheet<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(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy channel name'),
onTap: () {
close();
copyToClipboard(
context,
channel.name,
message: 'Channel name copied to clipboard',
);
},
),
ListTile(
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(
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,
title: 'Archive #${channel.name}?',
body: 'The channel will become read-only.',
confirmLabel: 'Archive',
action: () => ref
.read(channelActionsProvider)
.archiveChannel(channel.id),
),
),
if (canUnarchive)
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.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',
icon: LucideIcons.logOut,
label: 'Leave channel',
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Delete #${channel.name}?',
body:
'This permanently deletes the channel and cannot be undone.',
confirmLabel: 'Delete',
title: 'Leave #${channel.name}?',
body: 'You’ll stop receiving messages from this channel.',
confirmLabel: 'Leave',
action: () => ref
.read(channelActionsProvider)
.deleteChannel(channel.id),
.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),
),
),
],
],
],
],
),
),
),
);
@@ -333,17 +345,21 @@ class _ChannelQuickActionsRow extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star',
onTap: onToggleStar,
Expanded(
child: _ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star',
onTap: onToggleStar,
),
),
_ChannelQuickAction(
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
label: isUnread ? 'Mark Read' : 'Mark Unread',
onTap: onToggleRead,
const SizedBox(width: Grid.twelve),
Expanded(
child: _ChannelQuickAction(
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
label: isUnread ? 'Mark Read' : 'Mark Unread',
onTap: onToggleRead,
),
),
],
);
@@ -362,28 +378,35 @@ class _ChannelQuickAction extends StatelessWidget {
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 76,
height: 56,
alignment: Alignment.center,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Icon(icon, size: 24, 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,
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,
),
),
],
),
),
],
@@ -406,12 +429,16 @@ class _ActionTile extends StatelessWidget {
@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: onTap,
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
);
}
@@ -424,7 +451,7 @@ Future<void> _confirmAndRun(
required Future<void> Function() action,
}) async {
final pageContext = Navigator.of(sheetContext, rootNavigator: true).context;
final confirmed = await showDialog<bool>(
final confirmed = await showBuzzDialog<bool>(
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: Text(title),
@@ -465,69 +492,72 @@ Future<void> _showMoveSectionSheet(
}) async {
final sections = [...ref.read(channelSectionsProvider).store.sections]
..sort((a, b) => a.order.compareTo(b.order));
await showModalBottomSheet<void>(
await showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
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.folder),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
leading: const Icon(LucideIcons.folderPlus),
title: const Text('New section…'),
onTap: () async {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
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);
}
},
),
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);
},
),
],
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);
},
),
],
),
),
),
),
@@ -536,7 +566,7 @@ Future<void> _showMoveSectionSheet(
Future<String?> _showSectionNameDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showDialog<String>(
final result = await showBuzzDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('New Section'),
@@ -18,6 +18,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/skeleton.dart';
import '../profile/presence_cache_provider.dart';
import '../profile/profile_provider.dart';
@@ -39,7 +39,7 @@ class _MembersButton extends ConsumerWidget {
return IconButton(
color: context.colors.primary,
onPressed: () {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -97,7 +97,10 @@ class _SystemMessageRow extends ConsumerWidget {
isArchived: isArchived,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
padding: EdgeInsets.only(
top: usesMessageStyleLayout ? Grid.xs : Grid.xxs,
bottom: usesMessageStyleLayout ? 0 : Grid.xxs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -341,10 +344,14 @@ class _MessageStyleSystemMessageContent extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: messageAvatarSize,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, displayPubkey),
child: _UserAvatar(
profile: userCache[displayPubkey.toLowerCase()],
pubkey: displayPubkey,
size: messageAvatarSize,
),
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
@@ -449,12 +456,23 @@ Widget _systemEventAvatar(
height: 20,
child: Stack(
children: [
SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(
pubkey: event.actorPubkey!,
userCache: userCache,
),
),
Positioned(
left: 12,
child: SmallAvatar(
pubkey: event.targetPubkey!,
userCache: userCache,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.targetPubkey!),
child: SmallAvatar(
pubkey: event.targetPubkey!,
userCache: userCache,
),
),
),
],
@@ -463,7 +481,11 @@ Widget _systemEventAvatar(
}
if (event.actorPubkey != null) {
return SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showUserProfileSheet(context, event.actorPubkey!),
child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache),
);
}
// Fallback: generic icon when no actor is available.
@@ -20,6 +20,7 @@ import '../../shared/widgets/anchored_popover_menu.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/skeleton.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
@@ -299,7 +300,7 @@ class ChannelsPage extends HookConsumerWidget {
void openCommunitySwitcher() {
unawaited(HapticFeedback.selectionClick());
ref.invalidate(communityIconProvider);
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
@@ -278,7 +278,7 @@ class _SliverChannelsList extends HookConsumerWidget {
userSections.first.id != section.id,
onToggle: () => toggleSection(section.id),
onRename: () async {
final name = await showDialog<String>(
final name = await showBuzzDialog<String>(
context: context,
builder: (_) => _SectionNameDialog(
title: 'Rename Section',
@@ -293,7 +293,7 @@ class _SliverChannelsList extends HookConsumerWidget {
}
},
onDelete: () async {
final confirmed = await showDialog<bool>(
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text('Delete "${section.name}"?'),
@@ -422,7 +422,7 @@ Future<void> _confirmRemoveCommunity(
Community community, {
required bool closeSheetAfterRemoval,
}) async {
final confirmed = await showDialog<bool>(
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: const Text('Remove community?'),
@@ -85,7 +85,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
final created = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
@@ -96,7 +96,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
final opened = await showBuzzModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
@@ -25,6 +25,7 @@ import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../../shared/custom_emoji/custom_emoji.dart';
@@ -160,7 +160,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
required bool canInvite,
}) {
final verb = names.length == 1 ? 'is' : 'are';
return showDialog<_NonMemberMentionChoice>(
return showBuzzDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Mention people outside this channel?'),
@@ -11,6 +11,7 @@ import '../../shared/emoji/emoji_data_provider.dart';
import '../../shared/emoji/emoji_search.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'recent_emoji_provider.dart';
part 'emoji_picker/search_field.dart';
@@ -33,7 +34,7 @@ void showEmojiPicker({
required void Function(String emoji) onSelect,
VoidCallback? onDismiss,
}) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -6,6 +6,7 @@ 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 '../../shared/widgets/modal_presentation.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../profile/user_status.dart';
@@ -50,7 +51,7 @@ class MembersSheet extends HookConsumerWidget {
navigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!navigator.mounted) return;
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: navigator.context,
isScrollControlled: true,
showDragHandle: true,
@@ -302,7 +303,7 @@ class _MemberTile extends ConsumerWidget {
? profile!.displayName!.trim()
: member.labelFor(currentPubkey));
final canChangeRole = showManagementActions && !member.isBot;
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
@@ -360,7 +361,7 @@ class _MemberTile extends ConsumerWidget {
),
onTap: () async {
Navigator.of(context).pop();
final confirmed = await showDialog<bool>(
final confirmed = await showBuzzDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove member'),
+255 -185
View File
@@ -19,6 +19,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/native_emoji_glyph.dart';
import '../../shared/widgets/sheet_divider.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/reminders/remind_me_later_sheet.dart';
import '../../shared/reminders/reminder_service.dart';
import 'channel_management_provider.dart';
@@ -47,97 +48,104 @@ void showMessageActions({
bool isMember = false,
bool isArchived = false,
}) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showCloseButton: false,
builder: (sheetContext) => SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QuickReactionRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
if (!message.isSystem) ...[
// Fast actions: respond now, hand off context, defer.
_FastActionsRow(
message: message,
channelId: channelId,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
pageContext: context,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
const SizedBox(height: Grid.xs),
// Triage: come back to this message later.
_MarkReadUnreadTile(message: message, channelId: channelId),
_FollowThreadTile(message: message),
const SheetDivider(),
// Export: take the content out of the conversation.
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
// Copy to clipboard
final data = ClipboardData(text: message.content);
Clipboard.setData(data);
},
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
if (canManageMessage) ...[
if (!message.isSystem) const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.pencil),
title: const Text('Edit message'),
onTap: () {
Navigator.of(sheetContext).pop();
_showEditSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
);
},
),
],
],
],
),
),
),
),
@@ -156,78 +164,91 @@ void showImageActions({
required bool canManageMessage,
VoidCallback? onDeleted,
}) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.download),
title: const Text('Save image'),
onTap: () {
Navigator.of(sheetContext).pop();
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
leading: const Icon(LucideIcons.share2),
title: const Text('Share image'),
onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(context, ref, imageUrl, shareOrigin: shareOrigin),
);
},
),
ListTile(
leading: const Icon(LucideIcons.link2),
title: const Text('Copy image link'),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: 'Image link copied',
);
},
),
if (canManageMessage) ...[
const SheetDivider(),
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.download),
title: const Text('Save image'),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
unawaited(_saveImage(context, ref, imageUrl));
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.share2),
title: const Text('Share image'),
onTap: () {
final renderBox = context.findRenderObject() as RenderBox?;
final shareOrigin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
Navigator.of(sheetContext).pop();
unawaited(
_shareImage(
context,
ref,
imageUrl,
shareOrigin: shareOrigin,
),
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.link2),
title: const Text('Copy image link'),
onTap: () {
Navigator.of(sheetContext).pop();
copyToClipboard(
context,
imageUrl,
message: 'Image link copied',
);
},
),
if (canManageMessage) ...[
const SheetDivider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete message',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(
context: context,
ref: ref,
channelId: channelId,
messageId: message.id,
onDeleted: onDeleted,
);
},
),
],
],
],
),
),
),
),
@@ -387,6 +408,7 @@ class _MarkReadUnreadTile extends ConsumerWidget {
);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen),
title: Text(unread ? 'Mark read' : 'Mark unread'),
onTap: () {
@@ -428,6 +450,7 @@ class _FollowThreadTile extends ConsumerWidget {
final following = follows.isFollowing(rootId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing),
title: Text(following ? 'Unfollow thread' : 'Follow thread'),
onTap: () {
@@ -533,8 +556,12 @@ class _FastActionsRow extends ConsumerWidget {
];
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: tiles,
children: [
for (var index = 0; index < tiles.length; index++) ...[
Expanded(child: tiles[index]),
if (index < tiles.length - 1) const SizedBox(width: Grid.twelve),
],
],
);
}
}
@@ -553,30 +580,37 @@ class _FastActionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Pill-shaped icon container with the caption below the fill,
// matching the emoji circles' fill and tap treatment.
// The full-width tiles deliberately contrast with the compact emoji
// reactions immediately above them.
Container(
width: 76,
height: 56,
alignment: Alignment.center,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Icon(icon, size: 24, 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,
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,
),
),
],
),
),
],
@@ -636,32 +670,59 @@ class _QuickReactionRow extends ConsumerWidget {
pageRef.read(channelActionsProvider).addReaction(message.id, value);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final value in emoji)
return LayoutBuilder(
builder: (context, constraints) {
const desiredCircleSize = 52.0;
const minimumCircleSize = 44.0;
final itemCount = emoji.length + 1;
final gapCount = itemCount - 1;
final circleSize =
((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount)
.clamp(minimumCircleSize, desiredCircleSize)
.toDouble();
final gap =
((constraints.maxWidth - (circleSize * itemCount)) / gapCount)
.clamp(0.0, Grid.twelve)
.toDouble();
final circles = <Widget>[
for (final value in emoji)
_QuickReactionCircle(
key: ValueKey('quick-reaction-$value'),
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(value);
},
child: _QuickReactionGlyph(
value: value,
customByShortcode: customByShortcode,
),
),
_QuickReactionCircle(
key: const ValueKey('quick-reaction-more'),
size: circleSize,
onTap: () {
Navigator.of(sheetContext).pop();
react(value);
showEmojiPicker(context: pageContext, onSelect: react);
},
child: _QuickReactionGlyph(
value: value,
customByShortcode: customByShortcode,
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
_QuickReactionCircle(
onTap: () {
Navigator.of(sheetContext).pop();
showEmojiPicker(context: pageContext, onSelect: react);
},
child: Icon(
LucideIcons.plus,
size: 24,
color: context.colors.onSurfaceVariant,
),
),
],
];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < circles.length; index++) ...[
circles[index],
if (index < circles.length - 1) SizedBox(width: gap),
],
],
);
},
);
}
}
@@ -699,16 +760,25 @@ class _QuickReactionGlyph extends StatelessWidget {
class _QuickReactionCircle extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final double size;
const _QuickReactionCircle({required this.onTap, required this.child});
const _QuickReactionCircle({
super.key,
required this.onTap,
required this.child,
required this.size,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTap: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
child: Container(
width: 52,
height: 52,
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
@@ -727,7 +797,7 @@ void _showEditSheet({
required String channelId,
}) {
final controller = TextEditingController(text: message.content);
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -794,7 +864,7 @@ void _confirmDelete({
required String messageId,
VoidCallback? onDeleted,
}) {
showDialog<void>(
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete message'),
@@ -108,6 +108,9 @@ class MessageContent extends HookConsumerWidget {
final TextStyle? baseStyle;
/// Alignment applied to rendered markdown text.
final TextAlign? textAlign;
final int? maxLines;
/// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way
@@ -137,6 +140,7 @@ class MessageContent extends HookConsumerWidget {
this.onMediaReply,
this.onMediaMore,
this.baseStyle,
this.textAlign,
this.maxLines,
this.scaleEmojiOnly = false,
this.mediaCarouselLeadingOverflow = 0,
@@ -269,6 +273,7 @@ class MessageContent extends HookConsumerWidget {
_buildLink(context, ref, linkText, url, linkStyle, style),
imageBuilder: (context, imageUrl) =>
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
textAlign: textAlign,
maxLines: maxLines,
inlineComponents: [
_MentionMd(
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/emoji/emoji_burst.dart';
import '../../shared/emoji/emoji_data_provider.dart';
@@ -316,7 +317,7 @@ void showReactionDetailSheet({
required List<TimelineReaction> reactions,
required String initialEmoji,
}) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -17,13 +17,14 @@ import '../../shared/theme/theme_provider.dart';
/// leak across an in-place community or account switch.
const _recentEmojiPrefsKey = 'buzz.quick-reaction-emojis.v1';
/// Desktop's `DEFAULT_QUICK_REACTIONS`, used until the user has reacted enough
/// to fill the row.
/// Desktop's `DEFAULT_QUICK_REACTIONS`, plus one mobile-only slot that fits in
/// the compact action sheet, used until the user has reacted enough to fill it.
const defaultQuickEmojis = <String>[
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F602}',
'\u{1F389}',
'\u{1F525}',
];
/// Desktop's `MAX_STORED_REACTIONS`.
@@ -167,7 +168,7 @@ final recentEmojiProvider =
List<String> quickReactionEmoji(
List<RecentEmojiEntry> entries, {
required Set<String> customShortcodes,
int limit = 4,
int limit = 5,
}) {
final result = <String>[];
void add(String emoji) {
+35 -31
View File
@@ -7,6 +7,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
import '../profile/user_profile_sheet.dart';
@@ -230,44 +231,47 @@ class ForumPostCard extends HookConsumerWidget {
currentPubkey != null &&
post.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: post.content));
},
),
if (isOwn && onDelete != null)
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(color: sheetContext.colors.error),
),
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context);
Clipboard.setData(ClipboardData(text: post.content));
},
),
],
if (isOwn && onDelete != null)
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context);
},
),
],
),
),
),
),
@@ -275,7 +279,7 @@ class ForumPostCard extends HookConsumerWidget {
}
void _confirmDelete(BuildContext context) {
showDialog<void>(
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete post'),
@@ -12,6 +12,7 @@ import '../../shared/widgets/avatar_image.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 '../channels/compose_bar.dart';
import '../channels/message_content.dart';
import '../profile/user_cache_provider.dart';
@@ -113,43 +114,46 @@ class ForumThreadPage extends HookConsumerWidget {
WidgetRef ref,
ForumThreadResponse thread,
) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: thread.post.content));
},
),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: thread.post.content));
},
),
title: Text(
'Delete post',
style: TextStyle(color: sheetContext.colors.error),
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete post',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDeletePost(context, ref, thread.post.eventId);
},
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDeletePost(context, ref, thread.post.eventId);
},
),
],
],
),
),
),
),
@@ -157,7 +161,7 @@ class ForumThreadPage extends HookConsumerWidget {
}
void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) {
showDialog<void>(
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete post'),
@@ -533,44 +537,47 @@ class _ReplyRow extends ConsumerWidget {
currentPubkey != null &&
reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase();
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
Clipboard.setData(ClipboardData(text: reply.content));
},
),
if (isOwn)
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete reply',
style: TextStyle(color: sheetContext.colors.error),
),
leading: const Icon(LucideIcons.copy),
title: const Text('Copy text'),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context, ref);
Clipboard.setData(ClipboardData(text: reply.content));
},
),
],
if (isOwn)
ListTile(
leading: Icon(
LucideIcons.trash2,
color: sheetContext.colors.error,
),
title: Text(
'Delete reply',
style: TextStyle(color: sheetContext.colors.error),
),
onTap: () {
Navigator.of(sheetContext).pop();
_confirmDelete(context, ref);
},
),
],
),
),
),
),
@@ -578,7 +585,7 @@ class _ReplyRow extends ConsumerWidget {
}
void _confirmDelete(BuildContext context, WidgetRef ref) {
showDialog<void>(
showBuzzDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete reply'),
@@ -4,11 +4,12 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../pairing/pairing_page.dart';
import 'invite_join_provider.dart';
Future<void> showInviteJoinSheet(BuildContext context, WidgetRef ref) {
return showModalBottomSheet<void>(
return showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji.dart';
import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/emoji_picker.dart';
import 'user_status.dart';
import 'user_status_provider.dart';
@@ -21,7 +22,7 @@ const _emojiGlyphSize = 32.0;
const _saveButtonHeight = 52.0;
void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -1,28 +1,40 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/clipboard_utils.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
import '../channels/channel_management_provider.dart';
import '../channels/message_content.dart';
import 'presence_cache_provider.dart';
import 'user_cache_provider.dart';
import 'user_status_cache_provider.dart';
import '../channels/message_content.dart';
/// Show a user profile bottom sheet for the given [pubkey].
void showUserProfileSheet(BuildContext context, String pubkey) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
showDragHandle: false,
builder: (_) => UserProfileSheet(pubkey: pubkey),
);
).then((channel) {
if (channel == null || !context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
});
}
class UserProfileSheet extends HookConsumerWidget {
@@ -66,6 +78,8 @@ class UserProfileSheet extends HookConsumerWidget {
ref.read(userCacheProvider.notifier).preload([pk]);
return null;
}, [pk]);
final copied = useState(false);
final isOpeningDirectMessage = useState(false);
final displayName = profile?.displayName;
final avatarUrl = profile?.avatarUrl;
@@ -73,181 +87,282 @@ class UserProfileSheet extends HookConsumerWidget {
final initial =
profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?');
final presenceColor = switch (presence) {
Future<void> copyPublicKey() async {
await Clipboard.setData(ClipboardData(text: pubkey));
if (!context.mounted) return;
copied.value = true;
_showProfileCopyToast(context);
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
if (context.mounted) copied.value = false;
}),
);
}
Future<void> openDirectMessage() async {
if (isOpeningDirectMessage.value) return;
isOpeningDirectMessage.value = true;
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [pk]);
if (!context.mounted) return;
Navigator.of(context).pop(channel);
} catch (_) {
// Silently fail — user tapped but DM open failed.
if (context.mounted) isOpeningDirectMessage.value = false;
}
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Center the presence chip on the avatar's lower edge.
Padding(
padding: const EdgeInsets.only(bottom: Grid.xl / 2),
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: FractionallySizedBox(
widthFactor: 0.5,
child: _ProfileAvatar(
avatarUrl: avatarUrl,
initial: initial,
),
),
),
Positioned(
left: 0,
right: 0,
bottom: -(Grid.xl / 2),
child: _ProfilePresenceChip(presence: presence),
),
],
),
),
const SizedBox(height: Grid.half),
// Display name — centered, large
Center(
child: Text(
displayName ?? shortPubkey(pubkey),
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
// Match Settings: status is quiet, centered copy directly
// below the profile name rather than a separate information row.
if (userStatus != null && !userStatus.isEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xxs,
),
child: MessageContent(
content: userStatus.text.isNotEmpty
? '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}'
: userStatus.emoji,
baseStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
),
),
),
// NIP-05 handle — centered, secondary
if (nip05 != null && nip05.isNotEmpty) ...[
const SizedBox(height: Grid.half),
Center(
child: Text(
nip05,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
const SizedBox(height: Grid.xs + Grid.half),
Row(
children: [
if (pk != currentPubkey) ...[
Expanded(
child: _ProfileActionTile(
icon: isOpeningDirectMessage.value
? null
: LucideIcons.messageSquare,
label: isOpeningDirectMessage.value
? 'Opening…'
: 'Message',
isLoading: isOpeningDirectMessage.value,
onTap: openDirectMessage,
),
),
const SizedBox(width: Grid.twelve),
],
Expanded(
child: _ProfileActionTile(
icon: copied.value
? LucideIcons.check
: LucideIcons.key,
label: copied.value ? 'Copied' : 'Copy public key',
onTap: copyPublicKey,
),
),
],
),
const SizedBox(height: Grid.xs),
// About / bio section
if (about.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Divider(
color: context.colors.outlineVariant.withValues(
alpha: 0.3,
),
),
const SizedBox(height: Grid.xxs),
Text(
'About',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
about,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
],
),
),
),
),
],
),
);
}
}
/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar
/// belongs to the page Scaffold, which sits behind the modal sheet.
void _showProfileCopyToast(BuildContext context) {
final overlay = Overlay.of(context, rootOverlay: true);
final colors = context.colors;
final textStyle = context.textTheme.bodyMedium?.copyWith(
color: colors.onInverseSurface,
);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (overlayContext) => Positioned(
left: Grid.gutter,
right: Grid.gutter,
bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs,
child: Material(
color: colors.inverseSurface,
elevation: 6,
borderRadius: BorderRadius.circular(Radii.lg),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.check, size: 18, color: colors.onInverseSurface),
const SizedBox(width: Grid.xxs),
Text('Public key copied', style: textStyle),
],
),
),
),
),
);
overlay.insert(entry);
Future<void>.delayed(const Duration(seconds: 2), () {
if (entry.mounted) entry.remove();
});
}
/// The read-only version of the presence control in Settings. A viewed profile
/// reflects its owner's availability but does not allow another user to change
/// it.
class _ProfilePresenceChip extends StatelessWidget {
const _ProfilePresenceChip({required this.presence});
final String presence;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' => presence,
_ => 'offline',
};
final backgroundColor = switch (effectivePresence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
_ => context.colors.onSurfaceVariant,
};
final presenceLabel = switch (presence) {
final label = switch (effectivePresence) {
'online' => 'Online',
'away' => 'Away',
_ => 'Offline',
};
return SizedBox(
width: double.infinity,
child: Padding(
padding: EdgeInsets.fromLTRB(
Grid.sm,
0,
Grid.sm,
MediaQuery.viewInsetsOf(context).bottom,
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar — near full-width
_ProfileAvatar(avatarUrl: avatarUrl, initial: initial),
const SizedBox(height: Grid.xs),
// Display name — centered, large
Center(
child: Text(
displayName ?? shortPubkey(pubkey),
style: context.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
return Semantics(
label: 'Presence: $label',
child: SizedBox(
height: Grid.xl,
child: Center(
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
style: filterChipTextStyle.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w500,
),
// NIP-05 handle — centered, secondary
if (nip05 != null && nip05.isNotEmpty) ...[
const SizedBox(height: Grid.half),
Center(
child: Text(
nip05,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
const SizedBox(height: Grid.xs),
// Presence row — filled dot, not an outline icon
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half + 2),
child: Row(
children: [
SizedBox(
width: 24,
child: Center(
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: presenceColor,
shape: BoxShape.circle,
),
),
),
),
const SizedBox(width: Grid.xxs),
Text(
presenceLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
if (userStatus != null && !userStatus.isEmpty)
_InfoRowContent(
icon: LucideIcons.messageCircle,
child: MessageContent(
content:
'${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}',
),
),
_InfoRow(
icon: LucideIcons.key,
text: shortPubkey(pubkey),
textStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'monospace',
),
onTap: () async {
await copyToClipboard(
context,
pubkey,
message: 'Public key copied',
);
},
),
// About / bio section
if (about.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Divider(
color: context.colors.outlineVariant.withValues(alpha: 0.3),
),
const SizedBox(height: Grid.xxs),
Text(
'About',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Grid.half),
Text(
about,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
const SizedBox(height: Grid.xs),
// Action button — Message (hidden on own profile)
if (pk != currentPubkey) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
Navigator.of(context).pop();
try {
final channel = await ref
.read(channelActionsProvider)
.openDm(pubkeys: [pk]);
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
ChannelDetailPage(channel: channel),
),
);
} catch (_) {
// Silently fail — user tapped but DM open failed.
}
},
icon: const Icon(LucideIcons.messageSquare, size: 18),
label: const Text('Message'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: Grid.twelve,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.lg),
),
),
),
),
const SizedBox(height: Grid.xxs),
],
],
),
),
),
),
@@ -256,83 +371,57 @@ class UserProfileSheet extends HookConsumerWidget {
}
}
/// A row displaying an icon + text, used for profile info items.
class _InfoRowContent extends StatelessWidget {
final IconData icon;
final Widget child;
const _InfoRowContent({required this.icon, required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.quarter),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.xxs),
Expanded(child: child),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String text;
final TextStyle? textStyle;
final VoidCallback? onTap;
const _InfoRow({
class _ProfileActionTile extends StatelessWidget {
const _ProfileActionTile({
required this.icon,
required this.text,
this.textStyle,
this.onTap,
required this.label,
required this.onTap,
this.isLoading = false,
});
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) {
final child = Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.half + 2),
child: Row(
Widget build(BuildContext context) => GestureDetector(
onTap: isLoading
? null
: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 24,
child: Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
text,
style:
textStyle ??
context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
),
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: 'Opening direct message',
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
if (onTap != null)
Icon(
LucideIcons.copy,
size: 14,
color: context.colors.onSurfaceVariant,
),
],
),
);
if (onTap != null) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: child,
);
}
return child;
}
),
);
}
class _ProfileAvatar extends StatelessWidget {
@@ -345,8 +434,7 @@ class _ProfileAvatar extends StatelessWidget {
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: ClipOval(
child: AvatarImageContent(
imageUrl: avatarUrl,
fallback: _AvatarFallback(initial: initial),
@@ -16,6 +16,7 @@ import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'accent_picker_page.dart';
import 'theme_picker_page.dart';
@@ -54,7 +54,7 @@ class _AppearanceSection extends ConsumerWidget {
}
void _showAppearanceModeSheet(BuildContext context) {
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _AppearanceModeSheet(),
@@ -73,7 +73,7 @@ class _IdentityRow extends StatelessWidget {
}
void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) {
showDialog<void>(
showBuzzDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Community'),
@@ -4,6 +4,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
import '../widgets/sheet_divider.dart';
import '../widgets/modal_presentation.dart';
import 'reminder_service.dart';
import 'reminder_time_presets.dart';
@@ -37,56 +38,59 @@ void showRemindMeLaterSheet({
}
}
showModalBottomSheet<void>(
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: Grid.half,
bottom: Grid.xxs,
child: IconTheme.merge(
data: const IconThemeData(size: 22),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: Grid.half,
bottom: Grid.xxs,
),
child: Text(
'Remind me about this message',
style: Theme.of(sheetContext).textTheme.titleSmall,
),
),
child: Text(
'Remind me about this message',
style: Theme.of(sheetContext).textTheme.titleSmall,
),
),
for (final preset in reminderTimePresets)
for (final preset in reminderTimePresets)
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(preset.label),
onTap: () {
Navigator.of(sheetContext).pop();
submit(preset.getTimestamp());
},
),
const SheetDivider(),
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(preset.label),
onTap: () {
Navigator.of(sheetContext).pop();
submit(preset.getTimestamp());
leading: const Icon(LucideIcons.calendarClock),
title: const Text('Pick a date & time'),
onTap: () async {
final navigator = Navigator.of(sheetContext);
final timestamp = await _pickCustomDateTime(context);
// Cancelled or not-in-the-future: keep the preset sheet
// open so retrying doesn't mean long-pressing again.
if (timestamp == null) return;
if (navigator.mounted) navigator.pop();
await submit(timestamp);
},
),
const SheetDivider(),
ListTile(
leading: const Icon(LucideIcons.calendarClock),
title: const Text('Pick a date & time'),
onTap: () async {
final navigator = Navigator.of(sheetContext);
final timestamp = await _pickCustomDateTime(context);
// Cancelled or not-in-the-future: keep the preset sheet
// open so retrying doesn't mean long-pressing again.
if (timestamp == null) return;
if (navigator.mounted) navigator.pop();
await submit(timestamp);
},
),
],
],
),
),
),
),
@@ -98,17 +102,20 @@ void showRemindMeLaterSheet({
/// timestamp in seconds, or null when cancelled or not in the future.
Future<int?> _pickCustomDateTime(BuildContext context) async {
final now = DateTime.now();
final date = await showDatePicker(
final date = await showBuzzDialog<DateTime>(
context: context,
initialDate: now,
firstDate: DateTime(now.year, now.month, now.day),
lastDate: now.add(const Duration(days: 365 * 2)),
builder: (_) => DatePickerDialog(
initialDate: now,
firstDate: DateTime(now.year, now.month, now.day),
lastDate: now.add(const Duration(days: 365 * 2)),
),
);
if (date == null || !context.mounted) return null;
final time = await showTimePicker(
final time = await showBuzzDialog<TimeOfDay>(
context: context,
initialTime: const TimeOfDay(hour: 9, minute: 0),
builder: (_) =>
const TimePickerDialog(initialTime: TimeOfDay(hour: 9, minute: 0)),
);
if (time == null) return null;
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
/// An iOS-native sheet surface that adopts the system's concentric corners on
/// iOS 26 and newer. Other platforms keep the normal Flutter shape.
class ConcentricSheetSurface extends HookWidget {
const ConcentricSheetSurface({
required this.child,
required this.enabled,
this.color,
super.key,
});
final Widget child;
final bool enabled;
final Color? color;
static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
Future<bool> _checkNativeSurfaceSupport() async {
try {
final supported = await _surfaceChannel.invokeMethod<bool>('isSupported');
return supported == true;
} on MissingPluginException {
// The registrar is unavailable, so retain the Flutter surface.
return false;
} on PlatformException {
// The native surface is optional; retain the Flutter surface on failure.
return false;
}
}
@override
Widget build(BuildContext context) {
final shouldCheckNativeSurface =
enabled && defaultTargetPlatform == TargetPlatform.iOS;
final supportFuture = useMemoized(
() => shouldCheckNativeSurface
? _checkNativeSurfaceSupport()
: Future<bool>.value(false),
[shouldCheckNativeSurface],
);
final nativeSurfaceSupported = useFuture(supportFuture).data ?? false;
if (!shouldCheckNativeSurface) {
return child;
}
final surfaceColor = color ?? context.colors.surface;
return Padding(
padding: const EdgeInsets.only(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
),
child: Stack(
children: [
if (nativeSurfaceSupported)
Positioned.fill(
child: ExcludeSemantics(
child: UiKitView(
viewType: 'buzz/concentric_sheet_surface',
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
creationParams: <String, Object>{
'color': surfaceColor.toARGB32(),
'minimumRadius': Radii.dialog,
},
creationParamsCodec: const StandardMessageCodec(),
),
),
)
else
Positioned.fill(
child: Material(
color: surfaceColor,
borderRadius: BorderRadius.circular(Radii.dialog),
clipBehavior: Clip.antiAlias,
),
),
child,
],
),
);
}
}
@@ -0,0 +1,220 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
import 'concentric_sheet_surface.dart';
/// Shared motion for occasional modal UI.
///
/// The strong ease-out makes entrances respond immediately, while the shorter
/// exit keeps dismissals from feeling sluggish.
const buzzModalAnimationStyle = AnimationStyle(
curve: Cubic(0.23, 1, 0.32, 1),
duration: Duration(milliseconds: 280),
reverseCurve: Cubic(0.77, 0, 0.175, 1),
reverseDuration: Duration(milliseconds: 220),
);
/// Shows a bottom sheet with Buzz's shared motion and sheet chrome.
///
/// Sheets include the shared close control by default. On iOS, the surface
/// uses native concentric corners when available and paints a requested drag
/// handle inside that inset surface; other platforms retain Flutter's handle.
Future<T?> showBuzzModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
Color? backgroundColor,
String? barrierLabel,
double? elevation,
ShapeBorder? shape,
Clip? clipBehavior,
BoxConstraints? constraints,
Color? barrierColor,
bool isScrollControlled = false,
double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0,
bool useRootNavigator = false,
bool isDismissible = true,
bool enableDrag = true,
bool? showDragHandle,
bool showCloseButton = true,
bool useSafeArea = false,
RouteSettings? routeSettings,
AnimationController? transitionAnimationController,
Offset? anchorPoint,
AnimationStyle? sheetAnimationStyle,
bool? requestFocus,
}) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final theme = Theme.of(context);
final surfaceColor =
backgroundColor ??
theme.bottomSheetTheme.modalBackgroundColor ??
theme.bottomSheetTheme.backgroundColor ??
context.colors.surface;
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showModalBottomSheet<T>(
context: context,
builder: (sheetContext) => ConcentricSheetSurface(
enabled: isIos,
color: surfaceColor,
child: _SheetContent(
showCloseButton: showCloseButton,
showDragHandle: isIos && showDragHandle == true,
child: builder(sheetContext),
),
),
backgroundColor: isIos ? Colors.transparent : backgroundColor,
barrierLabel: barrierLabel,
elevation: elevation,
shape: shape,
clipBehavior: clipBehavior,
constraints: constraints,
barrierColor: barrierColor,
isScrollControlled: isScrollControlled,
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
useRootNavigator: useRootNavigator,
isDismissible: isDismissible,
enableDrag: enableDrag,
// The iOS route is transparent so its stock handle sits outside the inset
// concentric surface. Paint it inside the surface above instead.
showDragHandle: isIos ? false : showDragHandle,
useSafeArea: useSafeArea,
routeSettings: routeSettings,
transitionAnimationController: transitionAnimationController,
anchorPoint: anchorPoint,
sheetAnimationStyle: reduceMotion
? AnimationStyle.noAnimation
: (sheetAnimationStyle ?? buzzModalAnimationStyle),
requestFocus: requestFocus,
);
}
class _SheetContent extends StatelessWidget {
const _SheetContent({
required this.child,
required this.showCloseButton,
required this.showDragHandle,
});
final Widget child;
final bool showCloseButton;
final bool showDragHandle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showCloseButton)
Padding(
padding: const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.topCenter,
children: [
if (showDragHandle) const _SheetDragHandle(),
Align(
alignment: Alignment.bottomRight,
child: SizedBox.square(
dimension: 44,
child: IconButton(
tooltip: 'Close sheet',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
context.colors.surfaceContainerHighest,
foregroundColor: context.colors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.dialog),
),
),
icon: const Icon(LucideIcons.x, size: 22),
),
),
),
],
),
),
)
else if (showDragHandle)
const Padding(
padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs),
child: _SheetDragHandle(),
),
Flexible(child: child),
],
);
}
}
class _SheetDragHandle extends StatelessWidget {
const _SheetDragHandle();
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Drag handle',
child: Container(
key: const ValueKey('buzz-sheet-drag-handle'),
width: 32,
height: 4,
decoration: BoxDecoration(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(Radii.full),
),
),
);
}
}
/// Shows a dialog with Buzz's shared motion, respecting reduced-motion settings.
Future<T?> showBuzzDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color? barrierColor,
String? barrierLabel,
bool useSafeArea = true,
bool useRootNavigator = true,
RouteSettings? routeSettings,
Offset? anchorPoint,
TraversalEdgeBehavior? traversalEdgeBehavior,
bool fullscreenDialog = false,
bool? requestFocus,
AnimationStyle? animationStyle,
}) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
return showDialog<T>(
context: context,
builder: builder,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
barrierLabel: barrierLabel,
useSafeArea: useSafeArea,
useRootNavigator: useRootNavigator,
routeSettings: routeSettings,
anchorPoint: anchorPoint,
traversalEdgeBehavior: traversalEdgeBehavior,
fullscreenDialog: fullscreenDialog,
requestFocus: requestFocus,
animationStyle: reduceMotion
? AnimationStyle.noAnimation
: (animationStyle ?? buzzModalAnimationStyle),
);
}
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -27,6 +28,7 @@ import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_cache_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
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';
@@ -2254,6 +2256,82 @@ void main() {
);
});
testWidgets(
'keeps membership and huddle rows evenly spaced with authored messages',
(tester) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'message-alice',
pubkey: 'alice',
content: 'First message',
createdAt: 1000,
),
_textMsg(
id: 'message-bob',
pubkey: 'bob',
content: 'Second message',
createdAt: 1010,
),
_systemMsg(
id: 'membership-carol',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'carol',
},
createdAt: 1020,
),
_huddleMsg(
id: 'huddle-dave',
kind: EventKind.huddleStarted,
pubkey: 'dave',
createdAt: 1030,
),
_textMsg(
id: 'message-erin',
pubkey: 'erin',
content: 'Third message',
createdAt: 1040,
),
],
users: {
for (final name in ['alice', 'bob', 'carol', 'dave', 'erin'])
name: UserProfile(pubkey: name, displayName: name),
},
),
);
await tester.pumpAndSettle();
Rect avatarRect(String rowKey) => tester.getRect(
find
.descendant(
of: find.byKey(ValueKey(rowKey)),
matching: find.byType(CircleAvatar),
)
.first,
);
final avatars = [
avatarRect('message-row-message-alice'),
avatarRect('message-row-message-bob'),
avatarRect('system-message-row-membership-carol'),
avatarRect('system-message-row-huddle-dave'),
avatarRect('message-row-message-erin'),
];
final authoredMessageGap = avatars[1].top - avatars[0].bottom;
for (var index = 2; index < avatars.length; index++) {
expect(
avatars[index].top - avatars[index - 1].bottom,
closeTo(authoredMessageGap, 1),
reason: 'row $index should use the authored-message gap',
);
}
},
);
testWidgets('renders member_joined (self-join) system event', (
tester,
) async {
@@ -2280,6 +2358,118 @@ void main() {
);
});
testWidgets('opens a profile sheet from a membership system avatar', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_systemMsg(
id: 'sys-membership-avatar',
payload: {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
},
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(CircleAvatar));
await tester.pumpAndSettle();
expect(find.text('Copy public key'), findsOneWidget);
expect(find.text('alice'), findsNothing);
expect(find.byType(UserProfileSheet), findsOneWidget);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (_) async => null);
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.ensureVisible(find.text('Copy public key'));
await tester.pumpAndSettle();
final copyAction = find
.ancestor(
of: find.text('Copy public key'),
matching: find.byType(GestureDetector),
)
.last;
tester.widget<GestureDetector>(copyAction).onTap!();
await tester.pump();
await tester.pump();
expect(find.text('Public key copied'), findsOneWidget);
await tester.tap(find.byTooltip('Close sheet'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 2));
expect(tester.takeException(), isNull);
});
testWidgets('opens a profile sheet from a huddle system avatar', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_huddleMsg(
id: 'sys-huddle-avatar',
kind: EventKind.huddleStarted,
pubkey: 'alice',
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(CircleAvatar));
await tester.pumpAndSettle();
expect(find.text('Copy public key'), findsOneWidget);
expect(find.byType(UserProfileSheet), findsOneWidget);
});
testWidgets('opens a profile sheet from a generic system avatar', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: [
_systemMsg(
id: 'sys-removed-avatar',
payload: {
'type': 'member_removed',
'actor': 'alice',
'target': 'bob',
},
),
],
users: {
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(CircleAvatar).first);
await tester.pumpAndSettle();
expect(find.text('Copy public key'), findsOneWidget);
expect(find.byType(UserProfileSheet), findsOneWidget);
});
testWidgets('renders member_joined (added by other) system event', (
tester,
) async {
@@ -409,8 +409,8 @@ void main() {
final row = quickReactionEmoji(entries, customShortcodes: const {});
expect(row.first, '\u{1F525}');
expect(row, hasLength(4));
expect(row, containsAll(defaultQuickEmojis.take(3)));
expect(row, hasLength(5));
expect(row, containsAll(defaultQuickEmojis.take(4)));
});
test('drops custom emoji no longer in the palette', () {
@@ -196,6 +196,22 @@ void main() {
expect(find.text('Remind me'), findsNothing);
expect(find.text('Edit message'), findsNothing);
expect(find.text('Delete message'), findsNothing);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
expect(
tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))),
const Size.square(52),
);
});
testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions '
@@ -227,6 +243,47 @@ void main() {
expect(find.text('Follow thread'), findsNothing);
expect(find.text('Reply'), findsNothing);
expect(find.text('Remind me'), findsNothing);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
});
testWidgets('keeps six reaction targets within a narrow phone', (
tester,
) async {
tester.view.physicalSize = const Size(375, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final prefs = await _mockPrefs();
await _pumpSheet(tester, message: _message(), prefs: prefs);
expect(
find.byWidgetPredicate(
(widget) =>
widget.key is ValueKey<String> &&
(widget.key! as ValueKey<String>).value.startsWith(
'quick-reaction-',
),
),
findsNWidgets(6),
);
expect(
tester
.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}')))
.width,
inInclusiveRange(44, 52),
);
expect(tester.takeException(), isNull);
});
testWidgets('shows Edit/Delete only with manage rights', (tester) async {
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -155,6 +156,24 @@ bool _spanHasStyle(
void main() {
group('MessageContent', () {
testWidgets('forwards text alignment to markdown rendering', (
tester,
) async {
await tester.pumpWidget(
_testable(
const MessageContent(
content: 'Centered status',
textAlign: TextAlign.center,
),
),
);
expect(
tester.widget<GptMarkdown>(find.byType(GptMarkdown)).textAlign,
TextAlign.center,
);
});
testWidgets('opens local file links through an authenticated download', (
tester,
) async {
@@ -0,0 +1,226 @@
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/modal_presentation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets(
'keeps an opaque Flutter surface when iOS native support is unavailable',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'replaces the Flutter fallback when native support is available',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const ConcentricSheetSurface(
enabled: true,
color: Colors.red,
child: SizedBox(height: 80, child: Text('Sheet body')),
),
),
);
await tester.pump();
expect(find.byType(UiKitView), findsOneWidget);
expect(
find.byWidgetPredicate(
(widget) => widget is Material && widget.color == Colors.red,
),
findsNothing,
);
} finally {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
surfaceChannel,
null,
);
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets(
'non-iOS sheets use Flutter drag handle and shared close control',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
final closeButton = find.byTooltip('Close sheet');
expect(closeButton, findsOneWidget);
expect(tester.getSize(closeButton), const Size.square(44));
final closeGutter = find.ancestor(
of: closeButton,
matching: find.byWidgetPredicate(
(widget) =>
widget is Padding &&
widget.padding ==
const EdgeInsets.only(
top: Grid.xxs,
left: Grid.gutter,
right: Grid.gutter,
bottom: Grid.xs,
),
),
);
expect(closeGutter, findsOneWidget);
final gutterRect = tester.getRect(closeGutter);
final closeRect = tester.getRect(closeButton);
expect(closeRect.top - gutterRect.top, Grid.gutter);
expect(gutterRect.right - closeRect.right, Grid.gutter);
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isTrue,
);
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsNothing,
);
expect(find.text('Sheet body'), findsOneWidget);
await tester.tap(closeButton);
await tester.pumpAndSettle();
expect(find.text('Sheet body'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = null;
}
},
);
testWidgets('iOS paints the drag handle inside the concentric surface', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const Text('Sheet body'),
),
child: const Text('Open sheet'),
),
),
),
),
);
await tester.tap(find.text('Open sheet'));
await tester.pumpAndSettle();
expect(
tester.widget<BottomSheet>(find.byType(BottomSheet)).showDragHandle,
isFalse,
);
final internalHandle = find.byKey(
const ValueKey('buzz-sheet-drag-handle'),
);
expect(internalHandle, findsOneWidget);
expect(tester.getSize(internalHandle), const Size(32, 4));
expect(find.bySemanticsLabel('Drag handle'), findsOneWidget);
expect(find.byTooltip('Close sheet'), findsOneWidget);
expect(find.text('Sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('iOS compact sheets can omit X but retain the inside handle', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Builder(
builder: (context) => FilledButton(
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
showCloseButton: false,
builder: (_) => const Text('Compact sheet body'),
),
child: const Text('Open compact sheet'),
),
),
),
),
);
await tester.tap(find.text('Open compact sheet'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('buzz-sheet-drag-handle')),
findsOneWidget,
);
expect(find.byTooltip('Close sheet'), findsNothing);
expect(find.text('Compact sheet body'), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
}