2926 lines
104 KiB
Dart
2926 lines
104 KiB
Dart
// 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'])
|
||
@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<Material>(
|
||
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<String>.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<dynamic>;
|
||
// ignore: avoid_dynamic_calls
|
||
expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
|
||
|
||
// ignore: avoid_dynamic_calls
|
||
final selectedLabels = dialPainter.selectedLabels as List<dynamic>;
|
||
expect(
|
||
// ignore: avoid_dynamic_calls
|
||
selectedLabels.map<String>((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<String>.generate(24, (int index) {
|
||
return index == 0 ? '00' : index.toString();
|
||
});
|
||
final inner0To23 = List<bool>.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<dynamic>;
|
||
// ignore: avoid_dynamic_calls
|
||
expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23);
|
||
// ignore: avoid_dynamic_calls
|
||
expect(primaryLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23);
|
||
|
||
// ignore: avoid_dynamic_calls
|
||
final selectedLabels = dialPainter.selectedLabels as List<dynamic>;
|
||
expect(
|
||
// ignore: avoid_dynamic_calls
|
||
selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String),
|
||
labels00To23,
|
||
);
|
||
// ignore: avoid_dynamic_calls
|
||
expect(selectedLabels.map<bool>((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 = <String>[
|
||
'۰',
|
||
'۱',
|
||
'۲',
|
||
'۳',
|
||
'۴',
|
||
'۵',
|
||
'۶',
|
||
'۷',
|
||
'۸',
|
||
'۹',
|
||
'۱۰',
|
||
'۱۱',
|
||
'۱۲',
|
||
'۱۳',
|
||
'۱۴',
|
||
'۱۵',
|
||
'۱۶',
|
||
'۱۷',
|
||
'۱۸',
|
||
'۱۹',
|
||
'۲۰',
|
||
'۲۱',
|
||
'۲۲',
|
||
'۲۳',
|
||
];
|
||
final inner0To23 = List<bool>.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<dynamic>;
|
||
// ignore: avoid_dynamic_calls
|
||
expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23);
|
||
// ignore: avoid_dynamic_calls
|
||
expect(primaryLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23);
|
||
|
||
// ignore: avoid_dynamic_calls
|
||
final selectedLabels = dialPainter.selectedLabels as List<dynamic>;
|
||
expect(
|
||
// ignore: avoid_dynamic_calls
|
||
selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String),
|
||
labels00To23,
|
||
);
|
||
// ignore: avoid_dynamic_calls
|
||
expect(selectedLabels.map<bool>((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<RenderBox>(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<RenderBox>(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 = <String>[
|
||
'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<dynamic>;
|
||
expect(
|
||
// ignore: avoid_dynamic_calls
|
||
primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String),
|
||
labels12To11,
|
||
);
|
||
|
||
// ignore: avoid_dynamic_calls
|
||
final selectedLabels = dialPainter.selectedLabels as List<dynamic>;
|
||
expect(
|
||
// ignore: avoid_dynamic_calls
|
||
selectedLabels.map<String>((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: <NavigatorObserver>[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: <NavigatorObserver>[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<ModalBarrier>(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<ModalBarrier>(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<ModalBarrier>(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: <NavigatorObserver>[rootObserver],
|
||
home: Navigator(
|
||
observers: <NavigatorObserver>[nestedObserver],
|
||
onGenerateRoute: (RouteSettings settings) {
|
||
return MaterialPageRoute<dynamic>(
|
||
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: <NavigatorObserver>[rootObserver],
|
||
home: Navigator(
|
||
observers: <NavigatorObserver>[nestedObserver],
|
||
onGenerateRoute: (RouteSettings settings) {
|
||
return MaterialPageRoute<dynamic>(
|
||
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>[
|
||
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>[
|
||
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>[
|
||
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>[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>[SemanticsAction.tap, SemanticsAction.focus],
|
||
flags: <SemanticsFlag>[
|
||
SemanticsFlag.isButton,
|
||
SemanticsFlag.isChecked,
|
||
SemanticsFlag.isInMutuallyExclusiveGroup,
|
||
SemanticsFlag.hasCheckedState,
|
||
SemanticsFlag.isFocusable,
|
||
],
|
||
),
|
||
);
|
||
expect(
|
||
semantics,
|
||
includesNodeWith(
|
||
label: pmString,
|
||
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
|
||
flags: <SemanticsFlag>[
|
||
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: <LocalizationsDelegate<dynamic>>[
|
||
GlobalMaterialLocalizations.delegate,
|
||
GlobalWidgetsLocalizations.delegate,
|
||
GlobalCupertinoLocalizations.delegate,
|
||
],
|
||
supportedLocales: <Locale>[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>[SemanticsAction.tap, SemanticsAction.focus],
|
||
flags: <SemanticsFlag>[
|
||
SemanticsFlag.isTextField,
|
||
SemanticsFlag.isFocusable,
|
||
SemanticsFlag.hasEnabledState,
|
||
SemanticsFlag.isEnabled,
|
||
SemanticsFlag.isMultiline,
|
||
],
|
||
),
|
||
);
|
||
expect(
|
||
semantics,
|
||
includesNodeWith(
|
||
label: 'Minute',
|
||
value: '00',
|
||
actions: <SemanticsAction>[SemanticsAction.tap, SemanticsAction.focus],
|
||
flags: <SemanticsFlag>[
|
||
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<void> 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<void> 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<TextField> textFields = tester
|
||
.widgetList<TextField>(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<TextField> textFields = tester
|
||
.widgetList<TextField>(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<TextField> textFields = tester
|
||
.widgetList<TextField>(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<Semantics>(pmButtonSemantics)), isTrue);
|
||
expect(getPlatformSemanticProperty(tester.widget<Semantics>(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<dynamic> route, Route<dynamic>? previousRoute) {
|
||
if (route is DialogRoute) {
|
||
pickerCount++;
|
||
}
|
||
super.didPush(route, previousRoute);
|
||
}
|
||
|
||
@override
|
||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
if (route is DialogRoute) {
|
||
pickerCount--;
|
||
}
|
||
super.didPop(route, previousRoute);
|
||
}
|
||
}
|
||
|
||
Future<void> 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 <LocalizationsDelegate<dynamic>>[
|
||
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<void>(
|
||
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<TimeOfDay?> 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<TimeOfDay?> _restorableTimePickerRouteFuture =
|
||
RestorableRouteFuture<TimeOfDay?>(
|
||
onComplete: _selectTime,
|
||
onPresent: (NavigatorState navigator, Object? arguments) {
|
||
return navigator.restorablePush(
|
||
_timePickerRoute,
|
||
arguments: <String, String>{
|
||
'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<TimeOfDay> _timePickerRoute(BuildContext context, Object? arguments) {
|
||
final args = arguments! as Map<dynamic, dynamic>;
|
||
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<TimeOfDay>(
|
||
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<Offset?> startPicker(
|
||
WidgetTester tester,
|
||
ValueChanged<TimeOfDay?> 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<void> 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));
|
||
}
|