// 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/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import '../widgets/editable_text_utils.dart' show textOffsetToPosition; // These constants are copied from cupertino/text_selection_toolbar.dart. const double _kArrowScreenPadding = 26.0; const double _kToolbarContentDistance = 8.0; const double _kToolbarHeight = 43.0; // A custom text selection menu that just displays a single custom button. class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionControls { @override Widget buildToolbar( BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset selectionMidpoint, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ValueNotifier<ClipboardStatus>? clipboardStatus, Offset? lastSecondaryTapDownPosition, ) { final EdgeInsets mediaQueryPadding = MediaQuery.paddingOf(context); final double anchorX = (selectionMidpoint.dx + globalEditableRegion.left).clamp( _kArrowScreenPadding + mediaQueryPadding.left, MediaQuery.sizeOf(context).width - mediaQueryPadding.right - _kArrowScreenPadding, ); final Offset anchorAbove = Offset( anchorX, endpoints.first.point.dy - textLineHeight + globalEditableRegion.top, ); final Offset anchorBelow = Offset( anchorX, endpoints.last.point.dy + globalEditableRegion.top, ); return CupertinoTextSelectionToolbar( anchorAbove: anchorAbove, anchorBelow: anchorBelow, children: <Widget>[ CupertinoTextSelectionToolbarButton( onPressed: () {}, child: const Text('Custom button'), ), ], ); } } class TestBox extends SizedBox { const TestBox({super.key}) : super(width: itemWidth, height: itemHeight); static const double itemHeight = 44.0; static const double itemWidth = 100.0; } const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness( color: Color(0xEBF7F7F7), darkColor: Color(0xEB202020), ); void main() { TestWidgetsFlutterBinding.ensureInitialized(); // Find by a runtimeType String, including private types. Finder findPrivate(String type) { return find.descendant( of: find.byType(CupertinoApp), matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == type), ); } // Finding CupertinoTextSelectionToolbar won't give you the position as the user sees // it because it's a full-sized Stack at the top level. This method finds the // visible part of the toolbar for use in measurements. Finder findToolbar() => findPrivate('_CupertinoTextSelectionToolbarContent'); Finder findOverflowNextButton() => find.text('▶'); Finder findOverflowBackButton() => find.text('◀'); testWidgets('paginates children if they overflow', (WidgetTester tester) async { late StateSetter setState; final List<Widget> children = List<Widget>.generate(7, (int i) => const TestBox()); await tester.pumpWidget( CupertinoApp( home: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; return CupertinoTextSelectionToolbar( anchorAbove: const Offset(50.0, 100.0), anchorBelow: const Offset(50.0, 200.0), children: children, ); }, ), ), ), ); // All children fit on the screen, so they are all rendered. expect(find.byType(TestBox), findsNWidgets(children.length)); expect(findOverflowNextButton(), findsNothing); expect(findOverflowBackButton(), findsNothing); // Adding one more child makes the children overflow. setState(() { children.add( const TestBox(), ); }); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(children.length - 1)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsNothing); // Tap the overflow next button to show the next page of children. await tester.tap(findOverflowNextButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(1)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsOneWidget); // Tapping the overflow next button again does nothing because it is // disabled and there are no more children to display. await tester.tap(findOverflowNextButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(1)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsOneWidget); // Tap the overflow back button to go back to the first page. await tester.tap(findOverflowBackButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(7)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsNothing); // Adding 7 more children overflows onto a third page. setState(() { children.add(const TestBox()); children.add(const TestBox()); children.add(const TestBox()); children.add(const TestBox()); children.add(const TestBox()); children.add(const TestBox()); }); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(7)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsNothing); // Tap the overflow next button to show the second page of children. await tester.tap(findOverflowNextButton()); await tester.pumpAndSettle(); // With the back button, only six children fit on this page. expect(find.byType(TestBox), findsNWidgets(6)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsOneWidget); // Tap the overflow next button again to show the third page of children. await tester.tap(findOverflowNextButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(1)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsOneWidget); // Tap the overflow back button to go back to the second page. await tester.tap(findOverflowBackButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(6)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsOneWidget); // Tap the overflow back button to go back to the first page. await tester.tap(findOverflowBackButton()); await tester.pumpAndSettle(); expect(find.byType(TestBox), findsNWidgets(7)); expect(findOverflowNextButton(), findsOneWidget); expect(findOverflowBackButton(), findsNothing); }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. testWidgets('does not paginate if children fit with zero margin', (WidgetTester tester) async { final List<Widget> children = List<Widget>.generate(7, (int i) => const TestBox()); final double spacerWidth = 1.0 / tester.binding.window.devicePixelRatio; final double dividerWidth = 1.0 / tester.binding.window.devicePixelRatio; const double borderRadius = 8.0; // Should match _kToolbarBorderRadius final double width = 7 * TestBox.itemWidth + 6 * (dividerWidth + 2 * spacerWidth) + 2 * borderRadius; await tester.pumpWidget( CupertinoApp( home: Center( child: SizedBox( width: width, child: CupertinoTextSelectionToolbar( anchorAbove: const Offset(50.0, 100.0), anchorBelow: const Offset(50.0, 200.0), children: children, ), ), ), ), ); // All children fit on the screen, so they are all rendered. expect(find.byType(TestBox), findsNWidgets(children.length)); expect(findOverflowNextButton(), findsNothing); expect(findOverflowBackButton(), findsNothing); }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. testWidgets('positions itself at anchorAbove if it fits', (WidgetTester tester) async { late StateSetter setState; const double height = _kToolbarHeight; const double anchorBelowY = 500.0; double anchorAboveY = 0.0; const double paddingAbove = 12.0; await tester.pumpWidget( CupertinoApp( home: Center( child: StatefulBuilder( builder: (BuildContext context, StateSetter setter) { setState = setter; final MediaQueryData data = MediaQuery.of(context); // Add some custom vertical padding to make this test more strict. // By default in the testing environment, _kToolbarContentDistance // and the built-in padding from CupertinoApp can end up canceling // each other out. return MediaQuery( data: data.copyWith( padding: data.viewPadding.copyWith( top: paddingAbove, ), ), child: CupertinoTextSelectionToolbar( anchorAbove: Offset(50.0, anchorAboveY), anchorBelow: const Offset(50.0, anchorBelowY), children: <Widget>[ Container(color: const Color(0xffff0000), width: 50.0, height: height), Container(color: const Color(0xff00ff00), width: 50.0, height: height), Container(color: const Color(0xff0000ff), width: 50.0, height: height), ], ), ); }, ), ), ), ); // When the toolbar doesn't fit above aboveAnchor, it positions itself below // belowAnchor. double toolbarY = tester.getTopLeft(findToolbar()).dy; expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); expect(find.byType(CustomSingleChildLayout), findsOneWidget); final CustomSingleChildLayout layout = tester.widget(find.byType(CustomSingleChildLayout)); final TextSelectionToolbarLayoutDelegate delegate = layout.delegate as TextSelectionToolbarLayoutDelegate; expect(delegate.anchorBelow.dy, anchorBelowY - paddingAbove); // Even when it barely doesn't fit. setState(() { anchorAboveY = 70.0; }); await tester.pump(); toolbarY = tester.getTopLeft(findToolbar()).dy; expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistance)); // When it does fit above aboveAnchor, it positions itself there. setState(() { anchorAboveY = 80.0; }); await tester.pump(); toolbarY = tester.getTopLeft(findToolbar()).dy; expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance)); }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. testWidgets('can create and use a custom toolbar', (WidgetTester tester) async { final TextEditingController controller = TextEditingController( text: 'Select me custom menu', ); await tester.pumpWidget( CupertinoApp( home: Center( child: CupertinoTextField( controller: controller, selectionControls: _CustomCupertinoTextSelectionControls(), ), ), ), ); // The selection menu is not initially shown. expect(find.text('Custom button'), findsNothing); // Long press on "custom" to select it. final Offset customPos = textOffsetToPosition(tester, 11); final TestGesture gesture = await tester.startGesture(customPos, pointer: 7); await tester.pump(const Duration(seconds: 2)); await gesture.up(); await tester.pump(); // The custom selection menu is shown. expect(find.text('Custom button'), findsOneWidget); expect(find.text('Cut'), findsNothing); expect(find.text('Copy'), findsNothing); expect(find.text('Paste'), findsNothing); expect(find.text('Select all'), findsNothing); }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. for (final Brightness? themeBrightness in <Brightness?>[...Brightness.values, null]) { for (final Brightness? mediaBrightness in <Brightness?>[...Brightness.values, null]) { testWidgets('draws dark buttons in dark mode and light button in light mode when theme is $themeBrightness and MediaQuery is $mediaBrightness', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( theme: CupertinoThemeData( brightness: themeBrightness, ), home: Center( child: Builder( builder: (BuildContext context) { return MediaQuery( data: MediaQuery.of(context).copyWith(platformBrightness: mediaBrightness), child: CupertinoTextSelectionToolbar( anchorAbove: const Offset(100.0, 0.0), anchorBelow: const Offset(100.0, 0.0), children: <Widget>[ CupertinoTextSelectionToolbarButton.text( onPressed: () {}, text: 'Button', ), ], ), ); }, ), ), ), ); final Finder buttonFinder = find.byType(CupertinoButton); expect(buttonFinder, findsOneWidget); final Finder decorationFinder = find.descendant( of: find.byType(CupertinoButton), matching: find.byType(DecoratedBox) ); expect(decorationFinder, findsOneWidget); final DecoratedBox decoratedBox = tester.widget(decorationFinder); final BoxDecoration boxDecoration = decoratedBox.decoration as BoxDecoration; // Theme brightness is preferred, otherwise MediaQuery brightness is // used. If both are null, defaults to light. late final Brightness effectiveBrightness; if (themeBrightness != null) { effectiveBrightness = themeBrightness; } else { effectiveBrightness = mediaBrightness ?? Brightness.light; } expect( boxDecoration.color!.value, effectiveBrightness == Brightness.dark ? _kToolbarBackgroundColor.darkColor.value : _kToolbarBackgroundColor.color.value, ); }, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web. } } }