mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Add cross-platform mobile action menus
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, *)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
135
mobile/lib/shared/widgets/ios_native_menu_button.dart
Normal file
135
mobile/lib/shared/widgets/ios_native_menu_button.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
93
mobile/test/shared/widgets/ios_native_menu_button_test.dart
Normal file
93
mobile/test/shared/widgets/ios_native_menu_button_test.dart
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user