// 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(['reduced-test-set']) @TestOn('!chrome') library; import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import '../widgets/feedback_tester.dart'; import '../widgets/semantics_tester.dart'; void main() { const okString = 'OK'; const amString = 'AM'; const pmString = 'PM'; Material getMaterialFromDialog(WidgetTester tester) { return tester.widget( find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first, ); } Finder findBorderPainter() { return find.descendant( of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'), matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), ); } testWidgets('Material2 - Dialog size - dial mode', (WidgetTester tester) async { addTearDown(tester.view.reset); const timePickerPortraitSize = Size(310, 468); const timePickerLandscapeSize = Size(524, 342); const timePickerLandscapeSizeM2 = Size(508, 300); const padding = EdgeInsets.fromLTRB(8, 18, 8, 8); double width; double height; // portrait tester.view.physicalSize = const Size(800, 800.5); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate(tester, materialType: MaterialType.material2); width = timePickerPortraitSize.width + padding.horizontal; height = timePickerPortraitSize.height + padding.vertical; expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); // landscape tester.view.physicalSize = const Size(800.5, 800); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material2, ); width = timePickerLandscapeSize.width + padding.horizontal; height = timePickerLandscapeSizeM2.height + padding.vertical; expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); }); testWidgets('Material2 - Dialog size - input mode', (WidgetTester tester) async { const TimePickerEntryMode entryMode = TimePickerEntryMode.input; const timePickerInputSize = Size(312, 252); const dayPeriodPortraitSize = Size(52, 80); const padding = EdgeInsets.fromLTRB(8, 18, 8, 8); final double height = timePickerInputSize.height + padding.vertical; double width; await mediaQueryBoilerplate(tester, entryMode: entryMode, materialType: MaterialType.material2); width = timePickerInputSize.width + padding.horizontal; Size size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); expect(size.width, width); expect(size.height, lessThan(height)); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: entryMode, materialType: MaterialType.material2, ); width = timePickerInputSize.width - dayPeriodPortraitSize.width - 12 + padding.horizontal + 16; size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); expect(size.width, width); expect(size.height, lessThan(height)); }); testWidgets('Material2 - respects MediaQueryData.alwaysUse24HourFormat == true', ( WidgetTester tester, ) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material2, ); final labels00To22 = List.generate(12, (int index) { return (index * 2).toString().padLeft(2, '0'); }); final CustomPaint dialPaint = tester.widget(findDialPaint); final dynamic dialPainter = dialPaint.painter; // ignore: avoid_dynamic_calls final primaryLabels = dialPainter.primaryLabels as List; // ignore: avoid_dynamic_calls expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22); // ignore: avoid_dynamic_calls final selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls selectedLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22, ); }); testWidgets('Material3 - Dialog size - dial mode', (WidgetTester tester) async { addTearDown(tester.view.reset); const timePickerPortraitSize = Size(310, 468); const timePickerLandscapeSize = Size(524, 342); const padding = EdgeInsets.all(24.0); double width; double height; // portrait tester.view.physicalSize = const Size(800, 800.5); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate(tester, materialType: MaterialType.material3); width = timePickerPortraitSize.width + padding.horizontal; height = timePickerPortraitSize.height + padding.vertical; expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); // landscape tester.view.physicalSize = const Size(800.5, 800); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material3, ); width = timePickerLandscapeSize.width + padding.horizontal; height = timePickerLandscapeSize.height + padding.vertical; expect(tester.getSize(find.byWidget(getMaterialFromDialog(tester))), Size(width, height)); }); testWidgets('Material3 - Dialog size - input mode', (WidgetTester tester) async { final theme = ThemeData(); const TimePickerEntryMode entryMode = TimePickerEntryMode.input; const textScaleFactor = 1.0; const timePickerMinInputSize = Size(312, 252); const dayPeriodPortraitSize = Size(52, 80); const padding = EdgeInsets.all(24.0); final double height = timePickerMinInputSize.height * textScaleFactor + padding.vertical; double width; await mediaQueryBoilerplate(tester, entryMode: entryMode, materialType: MaterialType.material3); width = timePickerMinInputSize.width - (theme.useMaterial3 ? 32 : 0) + padding.horizontal; Size size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); expect(size.width, width); expect(size.height, lessThan(height)); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: entryMode, materialType: MaterialType.material3, ); width = timePickerMinInputSize.width - dayPeriodPortraitSize.width - 12 + padding.horizontal; size = tester.getSize(find.byWidget(getMaterialFromDialog(tester))); expect(size.width, width); expect(size.height, lessThan(height)); }); testWidgets('Material3 - respects MediaQueryData.alwaysUse24HourFormat == true', ( WidgetTester tester, ) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material3, ); final labels00To23 = List.generate(24, (int index) { return index == 0 ? '00' : index.toString(); }); final inner0To23 = List.generate(24, (int index) => index >= 12); final CustomPaint dialPaint = tester.widget(findDialPaint); final dynamic dialPainter = dialPaint.painter; // ignore: avoid_dynamic_calls final primaryLabels = dialPainter.primaryLabels as List; // ignore: avoid_dynamic_calls expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); // ignore: avoid_dynamic_calls expect(primaryLabels.map((dynamic tp) => tp.inner as bool), inner0To23); // ignore: avoid_dynamic_calls final selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls selectedLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23, ); // ignore: avoid_dynamic_calls expect(selectedLabels.map((dynamic tp) => tp.inner as bool), inner0To23); }); // Regression test for https://github.com/flutter/flutter/issues/164860 testWidgets('Material3 - formats 24-hour numbers correctly in Farsi', ( WidgetTester tester, ) async { await mediaQueryBoilerplate( tester, locale: const Locale('fa', 'IR'), materialType: MaterialType.material3, ); final labels00To23 = [ '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹', '۱۰', '۱۱', '۱۲', '۱۳', '۱۴', '۱۵', '۱۶', '۱۷', '۱۸', '۱۹', '۲۰', '۲۱', '۲۲', '۲۳', ]; final inner0To23 = List.generate(24, (int index) => index >= 12); final CustomPaint dialPaint = tester.widget(findDialPaint); final dynamic dialPainter = dialPaint.painter; // ignore: avoid_dynamic_calls final primaryLabels = dialPainter.primaryLabels as List; // ignore: avoid_dynamic_calls expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); // ignore: avoid_dynamic_calls expect(primaryLabels.map((dynamic tp) => tp.inner as bool), inner0To23); // ignore: avoid_dynamic_calls final selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls selectedLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23, ); // ignore: avoid_dynamic_calls expect(selectedLabels.map((dynamic tp) => tp.inner as bool), inner0To23); }); testWidgets('Material3 - Dial background uses correct default color', ( WidgetTester tester, ) async { var theme = ThemeData(); Widget buildTimePicker(ThemeData themeData) { return MaterialApp( theme: themeData, home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), ); }, ); }, ), ), ), ); } await tester.pumpWidget(buildTimePicker(theme)); // Open the time picker dialog. await tester.tap(find.text('X')); await tester.pumpAndSettle(); // Test default dial background color. RenderBox dial = tester.firstRenderObject(find.byType(CustomPaint)); expect( dial, paints ..circle(color: theme.colorScheme.surfaceContainerHighest) // Dial background color. ..circle(color: Color(theme.colorScheme.primary.value)), // Dial hand color. ); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); // Test dial background color when theme color scheme is changed. theme = theme.copyWith( colorScheme: theme.colorScheme.copyWith(surfaceVariant: const Color(0xffff0000)), ); await tester.pumpWidget(buildTimePicker(theme)); // Open the time picker dialog. await tester.tap(find.text('X')); await tester.pumpAndSettle(); dial = tester.firstRenderObject(find.byType(CustomPaint)); expect( dial, paints ..circle(color: theme.colorScheme.surfaceContainerHighest) // Dial background color. ..circle(color: Color(theme.colorScheme.primary.value)), // Dial hand color. ); }); for (final MaterialType materialType in MaterialType.values) { group('Dial (${materialType.name})', () { testWidgets('tap-select an hour', (WidgetTester tester) async { TimeOfDay? result; Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time; }, materialType: materialType))!; await tester.tapAt(Offset(center.dx, center.dy - 50)); // 12:00 AM await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); center = (await startPicker(tester, (TimeOfDay? time) { result = time; }, materialType: materialType))!; await tester.tapAt(Offset(center.dx + 50, center.dy)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 3, minute: 0))); center = (await startPicker(tester, (TimeOfDay? time) { result = time; }, materialType: materialType))!; await tester.tapAt(Offset(center.dx, center.dy + 50)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 0))); center = (await startPicker(tester, (TimeOfDay? time) { result = time; }, materialType: materialType))!; await tester.tapAt(Offset(center.dx, center.dy + 50)); await tester.tapAt(Offset(center.dx - 50, center.dy)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 9, minute: 0))); }); testWidgets('drag-select an hour', (WidgetTester tester) async { late TimeOfDay result; final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType))!; final hour0 = Offset(center.dx, center.dy - 50); // 12:00 AM final hour3 = Offset(center.dx + 50, center.dy); final hour6 = Offset(center.dx, center.dy + 50); final hour9 = Offset(center.dx - 50, center.dy); TestGesture gesture; gesture = await tester.startGesture(hour3); await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); expect(result.hour, 0); expect( await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType), equals(center), ); gesture = await tester.startGesture(hour0); await gesture.moveBy(hour3 - hour0); await gesture.up(); await finishPicker(tester); expect(result.hour, 3); expect( await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType), equals(center), ); gesture = await tester.startGesture(hour3); await gesture.moveBy(hour6 - hour3); await gesture.up(); await finishPicker(tester); expect(result.hour, equals(6)); expect( await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType), equals(center), ); gesture = await tester.startGesture(hour6); await gesture.moveBy(hour9 - hour6); await gesture.up(); await finishPicker(tester); expect(result.hour, equals(9)); }); testWidgets('tap-select switches from hour to minute', (WidgetTester tester) async { late TimeOfDay result; final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType))!; final hour6 = Offset(center.dx, center.dy + 50); // 6:00 final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) await tester.tapAt(hour6); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(min45); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); }); testWidgets('drag-select switches from hour to minute', (WidgetTester tester) async { late TimeOfDay result; final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType))!; final hour3 = Offset(center.dx + 50, center.dy); final hour6 = Offset(center.dx, center.dy + 50); final hour9 = Offset(center.dx - 50, center.dy); TestGesture gesture = await tester.startGesture(hour6); await gesture.moveBy(hour9 - hour6); await gesture.up(); await tester.pump(const Duration(milliseconds: 50)); gesture = await tester.startGesture(hour6); await gesture.moveBy(hour3 - hour6); await gesture.up(); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); }); testWidgets('tap-select rounds down to nearest 5 minute increment', ( WidgetTester tester, ) async { late TimeOfDay result; final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType))!; final hour6 = Offset(center.dx, center.dy + 50); // 6:00 final min46 = Offset(center.dx - 50, center.dy - 5); // 46 mins await tester.tapAt(hour6); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(min46); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); }); testWidgets('tap-select rounds up to nearest 5 minute increment', ( WidgetTester tester, ) async { late TimeOfDay result; final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }, materialType: materialType))!; final hour6 = Offset(center.dx, center.dy + 50); // 6:00 final min48 = Offset(center.dx - 50, center.dy - 15); // 48 mins await tester.tapAt(hour6); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(min48); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 50))); }); }); group('Dial Haptic Feedback (${materialType.name})', () { const kFastFeedbackInterval = Duration(milliseconds: 10); const kSlowFeedbackInterval = Duration(milliseconds: 200); late FeedbackTester feedback; setUp(() { feedback = FeedbackTester(); }); tearDown(() { feedback.dispose(); }); testWidgets('tap-select vibrates once', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; await tester.tapAt(Offset(center.dx, center.dy - 50)); await finishPicker(tester); expect(feedback.hapticCount, 1); }); testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; await tester.tapAt(Offset(center.dx, center.dy - 50)); await tester.pump(kFastFeedbackInterval); await tester.tapAt(Offset(center.dx, center.dy + 50)); await finishPicker(tester); expect(feedback.hapticCount, 1); }); testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; await tester.tapAt(Offset(center.dx, center.dy - 50)); await tester.pump(kSlowFeedbackInterval); await tester.tapAt(Offset(center.dx, center.dy + 50)); await tester.pump(kSlowFeedbackInterval); await tester.tapAt(Offset(center.dx, center.dy - 50)); await finishPicker(tester); expect(feedback.hapticCount, 3); }); testWidgets('drag-select vibrates once', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; final hour0 = Offset(center.dx, center.dy - 50); final hour3 = Offset(center.dx + 50, center.dy); final TestGesture gesture = await tester.startGesture(hour3); await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); expect(feedback.hapticCount, 1); }); testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; final hour0 = Offset(center.dx, center.dy - 50); final hour3 = Offset(center.dx + 50, center.dy); final TestGesture gesture = await tester.startGesture(hour3); await gesture.moveBy(hour0 - hour3); await tester.pump(kFastFeedbackInterval); await gesture.moveBy(hour3 - hour0); await tester.pump(kFastFeedbackInterval); await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); expect(feedback.hapticCount, 1); }); testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { final Offset center = (await startPicker( tester, (TimeOfDay? time) {}, materialType: materialType, ))!; final hour0 = Offset(center.dx, center.dy - 50); final hour3 = Offset(center.dx + 50, center.dy); final TestGesture gesture = await tester.startGesture(hour3); await gesture.moveBy(hour0 - hour3); await tester.pump(kSlowFeedbackInterval); await gesture.moveBy(hour3 - hour0); await tester.pump(kSlowFeedbackInterval); await gesture.moveBy(hour0 - hour3); await gesture.up(); await finishPicker(tester); expect(feedback.hapticCount, 3); }); }); group('Dialog (${materialType.name})', () { testWidgets('Material2 - Widgets have correct label capitalization', ( WidgetTester tester, ) async { await startPicker(tester, (TimeOfDay? time) {}, materialType: MaterialType.material2); expect(find.text('SELECT TIME'), findsOneWidget); expect(find.text('CANCEL'), findsOneWidget); }); testWidgets('Material3 - Widgets have correct label capitalization', ( WidgetTester tester, ) async { await startPicker(tester, (TimeOfDay? time) {}, materialType: MaterialType.material3); expect(find.text('Select time'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); }); testWidgets('Material2 - Widgets have correct label capitalization in input mode', ( WidgetTester tester, ) async { await startPicker( tester, (TimeOfDay? time) {}, entryMode: TimePickerEntryMode.input, materialType: MaterialType.material2, ); expect(find.text('ENTER TIME'), findsOneWidget); expect(find.text('CANCEL'), findsOneWidget); }); testWidgets('Material3 - Widgets have correct label capitalization in input mode', ( WidgetTester tester, ) async { await startPicker( tester, (TimeOfDay? time) {}, entryMode: TimePickerEntryMode.input, materialType: MaterialType.material3, ); expect(find.text('Enter time'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); }); testWidgets( 'Material3 - large actions label should not overflow in input mode', (WidgetTester tester) async { await startPicker( tester, (TimeOfDay? time) {}, entryMode: TimePickerEntryMode.input, materialType: MaterialType.material3, cancelText: 'Very very very long cancel text', confirmText: 'Very very very long confirm text', ); // Verify that no overflow errors occur. expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant.mobile(), ); testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', ( WidgetTester tester, ) async { await mediaQueryBoilerplate(tester, materialType: materialType); const labels12To11 = [ '12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', ]; final CustomPaint dialPaint = tester.widget(findDialPaint); final dynamic dialPainter = dialPaint.painter; // ignore: avoid_dynamic_calls final primaryLabels = dialPainter.primaryLabels as List; expect( // ignore: avoid_dynamic_calls primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11, ); // ignore: avoid_dynamic_calls final selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls selectedLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11, ); }); testWidgets('when change orientation, should reflect in render objects', ( WidgetTester tester, ) async { addTearDown(tester.view.reset); // portrait tester.view.physicalSize = const Size(800, 800.5); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate(tester, materialType: materialType); RenderObject render = tester.renderObject( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), ); expect((render as dynamic).orientation, Orientation.portrait); // landscape tester.view.physicalSize = const Size(800.5, 800); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate(tester, tapButton: false, materialType: materialType); render = tester.renderObject( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), ); expect((render as dynamic).orientation, Orientation.landscape); }); testWidgets('setting orientation should override MediaQuery orientation', ( WidgetTester tester, ) async { addTearDown(tester.view.reset); // portrait media query tester.view.physicalSize = const Size(800, 800.5); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate( tester, orientation: Orientation.landscape, materialType: materialType, ); final RenderObject render = tester.renderObject( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'), ); expect((render as dynamic).orientation, Orientation.landscape); }); testWidgets('builder parameter', (WidgetTester tester) async { Widget buildFrame(TextDirection textDirection) { return MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), builder: (BuildContext context, Widget? child) { return Directionality(textDirection: textDirection, child: child!); }, ); }, ); }, ), ), ), ); } await tester.pumpWidget(buildFrame(TextDirection.ltr)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); final double ltrOkRight = tester.getBottomRight(find.text(okString)).dx; await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); await tester.pumpWidget(buildFrame(TextDirection.rtl)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); // Verify that the time picker is being laid out RTL. // We expect the left edge of the 'OK' button in the RTL // layout to match the gap between right edge of the 'OK' // button and the right edge of the 800 wide view. expect(tester.getBottomLeft(find.text(okString)).dx, 800 - ltrOkRight); }); group('Barrier dismissible', () { late PickerObserver rootObserver; setUp(() { rootObserver = PickerObserver(); }); testWidgets('Barrier is dismissible with default parameter', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( navigatorObservers: [rootObserver], home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () => showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), ), ); }, ), ), ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect(rootObserver.pickerCount, 1); // Tap on the barrier. await tester.tapAt(const Offset(10.0, 10.0)); await tester.pumpAndSettle(); expect(rootObserver.pickerCount, 0); }); testWidgets('Barrier is not dismissible with barrierDismissible is false', ( WidgetTester tester, ) async { await tester.pumpWidget( MaterialApp( navigatorObservers: [rootObserver], home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () => showTimePicker( context: context, barrierDismissible: false, initialTime: const TimeOfDay(hour: 7, minute: 0), ), ); }, ), ), ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect(rootObserver.pickerCount, 1); // Tap on the barrier, which shouldn't do anything this time. await tester.tapAt(const Offset(10.0, 10.0)); await tester.pumpAndSettle(); expect(rootObserver.pickerCount, 1); }); }); testWidgets('Barrier color', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () => showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), ), ); }, ), ), ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect(tester.widget(find.byType(ModalBarrier).last).color, Colors.black54); // Dismiss the dialog. await tester.tapAt(const Offset(10.0, 10.0)); await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () => showTimePicker( context: context, barrierColor: Colors.pink, initialTime: const TimeOfDay(hour: 7, minute: 0), ), ); }, ), ), ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect(tester.widget(find.byType(ModalBarrier).last).color, Colors.pink); }); testWidgets('Barrier Label', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () => showTimePicker( context: context, barrierLabel: 'Custom Label', initialTime: const TimeOfDay(hour: 7, minute: 0), ), ); }, ), ), ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); expect( tester.widget(find.byType(ModalBarrier).last).semanticsLabel, 'Custom Label', ); }); testWidgets('uses root navigator by default', (WidgetTester tester) async { final rootObserver = PickerObserver(); final nestedObserver = PickerObserver(); await tester.pumpWidget( MaterialApp( navigatorObservers: [rootObserver], home: Navigator( observers: [nestedObserver], onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute( builder: (BuildContext context) { return ElevatedButton( onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), ); }, child: const Text('Show Picker'), ); }, ); }, ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); expect(rootObserver.pickerCount, 1); expect(nestedObserver.pickerCount, 0); }); testWidgets('uses nested navigator if useRootNavigator is false', ( WidgetTester tester, ) async { final rootObserver = PickerObserver(); final nestedObserver = PickerObserver(); await tester.pumpWidget( MaterialApp( navigatorObservers: [rootObserver], home: Navigator( observers: [nestedObserver], onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute( builder: (BuildContext context) { return ElevatedButton( onPressed: () { showTimePicker( context: context, useRootNavigator: false, initialTime: const TimeOfDay(hour: 7, minute: 0), ); }, child: const Text('Show Picker'), ); }, ); }, ), ), ); // Open the dialog. await tester.tap(find.byType(ElevatedButton)); expect(rootObserver.pickerCount, 0); expect(nestedObserver.pickerCount, 1); }); testWidgets('optional text parameters are utilized', (WidgetTester tester) async { const cancelText = 'Custom Cancel'; const confirmText = 'Custom OK'; const helperText = 'Custom Help'; await tester.pumpWidget( MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () async { await showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), cancelText: cancelText, confirmText: confirmText, helpText: helperText, ); }, ); }, ), ), ), ), ); // Open the picker. await tester.tap(find.text('X')); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.text(cancelText), findsOneWidget); expect(find.text(confirmText), findsOneWidget); expect(find.text(helperText), findsOneWidget); }); testWidgets('Material2 - OK Cancel button and helpText layout', (WidgetTester tester) async { const selectTimeString = 'SELECT TIME'; const cancelString = 'CANCEL'; Widget buildFrame(TextDirection textDirection) { return MaterialApp( theme: ThemeData(useMaterial3: false), home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), builder: (BuildContext context, Widget? child) { return Directionality(textDirection: textDirection, child: child!); }, ); }, ); }, ), ), ), ); } await tester.pumpWidget(buildFrame(TextDirection.ltr)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(154, 155))); expect( tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(280.5, 165)), ); expect(tester.getBottomRight(find.text(okString)).dx, 644); expect(tester.getBottomLeft(find.text(okString)).dx, 616); expect(tester.getBottomRight(find.text(cancelString)).dx, 582); await tester.tap(find.text(okString)); await tester.pumpAndSettle(); await tester.pumpWidget(buildFrame(TextDirection.rtl)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(519.5, 155))); expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(646, 165))); expect(tester.getBottomLeft(find.text(okString)).dx, 156); expect(tester.getBottomRight(find.text(okString)).dx, 184); expect(tester.getBottomLeft(find.text(cancelString)).dx, 218); await tester.tap(find.text(okString)); await tester.pumpAndSettle(); }); testWidgets('Material3 - OK Cancel button and helpText layout', (WidgetTester tester) async { const selectTimeString = 'Select time'; const cancelString = 'Cancel'; Widget buildFrame(TextDirection textDirection) { return MaterialApp( home: Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), builder: (BuildContext context, Widget? child) { return Directionality(textDirection: textDirection, child: child!); }, ); }, ); }, ), ), ), ); } await tester.pumpWidget(buildFrame(TextDirection.ltr)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(138, 129))); expect(tester.getBottomRight(find.text(selectTimeString)), const Offset(294.75, 149.0)); expect( tester.getBottomLeft(find.text(okString)).dx, moreOrLessEquals(615.9, epsilon: 0.001), ); expect(tester.getBottomRight(find.text(cancelString)).dx, 578); await tester.tap(find.text(okString)); await tester.pumpAndSettle(); await tester.pumpWidget(buildFrame(TextDirection.rtl)); await tester.tap(find.text('X')); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(505.25, 129.0))); expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(662, 149))); expect( tester.getBottomLeft(find.text(okString)).dx, moreOrLessEquals(155.9, epsilon: 0.001), ); expect( tester.getBottomRight(find.text(okString)).dx, moreOrLessEquals(184.1, epsilon: 0.001), ); expect(tester.getBottomLeft(find.text(cancelString)).dx, 222); await tester.tap(find.text(okString)); await tester.pumpAndSettle(); }); testWidgets('text scale affects certain elements and not others', ( WidgetTester tester, ) async { await mediaQueryBoilerplate( tester, initialTime: const TimeOfDay(hour: 7, minute: 41), materialType: materialType, ); final double minutesDisplayHeight = tester.getSize(find.text('41')).height; final double amHeight = tester.getSize(find.text(amString)).height; await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); // Verify that the time display is not affected by text scale. await mediaQueryBoilerplate( tester, textScaler: const TextScaler.linear(2), initialTime: const TimeOfDay(hour: 7, minute: 41), materialType: materialType, ); final double amHeight2x = tester.getSize(find.text(amString)).height; expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); expect(amHeight2x, math.min(38.0, amHeight * 2)); await tester.tap(find.text(okString)); // dismiss the dialog await tester.pumpAndSettle(); // Verify that text scale for AM/PM is at most 2x. await mediaQueryBoilerplate( tester, textScaler: const TextScaler.linear(3), initialTime: const TimeOfDay(hour: 7, minute: 41), materialType: materialType, ); expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); expect(tester.getSize(find.text(amString)).height, math.min(38.0, amHeight * 2)); }); group('showTimePicker avoids overlapping display features', () { testWidgets('positioning with anchorPoint', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( builder: (BuildContext context, Widget? child) { return MediaQuery( // Display has a vertical hinge down the middle data: const MediaQueryData( size: Size(800, 600), displayFeatures: [ DisplayFeature( bounds: Rect.fromLTRB(390, 0, 410, 600), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ), ], ), child: child!, ); }, home: const Center(child: Text('Test')), ), ); final BuildContext context = tester.element(find.text('Test')); showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), anchorPoint: const Offset(1000, 0), ); await tester.pumpAndSettle(); // Should take the right side of the screen expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); }); testWidgets('positioning with Directionality', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( builder: (BuildContext context, Widget? child) { return MediaQuery( // Display has a vertical hinge down the middle data: const MediaQueryData( size: Size(800, 600), displayFeatures: [ DisplayFeature( bounds: Rect.fromLTRB(390, 0, 410, 600), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ), ], ), child: Directionality(textDirection: TextDirection.rtl, child: child!), ); }, home: const Center(child: Text('Test')), ), ); final BuildContext context = tester.element(find.text('Test')); // By default it should place the dialog on the right screen showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); }); testWidgets('positioning with defaults', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( builder: (BuildContext context, Widget? child) { return MediaQuery( // Display has a vertical hinge down the middle data: const MediaQueryData( size: Size(800, 600), displayFeatures: [ DisplayFeature( bounds: Rect.fromLTRB(390, 0, 410, 600), type: DisplayFeatureType.hinge, state: DisplayFeatureState.unknown, ), ], ), child: child!, ); }, home: const Center(child: Text('Test')), ), ); final BuildContext context = tester.element(find.text('Test')); // By default it should place the dialog on the left screen showTimePicker(context: context, initialTime: const TimeOfDay(hour: 7, minute: 0)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byType(TimePickerDialog)), Offset.zero); expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(390, 600)); }); }); group('Works for various view sizes', () { for (final size in const [Size(100, 100), Size(300, 300), Size(800, 600)]) { testWidgets('Draws dial without overflows at $size', (WidgetTester tester) async { tester.view.physicalSize = size; addTearDown(tester.view.reset); await mediaQueryBoilerplate( tester, entryMode: TimePickerEntryMode.input, materialType: materialType, ); await tester.pumpAndSettle(); expect(tester.takeException(), isNot(throwsAssertionError)); }); testWidgets('Draws input without overflows at $size', (WidgetTester tester) async { tester.view.physicalSize = size; addTearDown(tester.view.reset); await mediaQueryBoilerplate(tester, materialType: materialType); await tester.pumpAndSettle(); expect(tester.takeException(), isNot(throwsAssertionError)); }); } }); }); group('Time picker - A11y and Semantics (${materialType.name})', () { testWidgets('provides semantics information for AM/PM indicator', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await mediaQueryBoilerplate(tester, materialType: materialType); expect( semantics, includesNodeWith( label: amString, actions: [SemanticsAction.tap, SemanticsAction.focus], flags: [ SemanticsFlag.isButton, SemanticsFlag.isChecked, SemanticsFlag.isInMutuallyExclusiveGroup, SemanticsFlag.hasCheckedState, SemanticsFlag.isFocusable, ], ), ); expect( semantics, includesNodeWith( label: pmString, actions: [SemanticsAction.tap, SemanticsAction.focus], flags: [ SemanticsFlag.isButton, SemanticsFlag.isInMutuallyExclusiveGroup, SemanticsFlag.hasCheckedState, SemanticsFlag.isFocusable, ], ), ); semantics.dispose(); }); testWidgets('Material2 - provides semantics information for header and footer', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material2, ); expect(semantics, isNot(includesNodeWith(label: ':'))); expect( semantics.nodesWith(value: 'Select minutes 00'), hasLength(1), reason: '00 appears once in the header', ); expect( semantics.nodesWith(value: 'Select hours 07'), hasLength(1), reason: '07 appears once in the header', ); expect(semantics, includesNodeWith(label: 'CANCEL')); expect(semantics, includesNodeWith(label: okString)); // In 24-hour mode we don't have AM/PM control. expect(semantics, isNot(includesNodeWith(label: amString))); expect(semantics, isNot(includesNodeWith(label: pmString))); semantics.dispose(); }); testWidgets('Material3 - provides semantics information for header and footer', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: MaterialType.material3, ); expect(semantics, isNot(includesNodeWith(label: ':'))); expect( semantics.nodesWith(value: 'Select minutes 00'), hasLength(1), reason: '00 appears once in the header', ); expect( semantics.nodesWith(value: 'Select hours 07'), hasLength(1), reason: '07 appears once in the header', ); expect(semantics, includesNodeWith(label: 'Cancel')); expect(semantics, includesNodeWith(label: okString)); // In 24-hour mode we don't have AM/PM control. expect(semantics, isNot(includesNodeWith(label: amString))); expect(semantics, isNot(includesNodeWith(label: pmString))); semantics.dispose(); }); testWidgets( 'TimePicker dialog displays centered separator between hour and minute selectors', (WidgetTester tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: ThemeData(useMaterial3: false), home: const Material( child: TimePickerDialog(initialTime: TimeOfDay(hour: 12, minute: 0)), ), ), ); await tester.pumpAndSettle(); await expectLater( find.byType(Dialog), matchesGoldenFile('m2_time_picker.dialog.separator.alignment.png'), ); }, ); testWidgets( 'TimePicker dialog displays centered separator between hour and minute selectors', (WidgetTester tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( const MaterialApp( home: Material(child: TimePickerDialog(initialTime: TimeOfDay(hour: 12, minute: 0))), ), ); await tester.pumpAndSettle(); await expectLater( find.byType(Dialog), matchesGoldenFile('m3_time_picker.dialog.separator.alignment.png'), ); }, ); testWidgets( 'TimePicker dialog displays centered separator between hour and minute inputs for non-english locale', (WidgetTester tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( const MaterialApp( localizationsDelegates: >[ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: [Locale('en'), Locale('es')], locale: Locale('es'), home: Material( child: TimePickerDialog( initialTime: TimeOfDay(hour: 12, minute: 0), initialEntryMode: TimePickerEntryMode.input, ), ), ), ); await tester.pumpAndSettle(); await expectLater( find.byType(Dialog), matchesGoldenFile('time_picker.dialog.separator.alignment.non_english_locale.png'), ); }, ); testWidgets('provides semantics information for text fields', (WidgetTester tester) async { final semantics = SemanticsTester(tester); await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, accessibleNavigation: true, materialType: materialType, ); expect( semantics, includesNodeWith( label: 'Hour', value: '07', actions: [SemanticsAction.tap, SemanticsAction.focus], flags: [ SemanticsFlag.isTextField, SemanticsFlag.isFocusable, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isMultiline, ], ), ); expect( semantics, includesNodeWith( label: 'Minute', value: '00', actions: [SemanticsAction.tap, SemanticsAction.focus], flags: [ SemanticsFlag.isTextField, SemanticsFlag.isFocusable, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isMultiline, ], ), ); semantics.dispose(); }); testWidgets('can increment and decrement hours', (WidgetTester tester) async { final semantics = SemanticsTester(tester); Future actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue, }) async { final SemanticsNode elevenHours = semantics .nodesWith( value: 'Select hours $initialValue', ancestor: tester.renderObject(_dialHourControl).debugSemantics, ) .single; tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action); await tester.pumpAndSettle(); expect( find.descendant(of: _dialHourControl, matching: find.text(finalValue)), findsOneWidget, ); } // 12-hour format await mediaQueryBoilerplate( tester, initialTime: const TimeOfDay(hour: 11, minute: 0), materialType: materialType, ); await actAndExpect(initialValue: '11', action: SemanticsAction.increase, finalValue: '12'); await actAndExpect(initialValue: '12', action: SemanticsAction.increase, finalValue: '1'); // Ensure we preserve day period as we roll over. final dynamic pickerState = tester.state(_timePicker); // ignore: avoid_dynamic_calls expect(pickerState.selectedTime.value, const TimeOfDay(hour: 1, minute: 0)); await actAndExpect(initialValue: '1', action: SemanticsAction.decrease, finalValue: '12'); await tester.pumpWidget(Container()); // clear old boilerplate // 24-hour format await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, initialTime: const TimeOfDay(hour: 23, minute: 0), materialType: materialType, ); await actAndExpect(initialValue: '23', action: SemanticsAction.increase, finalValue: '00'); await actAndExpect(initialValue: '00', action: SemanticsAction.increase, finalValue: '01'); await actAndExpect(initialValue: '01', action: SemanticsAction.decrease, finalValue: '00'); await actAndExpect(initialValue: '00', action: SemanticsAction.decrease, finalValue: '23'); semantics.dispose(); }); testWidgets('can increment and decrement minutes', (WidgetTester tester) async { final semantics = SemanticsTester(tester); Future actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue, }) async { final SemanticsNode elevenHours = semantics .nodesWith( value: 'Select minutes $initialValue', ancestor: tester.renderObject(_dialMinuteControl).debugSemantics, ) .single; tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action); await tester.pumpAndSettle(); expect( find.descendant(of: _dialMinuteControl, matching: find.text(finalValue)), findsOneWidget, ); } await mediaQueryBoilerplate( tester, initialTime: const TimeOfDay(hour: 11, minute: 58), materialType: materialType, ); await actAndExpect(initialValue: '58', action: SemanticsAction.increase, finalValue: '59'); await actAndExpect(initialValue: '59', action: SemanticsAction.increase, finalValue: '00'); // Ensure we preserve hour period as we roll over. final dynamic pickerState = tester.state(_timePicker); // ignore: avoid_dynamic_calls expect(pickerState.selectedTime.value, const TimeOfDay(hour: 11, minute: 0)); await actAndExpect(initialValue: '00', action: SemanticsAction.decrease, finalValue: '59'); await actAndExpect(initialValue: '59', action: SemanticsAction.decrease, finalValue: '58'); semantics.dispose(); }); testWidgets('header touch regions are large enough', (WidgetTester tester) async { // Ensure picker is displayed in portrait mode. tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await mediaQueryBoilerplate(tester, materialType: materialType); final Size dayPeriodControlSize = tester.getSize( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), ); expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48)); expect(dayPeriodControlSize.height, greaterThanOrEqualTo(80)); final Size hourSize = tester.getSize( find.ancestor(of: find.text('7'), matching: find.byType(InkWell)), ); expect(hourSize.width, greaterThanOrEqualTo(48)); expect(hourSize.height, greaterThanOrEqualTo(48)); final Size minuteSize = tester.getSize( find.ancestor(of: find.text('00'), matching: find.byType(InkWell)), ); expect(minuteSize.width, greaterThanOrEqualTo(48)); expect(minuteSize.height, greaterThanOrEqualTo(48)); }); testWidgets( 'Period selector touch target respects accessibility guidelines - Portrait mode', (WidgetTester tester) async { final semantics = SemanticsTester(tester); const minInteractiveSize = Size(kMinInteractiveDimension, kMinInteractiveDimension); // Ensure picker is displayed in portrait mode. tester.view.physicalSize = const Size(600, 1000); addTearDown(tester.view.reset); await mediaQueryBoilerplate(tester, materialType: materialType); final SemanticsNode amButton = semantics.nodesWith(label: amString).single; expect(amButton.rect.size >= minInteractiveSize, isTrue); final SemanticsNode pmButton = semantics.nodesWith(label: pmString).single; expect(pmButton.rect.size >= minInteractiveSize, isTrue); semantics.dispose(); }, ); testWidgets( 'Period selector touch target respects accessibility guidelines - Landscape mode', (WidgetTester tester) async { final semantics = SemanticsTester(tester); const minInteractiveSize = Size(kMinInteractiveDimension, kMinInteractiveDimension); await mediaQueryBoilerplate(tester, materialType: materialType); final SemanticsNode amButton = semantics.nodesWith(label: amString).single; expect(amButton.rect.size >= minInteractiveSize, isTrue); final SemanticsNode pmButton = semantics.nodesWith(label: pmString).single; expect(pmButton.rect.size >= minInteractiveSize, isTrue); semantics.dispose(); }, ); }); group('Time picker - Input (${materialType.name})', () { testWidgets('Initial entry mode is used', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, materialType: materialType, ); expect(find.byType(TextField), findsNWidgets(2)); }); testWidgets('Initial time is the default', (WidgetTester tester) async { late TimeOfDay result; await startPicker( tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input, materialType: materialType, ); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 7, minute: 0))); }); testWidgets('Help text is used - Input', (WidgetTester tester) async { const helpText = 'help'; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, helpText: helpText, materialType: materialType, ); expect(find.text(helpText), findsOneWidget); }); testWidgets('Help text is used in Material3 - Input', (WidgetTester tester) async { const helpText = 'help'; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, helpText: helpText, materialType: materialType, ); expect(find.text(helpText), findsOneWidget); }); testWidgets('Hour label text is used - Input', (WidgetTester tester) async { const hourLabelText = 'Custom hour label'; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, hourLabelText: hourLabelText, materialType: materialType, ); expect(find.text(hourLabelText), findsOneWidget); }); testWidgets('Minute label text is used - Input', (WidgetTester tester) async { const minuteLabelText = 'Custom minute label'; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, minuteLabelText: minuteLabelText, materialType: materialType, ); expect(find.text(minuteLabelText), findsOneWidget); }); testWidgets('Invalid error text is used - Input', (WidgetTester tester) async { const errorInvalidText = 'Custom validation error'; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, errorInvalidText: errorInvalidText, materialType: materialType, ); // Input invalid time (hour) to force validation error await tester.enterText(find.byType(TextField).first, '88'); final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( tester.element(find.byType(TextButton).first), ); // Tap the ok button to trigger the validation error with custom translation await tester.tap(find.text(materialLocalizations.okButtonLabel)); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.text(errorInvalidText), findsOneWidget); }); testWidgets('TimePicker default entry icons', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp(home: TimePickerDialog(initialTime: TimeOfDay.now()))); // Check that the default icon for the dial mode is displayed. expect(find.byIcon(Icons.keyboard_outlined), findsOneWidget); expect(find.byIcon(Icons.access_time), findsNothing); // Tap the icon to switch to input mode. await tester.tap(find.byIcon(Icons.keyboard_outlined)); await tester.pumpAndSettle(); // Check that the icon for the input mode is displayed. expect(find.byIcon(Icons.access_time), findsOneWidget); expect(find.byIcon(Icons.keyboard_outlined), findsNothing); }); testWidgets('Can override TimePicker entry icons', (WidgetTester tester) async { const customInputIcon = Icon(Icons.text_fields); const customTimerIcon = Icon(Icons.watch); await tester.pumpWidget( MaterialApp( home: TimePickerDialog( initialTime: TimeOfDay.now(), switchToInputEntryModeIcon: customInputIcon, switchToTimerEntryModeIcon: customTimerIcon, ), ), ); // Check that the custom icons are displayed. expect(find.byIcon(Icons.text_fields), findsOneWidget); expect(find.byIcon(Icons.watch), findsNothing); // Tap the custom icon to switch to input mode. await tester.tap(find.byIcon(Icons.text_fields)); await tester.pumpAndSettle(); // Check that the custom icon for the input mode is displayed. expect(find.byIcon(Icons.text_fields), findsNothing); expect(find.byIcon(Icons.watch), findsOneWidget); }); testWidgets('Can switch from input to dial entry mode', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, materialType: materialType, ); await tester.tap(find.byIcon(Icons.access_time)); await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); }); testWidgets('Can switch from dial to input entry mode', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, materialType: materialType, ); await tester.tap(find.byIcon(Icons.keyboard_outlined)); await tester.pumpAndSettle(); expect(find.byType(TextField), findsWidgets); }); testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.inputOnly, materialType: materialType, ); expect(find.byType(TextField), findsWidgets); expect(find.byIcon(Icons.access_time), findsNothing); }); testWidgets('Can not switch out of dialOnly mode', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.dialOnly, materialType: materialType, ); expect(find.byType(TextField), findsNothing); expect(find.byIcon(Icons.keyboard_outlined), findsNothing); }); testWidgets('Switching to dial entry mode triggers entry callback', ( WidgetTester tester, ) async { var triggeredCallback = false; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, entryMode: TimePickerEntryMode.input, onEntryModeChange: (TimePickerEntryMode mode) { if (mode == TimePickerEntryMode.dial) { triggeredCallback = true; } }, materialType: materialType, ); await tester.tap(find.byIcon(Icons.access_time)); await tester.pumpAndSettle(); expect(triggeredCallback, true); }); testWidgets('Switching to input entry mode triggers entry callback', ( WidgetTester tester, ) async { var triggeredCallback = false; await mediaQueryBoilerplate( tester, alwaysUse24HourFormat: true, onEntryModeChange: (TimePickerEntryMode mode) { if (mode == TimePickerEntryMode.input) { triggeredCallback = true; } }, materialType: materialType, ); await tester.tap(find.byIcon(Icons.keyboard_outlined)); await tester.pumpAndSettle(); expect(triggeredCallback, true); }); testWidgets('Can double tap hours (when selected) to enter input mode', ( WidgetTester tester, ) async { await mediaQueryBoilerplate(tester, materialType: materialType); final Finder hourFinder = find.ancestor(of: find.text('7'), matching: find.byType(InkWell)); expect(find.byType(TextField), findsNothing); // Double tap the hour. await tester.tap(hourFinder); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(hourFinder); await tester.pumpAndSettle(); expect(find.byType(TextField), findsWidgets); }); testWidgets('Can not double tap hours (when not selected) to enter input mode', ( WidgetTester tester, ) async { await mediaQueryBoilerplate(tester, materialType: materialType); final Finder hourFinder = find.ancestor(of: find.text('7'), matching: find.byType(InkWell)); final Finder minuteFinder = find.ancestor( of: find.text('00'), matching: find.byType(InkWell), ); expect(find.byType(TextField), findsNothing); // Switch to minutes mode. await tester.tap(minuteFinder); await tester.pumpAndSettle(); // Double tap the hour. await tester.tap(hourFinder); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(hourFinder); await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); }); testWidgets('Can double tap minutes (when selected) to enter input mode', ( WidgetTester tester, ) async { await mediaQueryBoilerplate(tester, materialType: materialType); final Finder minuteFinder = find.ancestor( of: find.text('00'), matching: find.byType(InkWell), ); expect(find.byType(TextField), findsNothing); // Switch to minutes mode. await tester.tap(minuteFinder); await tester.pumpAndSettle(); // Double tap the minutes. await tester.tap(minuteFinder); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(minuteFinder); await tester.pumpAndSettle(); expect(find.byType(TextField), findsWidgets); }); testWidgets('Can not double tap minutes (when not selected) to enter input mode', ( WidgetTester tester, ) async { await mediaQueryBoilerplate(tester, materialType: materialType); final Finder minuteFinder = find.ancestor( of: find.text('00'), matching: find.byType(InkWell), ); expect(find.byType(TextField), findsNothing); // Double tap the minutes. await tester.tap(minuteFinder); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(minuteFinder); await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); }); testWidgets('Entered text returns time', (WidgetTester tester) async { late TimeOfDay result; await startPicker( tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input, materialType: materialType, ); await tester.enterText(find.byType(TextField).first, '9'); await tester.enterText(find.byType(TextField).last, '12'); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); }); testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async { late TimeOfDay result; await startPicker( tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input, materialType: materialType, ); await tester.enterText(find.byType(TextField).first, '8'); await tester.enterText(find.byType(TextField).last, '15'); await tester.tap(find.byIcon(Icons.access_time)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); }); testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async { TimeOfDay? result; await startPicker( tester, (TimeOfDay? time) { result = time; }, entryMode: TimePickerEntryMode.input, materialType: materialType, ); // Invalid hour. await tester.enterText(find.byType(TextField).first, '88'); await tester.enterText(find.byType(TextField).last, '15'); await finishPicker(tester); expect(result, null); // Invalid minute. await tester.enterText(find.byType(TextField).first, '8'); await tester.enterText(find.byType(TextField).last, '95'); await finishPicker(tester); expect(result, null); await tester.enterText(find.byType(TextField).first, '8'); await tester.enterText(find.byType(TextField).last, '15'); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); }); // Fixes regression that was reverted in https://github.com/flutter/flutter/pull/64094#pullrequestreview-469836378. testWidgets('Ensure hour/minute fields are top-aligned with the separator', ( WidgetTester tester, ) async { await startPicker( tester, (TimeOfDay? time) {}, entryMode: TimePickerEntryMode.input, materialType: materialType, ); final double hourFieldTop = tester .getTopLeft( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField'), ) .dy; final double minuteFieldTop = tester .getTopLeft( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField'), ) .dy; final double separatorTop = tester .getTopLeft( find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_TimeSelectorSeparator'), ) .dy; expect(hourFieldTop, separatorTop); expect(minuteFieldTop, separatorTop); }); testWidgets('Can switch between hour/minute fields using keyboard input action', ( WidgetTester tester, ) async { await startPicker( tester, (TimeOfDay? time) {}, entryMode: TimePickerEntryMode.input, materialType: materialType, ); final Finder hourFinder = find.byType(TextField).first; final TextField hourField = tester.widget(hourFinder); await tester.tap(hourFinder); expect(hourField.focusNode!.hasFocus, isTrue); await tester.enterText(find.byType(TextField).first, '08'); final Finder minuteFinder = find.byType(TextField).last; final TextField minuteField = tester.widget(minuteFinder); expect(hourField.focusNode!.hasFocus, isFalse); expect(minuteField.focusNode!.hasFocus, isTrue); expect(tester.testTextInput.setClientArgs!['inputAction'], equals('TextInputAction.done')); await tester.testTextInput.receiveAction(TextInputAction.done); expect(hourField.focusNode!.hasFocus, isFalse); expect(minuteField.focusNode!.hasFocus, isFalse); }); testWidgets( 'TAB key selects text in hour and minute fields on the web', (WidgetTester tester) async { await mediaQueryBoilerplate( tester, entryMode: TimePickerEntryMode.input, materialType: materialType, ); // Focus on the hour field. final Finder hourField = find.byType(TextField).first; await tester.tap(hourField); await tester.pumpAndSettle(); // Verify that the hour field is focused and its text is selected. final TextField hourTextField = tester.widget(hourField); expect(hourTextField.focusNode!.hasFocus, isTrue); expect(hourTextField.controller!.selection.baseOffset, 0); expect( hourTextField.controller!.selection.extentOffset, hourTextField.controller!.text.length, ); // Press TAB to move to the minute field. await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pumpAndSettle(); // Verify that the minute field is focused and its text is selected. final Finder minuteField = find.byType(TextField).last; final TextField minuteTextField = tester.widget(minuteField); expect(minuteTextField.controller!.selection.baseOffset, 0); expect( minuteTextField.controller!.selection.extentOffset, minuteTextField.controller!.text.length, ); }, skip: !kIsWeb, // [intended] Web-specific behavior ); }); group('Time picker - Restoration (${materialType.name})', () { testWidgets('Time Picker state restoration test - dial mode', (WidgetTester tester) async { TimeOfDay? result; final Offset center = (await startPicker( tester, (TimeOfDay? time) { result = time; }, restorationId: 'restorable_time_picker', materialType: materialType, ))!; final hour6 = Offset(center.dx, center.dy + 50); // 6:00 final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) await tester.tapAt(hour6); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); await tester.tapAt(min45); await tester.pump(const Duration(milliseconds: 50)); final TestRestorationData restorationData = await tester.getRestorationData(); await tester.restartAndRestore(); // Setting to PM adds 12 hours (18:45) await tester.tap(find.text(pmString)); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 18, minute: 45))); // Test restoring from before PM was selected (6:45) await tester.restoreFrom(restorationData); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); }); testWidgets('Time Picker state restoration test - input mode', (WidgetTester tester) async { TimeOfDay? result; await startPicker( tester, (TimeOfDay? time) { result = time; }, entryMode: TimePickerEntryMode.input, restorationId: 'restorable_time_picker', materialType: materialType, ); await tester.enterText(find.byType(TextField).first, '9'); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); await tester.enterText(find.byType(TextField).last, '12'); await tester.pump(const Duration(milliseconds: 50)); final TestRestorationData restorationData = await tester.getRestorationData(); await tester.restartAndRestore(); // Setting to PM adds 12 hours (21:12) await tester.tap(find.text(pmString)); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 21, minute: 12))); // Restoring from before PM was set (9:12) await tester.restoreFrom(restorationData); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); }); testWidgets('Time Picker state restoration test - switching modes', ( WidgetTester tester, ) async { TimeOfDay? result; final Offset center = (await startPicker( tester, (TimeOfDay? time) { result = time; }, restorationId: 'restorable_time_picker', materialType: materialType, ))!; final TestRestorationData restorationData = await tester.getRestorationData(); // Switch to input mode from dial mode. await tester.tap(find.byIcon(Icons.keyboard_outlined)); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); // Select time using input mode controls. await tester.enterText(find.byType(TextField).first, '9'); await tester.enterText(find.byType(TextField).last, '12'); await tester.pump(const Duration(milliseconds: 50)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); // Restoring from dial mode. await tester.restoreFrom(restorationData); final hour6 = Offset(center.dx, center.dy + 50); // 6:00 final min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours) await tester.tapAt(hour6); await tester.pump(const Duration(milliseconds: 50)); await tester.restartAndRestore(); await tester.tapAt(min45); await tester.pump(const Duration(milliseconds: 50)); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); }); }); group('Time picker - emptyInitialInput (${materialType.name})', () { testWidgets('Fields are empty and show correct hints when emptyInitialInput is true', ( WidgetTester tester, ) async { await startPicker( tester, (_) {}, entryMode: TimePickerEntryMode.input, materialType: materialType, emptyInitialInput: true, ); await tester.pump(); final List textFields = tester .widgetList(find.byType(TextField)) .toList(); expect(textFields[0].controller?.text, isEmpty); // hour expect(textFields[1].controller?.text, isEmpty); // minute expect(textFields[0].decoration?.hintText, isNull); expect(textFields[1].decoration?.hintText, isNull); await finishPicker(tester); }); testWidgets('User sets hour/minute after initially empty fields', ( WidgetTester tester, ) async { late TimeOfDay result; await startPicker( tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input, materialType: materialType, emptyInitialInput: true, ); final List textFields = tester .widgetList(find.byType(TextField)) .toList(); expect(textFields[0].controller?.text, isEmpty); // hour expect(textFields[1].controller?.text, isEmpty); // minute expect(textFields[0].decoration?.hintText, isNull); expect(textFields[1].decoration?.hintText, isNull); await tester.enterText(find.byType(TextField).first, '11'); await tester.enterText(find.byType(TextField).last, '30'); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 11, minute: 30))); }); testWidgets('User overrides default values when emptyInitialInput is false', ( WidgetTester tester, ) async { late TimeOfDay result; await startPicker( tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input, materialType: materialType, ); final List textFields = tester .widgetList(find.byType(TextField)) .toList(); expect(textFields[0].controller?.text, '7'); // hour expect(textFields[1].controller?.text, '00'); // minute await tester.enterText(find.byType(TextField).first, '8'); await tester.enterText(find.byType(TextField).last, '15'); await tester.pump(); await finishPicker(tester); expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); }); }); } testWidgets('Material3 - Time selector separator default text style', ( WidgetTester tester, ) async { final theme = ThemeData(); await startPicker(tester, (TimeOfDay? value) {}, theme: theme); final RenderParagraph paragraph = tester.renderObject(find.text(':')); expect(paragraph.text.style!.color, theme.colorScheme.onSurface); expect(paragraph.text.style!.fontSize, 57.0); }); testWidgets('Material2 - Time selector separator default text style', ( WidgetTester tester, ) async { final theme = ThemeData(useMaterial3: false); await startPicker(tester, (TimeOfDay? value) {}, theme: theme); final RenderParagraph paragraph = tester.renderObject(find.text(':')); expect(paragraph.text.style!.color, theme.colorScheme.onSurface); expect(paragraph.text.style!.fontSize, 56.0); }); testWidgets('provides semantics information for hour/minute mode announcement', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); const time = TimeOfDay(hour: 8, minute: 12); await mediaQueryBoilerplate(tester, initialTime: time, materialType: MaterialType.material3); final MaterialLocalizations localizations = MaterialLocalizations.of( tester.element(find.byType(TimePickerDialog)), ); final String formattedHour = localizations.formatHour(time); final String formattedMinute = localizations.formatMinute(time); expect( find.semantics.byValue('${localizations.timePickerHourModeAnnouncement} $formattedHour'), findsOne, ); expect( find.semantics.byValue('${localizations.timePickerMinuteModeAnnouncement} $formattedMinute'), findsOne, ); semantics.dispose(); }); testWidgets('provides semantics information for the header (selected time)', ( WidgetTester tester, ) async { final semantics = SemanticsTester(tester); const initialTime = TimeOfDay(hour: 7, minute: 15); await mediaQueryBoilerplate( tester, initialTime: initialTime, materialType: MaterialType.material3, ); final MaterialLocalizations localizations = MaterialLocalizations.of( tester.element(find.byType(TimePickerDialog)), ); final String expectedLabel12Hour = localizations.formatTimeOfDay(initialTime); final String expectedHelpText = localizations.timePickerDialHelpText; expect( semantics, includesNodeWith(label: '$expectedLabel12Hour\n$expectedHelpText'), reason: 'Header should have semantics label: $expectedLabel12Hour (12-hour)', ); semantics.dispose(); }); // This is a regression test for https://github.com/flutter/flutter/issues/153549. testWidgets('Time picker hour minute does not resize on error', (WidgetTester tester) async { await startPicker(entryMode: TimePickerEntryMode.input, tester, (TimeOfDay? value) {}); expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); // Enter invalid hour. await tester.enterText(find.byType(TextField).first, 'AB'); await tester.tap(find.text(okString)); expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); }); // This is a regression test for https://github.com/flutter/flutter/issues/153549. testWidgets('Material2 - Time picker hour minute does not resize on error', ( WidgetTester tester, ) async { await startPicker( entryMode: TimePickerEntryMode.input, tester, (TimeOfDay? value) {}, materialType: MaterialType.material2, ); expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); // Enter invalid hour. await tester.enterText(find.byType(TextField).first, 'AB'); await tester.tap(find.text(okString)); expect(tester.getSize(findBorderPainter().first), const Size(96.0, 70.0)); }); // Regression test for https://github.com/flutter/flutter/issues/162229. testWidgets( 'Time picker spacing between time control and day period control for locales using "a h:mm" pattern', (WidgetTester tester) async { addTearDown(tester.view.reset); final Finder amMaterialFinder = find.descendant( of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_AmPmButton').first, matching: find.byType(Material), ); final Finder timeControlFinder = find .ancestor(of: find.text('7'), matching: find.byType(Row)) .first; // Render in portrait mode. tester.view.physicalSize = const Size(800, 800.5); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate( tester, materialType: MaterialType.material3, locale: const Locale('ko', 'KR'), ); const dayPeriodPortraitGap = 12.0; // From Material spec. expect( tester.getBottomLeft(timeControlFinder).dx - tester.getBottomRight(amMaterialFinder).dx, dayPeriodPortraitGap, ); // Dismiss the dialog. final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( tester.element(find.byType(TextButton).first), ); await tester.tap(find.text(materialLocalizations.okButtonLabel)); await tester.pumpAndSettle(); // Render in landscape mode. tester.view.physicalSize = const Size(800.5, 800); tester.view.devicePixelRatio = 1; await mediaQueryBoilerplate( tester, materialType: MaterialType.material3, locale: const Locale('ko', 'KR'), ); const dayPeriodLandscapeGap = 16.0; // From Material spec. expect( tester.getTopLeft(timeControlFinder).dy - tester.getBottomLeft(amMaterialFinder).dy, dayPeriodLandscapeGap, ); }, ); testWidgets( 'AM/PM buttons have correct selected/checked semantics for platform variant', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/173302 await tester.pumpWidget( MaterialApp( home: Builder( builder: (BuildContext context) { return TextButton( onPressed: () { showTimePicker( context: context, initialTime: const TimeOfDay(hour: 14, minute: 0), ); }, child: const Text('Open Picker'), ); }, ), ), ); await tester.tap(find.text('Open Picker')); await tester.pumpAndSettle(); final Finder pmButtonSemantics = find.ancestor( of: find.widgetWithText(InkWell, 'PM'), matching: find.byWidgetPredicate( (Widget widget) => widget is Semantics && (widget.properties.button ?? false), ), ); final Finder amButtonSemantics = find.ancestor( of: find.widgetWithText(InkWell, 'AM'), matching: find.byWidgetPredicate( (Widget widget) => widget is Semantics && (widget.properties.button ?? false), ), ); bool? getPlatformSemanticProperty(Semantics semantics) { return switch (defaultTargetPlatform) { TargetPlatform.iOS => semantics.properties.selected, _ => semantics.properties.checked, }; } expect(getPlatformSemanticProperty(tester.widget(pmButtonSemantics)), isTrue); expect(getPlatformSemanticProperty(tester.widget(amButtonSemantics)), isFalse); }, variant: TargetPlatformVariant.all(), ); testWidgets('TimePickerDialog does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Center( child: SizedBox.shrink( child: TimePickerDialog(initialTime: TimeOfDay(hour: 10, minute: 12)), ), ), ), ); expect(tester.getSize(find.byType(TimePickerDialog)), Size.zero); }); } final Finder findDialPaint = find.descendant( of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), ); class PickerObserver extends NavigatorObserver { int pickerCount = 0; @override void didPush(Route route, Route? previousRoute) { if (route is DialogRoute) { pickerCount++; } super.didPush(route, previousRoute); } @override void didPop(Route route, Route? previousRoute) { if (route is DialogRoute) { pickerCount--; } super.didPop(route, previousRoute); } } Future mediaQueryBoilerplate( WidgetTester tester, { bool alwaysUse24HourFormat = false, TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0), TextScaler textScaler = TextScaler.noScaling, TimePickerEntryMode entryMode = TimePickerEntryMode.dial, String? helpText, String? hourLabelText, String? minuteLabelText, String? errorInvalidText, bool accessibleNavigation = false, EntryModeChangeCallback? onEntryModeChange, bool tapButton = true, required MaterialType materialType, Orientation? orientation, Locale locale = const Locale('en', 'US'), }) async { await tester.pumpWidget( Theme( data: ThemeData(useMaterial3: materialType == MaterialType.material3), child: Localizations( locale: locale, delegates: const >[ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], child: MediaQuery( data: MediaQueryData( alwaysUse24HourFormat: alwaysUse24HourFormat, textScaler: textScaler, accessibleNavigation: accessibleNavigation, size: tester.view.physicalSize / tester.view.devicePixelRatio, ), child: Material( child: Center( child: Directionality( textDirection: TextDirection.ltr, child: Navigator( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute( builder: (BuildContext context) { return TextButton( onPressed: () { showTimePicker( context: context, initialTime: initialTime, initialEntryMode: entryMode, helpText: helpText, hourLabelText: hourLabelText, minuteLabelText: minuteLabelText, errorInvalidText: errorInvalidText, onEntryModeChanged: onEntryModeChange, orientation: orientation, ); }, child: const Text('X'), ); }, ); }, ), ), ), ), ), ), ), ); if (tapButton) { await tester.tap(find.text('X')); } await tester.pumpAndSettle(); } final Finder _dialHourControl = find.byWidgetPredicate( (Widget w) => '${w.runtimeType}' == '_DialHourControl', ); final Finder _dialMinuteControl = find.byWidgetPredicate( (Widget widget) => '${widget.runtimeType}' == '_DialMinuteControl', ); final Finder _timePicker = find.byWidgetPredicate( (Widget widget) => '${widget.runtimeType}' == '_TimePicker', ); class _TimePickerLauncher extends StatefulWidget { const _TimePickerLauncher({ required this.onChanged, this.entryMode = TimePickerEntryMode.dial, this.restorationId, this.cancelText, this.confirmText, required this.emptyInitialInput, }); final ValueChanged onChanged; final TimePickerEntryMode entryMode; final String? restorationId; final String? cancelText; final String? confirmText; final bool emptyInitialInput; @override _TimePickerLauncherState createState() => _TimePickerLauncherState(); } @pragma('vm:entry-point') class _TimePickerLauncherState extends State<_TimePickerLauncher> with RestorationMixin { @override String? get restorationId => widget.restorationId; late final RestorableRouteFuture _restorableTimePickerRouteFuture = RestorableRouteFuture( onComplete: _selectTime, onPresent: (NavigatorState navigator, Object? arguments) { return navigator.restorablePush( _timePickerRoute, arguments: { 'entry_mode': widget.entryMode.name, if (widget.cancelText != null) 'cancel_text': widget.cancelText!, if (widget.confirmText != null) 'confirm_text': widget.confirmText!, }, ); }, ); @override void dispose() { _restorableTimePickerRouteFuture.dispose(); super.dispose(); } @pragma('vm:entry-point') static Route _timePickerRoute(BuildContext context, Object? arguments) { final args = arguments! as Map; final TimePickerEntryMode entryMode = TimePickerEntryMode.values.firstWhere( (TimePickerEntryMode element) => element.name == args['entry_mode'], ); final cancelText = args['cancel_text'] as String?; final confirmText = args['confirm_text'] as String?; return DialogRoute( context: context, builder: (BuildContext context) { return TimePickerDialog( restorationId: 'time_picker_dialog', initialTime: const TimeOfDay(hour: 7, minute: 0), initialEntryMode: entryMode, cancelText: cancelText, confirmText: confirmText, ); }, ); } @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(_restorableTimePickerRouteFuture, 'time_picker_route_future'); } void _selectTime(TimeOfDay? newSelectedTime) { widget.onChanged(newSelectedTime); } @override Widget build(BuildContext context) { return Material( child: Center( child: Builder( builder: (BuildContext context) { return ElevatedButton( child: const Text('X'), onPressed: () async { if (widget.restorationId == null) { widget.onChanged( await showTimePicker( context: context, initialTime: const TimeOfDay(hour: 7, minute: 0), initialEntryMode: widget.entryMode, emptyInitialInput: widget.emptyInitialInput, ), ); } else { _restorableTimePickerRouteFuture.present(); } }, ); }, ), ), ); } } // The version of material design layout, etc. to test. Corresponds to // useMaterial3 true/false in the ThemeData, but used an enum here so that it // wasn't just a boolean, for easier identification of the name of the mode in // tests. enum MaterialType { material2, material3 } Future startPicker( WidgetTester tester, ValueChanged onChanged, { TimePickerEntryMode entryMode = TimePickerEntryMode.dial, String? restorationId, ThemeData? theme, MaterialType? materialType, String? cancelText, String? confirmText, bool emptyInitialInput = false, }) async { await tester.pumpWidget( MaterialApp( theme: theme ?? ThemeData(useMaterial3: materialType == MaterialType.material3), restorationScopeId: 'app', locale: const Locale('en', 'US'), home: _TimePickerLauncher( onChanged: onChanged, entryMode: entryMode, restorationId: restorationId, cancelText: cancelText, confirmText: confirmText, emptyInitialInput: emptyInitialInput, ), ), ); await tester.tap(find.text('X')); await tester.pumpAndSettle(const Duration(seconds: 1)); final Finder customPaintFinder = find.descendant( of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), matching: find.byType(CustomPaint), ); return entryMode == TimePickerEntryMode.dial ? tester.getCenter(customPaintFinder) : null; } Future finishPicker(WidgetTester tester) async { final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( tester.element(find.byType(ElevatedButton)), ); await tester.tap(find.text(materialLocalizations.okButtonLabel)); await tester.pumpAndSettle(const Duration(seconds: 1)); }