From eb7fb9a3d7746254d3acdcc9c274feb2d4e1af77 Mon Sep 17 00:00:00 2001 From: "Md. Murad Hossin" <48625061+muradhossin@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:30:13 +0600 Subject: [PATCH] Fix/ios share context menu (#176199) This PR fixes https://github.com/flutter/flutter/issues/173491 by adding the missing 'Share' option to the default iOS SystemContextMenu when shareEnabled is true. Changes: Added IOSSystemContextMenuItemShare to getDefaultItems in system_context_menu.dart. Added a widget test to ensure Share is present in the default items for non-empty selections on iOS. Rationale: This aligns Flutter's default iOS text selection context menu with native iOS behavior, ensuring users see the expected 'Share' option when selecting text. Demo: Video showing Share option in iOS context menu: https://github.com/user-attachments/assets/e04cd1f9-7d92-4147-a09b-719f03d9c625 --- .../lib/src/widgets/system_context_menu.dart | 40 ++++++++--- .../widgets/system_context_menu_test.dart | 70 +++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/flutter/lib/src/widgets/system_context_menu.dart b/packages/flutter/lib/src/widgets/system_context_menu.dart index c8d9423d581..b4b1f193a2a 100644 --- a/packages/flutter/lib/src/widgets/system_context_menu.dart +++ b/packages/flutter/lib/src/widgets/system_context_menu.dart @@ -10,6 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'basic.dart'; +import 'context_menu_button_item.dart'; import 'editable_text.dart'; import 'framework.dart'; import 'localizations.dart'; @@ -156,15 +157,36 @@ class SystemContextMenu extends StatefulWidget { /// * [EditableTextState.contextMenuButtonItems], which provides the default /// [ContextMenuButtonItem]s for the Flutter-rendered context menu. static List getDefaultItems(EditableTextState editableTextState) { - return [ - if (editableTextState.copyEnabled) const IOSSystemContextMenuItemCopy(), - if (editableTextState.cutEnabled) const IOSSystemContextMenuItemCut(), - if (editableTextState.pasteEnabled) const IOSSystemContextMenuItemPaste(), - if (editableTextState.selectAllEnabled) const IOSSystemContextMenuItemSelectAll(), - if (editableTextState.lookUpEnabled) const IOSSystemContextMenuItemLookUp(), - if (editableTextState.searchWebEnabled) const IOSSystemContextMenuItemSearchWeb(), - if (editableTextState.liveTextInputEnabled) const IOSSystemContextMenuItemLiveText(), - ]; + final items = []; + + // Use the generic Flutter-rendered context menu model as the single source of truth. + for (final ContextMenuButtonItem button in editableTextState.contextMenuButtonItems) { + switch (button.type) { + case ContextMenuButtonType.copy: + items.add(const IOSSystemContextMenuItemCopy()); + case ContextMenuButtonType.cut: + items.add(const IOSSystemContextMenuItemCut()); + case ContextMenuButtonType.paste: + items.add(const IOSSystemContextMenuItemPaste()); + case ContextMenuButtonType.selectAll: + items.add(const IOSSystemContextMenuItemSelectAll()); + case ContextMenuButtonType.lookUp: + items.add(const IOSSystemContextMenuItemLookUp()); + case ContextMenuButtonType.searchWeb: + items.add(const IOSSystemContextMenuItemSearchWeb()); + case ContextMenuButtonType.share: + items.add(const IOSSystemContextMenuItemShare()); + case ContextMenuButtonType.liveTextInput: + items.add(const IOSSystemContextMenuItemLiveText()); + case ContextMenuButtonType.delete: + // No native iOS system menu button for Delete — intentionally ignored. + case ContextMenuButtonType.custom: + // Custom items are provided explicitly via SystemContextMenu.items, + // not via defaults. Intentionally ignore in default mapping. + } + } + + return items; } @override diff --git a/packages/flutter/test/widgets/system_context_menu_test.dart b/packages/flutter/test/widgets/system_context_menu_test.dart index ef86eb8aa96..b75c976f10f 100644 --- a/packages/flutter/test/widgets/system_context_menu_test.dart +++ b/packages/flutter/test/widgets/system_context_menu_test.dart @@ -1332,4 +1332,74 @@ void main() { skip: kIsWeb, // [intended] variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); + + testWidgets( + 'Default iOS SystemContextMenu includes Share for non-empty selection', + (WidgetTester tester) async { + final itemsReceived = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + if (methodCall.method == 'ContextMenu.showSystemContextMenu') { + final arguments = methodCall.arguments as Map; + final untypedItems = arguments['items'] as List; + final lastItems = [ + for (final dynamic value in untypedItems) + systemContextMenuItemDataFromJson(value as Map), + ]; + itemsReceived.add(lastItems); + } + return; + }, + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + final controller = TextEditingController(text: 'Hello world'); + addTearDown(controller.dispose); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(supportsShowingSystemContextMenu: true), + child: MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText(editableTextState: editableTextState); + }, + ), + ), + ), + ), + ), + ); + + // Focus the field first (this establishes the TextInputConnection). + await tester.tap(find.byType(TextField)); + await tester.pump(); + final EditableTextState state = tester.state(find.byType(EditableText)); + + // Set a non-empty selection to enable sharing. + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 5); // "Hello" + await tester.pump(); + + // Nit: ensure no platform message sent before showing toolbar. + expect(itemsReceived, isEmpty); + + // Show the context menu. + expect(state.showToolbar(), true); + await tester.pump(); + + // Assert that the platform message included a Share item. + expect(itemsReceived, isNotEmpty); + expect(itemsReceived.last, contains(isA())); + }, + skip: kIsWeb, // [intended] + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); }