Add cross-platform mobile action menus

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-09 15:48:01 +01:00
parent 30c01bae81
commit e298fcbb25
17 changed files with 2049 additions and 334 deletions

View File

@@ -11,6 +11,7 @@ import UserNotifications
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?
private var nativeMenuButtonSupportChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -116,6 +117,27 @@ import UserNotifications
result(true)
}
}
if #available(iOS 16.0, *),
let nativeMenuButtonRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMenuButton"
) {
nativeMenuButtonRegistrar.register(
NativeMenuButtonFactory(messenger: messenger),
withId: "buzz/native_menu_button"
)
nativeMenuButtonSupportChannel = FlutterMethodChannel(
name: "buzz/native_menu_button",
binaryMessenger: messenger
)
nativeMenuButtonSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
result(true)
}
}
}
private static func handleQrScannerMethodCall(

View File

@@ -430,3 +430,179 @@ final class NativeMessageActionSurfacePlatformView: NSObject,
channel.invokeMethod("selected", arguments: ["id": definition.id])
}
}
struct NativeMenuButtonItemDefinition {
let id: String
let title: String
let systemImage: String?
let group: String
let checked: Bool
let enabled: Bool
let destructive: Bool
init?(arguments: [String: Any]) {
guard
let id = arguments["id"] as? String,
let title = arguments["title"] as? String
else {
return nil
}
self.id = id
self.title = title
systemImage = arguments["systemImage"] as? String
group = arguments["group"] as? String ?? "primary"
checked = arguments["checked"] as? Bool ?? false
enabled = arguments["enabled"] as? Bool ?? true
destructive = arguments["destructive"] as? Bool ?? false
}
}
@available(iOS 16.0, *)
final class NativeMenuButtonFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeMenuButtonPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
@available(iOS 16.0, *)
final class NativeMenuButtonPlatformView: NSObject, FlutterPlatformView {
private let button = UIButton(type: .system)
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
channel = FlutterMethodChannel(
name: "buzz/native_menu_button/\(viewId)",
binaryMessenger: messenger
)
super.init()
button.frame = frame
button.autoresizingMask = [.flexibleWidth, .flexibleHeight]
button.showsMenuAsPrimaryAction = true
button.changesSelectionAsPrimaryAction = false
update(arguments: args)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "update" else {
result(FlutterMethodNotImplemented)
return
}
DispatchQueue.main.async {
self?.update(arguments: call.arguments)
result(nil)
}
}
}
func view() -> UIView {
button
}
private func update(arguments args: Any?) {
guard let arguments = args as? [String: Any] else { return }
let title = arguments["title"] as? String
let systemImage = arguments["systemImage"] as? String
let accessibilityLabel = arguments["accessibilityLabel"] as? String
let foregroundColor = color(arguments["foregroundColor"] as? NSNumber)
let itemArguments = arguments["items"] as? [[String: Any]] ?? []
let items = itemArguments.compactMap(
NativeMenuButtonItemDefinition.init(arguments:)
)
var configuration = UIButton.Configuration.plain()
configuration.title = title
configuration.image = systemImage.flatMap(UIImage.init(systemName:))
configuration.imagePlacement = title == nil ? .leading : .trailing
configuration.imagePadding = title == nil ? 0 : 4
configuration.baseForegroundColor = foregroundColor
configuration.contentInsets = .zero
configuration.titleTextAttributesTransformer =
UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = UIFont.preferredFont(forTextStyle: .subheadline)
.withWeight(.semibold)
return outgoing
}
button.configuration = configuration
button.accessibilityLabel = accessibilityLabel
button.menu = makeMenu(items: items)
}
private func makeMenu(items: [NativeMenuButtonItemDefinition]) -> UIMenu {
var groupOrder: [String] = []
for item in items where !groupOrder.contains(item.group) {
groupOrder.append(item.group)
}
let groups = groupOrder.map { group -> UIMenu in
let actions = items
.filter { $0.group == group }
.map { definition in
var attributes = UIMenuElement.Attributes()
if !definition.enabled { attributes.insert(.disabled) }
if definition.destructive { attributes.insert(.destructive) }
let image = definition.systemImage.flatMap(UIImage.init(systemName:))
return UIAction(
title: definition.title,
image: image,
attributes: attributes,
state: definition.checked ? .on : .off
) { [weak self] _ in
self?.channel.invokeMethod(
"selected",
arguments: ["id": definition.id]
)
}
}
return UIMenu(options: .displayInline, children: actions)
}
return UIMenu(children: groups)
}
private func color(_ number: NSNumber?) -> UIColor {
guard let number else { return .label }
let argb = UInt32(truncating: number)
return UIColor(
red: CGFloat((argb >> 16) & 0xFF) / 255,
green: CGFloat((argb >> 8) & 0xFF) / 255,
blue: CGFloat(argb & 0xFF) / 255,
alpha: CGFloat((argb >> 24) & 0xFF) / 255
)
}
}
private extension UIFont {
func withWeight(_ weight: UIFont.Weight) -> UIFont {
let traits = [UIFontDescriptor.TraitKey.weight: weight]
let descriptor = fontDescriptor.addingAttributes([
UIFontDescriptor.AttributeName.traits: traits
])
return UIFont(descriptor: descriptor, size: pointSize)
}
}

View File

@@ -160,7 +160,7 @@ class RunnerTests: XCTestCase {
@available(iOS 16.0, *)
@MainActor
func testNativeMessageActionSeparatorsShareGeometry() {
func testNativeMessageActionSeparatorsShareGeometry() throws {
let separators = [
NativeMessageActionSeparatorView(),
NativeMessageActionSeparatorView(),
@@ -188,10 +188,12 @@ class RunnerTests: XCTestCase {
NativeMessageActionSurfaceLayout.surfaceWidth
- (NativeMessageActionSurfaceLayout.separatorHorizontalInset * 2)
)
XCTAssertEqual(
lineFrames.first?.height,
let renderedHeight = try XCTUnwrap(lineFrames.first?.height)
XCTAssertGreaterThanOrEqual(
renderedHeight,
NativeMessageActionSurfaceLayout.separatorHeight
)
XCTAssertLessThanOrEqual(renderedHeight, 1)
}
@available(iOS 16.0, *)

View File

@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -18,6 +21,7 @@ import '../../shared/widgets/bee_refresh_indicator.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_native_menu_button.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
@@ -38,6 +42,7 @@ import 'reminders_provider.dart';
part 'activity_page/header_actions.dart';
part 'activity_page/inbox_row.dart';
part 'activity_page/row_actions_popover.dart';
part 'activity_page/lists.dart';
part 'activity_page/status_views.dart';

View File

@@ -11,7 +11,7 @@ const _filterLabels = <InboxFilter, String>{
InboxFilter.drafts: 'Drafts',
};
class _ActivityActionsPill extends StatelessWidget {
class _ActivityActionsPill extends HookWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
@@ -33,35 +33,176 @@ class _ActivityActionsPill extends StatelessWidget {
});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer,
borderRadius: BorderRadius.circular(Radii.full),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
),
_InboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
onUnreadOnlyChanged: onUnreadOnlyChanged,
onMarkAllRead: onMarkAllRead,
),
],
Widget build(BuildContext context) {
final nativeMenuSupported =
useFuture(useMemoized(IosNativeMenuButton.isSupported)).data ?? false;
final rowActionOverlayPresented = useValueListenable(
_activityRowActionOverlayPresented,
);
final showNativeMenu = nativeMenuSupported && !rowActionOverlayPresented;
return ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer,
borderRadius: BorderRadius.circular(Radii.full),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showNativeMenu)
_NativeFilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
)
else
_FilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
),
if (showNativeMenu)
_NativeInboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
onUnreadOnlyChanged: onUnreadOnlyChanged,
onMarkAllRead: onMarkAllRead,
)
else
_InboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
onUnreadOnlyChanged: onUnreadOnlyChanged,
onMarkAllRead: onMarkAllRead,
),
],
),
),
),
),
);
);
}
}
class _NativeFilterMenuButton extends StatelessWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
final ValueChanged<InboxFilter> onChanged;
const _NativeFilterMenuButton({
required this.filter,
required this.dueReminderCount,
required this.draftCount,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final label = _filterLabels[filter]!;
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
textScaler: MediaQuery.textScalerOf(context),
textDirection: Directionality.of(context),
maxLines: 1,
)..layout();
final width = math.max(Grid.xl, textPainter.width + Grid.lg);
return SizedBox(
key: const ValueKey('activity-filter-menu'),
width: width,
height: Grid.xl,
child: IosNativeMenuButton(
key: const ValueKey('activity-native-filter'),
title: label,
systemImage: 'chevron.down',
accessibilityLabel: 'Filter activity: $label',
foregroundColor: navigationPrimaryForeground(context),
items: [
for (final entry in _filterLabels.entries)
IosNativeMenuItem(
id: entry.key.name,
title: switch (entry.key) {
InboxFilter.reminders when dueReminderCount > 0 =>
'${entry.value} ($dueReminderCount)',
InboxFilter.drafts when draftCount > 0 =>
'${entry.value} ($draftCount)',
_ => entry.value,
},
group:
entry.key == InboxFilter.reminders ||
entry.key == InboxFilter.drafts
? 'saved'
: 'inbox',
checked: entry.key == filter,
),
],
onSelected: (id) {
final selected = InboxFilter.values
.where((candidate) => candidate.name == id)
.firstOrNull;
if (selected != null) onChanged(selected);
},
),
);
}
}
class _NativeInboxOptionsButton extends StatelessWidget {
final bool unreadOnly;
final int unreadCount;
final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onMarkAllRead;
const _NativeInboxOptionsButton({
required this.unreadOnly,
required this.unreadCount,
required this.onUnreadOnlyChanged,
required this.onMarkAllRead,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('activity-options-menu'),
width: Grid.xl,
height: Grid.xl,
child: IosNativeMenuButton(
key: const ValueKey('activity-native-options'),
systemImage: 'ellipsis',
accessibilityLabel: 'Activity options',
foregroundColor: navigationPrimaryForeground(context),
items: [
IosNativeMenuItem(
id: 'unread-only',
title: unreadOnly ? 'Show all' : 'Show unread',
systemImage: unreadOnly ? 'tray.full' : 'envelope.badge',
checked: unreadOnly,
),
IosNativeMenuItem(
id: 'mark-all-read',
title: 'Mark all as read',
systemImage: 'envelope.open',
enabled: unreadCount > 0,
),
],
onSelected: (id) {
if (id == 'unread-only') onUnreadOnlyChanged(!unreadOnly);
if (id == 'mark-all-read') onMarkAllRead();
},
),
);
}
}
/// Compact filter dropdown replacing the old chip rail — mirrors desktop's

View File

@@ -69,6 +69,9 @@ class _InboxRow extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
const restingRevealWidth = 132.0;
const commitThreshold = 0.58;
final actionSnapshotKey = useMemoized(GlobalKey.new, const []);
final actionSnapshotPixelRatio = MediaQuery.devicePixelRatioOf(context);
final actionPopoverPresented = useState(false);
final revealAmount = useState(0.0);
final isDragging = useState(false);
final labelHapticFired = useRef(false);
@@ -124,6 +127,56 @@ class _InboxRow extends HookConsumerWidget {
isDone ? onMarkUnread() : onMarkRead();
}
Future<ui.Image> captureActionSnapshot() async {
await WidgetsBinding.instance.endOfFrame;
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
throw StateError('Activity row snapshot is unavailable');
}
return renderObject.toImage(pixelRatio: actionSnapshotPixelRatio);
}
Rect? actionSnapshotRect() {
final renderObject = actionSnapshotKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) {
return null;
}
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
Future<void> openRowActions() async {
if (isDragging.value) return;
final anchorRect = actionSnapshotRect();
if (anchorRect == null) {
_showRowActionsSheet(context);
return;
}
final presented = await _showActivityRowActionsPopover(
context: context,
anchorRect: anchorRect,
captureAnchorSnapshot: captureActionSnapshot,
actions: [
_ActivityRowAction(
id: isDone ? 'markUnread' : 'markRead',
title: isDone ? 'Mark unread' : 'Mark as read',
symbol: isDone ? 'envelope.badge' : 'envelope.open',
icon: isDone ? LucideIcons.mailOpen : LucideIcons.mailCheck,
onSelected: isDone ? onMarkUnread : onMarkRead,
),
_ActivityRowAction(
id: 'openConversation',
title: 'Open conversation',
symbol: 'arrow.up.right.square',
icon: LucideIcons.externalLink,
onSelected: onTap,
),
],
onPopoverPresented: () => actionPopoverPresented.value = true,
onPopoverDismissed: () => actionPopoverPresented.value = false,
);
if (!presented && context.mounted) _showRowActionsSheet(context);
}
return LayoutBuilder(
builder: (context, constraints) {
final actionExtent = constraints.maxWidth;
@@ -164,168 +217,186 @@ class _InboxRow extends HookConsumerWidget {
? Duration.zero
: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) {
isDragging.value = true;
labelHapticFired.value = false;
},
onHorizontalDragUpdate: (details) {
isDragging.value = true;
final previous = revealAmount.value;
final next =
(previous - (details.delta.dx * swipeDirection))
.clamp(0, actionExtent)
.toDouble();
if (!labelHapticFired.value &&
previous < _inboxSwipeLabelRevealWidth &&
next >= _inboxSwipeLabelRevealWidth) {
labelHapticFired.value = true;
unawaited(HapticFeedback.selectionClick());
}
revealAmount.value = next;
},
onHorizontalDragEnd: (details) {
isDragging.value = false;
final velocity = details.primaryVelocity ?? 0;
final shouldCommit =
(velocity * swipeDirection) < -900 ||
revealAmount.value >= actionExtent * commitThreshold;
if (shouldCommit) {
toggleReadState();
} else {
revealAmount.value =
(velocity * swipeDirection) < -200 ||
revealAmount.value >= restingRevealWidth / 2
? restingRevealWidth
: 0;
}
},
child: Material(
color: context.colors.surface,
child: InkWell(
key: ValueKey('inbox-row-${item.id}'),
onTap: onTap,
onLongPress: () => _showRowActions(context),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.twelve,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowAvatar(
pubkey: item.item.pubkey,
profile: profile,
child: Opacity(
key: ValueKey('activity-row-action-source-${item.id}'),
opacity: actionPopoverPresented.value ? 0 : 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) {
isDragging.value = true;
labelHapticFired.value = false;
},
onHorizontalDragUpdate: (details) {
isDragging.value = true;
final previous = revealAmount.value;
final next =
(previous - (details.delta.dx * swipeDirection))
.clamp(0, actionExtent)
.toDouble();
if (!labelHapticFired.value &&
previous < _inboxSwipeLabelRevealWidth &&
next >= _inboxSwipeLabelRevealWidth) {
labelHapticFired.value = true;
unawaited(HapticFeedback.selectionClick());
}
revealAmount.value = next;
},
onHorizontalDragEnd: (details) {
isDragging.value = false;
final velocity = details.primaryVelocity ?? 0;
final shouldCommit =
(velocity * swipeDirection) < -900 ||
revealAmount.value >= actionExtent * commitThreshold;
if (shouldCommit) {
toggleReadState();
} else {
revealAmount.value =
(velocity * swipeDirection) < -200 ||
revealAmount.value >= restingRevealWidth / 2
? restingRevealWidth
: 0;
}
},
child: RepaintBoundary(
key: actionSnapshotKey,
child: Material(
color: context.colors.surface,
child: _ActivityLongPressInkWell(
key: ValueKey('inbox-row-${item.id}'),
onTap: onTap,
onLongPress: () => unawaited(openRowActions()),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.twelve,
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sender + unread dot + timestamp.
Row(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowAvatar(
pubkey: item.item.pubkey,
profile: profile,
),
const SizedBox(width: messageAvatarContentGap),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Expanded(
child: MessageAuthorMeta(
displayName: senderLabel,
username: messageUsernameLabel(
profile,
),
timestamp: _inboxTimestamp(
item.latestActivityAt,
),
nameColor: context.colors.onSurface,
metadataColor: mutedColor,
nameStyle: activityUsernameTextStyle,
metadataStyle:
activityTimestampTextStyle,
displayNameKey: ValueKey(
'activity-author-${item.id}',
),
usernameKey: ValueKey(
'activity-username-${item.id}',
),
timestampKey: ValueKey(
'activity-timestamp-${item.id}',
),
),
),
if (!isDone) ...[
const SizedBox(width: Grid.xxs),
Container(
key: ValueKey(
'inbox-unread-dot-${item.id}',
),
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary,
),
),
],
],
),
const SizedBox(height: Grid.quarter),
// Contextual label: "Mentioned in #channel" etc.
Row(
children: [
Flexible(
child: Text(
label.text,
style: activityContextTextStyle
.copyWith(color: labelColor),
overflow: TextOverflow.ellipsis,
),
),
if (label.channelLabel != null) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal:
Grid.half + Grid.quarter,
vertical: Grid.quarter / 2,
// Sender + unread dot + timestamp.
Row(
children: [
Expanded(
child: MessageAuthorMeta(
displayName: senderLabel,
username: messageUsernameLabel(
profile,
),
timestamp: _inboxTimestamp(
item.latestActivityAt,
),
nameColor:
context.colors.onSurface,
metadataColor: mutedColor,
nameStyle:
activityUsernameTextStyle,
metadataStyle:
activityTimestampTextStyle,
displayNameKey: ValueKey(
'activity-author-${item.id}',
),
usernameKey: ValueKey(
'activity-username-${item.id}',
),
timestampKey: ValueKey(
'activity-timestamp-${item.id}',
),
),
decoration: BoxDecoration(
color: context
.colors
.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(
Grid.half,
),
),
if (!isDone) ...[
const SizedBox(width: Grid.xxs),
Container(
key: ValueKey(
'inbox-unread-dot-${item.id}',
),
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primary,
),
),
],
],
),
const SizedBox(height: Grid.quarter),
// Contextual label: "Mentioned in #channel" etc.
Row(
children: [
Flexible(
child: Text(
'#${label.channelLabel}',
label.text,
style: activityContextTextStyle
.copyWith(color: mutedColor),
.copyWith(color: labelColor),
overflow: TextOverflow.ellipsis,
),
),
),
],
if (label.channelLabel != null) ...[
const SizedBox(width: Grid.half),
Flexible(
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal:
Grid.half +
Grid.quarter,
vertical:
Grid.quarter / 2,
),
decoration: BoxDecoration(
color: context
.colors
.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(
Grid.half,
),
),
child: Text(
'#${label.channelLabel}',
style:
activityContextTextStyle
.copyWith(
color: mutedColor,
),
overflow:
TextOverflow.ellipsis,
),
),
),
],
],
),
const SizedBox(height: Grid.half),
// Message preview.
MessageContent(
content: item.item.displayContent,
mentionNames: mentionNames,
agentMentionPubkeys:
agentMentionPubkeys,
tags: item.item.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle
.copyWith(
color: context.colors.onSurface,
),
),
],
),
const SizedBox(height: Grid.half),
// Message preview.
MessageContent(
content: item.item.displayContent,
mentionNames: mentionNames,
agentMentionPubkeys: agentMentionPubkeys,
tags: item.item.tags,
maxLines: 2,
baseStyle: activityPreviewTextStyle
.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
],
),
],
),
),
),
),
@@ -339,7 +410,7 @@ class _InboxRow extends HookConsumerWidget {
);
}
void _showRowActions(BuildContext context) {
void _showRowActionsSheet(BuildContext context) {
showBuzzModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
@@ -382,6 +453,31 @@ class _InboxRow extends HookConsumerWidget {
}
}
class _ActivityLongPressInkWell extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback onLongPress;
final Widget child;
const _ActivityLongPressInkWell({
super.key,
required this.onTap,
required this.onLongPress,
required this.child,
});
@override
Widget build(BuildContext context) {
return Semantics(
onLongPress: onLongPress,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onLongPressStart: (_) => onLongPress(),
child: InkWell(onTap: onTap, child: child),
),
);
}
}
class _InboxSwipeAction extends StatelessWidget {
final Color color;
final Color foregroundColor;

View File

@@ -0,0 +1,468 @@
part of '../activity_page.dart';
const _activityActionRowHeight = 50.0;
const _activityActionVerticalInset = Grid.half;
const _activityContextGap = Grid.twelve;
const _activityContextMenuMaxWidth = 288.0;
const _activityContextPreviewMaxWidth = 358.0;
const _activityContextPreviewInset = Grid.xxs;
const _activityContextTransitionDuration = Duration(milliseconds: 240);
const _activityActionSurfaceRadius = 26.0;
const _activityActionIconSize = 22.0;
const _activityActionIconSlotWidth = 32.0;
const _activityNativeActionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
bool _activityRowActionsPresentationInFlight = false;
final _activityRowActionOverlayPresented = ValueNotifier(false);
enum _ActivityActionSurfaceKind { iosNative, androidFlutter }
class _ActivityRowAction {
final String id;
final String title;
final String symbol;
final IconData icon;
final FutureOr<void> Function() onSelected;
const _ActivityRowAction({
required this.id,
required this.title,
required this.symbol,
required this.icon,
required this.onSelected,
});
Map<String, Object> toPlatformArguments() => {
'id': id,
'title': title,
'symbol': symbol,
'group': 'triage',
'destructive': false,
};
}
Future<bool> _showActivityRowActionsPopover({
required BuildContext context,
required Rect anchorRect,
required Future<ui.Image> Function() captureAnchorSnapshot,
required List<_ActivityRowAction> actions,
required VoidCallback onPopoverPresented,
required VoidCallback onPopoverDismissed,
}) async {
if (_activityRowActionsPresentationInFlight) return true;
_activityRowActionsPresentationInFlight = true;
try {
final surfaceKind = await _activityActionSurfaceKind();
if (surfaceKind == null || !context.mounted) return false;
final ui.Image snapshot;
try {
snapshot = await captureAnchorSnapshot();
} catch (_) {
return false;
}
if (!context.mounted) {
snapshot.dispose();
return false;
}
unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
_activityRowActionOverlayPresented.value = true;
onPopoverPresented();
String? selectedActionId;
var overlayBuilt = false;
var dismissalCompleted = false;
void completeDismissal() {
if (dismissalCompleted) return;
dismissalCompleted = true;
snapshot.dispose();
_activityRowActionOverlayPresented.value = false;
if (context.mounted) onPopoverDismissed();
}
try {
selectedActionId = await showGeneralDialog<String>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss activity actions',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: _activityContextTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) {
overlayBuilt = true;
return _ActivityRowActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
animation: animation,
actions: actions,
surfaceKind: surfaceKind,
onTransitionDismissed: completeDismissal,
);
},
);
} finally {
if (!overlayBuilt) completeDismissal();
}
final selectedAction = actions
.where((action) => action.id == selectedActionId)
.firstOrNull;
if (selectedAction != null) await selectedAction.onSelected();
return true;
} finally {
_activityRowActionsPresentationInFlight = false;
}
}
Future<_ActivityActionSurfaceKind?> _activityActionSurfaceKind() async {
if (defaultTargetPlatform != TargetPlatform.iOS) {
return _ActivityActionSurfaceKind.androidFlutter;
}
try {
final supported =
await _activityNativeActionSurfaceChannel.invokeMethod<bool>(
'isSupported',
) ??
false;
return supported ? _ActivityActionSurfaceKind.iosNative : null;
} on MissingPluginException {
return null;
} on PlatformException {
return null;
}
}
class _ActivityRowActionsPopover extends HookWidget {
final Rect anchorRect;
final ui.Image anchorSnapshot;
final Animation<double> animation;
final List<_ActivityRowAction> actions;
final _ActivityActionSurfaceKind surfaceKind;
final VoidCallback onTransitionDismissed;
const _ActivityRowActionsPopover({
required this.anchorRect,
required this.anchorSnapshot,
required this.animation,
required this.actions,
required this.surfaceKind,
required this.onTransitionDismissed,
});
@override
Widget build(BuildContext context) {
final transitionBegan = useRef(
animation.status != AnimationStatus.dismissed,
);
useEffect(() {
var completed = false;
void finish() {
if (completed) return;
completed = true;
onTransitionDismissed();
}
void handleStatus(AnimationStatus status) {
if (status != AnimationStatus.dismissed) transitionBegan.value = true;
if (transitionBegan.value && status == AnimationStatus.dismissed) {
finish();
}
}
animation.addStatusListener(handleStatus);
handleStatus(animation.status);
return () {
animation.removeStatusListener(handleStatus);
finish();
};
}, [animation, onTransitionDismissed]);
final mediaQuery = MediaQuery.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final safeLeft = mediaQuery.padding.left + Grid.xxs;
final safeRight =
constraints.maxWidth - mediaQuery.padding.right - Grid.xxs;
final safeTop = mediaQuery.padding.top + Grid.xxs;
final safeBottom =
constraints.maxHeight - mediaQuery.padding.bottom - Grid.xxs;
final availableWidth = safeRight - safeLeft;
final availableHeight = safeBottom - safeTop;
final menuWidth = math.min(
_activityContextMenuMaxWidth,
availableWidth,
);
final preferredMenuHeight =
(_activityActionVerticalInset * 2) +
(actions.length * _activityActionRowHeight);
final menuHeight = math.min(
preferredMenuHeight,
math.max(
_activityActionRowHeight,
availableHeight - _activityContextGap - 56,
),
);
final previewMaximumHeight = math.max(
56,
availableHeight - menuHeight - _activityContextGap,
);
final previewInsetExtent = _activityContextPreviewInset * 2;
final previewScale = math.min(
1,
math.min(
(math.min(_activityContextPreviewMaxWidth, availableWidth) -
previewInsetExtent) /
anchorRect.width,
(previewMaximumHeight - previewInsetExtent) / anchorRect.height,
),
);
final previewContentSize = Size(
anchorRect.width * previewScale,
anchorRect.height * previewScale,
);
final previewSize = Size(
previewContentSize.width + previewInsetExtent,
previewContentSize.height + previewInsetExtent,
);
final previewLeft =
safeLeft + ((availableWidth - previewSize.width) / 2);
final totalHeight =
previewSize.height + _activityContextGap + menuHeight;
final contentTop = safeTop + ((availableHeight - totalHeight) / 2);
final targetPreviewRect = Rect.fromLTWH(
previewLeft,
contentTop,
previewSize.width,
previewSize.height,
);
final menuRect = Rect.fromLTWH(
safeLeft + ((availableWidth - menuWidth) / 2),
targetPreviewRect.bottom + _activityContextGap,
menuWidth,
menuHeight,
);
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final progress = Curves.easeOutCubic.transform(
animation.value,
);
return BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 14 * progress,
sigmaY: 14 * progress,
),
child: ColoredBox(
color: context.colors.inverseSurface.withValues(
alpha: 0.12 * progress,
),
),
);
},
),
),
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
),
),
AnimatedBuilder(
animation: animation,
builder: (context, child) {
final movement = Curves.easeInOutCubic.transform(
animation.value,
);
final displayRect = Rect.lerp(
anchorRect.inflate(_activityContextPreviewInset),
targetPreviewRect,
movement,
)!;
return Positioned.fromRect(rect: displayRect, child: child!);
},
child: DecoratedBox(
key: const ValueKey('activity-row-action-preview-container'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(_activityContextPreviewInset),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.xs),
child: RawImage(
key: const ValueKey('activity-row-action-preview'),
image: anchorSnapshot,
fit: BoxFit.fill,
filterQuality: FilterQuality.medium,
),
),
),
),
),
Positioned.fromRect(
rect: menuRect,
child: AnimatedBuilder(
animation: animation,
child: surfaceKind == _ActivityActionSurfaceKind.iosNative
? _IosNativeActivityActionSurface(actions: actions)
: _AndroidActivityActionSurface(actions: actions),
builder: (context, child) {
final appearance = const Interval(
0.12,
0.72,
curve: Curves.easeOutCubic,
).transform(animation.value);
return Opacity(
opacity: appearance,
child: Transform.scale(
alignment: Alignment.topCenter,
scale: ui.lerpDouble(0.96, 1, appearance)!,
child: child,
),
);
},
),
),
],
);
},
);
}
}
class _IosNativeActivityActionSurface extends HookWidget {
final List<_ActivityRowAction> actions;
const _IosNativeActivityActionSurface({required this.actions});
@override
Widget build(BuildContext context) {
final viewId = useState<int?>(null);
final navigator = Navigator.of(context);
final navigatorRef = useRef(navigator)..value = navigator;
useEffect(() {
final id = viewId.value;
if (id == null) return null;
final channel = MethodChannel('buzz/native_message_action_surface/$id');
channel.setMethodCallHandler((call) async {
if (call.method != 'selected' || call.arguments is! Map) return;
final actionId = (call.arguments as Map)['id'];
if (actionId is String) navigatorRef.value.pop(actionId);
});
return () => channel.setMethodCallHandler(null);
}, [viewId.value]);
return ClipRRect(
key: const ValueKey('activity-native-action-surface'),
borderRadius: BorderRadius.circular(_activityActionSurfaceRadius),
child: UiKitView(
viewType: 'buzz/native_message_action_surface',
creationParams: {
'actions': [
for (final action in actions) action.toPlatformArguments(),
],
},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) => viewId.value = id,
),
);
}
}
class _AndroidActivityActionSurface extends StatelessWidget {
final List<_ActivityRowAction> actions;
const _AndroidActivityActionSurface({required this.actions});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('activity-android-action-surface'),
color: context.colors.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
elevation: 10,
shadowColor: Colors.black.withValues(alpha: 0.22),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_activityActionSurfaceRadius),
side: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.55),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: _activityActionVerticalInset,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in actions)
Semantics(
button: true,
label: action.title,
excludeSemantics: true,
child: InkWell(
key: ValueKey('activity-row-action-${action.id}'),
onTap: () => Navigator.of(context).pop(action.id),
child: SizedBox(
height: _activityActionRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
SizedBox(
width: _activityActionIconSlotWidth,
child: Center(
child: Icon(
action.icon,
size: _activityActionIconSize,
color: context.colors.onSurface,
),
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: Text(
action.title,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
),
),
),
],
),
),
),
),
),
],
),
),
);
}
}

View File

@@ -40,115 +40,14 @@ import 'timeline_message.dart';
part 'message_actions/reaction_popover.dart';
part 'message_actions/ios_native_message_actions.dart';
part 'message_actions/ios_message_actions_popover.dart';
part 'message_actions/android_message_actions_popover.dart';
part 'message_actions/show_message_actions.dart';
part 'message_actions/message_actions_sheet.dart';
/// Preview length for reminder targets — matches desktop's
/// `msg.body.slice(0, 100)`.
const _reminderPreviewLength = 100;
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
Future<ui.Image> Function()? captureAnchorSnapshot,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
VoidCallback? onPopoverPresented,
VoidCallback? onPopoverDismissed,
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
if (Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosReactionOnlyPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
}),
);
return;
}
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
return;
}
if (anchorRect != null &&
Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosMessageActionsPopover(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}),
);
return;
}
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}
/// Image-focused actions shown from the full-screen viewer.
void showImageActions({
required BuildContext context,

View File

@@ -0,0 +1,225 @@
part of '../message_actions.dart';
const _androidActionRowMinHeight = 52.0;
const _androidActionSeparatorHeight = 0.5;
const _androidActionVerticalInset = Grid.half;
const _androidContextMenuMaxWidth = 288.0;
const _androidContextMenuRadius = 26.0;
const _androidActionIconSize = 22.0;
const _androidActionIconSlotWidth = 32.0;
bool _androidMessageActionsPresentationInFlight = false;
Future<bool> _showAndroidMessageActionsPopover({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
required List<TimelineMessage>? allMessages,
required String? currentPubkey,
required bool isMember,
required bool isArchived,
required Rect anchorRect,
required Future<ui.Image> Function() captureAnchorSnapshot,
required EdgeInsets spotlightPadding,
required VoidCallback? onPopoverPresented,
required VoidCallback? onPopoverDismissed,
}) async {
if (_androidMessageActionsPresentationInFlight) return true;
_androidMessageActionsPresentationInFlight = true;
try {
final actions = _buildMessageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
if (actions.isEmpty) return false;
final ui.Image snapshot;
try {
snapshot = await captureAnchorSnapshot();
} catch (_) {
return false;
}
if (!context.mounted) {
snapshot.dispose();
return false;
}
unawaited(HapticFeedback.mediumImpact());
final reduceMotion = MediaQuery.disableAnimationsOf(context);
onPopoverPresented?.call();
String? selectedActionId;
try {
selectedActionId = await showGeneralDialog<String>(
context: context,
barrierDismissible: true,
barrierLabel: 'Dismiss message actions',
barrierColor: Colors.transparent,
transitionDuration: reduceMotion
? Duration.zero
: _iosContextTransitionDuration,
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_MessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
spotlightPadding: spotlightPadding,
animation: animation,
message: message,
pageContext: context,
pageRef: ref,
actions: actions,
actionSurfaceKind: _MessageActionSurfaceKind.androidFlutter,
),
);
} finally {
snapshot.dispose();
if (context.mounted) onPopoverDismissed?.call();
}
final selectedAction = actions
.where((action) => action.id == selectedActionId)
.firstOrNull;
if (selectedAction != null) await selectedAction.onSelected();
return true;
} finally {
_androidMessageActionsPresentationInFlight = false;
}
}
class _AndroidMessageActionSurface extends StatelessWidget {
final List<_MessageAction> actions;
final ValueChanged<String> onSelected;
const _AndroidMessageActionSurface({
required this.actions,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('android-message-action-surface'),
color: context.colors.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
elevation: 10,
shadowColor: Colors.black.withValues(alpha: 0.22),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_androidContextMenuRadius),
side: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.55),
width: 0.5,
),
),
clipBehavior: Clip.antiAlias,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: _androidActionVerticalInset,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < actions.length; index++) ...[
if (index > 0 &&
actions[index - 1].group != actions[index].group)
Container(
key: ValueKey(
'android-message-action-divider-${actions[index].group.name}',
),
height: _androidActionSeparatorHeight,
margin: const EdgeInsets.symmetric(horizontal: Grid.xs),
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
_AndroidMessageActionRow(
action: actions[index],
onSelected: onSelected,
),
],
],
),
),
),
);
}
}
class _AndroidMessageActionRow extends StatelessWidget {
final _MessageAction action;
final ValueChanged<String> onSelected;
const _AndroidMessageActionRow({
required this.action,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final foreground = action.destructive
? context.colors.error
: context.colors.onSurface;
return Semantics(
button: true,
label: action.title,
excludeSemantics: true,
child: InkWell(
key: ValueKey('android-message-action-${action.id}'),
onTap: () => onSelected(action.id),
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: _androidActionRowMinHeight,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Row(
children: [
SizedBox(
width: _androidActionIconSlotWidth,
child: Center(
child: Icon(
action.flutterIcon,
key: ValueKey('android-message-action-icon-${action.id}'),
size: _androidActionIconSize,
color: foreground,
),
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: Text(
action.title,
key: ValueKey('android-message-action-label-${action.id}'),
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
),
),
),
],
),
),
),
),
);
}
}
double _androidActionSurfacePreferredHeight(List<_MessageAction> actions) {
var separatorCount = 0;
for (var index = 1; index < actions.length; index++) {
if (actions[index - 1].group != actions[index].group) separatorCount += 1;
}
return (_androidActionVerticalInset * 2) +
(actions.length * _androidActionRowMinHeight) +
(separatorCount * _androidActionSeparatorHeight);
}

View File

@@ -50,7 +50,7 @@ Future<bool> _showIosReactionOnlyPopover({
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_IosMessageActionsPopover(
_MessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
spotlightPadding: spotlightPadding,
@@ -59,6 +59,7 @@ Future<bool> _showIosReactionOnlyPopover({
pageContext: context,
pageRef: ref,
actions: const [],
actionSurfaceKind: _MessageActionSurfaceKind.iosNative,
),
);
} finally {
@@ -131,7 +132,7 @@ Future<bool> _presentIosMessageActionsPopover({
return false;
}
final actions = _buildIosNativeMessageActions(
final actions = _buildMessageActions(
context: context,
ref: ref,
message: message,
@@ -171,7 +172,7 @@ Future<bool> _presentIosMessageActionsPopover({
transitionBuilder: (context, animation, secondaryAnimation, child) =>
child,
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
_IosMessageActionsPopover(
_MessageActionsPopover(
anchorRect: anchorRect,
anchorSnapshot: snapshot,
spotlightPadding: spotlightPadding,
@@ -180,6 +181,7 @@ Future<bool> _presentIosMessageActionsPopover({
pageContext: context,
pageRef: ref,
actions: actions,
actionSurfaceKind: _MessageActionSurfaceKind.iosNative,
),
);
} finally {
@@ -194,7 +196,9 @@ Future<bool> _presentIosMessageActionsPopover({
return true;
}
class _IosMessageActionsPopover extends StatelessWidget {
enum _MessageActionSurfaceKind { iosNative, androidFlutter }
class _MessageActionsPopover extends StatelessWidget {
final Rect anchorRect;
final ui.Image anchorSnapshot;
final EdgeInsets spotlightPadding;
@@ -202,9 +206,10 @@ class _IosMessageActionsPopover extends StatelessWidget {
final TimelineMessage message;
final BuildContext pageContext;
final WidgetRef pageRef;
final List<_IosNativeMessageAction> actions;
final List<_MessageAction> actions;
final _MessageActionSurfaceKind actionSurfaceKind;
const _IosMessageActionsPopover({
const _MessageActionsPopover({
required this.anchorRect,
required this.anchorSnapshot,
required this.spotlightPadding,
@@ -213,6 +218,7 @@ class _IosMessageActionsPopover extends StatelessWidget {
required this.pageContext,
required this.pageRef,
required this.actions,
required this.actionSurfaceKind,
});
@override
@@ -231,13 +237,22 @@ class _IosMessageActionsPopover extends StatelessWidget {
final hasActionMenu = actions.isNotEmpty;
final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth);
final menuWidth = hasActionMenu
? math.min(_iosContextMenuMaxWidth, availableWidth)
? math.min(
actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter
? _androidContextMenuMaxWidth
: _iosContextMenuMaxWidth,
availableWidth,
)
: 0.0;
final menuHeight = hasActionMenu
? math.min(
_iosActionSurfacePreferredHeight(actions),
actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter
? _androidActionSurfacePreferredHeight(actions)
: _iosActionSurfacePreferredHeight(actions),
math.max(
_iosActionRowHeight * 3,
actionSurfaceKind == _MessageActionSurfaceKind.androidFlutter
? _androidActionRowMinHeight * 3
: _iosActionRowHeight * 3,
availableHeight -
_reactionTrayMaxHeight -
(_iosContextGap * 2) -
@@ -384,11 +399,19 @@ class _IosMessageActionsPopover extends StatelessWidget {
rect: menuRect,
child: AnimatedBuilder(
animation: animation,
child: _IosNativeMessageActionSurface(
actions: actions,
onSelected: (actionId) =>
Navigator.of(context).pop(actionId),
),
child:
actionSurfaceKind ==
_MessageActionSurfaceKind.androidFlutter
? _AndroidMessageActionSurface(
actions: actions,
onSelected: (actionId) =>
Navigator.of(context).pop(actionId),
)
: _IosNativeMessageActionSurface(
actions: actions,
onSelected: (actionId) =>
Navigator.of(context).pop(actionId),
),
builder: (context, child) {
final appearance = const Interval(
0.12,
@@ -398,7 +421,11 @@ class _IosMessageActionsPopover extends StatelessWidget {
return Opacity(
opacity: appearance,
child: Transform.scale(
alignment: Alignment.topLeft,
alignment:
actionSurfaceKind ==
_MessageActionSurfaceKind.androidFlutter
? Alignment.topCenter
: Alignment.topLeft,
scale: ui.lerpDouble(0.96, 1, appearance)!,
child: child,
),
@@ -492,7 +519,7 @@ class _IosReactionTray extends StatelessWidget {
}
class _IosNativeMessageActionSurface extends HookWidget {
final List<_IosNativeMessageAction> actions;
final List<_MessageAction> actions;
final ValueChanged<String> onSelected;
const _IosNativeMessageActionSurface({
@@ -540,7 +567,7 @@ class _IosNativeMessageActionSurface extends HookWidget {
}
class _TestIosNativeMessageActionSurface extends StatelessWidget {
final List<_IosNativeMessageAction> actions;
final List<_MessageAction> actions;
final ValueChanged<String> onSelected;
const _TestIosNativeMessageActionSurface({
@@ -561,7 +588,7 @@ class _TestIosNativeMessageActionSurface extends StatelessWidget {
children: [
for (final action in actions)
SizedBox(
height: action.group == _IosNativeMessageActionGroup.promoted
height: action.group == _MessageActionGroup.promoted
? _iosActionPromotedHeight
: _iosActionRowHeight,
child: ListTile(
@@ -577,11 +604,11 @@ class _TestIosNativeMessageActionSurface extends StatelessWidget {
}
}
double _iosActionSurfacePreferredHeight(List<_IosNativeMessageAction> actions) {
double _iosActionSurfacePreferredHeight(List<_MessageAction> actions) {
final groups = {for (final action in actions) action.group};
final promoted = groups.contains(_IosNativeMessageActionGroup.promoted);
final promoted = groups.contains(_MessageActionGroup.promoted);
final rowCount = actions
.where((action) => action.group != _IosNativeMessageActionGroup.promoted)
.where((action) => action.group != _MessageActionGroup.promoted)
.length;
return (_iosActionVerticalInset * 2) +
(promoted ? _iosActionPromotedHeight : 0) +

View File

@@ -4,7 +4,7 @@ const _nativeMessageActionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
List<_IosNativeMessageAction> _buildIosNativeMessageActions({
List<_MessageAction> _buildMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
@@ -15,18 +15,18 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
required bool isMember,
required bool isArchived,
}) {
final actions = <_IosNativeMessageAction>[];
final actions = <_MessageAction>[];
final messages = allMessages;
final canRemind = ref.read(reminderServiceProvider) != null;
if (!message.isSystem) {
if (messages != null) {
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'reply',
title: 'Reply',
symbol: 'arrowshape.turn.up.left',
group: _IosNativeMessageActionGroup.promoted,
group: _MessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
Navigator.of(context).push(
@@ -46,11 +46,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
);
}
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'copyLink',
title: 'Copy link',
symbol: 'link',
group: _IosNativeMessageActionGroup.promoted,
group: _MessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
copyToClipboard(
@@ -63,11 +63,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
);
if (canRemind) {
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'remind',
title: 'Remind me',
symbol: 'clock',
group: _IosNativeMessageActionGroup.promoted,
group: _MessageActionGroup.promoted,
onSelected: () {
if (!context.mounted) return;
showRemindMeLaterSheet(
@@ -97,11 +97,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
threadRootId: message.rootId,
);
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: unread ? 'markRead' : 'markUnread',
title: unread ? 'Mark read' : 'Mark unread',
symbol: unread ? 'envelope.open' : 'envelope.badge',
group: _IosNativeMessageActionGroup.triage,
group: _MessageActionGroup.triage,
onSelected: () {
final notifier = ref.read(readStateProvider.notifier);
if (unread) {
@@ -123,11 +123,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
final rootId = message.rootId ?? message.id;
final following = ref.read(threadFollowsProvider).isFollowing(rootId);
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: following ? 'unfollowThread' : 'followThread',
title: following ? 'Unfollow thread' : 'Follow thread',
symbol: following ? 'bell.slash' : 'bell',
group: _IosNativeMessageActionGroup.triage,
group: _MessageActionGroup.triage,
onSelected: () {
final notifier = ref.read(threadFollowsProvider.notifier);
if (following) {
@@ -139,11 +139,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
),
);
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'copyText',
title: 'Copy text',
symbol: 'doc.on.doc',
group: _IosNativeMessageActionGroup.export,
group: _MessageActionGroup.export,
onSelected: () =>
Clipboard.setData(ClipboardData(text: message.content)),
),
@@ -152,11 +152,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
if (canManageMessage) {
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'edit',
title: 'Edit message',
symbol: 'pencil',
group: _IosNativeMessageActionGroup.manage,
group: _MessageActionGroup.manage,
onSelected: () {
if (!context.mounted) return;
_showEditSheet(
@@ -169,11 +169,11 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
),
);
actions.add(
_IosNativeMessageAction(
_MessageAction(
id: 'delete',
title: 'Delete message',
symbol: 'trash',
group: _IosNativeMessageActionGroup.manage,
group: _MessageActionGroup.manage,
destructive: true,
onSelected: () {
if (!context.mounted) return;
@@ -191,17 +191,17 @@ List<_IosNativeMessageAction> _buildIosNativeMessageActions({
return actions;
}
enum _IosNativeMessageActionGroup { promoted, triage, export, manage }
enum _MessageActionGroup { promoted, triage, export, manage }
class _IosNativeMessageAction {
class _MessageAction {
final String id;
final String title;
final String symbol;
final _IosNativeMessageActionGroup group;
final _MessageActionGroup group;
final bool destructive;
final FutureOr<void> Function() onSelected;
const _IosNativeMessageAction({
const _MessageAction({
required this.id,
required this.title,
required this.symbol,
@@ -210,6 +210,20 @@ class _IosNativeMessageAction {
this.destructive = false,
});
IconData get flutterIcon => switch (id) {
'reply' => LucideIcons.messageSquareReply,
'copyLink' => LucideIcons.link2,
'remind' => LucideIcons.clock,
'markRead' => LucideIcons.mailCheck,
'markUnread' => LucideIcons.mailOpen,
'followThread' => LucideIcons.bellRing,
'unfollowThread' => LucideIcons.bellOff,
'copyText' => LucideIcons.copy,
'edit' => LucideIcons.pencil,
'delete' => LucideIcons.trash2,
_ => LucideIcons.ellipsis,
};
Map<String, Object> toPlatformArguments() => {
'id': id,
'title': title,

View File

@@ -0,0 +1,141 @@
part of '../message_actions.dart';
void showMessageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
List<TimelineMessage>? allMessages,
String? currentPubkey,
bool isMember = false,
bool isArchived = false,
Rect? anchorRect,
Future<ui.Image> Function()? captureAnchorSnapshot,
EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs),
VoidCallback? onPopoverPresented,
VoidCallback? onPopoverDismissed,
}) {
final hasReactionOnlyActions = message.isSystem && !canManageMessage;
if (anchorRect != null && hasReactionOnlyActions) {
if (Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosReactionOnlyPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
}),
);
return;
}
_showMessageReactionPopover(
context: context,
ref: ref,
message: message,
anchorRect: anchorRect,
spotlightPadding: popoverSpotlightPadding,
);
return;
}
if (anchorRect != null &&
Theme.of(context).platform == TargetPlatform.iOS &&
captureAnchorSnapshot != null) {
unawaited(
_showIosMessageActionsPopover(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}),
);
return;
}
if (anchorRect != null &&
Theme.of(context).platform == TargetPlatform.android &&
captureAnchorSnapshot != null) {
unawaited(
_showAndroidMessageActionsPopover(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
anchorRect: anchorRect,
captureAnchorSnapshot: captureAnchorSnapshot,
spotlightPadding: popoverSpotlightPadding,
onPopoverPresented: onPopoverPresented,
onPopoverDismissed: onPopoverDismissed,
).then((shown) {
if (shown || !context.mounted) return;
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}),
);
return;
}
_showMessageActionsSheet(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
allMessages: allMessages,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
);
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
const _supportChannel = MethodChannel('buzz/native_menu_button');
/// A single action displayed by an iOS system menu.
class IosNativeMenuItem {
final String id;
final String title;
final String? systemImage;
final String group;
final bool checked;
final bool enabled;
final bool destructive;
const IosNativeMenuItem({
required this.id,
required this.title,
this.systemImage,
this.group = 'primary',
this.checked = false,
this.enabled = true,
this.destructive = false,
});
Map<String, Object?> toPlatformArguments() => {
'id': id,
'title': title,
if (systemImage != null) 'systemImage': systemImage,
'group': group,
'checked': checked,
'enabled': enabled,
'destructive': destructive,
};
}
/// A native UIButton whose primary action opens a UIKit UIMenu.
///
/// Call [isSupported] before building this widget. Android and older iOS
/// versions should retain their existing Flutter menu trigger.
class IosNativeMenuButton extends HookWidget {
final String? title;
final String? systemImage;
final String accessibilityLabel;
final Color foregroundColor;
final List<IosNativeMenuItem> items;
final ValueChanged<String> onSelected;
const IosNativeMenuButton({
super.key,
this.title,
this.systemImage,
required this.accessibilityLabel,
required this.foregroundColor,
required this.items,
required this.onSelected,
});
static Future<bool> isSupported() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
try {
return await _supportChannel.invokeMethod<bool>('isSupported') ?? false;
} on MissingPluginException {
return false;
} on PlatformException {
return false;
}
}
@override
Widget build(BuildContext context) {
final viewId = useState<int?>(null);
final onSelectedRef = useRef(onSelected)..value = onSelected;
final arguments = <String, Object?>{
if (title != null) 'title': title,
if (systemImage != null) 'systemImage': systemImage,
'accessibilityLabel': accessibilityLabel,
'foregroundColor': foregroundColor.toARGB32(),
'items': [for (final item in items) item.toPlatformArguments()],
};
final itemSignature = Object.hashAll([
for (final item in items)
Object.hash(
item.id,
item.title,
item.systemImage,
item.group,
item.checked,
item.enabled,
item.destructive,
),
]);
useEffect(() {
final id = viewId.value;
if (id == null) return null;
final channel = MethodChannel('buzz/native_menu_button/$id');
channel.setMethodCallHandler((call) async {
if (call.method != 'selected' || call.arguments is! Map) return;
final selectedId = (call.arguments as Map)['id'];
if (selectedId is String) onSelectedRef.value(selectedId);
});
return () => channel.setMethodCallHandler(null);
}, [viewId.value]);
useEffect(
() {
final id = viewId.value;
if (id == null) return null;
final channel = MethodChannel('buzz/native_menu_button/$id');
unawaited(channel.invokeMethod<void>('update', arguments));
return null;
},
[
viewId.value,
title,
systemImage,
accessibilityLabel,
foregroundColor.toARGB32(),
itemSignature,
],
);
return UiKitView(
viewType: 'buzz/native_menu_button',
creationParams: arguments,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) => viewId.value = id,
);
}
}

View File

@@ -683,17 +683,122 @@ void main() {
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
final preview = find.byKey(
const ValueKey('activity-row-action-preview-container'),
);
final menu = find.byKey(const ValueKey('activity-android-action-surface'));
expect(preview, findsOneWidget);
expect(menu, findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
expect(
tester.getTopLeft(menu).dy,
greaterThan(tester.getBottomLeft(preview).dy),
);
final source = tester.widget<Opacity>(
find.byKey(const ValueKey('activity-row-action-source-m1')),
);
expect(source.opacity, 0);
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Mark unread'),
),
find.byKey(const ValueKey('activity-row-action-markUnread')),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
});
testWidgets('iOS long press removes native header views above the overlay', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const menuButtonChannel = MethodChannel('buzz/native_menu_button');
const actionSurfaceChannel = MethodChannel(
'buzz/native_message_action_surface',
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
menuButtonChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
actionSurfaceChannel,
(call) async => call.method == 'isSupported' ? true : null,
);
try {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-native-filter')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('activity-native-options')),
findsOneWidget,
);
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-native-action-surface')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('activity-native-filter')),
findsNothing,
);
expect(
find.byKey(const ValueKey('activity-native-options')),
findsNothing,
);
expect(find.byType(UiKitView), findsOneWidget);
await tester.tapAt(const Offset(10, 300));
await tester.pump();
expect(
find.byKey(const ValueKey('activity-native-filter')),
findsNothing,
);
expect(
find.byKey(const ValueKey('activity-native-options')),
findsNothing,
);
await tester.pump(const Duration(milliseconds: 120));
expect(
find.byKey(const ValueKey('activity-native-filter')),
findsNothing,
);
expect(
find.byKey(const ValueKey('activity-native-options')),
findsNothing,
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('activity-native-filter')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('activity-native-options')),
findsOneWidget,
);
} finally {
await tester.pumpWidget(const SizedBox.shrink());
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
menuButtonChannel,
null,
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
actionSurfaceChannel,
null,
);
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('swiping an inbox row reveals and runs its row actions', (
tester,
) async {

View File

@@ -1625,8 +1625,19 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing);
expect(find.byType(BottomSheet), findsOneWidget);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('android-message-action-surface')),
findsOneWidget,
);
expect(find.byType(BottomSheet), findsNothing);
expect(find.text('Copy text'), findsOneWidget);
});

View File

@@ -417,6 +417,161 @@ void main() {
);
group('showMessageActions', () {
testWidgets(
'Android composes the existing tray, lifted preview, and curved actions',
(tester) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(),
prefs: prefs,
canManageMessage: true,
allMessages: [_message()],
reminderService: _stubReminderService(),
anchorRect: const Rect.fromLTWH(32, 260, 300, 72),
);
expect(
find.byKey(const ValueKey('reaction-popover-tray')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('message-action-preview-container')),
findsOneWidget,
);
final surface = find.byKey(
const ValueKey('android-message-action-surface'),
);
expect(surface, findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
const actionIds = [
'reply',
'copyLink',
'remind',
'markUnread',
'followThread',
'copyText',
'edit',
'delete',
];
const actionTitles = [
'Reply',
'Copy link',
'Remind me',
'Mark unread',
'Follow thread',
'Copy text',
'Edit message',
'Delete message',
];
for (var index = 0; index < actionIds.length; index++) {
expect(
find.byKey(ValueKey('android-message-action-${actionIds[index]}')),
findsOneWidget,
);
expect(
tester
.widget<Text>(
find.byKey(
ValueKey(
'android-message-action-label-${actionIds[index]}',
),
),
)
.data,
actionTitles[index],
);
}
final surfaceRect = tester.getRect(surface);
final previewRect = tester.getRect(
find.byKey(const ValueKey('message-action-preview-container')),
);
final trayRect = tester.getRect(
find.byKey(const ValueKey('reaction-popover-tray')),
);
expect(surfaceRect.width, 288);
expect(surfaceRect.center.dx, closeTo(previewRect.center.dx, 0.01));
expect(surfaceRect.center.dx, closeTo(trayRect.center.dx, 0.01));
expect(
tester.widget<Material>(surface).shape,
isA<RoundedRectangleBorder>().having(
(shape) => shape.borderRadius,
'border radius',
BorderRadius.circular(26),
),
);
expect(
find.byKey(const ValueKey('android-message-action-divider-triage')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('android-message-action-divider-export')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('android-message-action-divider-manage')),
findsOneWidget,
);
final iconCenters = [
for (final id in actionIds)
tester
.getRect(
find.byKey(ValueKey('android-message-action-icon-$id')),
)
.center
.dx,
];
final labelLefts = [
for (final id in actionIds)
tester
.getRect(
find.byKey(ValueKey('android-message-action-label-$id')),
)
.left,
];
for (final center in iconCenters.skip(1)) {
expect(center, closeTo(iconCenters.first, 0.01));
}
for (final left in labelLefts.skip(1)) {
expect(left, closeTo(labelLefts.first, 0.01));
}
Navigator.of(tester.element(surface)).pop();
await tester.pumpAndSettle();
},
);
testWidgets('Android runs its selected action after dismissing the menu', (
tester,
) async {
final prefs = await _mockPrefs();
await _pumpSheet(
tester,
message: _message(rootId: 'android-root'),
prefs: prefs,
anchorRect: const Rect.fromLTWH(32, 260, 300, 72),
);
final container = ProviderScope.containerOf(
tester.element(find.text('open')),
);
await tester.tap(
find.byKey(const ValueKey('android-message-action-followThread')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('android-message-action-surface')),
findsNothing,
);
expect(container.read(threadFollowsProvider).followedRootIds, {
'android-root',
});
});
testWidgets(
'composes the Flutter tray, lifted preview, and native actions',
(tester) async {

View File

@@ -0,0 +1,93 @@
import 'package:buzz/shared/widgets/ios_native_menu_button.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() {
test('serializes native iOS menu item state and grouping', () {
const item = IosNativeMenuItem(
id: 'mentions',
title: 'Mentions',
systemImage: 'at',
group: 'inbox',
checked: true,
enabled: false,
);
expect(item.toPlatformArguments(), {
'id': 'mentions',
'title': 'Mentions',
'systemImage': 'at',
'group': 'inbox',
'checked': true,
'enabled': false,
'destructive': false,
});
});
testWidgets('keeps its selection callback while menu content updates', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
const viewId = 74;
final channel = MethodChannel('buzz/native_menu_button/$viewId');
final selected = <String>[];
final checked = ValueNotifier(false);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => null,
);
try {
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<bool>(
valueListenable: checked,
builder: (context, value, child) => SizedBox(
width: 120,
height: 48,
child: IosNativeMenuButton(
title: value ? 'Mentions' : 'All',
accessibilityLabel: 'Filter activity',
foregroundColor: Colors.black,
items: [
IosNativeMenuItem(
id: 'mention',
title: 'Mentions',
checked: value,
),
],
onSelected: selected.add,
),
),
),
),
);
final platformView = tester.widget<UiKitView>(find.byType(UiKitView));
platformView.onPlatformViewCreated?.call(viewId);
await tester.pump();
checked.value = true;
await tester.pump();
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('selected', {'id': 'mention'}),
),
null,
);
await tester.pump();
expect(selected, ['mention']);
} finally {
checked.dispose();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
);
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
}