blob: 3fe50706fc08b7a13e5fc025b849a841c6b5bad7 [file] [log] [blame]
// 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.
// This file is run as part of a reduced test set in CI on Mac and Windows
// machines.
@Tags(<String>['reduced-test-set'])
library;
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 Size _kToolbarArrowSize = Size(14.0, 7.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,
ValueListenable<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 _kToolbarTextColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.black,
darkColor: CupertinoColors.white,
);
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');
// Check if the middle point of the chevron is pointing left or right.
//
// Offset.dx: a right or left margin (_kToolbarChevronSize / 4 => 2.5) to center the icon horizontally
// Offset.dy: always in the exact vertical center (_kToolbarChevronSize / 2 => 5)
PaintPattern overflowNextPaintPattern() => paints
..line(p1: const Offset(2.5, 0), p2: const Offset(7.5, 5))
..line(p1: const Offset(7.5, 5), p2: const Offset(2.5, 10));
PaintPattern overflowBackPaintPattern() => paints
..line(p1: const Offset(7.5, 0), p2: const Offset(2.5, 5))
..line(p1: const Offset(2.5, 5), p2: const Offset(7.5, 10));
Finder findOverflowNextButton() {
return find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_RightCupertinoChevronPainter',
);
}
Finder findOverflowBackButton() {
return find.byWidgetPredicate((Widget widget) =>
widget is CustomPaint &&
'${widget.painter?.runtimeType}' == '_LeftCupertinoChevronPainter',
);
}
testWidgets('chevrons point to the correct side', (WidgetTester tester) async {
// Add enough TestBoxes to need 3 pages.
final List<Widget> children = List<Widget>.generate(15, (int i) => const TestBox());
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: children,
),
),
),
);
expect(findOverflowBackButton(), findsNothing);
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowNextButton(), overflowNextPaintPattern());
// Tap the overflow next button to show the next page of children.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsOneWidget);
expect(findOverflowBackButton(), overflowBackPaintPattern());
expect(findOverflowNextButton(), overflowNextPaintPattern());
// Tap the overflow next button to show the last page of children.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(findOverflowBackButton(), findsOneWidget);
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), overflowBackPaintPattern());
}, skip: kIsWeb); // Path.combine is not implemented in the HTML backend https://github.com/flutter/flutter/issues/44572
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.
// The next button is hidden as there's no next page.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the first page.
await tester.tapAt(tester.getCenter(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.addAll(List<TestBox>.filled(6, 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.tapAt(tester.getCenter(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.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
expect(find.byType(TestBox), findsNWidgets(1));
expect(findOverflowNextButton(), findsNothing);
expect(findOverflowBackButton(), findsOneWidget);
// Tap the overflow back button to go back to the second page.
await tester.tapAt(tester.getCenter(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.tapAt(tester.getCenter(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.view.devicePixelRatio;
final double dividerWidth = 1.0 / tester.view.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('correctly sizes large toolbar buttons', (WidgetTester tester) async {
final GlobalKey firstBoxKey = GlobalKey();
final GlobalKey secondBoxKey = GlobalKey();
final GlobalKey thirdBoxKey = GlobalKey();
final GlobalKey fourthBoxKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: SizedBox(
width: 420,
child: CupertinoTextSelectionToolbar(
anchorAbove: const Offset(50.0, 100.0),
anchorBelow: const Offset(50.0, 200.0),
children: <Widget>[
SizedBox(key: firstBoxKey, width: 100),
SizedBox(key: secondBoxKey, width: 300),
SizedBox(key: thirdBoxKey, width: 100),
SizedBox(key: fourthBoxKey, width: 100),
],
),
),
),
),
);
// The first page isn't wide enough to show the second button.
expect(find.byKey(firstBoxKey), findsOneWidget);
expect(find.byKey(secondBoxKey), findsNothing);
expect(find.byKey(thirdBoxKey), findsNothing);
expect(find.byKey(fourthBoxKey), findsNothing);
// Show the next page.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
// The second page should show only the second button.
expect(find.byKey(firstBoxKey), findsNothing);
expect(find.byKey(secondBoxKey), findsOneWidget);
expect(find.byKey(thirdBoxKey), findsNothing);
expect(find.byKey(fourthBoxKey), findsNothing);
// The button's width shouldn't be limited by the first page's width.
expect(tester.getSize(find.byKey(secondBoxKey)).width, 300);
// Show the next page.
await tester.tapAt(tester.getCenter(findOverflowNextButton()));
await tester.pumpAndSettle();
// The third page should show the last two items.
expect(find.byKey(firstBoxKey), findsNothing);
expect(find.byKey(secondBoxKey), findsNothing);
expect(find.byKey(thirdBoxKey), findsOneWidget);
expect(find.byKey(fourthBoxKey), findsOneWidget);
}, 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 = 50.0;
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 + _kToolbarArrowSize.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',
);
addTearDown(controller.dispose);
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 textFinder = find.descendant(
of: find.byType(CupertinoButton),
matching: find.byType(Text)
);
expect(textFinder, findsOneWidget);
final Text text = tester.widget(textFinder);
// Theme brightness is preferred, otherwise MediaQuery brightness is
// used. If both are null, defaults to light.
final Brightness effectiveBrightness = themeBrightness ?? mediaBrightness ?? Brightness.light;
expect(
text.style!.color!.value,
effectiveBrightness == Brightness.dark
? _kToolbarTextColor.darkColor.value
: _kToolbarTextColor.color.value,
);
}, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web.
}
}
testWidgets('draws a shadow below the toolbar in light mode', (WidgetTester tester) async {
late StateSetter setState;
const double height = 50.0;
double anchorAboveY = 0.0;
await tester.pumpWidget(
CupertinoApp(
theme: const CupertinoThemeData(
brightness: Brightness.light,
),
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: 12.0,
),
),
child: CupertinoTextSelectionToolbar(
anchorAbove: Offset(50.0, anchorAboveY),
anchorBelow: const Offset(50.0, 500.0),
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),
],
),
);
},
),
),
),
);
final double dividerWidth = 1.0 / tester.view.devicePixelRatio;
expect(
find.byType(CupertinoTextSelectionToolbar),
paints..rrect(
rrect: RRect.fromLTRBR(8.0, 515.0, 158.0 + 2 * dividerWidth, 558.0, const Radius.circular(8.0)),
color: const Color(0x33000000),
),
);
// When the toolbar is above the content, the shadow sits around the arrow
// with no offset.
setState(() {
anchorAboveY = 80.0;
});
await tester.pump();
expect(
find.byType(CupertinoTextSelectionToolbar),
paints..rrect(
rrect: RRect.fromLTRBR(8.0, 29.0, 158.0 + 2 * dividerWidth, 72.0, const Radius.circular(8.0)),
color: const Color(0x33000000),
),
);
}, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web.
testWidgets('Basic golden tests', (WidgetTester tester) async {
final Key key = UniqueKey();
Widget buildToolbar(Brightness brightness, Offset offset) {
final Widget toolbar = CupertinoTextSelectionToolbar(
anchorAbove: offset,
anchorBelow: offset,
children: <Widget>[
CupertinoTextSelectionToolbarButton.text(onPressed: () {}, text: 'Lorem ipsum'),
CupertinoTextSelectionToolbarButton.text(onPressed: () {}, text: 'dolor sit amet'),
CupertinoTextSelectionToolbarButton.text(onPressed: () {}, text: 'Lorem ipsum \ndolor sit amet'),
CupertinoTextSelectionToolbarButton.buttonItem(buttonItem: ContextMenuButtonItem(onPressed: () {}, type: ContextMenuButtonType.copy)),
],
);
return CupertinoApp(
theme: CupertinoThemeData(brightness: brightness),
home: Center(
child: SizedBox(
height: 200,
child: RepaintBoundary(key: key, child: toolbar),
),
),
);
}
// The String describes the location of the toolbar in relation to the
// content the arrow points to.
const List<(String, Offset)> toolbarLocation = <(String, Offset)>[
('BottomRight', Offset.zero),
('BottomLeft', Offset(100000, 0)),
('TopRight', Offset(0, 100)),
('TopLeft', Offset(100000, 100)),
];
debugDisableShadows = false;
addTearDown(() => debugDisableShadows = true);
for (final Brightness brightness in Brightness.values) {
for (final (String location, Offset offset) in toolbarLocation) {
await tester.pumpWidget(buildToolbar(brightness, offset));
await expectLater(
find.byKey(key),
matchesGoldenFile('cupertino_selection_toolbar.$location.$brightness.png'),
);
}
}
debugDisableShadows = true;
}, skip: kIsWeb); // [intended] We do not use Flutter-rendered context menu on the Web.
}