Unverified Commit 91ef92d2 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

hasStrings support for eliminating clipboard notifications (#87678)

Use the hasStrings clipboard method when possible instead of reading the contents of the clipboard.
parent b2adffa9
...@@ -59,4 +59,20 @@ class Clipboard { ...@@ -59,4 +59,20 @@ class Clipboard {
return null; return null;
return ClipboardData(text: result['text'] as String?); return ClipboardData(text: result['text'] as String?);
} }
/// Returns a future that resolves to true iff the clipboard contains string
/// data.
///
/// See also:
/// * [The iOS hasStrings method](https://developer.apple.com/documentation/uikit/uipasteboard/1829416-hasstrings?language=objc).
static Future<bool> hasStrings() async {
final Map<String, dynamic>? result = await SystemChannels.platform.invokeMethod(
'Clipboard.hasStrings',
Clipboard.kTextPlain,
);
if (result == null) {
return false;
}
return result['value'] as bool;
}
} }
...@@ -1579,28 +1579,13 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget ...@@ -1579,28 +1579,13 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget
/// Check the [Clipboard] and update [value] if needed. /// Check the [Clipboard] and update [value] if needed.
Future<void> update() async { Future<void> update() async {
// iOS 14 added a notification that appears when an app accesses the if (_disposed) {
// clipboard. To avoid the notification, don't access the clipboard on iOS, return;
// and instead always show the paste button, even when the clipboard is
// empty.
// TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
// won't trigger the notification.
// https://github.com/flutter/flutter/issues/60145
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
value = ClipboardStatus.pasteable;
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
break;
} }
ClipboardData? data; final bool hasStrings;
try { try {
data = await Clipboard.getData(Clipboard.kTextPlain); hasStrings = await Clipboard.hasStrings();
} catch (stacktrace) { } catch (stacktrace) {
// In the case of an error from the Clipboard API, set the value to // In the case of an error from the Clipboard API, set the value to
// unknown so that it will try to update again later. // unknown so that it will try to update again later.
...@@ -1611,13 +1596,14 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget ...@@ -1611,13 +1596,14 @@ class ClipboardStatusNotifier extends ValueNotifier<ClipboardStatus> with Widget
return; return;
} }
final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text!.isNotEmpty final ClipboardStatus nextStatus = hasStrings
? ClipboardStatus.pasteable ? ClipboardStatus.pasteable
: ClipboardStatus.notPasteable; : ClipboardStatus.notPasteable;
if (_disposed || clipboardStatus == value) {
if (_disposed || nextStatus == value) {
return; return;
} }
value = clipboardStatus; value = nextStatus;
} }
@override @override
......
...@@ -18,27 +18,12 @@ import 'package:flutter/services.dart'; ...@@ -18,27 +18,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
// On web, the context menu (aka toolbar) is provided by the browser. // On web, the context menu (aka toolbar) is provided by the browser.
final bool isContextMenuProvidedByPlatform = isBrowser; final bool isContextMenuProvidedByPlatform = isBrowser;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments! as Object;
break;
}
}
}
class MockTextSelectionControls extends TextSelectionControls { class MockTextSelectionControls extends TextSelectionControls {
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
......
...@@ -11,24 +11,9 @@ import 'package:flutter/rendering.dart'; ...@@ -11,24 +11,9 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition, findRenderEditable; import '../widgets/editable_text_utils.dart' show textOffsetToPosition, findRenderEditable;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments! as Object;
break;
}
}
}
class _LongCupertinoLocalizationsDelegate extends LocalizationsDelegate<CupertinoLocalizations> { class _LongCupertinoLocalizationsDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
const _LongCupertinoLocalizationsDelegate(); const _LongCupertinoLocalizationsDelegate();
...@@ -69,7 +54,6 @@ const _LongCupertinoLocalizations _longLocalizations = _LongCupertinoLocalizatio ...@@ -69,7 +54,6 @@ const _LongCupertinoLocalizations _longLocalizations = _LongCupertinoLocalizatio
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
// Returns true iff the button is visually enabled. // Returns true iff the button is visually enabled.
bool appearsEnabled(WidgetTester tester, String text) { bool appearsEnabled(WidgetTester tester, String text) {
...@@ -92,6 +76,23 @@ void main() { ...@@ -92,6 +76,23 @@ void main() {
}).toList(); }).toList();
} }
setUp(() async {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
group('canSelectAll', () { group('canSelectAll', () {
Widget createEditableText({ Widget createEditableText({
Key? key, Key? key,
...@@ -185,76 +186,6 @@ void main() { ...@@ -185,76 +186,6 @@ void main() {
}); });
}); });
// TODO(justinmc): https://github.com/flutter/flutter/issues/60145
testWidgets('Paste always appears regardless of clipboard content on iOS', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
CupertinoApp(
home: Column(
children: <Widget>[
CupertinoTextField(
controller: controller,
),
],
),
),
);
// Make sure the clipboard is empty to start.
await Clipboard.setData(const ClipboardData(text: ''));
// Double tap to select the first word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, isFalse);
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
// Paste is showing even though clipboard is empty.
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.descendant(
of: find.byType(Overlay),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TextSelectionHandleOverlay'),
), findsNWidgets(2));
// Tap copy to add something to the clipboard and close the menu.
await tester.tapAt(tester.getCenter(find.text('Copy')));
await tester.pumpAndSettle();
// The menu is gone, but the handles are visible on the existing selection.
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(controller.selection.isCollapsed, isFalse);
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
expect(find.descendant(
of: find.byType(Overlay),
matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TextSelectionHandleOverlay'),
), findsNWidgets(2));
// Double tap to show the menu again.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
// Paste still shows.
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
},
skip: isBrowser, // [intended] We do not use Flutter-rendered context menu on the Web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
group('Text selection menu overflow (iOS)', () { group('Text selection menu overflow (iOS)', () {
testWidgets('All menu items show when they fit.', (WidgetTester tester) async { testWidgets('All menu items show when they fit.', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'abc def ghi'); final TextEditingController controller = TextEditingController(text: 'abc def ghi');
......
...@@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; ...@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
...@@ -301,19 +303,3 @@ void main() { ...@@ -301,19 +303,3 @@ void main() {
}); });
} }
class MockClipboard {
dynamic _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
...@@ -7,24 +7,9 @@ import 'package:flutter/material.dart'; ...@@ -7,24 +7,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
class MockClipboard {
dynamic _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
......
...@@ -19,6 +19,7 @@ import 'package:flutter/rendering.dart'; ...@@ -19,6 +19,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition; import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition;
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart'; import 'feedback_tester.dart';
...@@ -31,22 +32,6 @@ final bool isContextMenuProvidedByPlatform = isBrowser; ...@@ -31,22 +32,6 @@ final bool isContextMenuProvidedByPlatform = isBrowser;
// On web, key events in text fields are handled by the browser. // On web, key events in text fields are handled by the browser.
final bool areKeyEventsHandledByPlatform = isBrowser; final bool areKeyEventsHandledByPlatform = isBrowser;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments as Object;
break;
}
}
}
class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
@override @override
bool isSupported(Locale locale) => true; bool isSupported(Locale locale) => true;
...@@ -6220,8 +6205,8 @@ void main() { ...@@ -6220,8 +6205,8 @@ void main() {
semantics.dispose(); semantics.dispose();
// On web (just like iOS), we don't check for pasteability because that // On web, we don't check for pasteability because that triggers a
// triggers a permission dialog in the browser. // permission dialog in the browser.
// https://github.com/flutter/flutter/pull/57139#issuecomment-629048058 // https://github.com/flutter/flutter/pull/57139#issuecomment-629048058
}, skip: isBrowser); // [intended] see above. }, skip: isBrowser); // [intended] see above.
...@@ -9550,11 +9535,19 @@ void main() { ...@@ -9550,11 +9535,19 @@ void main() {
), ),
); );
bool triedToReadClipboard = false; bool calledGetData = false;
bool calledHasStrings = false;
tester.binding.defaultBinaryMessenger tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData') { switch (methodCall.method) {
triedToReadClipboard = true; case 'Clipboard.getData':
calledGetData = true;
break;
case 'Clipboard.hasStrings':
calledHasStrings = true;
break;
default:
break;
} }
return null; return null;
}); });
...@@ -9567,14 +9560,16 @@ void main() { ...@@ -9567,14 +9560,16 @@ void main() {
await tester.tapAt(textfieldStart + const Offset(150.0, 9.0)); await tester.tapAt(textfieldStart + const Offset(150.0, 9.0));
await tester.pump(); await tester.pump();
// getData is not called unless something is pasted. hasStrings is used to
// check the status of the clipboard.
expect(calledGetData, false);
if (kIsWeb) { if (kIsWeb) {
// The clipboard is not checked because it requires user permissions and // hasStrings is not checked because web doesn't show a custom text
// web doesn't show a custom text selection menu. // selection menu.
expect(triedToReadClipboard, false); expect(calledHasStrings, false);
} else { } else {
// The clipboard is checked in order to decide if the content can be // hasStrings is checked in order to decide if the content can be pasted.
// pasted. expect(calledHasStrings, true);
expect(triedToReadClipboard, true);
} }
}); });
......
...@@ -11,24 +11,9 @@ import 'package:flutter/services.dart'; ...@@ -11,24 +11,9 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart'; import '../widgets/editable_text_utils.dart';
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments as Object;
break;
}
}
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
......
...@@ -7,33 +7,31 @@ import 'package:flutter/rendering.dart'; ...@@ -7,33 +7,31 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition; import '../widgets/editable_text_utils.dart' show findRenderEditable, globalize, textOffsetToPosition;
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments as Object;
break;
}
}
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
setUp(() async { setUp(() async {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'clipboard data')); await Clipboard.setData(const ClipboardData(text: 'clipboard data'));
}); });
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
group('canSelectAll', () { group('canSelectAll', () {
Widget createEditableText({ Widget createEditableText({
required Key key, required Key key,
...@@ -671,59 +669,4 @@ void main() { ...@@ -671,59 +669,4 @@ void main() {
skip: isBrowser, // [intended] we don't supply the cut/copy/paste buttons on the web. skip: isBrowser, // [intended] we don't supply the cut/copy/paste buttons on the web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }) variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android })
); );
// TODO(justinmc): https://github.com/flutter/flutter/issues/60145
testWidgets('Paste always appears regardless of clipboard content on iOS', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
TextField(
controller: controller,
),
],
),
),
),
);
// Make sure the clipboard is empty.
await Clipboard.setData(const ClipboardData(text: ''));
// Double tap to select the first word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
// Paste is showing even though clipboard is empty.
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
// Tap copy to add something to the clipboard and close the menu.
await tester.tapAt(tester.getCenter(find.text('Copy')));
await tester.pumpAndSettle();
expect(find.text('Copy'), findsNothing);
expect(find.text('Cut'), findsNothing);
// Double tap to show the menu again.
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
// Paste still shows.
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
},
skip: isBrowser, // [intended] we don't supply the cut/copy/paste buttons on the web.
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })
);
} }
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
class MockClipboard {
MockClipboard({
this.hasStringsThrows = false,
});
final bool hasStringsThrows;
dynamic _clipboardData = <String, dynamic>{
'text': null,
};
Future<Object?> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.hasStrings':
if (hasStringsThrows)
throw Exception();
final Map<String, dynamic>? clipboardDataMap = _clipboardData as Map<String, dynamic>?;
final String? text = clipboardDataMap?['text'] as String?;
return <String, bool>{'value': text != null && text.isNotEmpty};
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
...@@ -82,6 +82,8 @@ void main() { ...@@ -82,6 +82,8 @@ void main() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData') if (methodCall.method == 'Clipboard.getData')
return const <String, dynamic>{'text': clipboardContent}; return const <String, dynamic>{'text': clipboardContent};
if (methodCall.method == 'Clipboard.hasStrings')
return <String, dynamic>{'value': clipboardContent.isNotEmpty};
return null; return null;
}); });
...@@ -134,6 +136,8 @@ void main() { ...@@ -134,6 +136,8 @@ void main() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData') if (methodCall.method == 'Clipboard.getData')
return const <String, dynamic>{'text': clipboardContent}; return const <String, dynamic>{'text': clipboardContent};
if (methodCall.method == 'Clipboard.hasStrings')
return <String, dynamic>{'value': clipboardContent.isNotEmpty};
return null; return null;
}); });
...@@ -860,6 +864,8 @@ void main() { ...@@ -860,6 +864,8 @@ void main() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData') if (methodCall.method == 'Clipboard.getData')
return const <String, dynamic>{'text': clipboardContent}; return const <String, dynamic>{'text': clipboardContent};
if (methodCall.method == 'Clipboard.hasStrings')
return <String, dynamic>{'value': clipboardContent.isNotEmpty};
return null; return null;
}); });
...@@ -918,6 +924,8 @@ void main() { ...@@ -918,6 +924,8 @@ void main() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
if (methodCall.method == 'Clipboard.getData') if (methodCall.method == 'Clipboard.getData')
return const <String, dynamic>{'text': clipboardContent}; return const <String, dynamic>{'text': clipboardContent};
if (methodCall.method == 'Clipboard.hasStrings')
return <String, dynamic>{'value': clipboardContent.isNotEmpty};
return null; return null;
}); });
......
...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/clipboard_utils.dart';
import 'editable_text_utils.dart'; import 'editable_text_utils.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
...@@ -48,22 +49,6 @@ enum HandlePositionInViewport { ...@@ -48,22 +49,6 @@ enum HandlePositionInViewport {
leftEdge, rightEdge, within, leftEdge, rightEdge, within,
} }
class MockClipboard {
Object _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments as Object;
break;
}
}
}
void main() { void main() {
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
(TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding) (TestWidgetsFlutterBinding.ensureInitialized() as TestWidgetsFlutterBinding)
...@@ -1336,6 +1321,56 @@ void main() { ...@@ -1336,6 +1321,56 @@ void main() {
expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget); expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget);
}); });
testWidgets('Paste is shown only when there is something to paste', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
// Make sure the clipboard has a valid string on it.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
// Show the toolbar.
state.renderEditable.selectWordsInRange(
from: Offset.zero,
cause: SelectionChangedCause.tap,
);
await tester.pump();
// The Paste button is shown (except on web, which doesn't show the Flutter
// toolbar).
expect(state.showToolbar(), kIsWeb ? isFalse : isTrue);
await tester.pumpAndSettle();
expect(find.text('Paste'), kIsWeb ? findsNothing : findsOneWidget);
// Hide the menu again.
state.hideToolbar();
await tester.pump();
expect(find.text('Paste'), findsNothing);
// Clear the clipboard
await Clipboard.setData(const ClipboardData(text: ''));
// Show the toolbar again.
expect(state.showToolbar(), kIsWeb ? isFalse : isTrue);
await tester.pumpAndSettle();
// Paste is not shown.
await tester.pumpAndSettle();
expect(find.text('Paste'), findsNothing);
});
testWidgets('can show the toolbar after clearing all text', (WidgetTester tester) async { testWidgets('can show the toolbar after clearing all text', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/35998. // Regression test for https://github.com/flutter/flutter/issues/35998.
await tester.pumpWidget( await tester.pumpWidget(
......
...@@ -13,25 +13,10 @@ import 'package:flutter/rendering.dart'; ...@@ -13,25 +13,10 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition; import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
class MockClipboard {
dynamic _clipboardData = <String, dynamic>{
'text': null,
};
Future<dynamic> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> { class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
@override @override
bool isSupported(Locale locale) => true; bool isSupported(Locale locale) => true;
...@@ -126,7 +111,6 @@ double getOpacity(WidgetTester tester, Finder finder) { ...@@ -126,7 +111,6 @@ double getOpacity(WidgetTester tester, Finder finder) {
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard(); final MockClipboard mockClipboard = MockClipboard();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
const String kThreeLines = const String kThreeLines =
'First line of text is\n' 'First line of text is\n'
...@@ -165,11 +149,22 @@ void main() { ...@@ -165,11 +149,22 @@ void main() {
setUp(() async { setUp(() async {
debugResetSemanticsIdCounter(); debugResetSemanticsIdCounter();
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text // Fill the clipboard so that the Paste option is available in the text
// selection menu. // selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data')); await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
}); });
tearDown(() {
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
Widget selectableTextBuilder({ Widget selectableTextBuilder({
String text = '', String text = '',
int? maxLines = 1, int? maxLines = 1,
......
...@@ -8,29 +8,7 @@ import 'package:flutter/rendering.dart'; ...@@ -8,29 +8,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
class MockClipboard { import 'clipboard_utils.dart';
MockClipboard({
this.getDataThrows = false,
});
final bool getDataThrows;
dynamic _clipboardData = <String, dynamic>{
'text': null,
};
Future<Object?> handleMethodCall(MethodCall methodCall) async {
switch (methodCall.method) {
case 'Clipboard.getData':
if (getDataThrows)
throw Exception();
return _clipboardData;
case 'Clipboard.setData':
_clipboardData = methodCall.arguments;
break;
}
}
}
void main() { void main() {
late int tapCount; late int tapCount;
...@@ -757,7 +735,7 @@ void main() { ...@@ -757,7 +735,7 @@ void main() {
group('ClipboardStatusNotifier', () { group('ClipboardStatusNotifier', () {
group('when Clipboard fails', () { group('when Clipboard fails', () {
setUp(() { setUp(() {
final MockClipboard mockClipboard = MockClipboard(getDataThrows: true); final MockClipboard mockClipboard = MockClipboard(hasStringsThrows: true);
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall); TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment