1416 lines
52 KiB
Dart
1416 lines
52 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.
|
|
|
|
@TestOn('!chrome')
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import '../widgets/semantics_tester.dart';
|
|
import 'feedback_tester.dart';
|
|
|
|
final Finder _hourControl = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl');
|
|
final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl');
|
|
final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == 'TimePickerDialog');
|
|
|
|
class _TimePickerLauncher extends StatefulWidget {
|
|
const _TimePickerLauncher({
|
|
required this.onChanged,
|
|
this.entryMode = TimePickerEntryMode.dial,
|
|
this.restorationId,
|
|
});
|
|
|
|
final ValueChanged<TimeOfDay?> onChanged;
|
|
final TimePickerEntryMode entryMode;
|
|
final String? restorationId;
|
|
|
|
@override
|
|
_TimePickerLauncherState createState() => _TimePickerLauncherState();
|
|
}
|
|
|
|
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, int>{
|
|
'entryMode': widget.entryMode.index,
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
static Route<TimeOfDay> _timePickerRoute(
|
|
BuildContext context,
|
|
Object? arguments,
|
|
) {
|
|
final Map<dynamic, dynamic> args = arguments! as Map<dynamic, dynamic>;
|
|
final TimePickerEntryMode entryMode = TimePickerEntryMode.values[args['entryMode'] as int];
|
|
return DialogRoute<TimeOfDay>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return TimePickerDialog(
|
|
restorationId: 'time_picker_dialog',
|
|
initialTime: const TimeOfDay(hour: 7, minute: 0),
|
|
initialEntryMode: entryMode,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@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,
|
|
));
|
|
} else {
|
|
_restorableTimePickerRouteFuture.present();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<Offset?> startPicker(
|
|
WidgetTester tester,
|
|
ValueChanged<TimeOfDay?> onChanged, {
|
|
TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
|
|
String? restorationId,
|
|
}) async {
|
|
await tester.pumpWidget(MaterialApp(
|
|
restorationScopeId: 'app',
|
|
locale: const Locale('en', 'US'),
|
|
home: _TimePickerLauncher(
|
|
onChanged: onChanged,
|
|
entryMode: entryMode,
|
|
restorationId: restorationId,
|
|
),
|
|
));
|
|
await tester.tap(find.text('X'));
|
|
await tester.pumpAndSettle(const Duration(seconds: 1));
|
|
return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey<String>('time-picker-dial'))) : 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));
|
|
}
|
|
|
|
void main() {
|
|
group('Time picker - Dial', () {
|
|
_tests();
|
|
});
|
|
|
|
group('Time picker - Input', () {
|
|
_testsInput();
|
|
});
|
|
}
|
|
|
|
void _tests() {
|
|
testWidgets('tap-select an hour', (WidgetTester tester) async {
|
|
TimeOfDay? result;
|
|
|
|
Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy - 50.0)); // 12:00 AM
|
|
await finishPicker(tester);
|
|
expect(result, equals(const TimeOfDay(hour: 0, minute: 0)));
|
|
|
|
center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
|
|
await tester.tapAt(Offset(center.dx + 50.0, center.dy));
|
|
await finishPicker(tester);
|
|
expect(result, equals(const TimeOfDay(hour: 3, minute: 0)));
|
|
|
|
center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy + 50.0));
|
|
await finishPicker(tester);
|
|
expect(result, equals(const TimeOfDay(hour: 6, minute: 0)));
|
|
|
|
center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy + 50.0));
|
|
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!; }))!;
|
|
final Offset hour0 = Offset(center.dx, center.dy - 50.0); // 12:00 AM
|
|
final Offset hour3 = Offset(center.dx + 50.0, center.dy);
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0);
|
|
final Offset hour9 = Offset(center.dx - 50.0, 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!; }), 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!; }), 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!; }), 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!; }))!;
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
|
|
final Offset min45 = Offset(center.dx - 50.0, 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!; }))!;
|
|
final Offset hour3 = Offset(center.dx + 50.0, center.dy);
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0);
|
|
final Offset hour9 = Offset(center.dx - 50.0, 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!; }))!;
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
|
|
final Offset min46 = Offset(center.dx - 50.0, 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!; }))!;
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
|
|
final Offset min48 = Offset(center.dx - 50.0, 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('haptic feedback', () {
|
|
const Duration kFastFeedbackInterval = Duration(milliseconds: 10);
|
|
const Duration 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) { }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
|
|
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) { }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
|
|
await tester.pump(kFastFeedbackInterval);
|
|
await tester.tapAt(Offset(center.dx, center.dy + 50.0));
|
|
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) { }))!;
|
|
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
|
|
await tester.pump(kSlowFeedbackInterval);
|
|
await tester.tapAt(Offset(center.dx, center.dy + 50.0));
|
|
await tester.pump(kSlowFeedbackInterval);
|
|
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
|
|
await finishPicker(tester);
|
|
expect(feedback.hapticCount, 3);
|
|
});
|
|
|
|
testWidgets('drag-select vibrates once', (WidgetTester tester) async {
|
|
final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!;
|
|
final Offset hour0 = Offset(center.dx, center.dy - 50.0);
|
|
final Offset hour3 = Offset(center.dx + 50.0, 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) { }))!;
|
|
final Offset hour0 = Offset(center.dx, center.dy - 50.0);
|
|
final Offset hour3 = Offset(center.dx + 50.0, 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) { }))!;
|
|
final Offset hour0 = Offset(center.dx, center.dy - 50.0);
|
|
final Offset hour3 = Offset(center.dx + 50.0, 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);
|
|
});
|
|
});
|
|
|
|
const List<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
|
|
const List<String> labels00To22 = <String>['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22'];
|
|
|
|
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, false);
|
|
|
|
final CustomPaint dialPaint = tester.widget(findDialPaint);
|
|
final dynamic dialPainter = dialPaint.painter;
|
|
// ignore: avoid_dynamic_calls
|
|
final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
|
|
// ignore: avoid_dynamic_calls
|
|
expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
|
|
|
|
// ignore: avoid_dynamic_calls
|
|
final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
|
|
// ignore: avoid_dynamic_calls
|
|
expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
|
|
});
|
|
|
|
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, true);
|
|
|
|
final CustomPaint dialPaint = tester.widget(findDialPaint);
|
|
final dynamic dialPainter = dialPaint.painter;
|
|
// ignore: avoid_dynamic_calls
|
|
final List<dynamic> 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 List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
|
|
// ignore: avoid_dynamic_calls
|
|
expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
|
|
});
|
|
|
|
testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
await mediaQueryBoilerplate(tester, false);
|
|
|
|
expect(
|
|
semantics,
|
|
includesNodeWith(
|
|
label: 'AM',
|
|
actions: <SemanticsAction>[SemanticsAction.tap],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isButton,
|
|
SemanticsFlag.isChecked,
|
|
SemanticsFlag.isInMutuallyExclusiveGroup,
|
|
SemanticsFlag.hasCheckedState,
|
|
SemanticsFlag.isFocusable,
|
|
],
|
|
),
|
|
);
|
|
expect(
|
|
semantics,
|
|
includesNodeWith(
|
|
label: 'PM',
|
|
actions: <SemanticsAction>[SemanticsAction.tap],
|
|
flags: <SemanticsFlag>[
|
|
SemanticsFlag.isButton,
|
|
SemanticsFlag.isInMutuallyExclusiveGroup,
|
|
SemanticsFlag.hasCheckedState,
|
|
SemanticsFlag.isFocusable,
|
|
],
|
|
),
|
|
);
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('provides semantics information for header and footer', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
await mediaQueryBoilerplate(tester, true);
|
|
|
|
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: 'OK'));
|
|
|
|
// In 24-hour mode we don't have AM/PM control.
|
|
expect(semantics, isNot(includesNodeWith(label: 'AM')));
|
|
expect(semantics, isNot(includesNodeWith(label: 'PM')));
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('provides semantics information for text fields', (WidgetTester tester) async {
|
|
final SemanticsTester semantics = SemanticsTester(tester);
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, accessibleNavigation: true);
|
|
|
|
expect(
|
|
semantics,
|
|
includesNodeWith(
|
|
label: 'Hour',
|
|
value: '07',
|
|
actions: <SemanticsAction>[SemanticsAction.tap],
|
|
flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isMultiline],
|
|
),
|
|
);
|
|
expect(
|
|
semantics,
|
|
includesNodeWith(
|
|
label: 'Minute',
|
|
value: '00',
|
|
actions: <SemanticsAction>[SemanticsAction.tap],
|
|
flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isMultiline],
|
|
),
|
|
);
|
|
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('can increment and decrement hours', (WidgetTester tester) async {
|
|
final SemanticsTester 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(_hourControl).debugSemantics,
|
|
).single;
|
|
tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.descendant(of: _hourControl, matching: find.text(finalValue)),
|
|
findsOneWidget,
|
|
);
|
|
}
|
|
|
|
// 12-hour format
|
|
await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 0));
|
|
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(_timePickerDialog);
|
|
// 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, true, initialTime: const TimeOfDay(hour: 23, minute: 0));
|
|
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 SemanticsTester 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(_minuteControl).debugSemantics,
|
|
).single;
|
|
tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.descendant(of: _minuteControl, matching: find.text(finalValue)),
|
|
findsOneWidget,
|
|
);
|
|
}
|
|
|
|
await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 58));
|
|
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(_timePickerDialog);
|
|
// 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.binding.window.physicalSizeTestValue = const Size(400, 800);
|
|
tester.binding.window.devicePixelRatioTestValue = 1;
|
|
await mediaQueryBoilerplate(tester, false);
|
|
|
|
final Size dayPeriodControlSize = tester.getSize(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'));
|
|
expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48.0));
|
|
// Height should be double the minimum size to account for both AM/PM stacked.
|
|
expect(dayPeriodControlSize.height, greaterThanOrEqualTo(48.0 * 2));
|
|
|
|
final Size hourSize = tester.getSize(find.ancestor(
|
|
of: find.text('7'),
|
|
matching: find.byType(InkWell),
|
|
));
|
|
expect(hourSize.width, greaterThanOrEqualTo(48.0));
|
|
expect(hourSize.height, greaterThanOrEqualTo(48.0));
|
|
|
|
final Size minuteSize = tester.getSize(find.ancestor(
|
|
of: find.text('00'),
|
|
matching: find.byType(InkWell),
|
|
));
|
|
expect(minuteSize.width, greaterThanOrEqualTo(48.0));
|
|
expect(minuteSize.height, greaterThanOrEqualTo(48.0));
|
|
|
|
tester.binding.window.clearPhysicalSizeTestValue();
|
|
tester.binding.window.clearDevicePixelRatioTestValue();
|
|
});
|
|
|
|
testWidgets('when change orientation, should reflect in render objects', (WidgetTester tester) async {
|
|
// portrait
|
|
tester.binding.window.physicalSizeTestValue = const Size(800, 800.5);
|
|
tester.binding.window.devicePixelRatioTestValue = 1;
|
|
await mediaQueryBoilerplate(tester, false);
|
|
|
|
RenderObject render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'));
|
|
expect((render as dynamic).orientation, Orientation.portrait); // ignore: avoid_dynamic_calls
|
|
|
|
// landscape
|
|
tester.binding.window.physicalSizeTestValue = const Size(800.5, 800);
|
|
tester.binding.window.devicePixelRatioTestValue = 1;
|
|
await mediaQueryBoilerplate(tester, false, tapButton: false);
|
|
|
|
render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'));
|
|
expect((render as dynamic).orientation, Orientation.landscape); // ignore: avoid_dynamic_calls
|
|
|
|
tester.binding.window.clearPhysicalSizeTestValue();
|
|
tester.binding.window.clearDevicePixelRatioTestValue();
|
|
});
|
|
|
|
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('OK')).dx;
|
|
|
|
await tester.tap(find.text('OK')); // 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 window.
|
|
expect(tester.getBottomLeft(find.text('OK')).dx, 800 - ltrOkRight);
|
|
});
|
|
|
|
testWidgets('uses root navigator by default', (WidgetTester tester) async {
|
|
final PickerObserver rootObserver = PickerObserver();
|
|
final PickerObserver 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 PickerObserver rootObserver = PickerObserver();
|
|
final PickerObserver 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 String cancelText = 'Custom Cancel';
|
|
const String confirmText = 'Custom OK';
|
|
const String 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('OK Cancel button layout', (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();
|
|
expect(tester.getBottomRight(find.text('OK')).dx, 638);
|
|
expect(tester.getBottomLeft(find.text('OK')).dx, 610);
|
|
expect(tester.getBottomRight(find.text('CANCEL')).dx, 576);
|
|
await tester.tap(find.text('OK'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.pumpWidget(buildFrame(TextDirection.rtl));
|
|
await tester.tap(find.text('X'));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.getBottomLeft(find.text('OK')).dx, 162);
|
|
expect(tester.getBottomRight(find.text('OK')).dx, 190);
|
|
expect(tester.getBottomLeft(find.text('CANCEL')).dx, 224);
|
|
await tester.tap(find.text('OK'));
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('text scale affects certain elements and not others', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(
|
|
tester,
|
|
false,
|
|
initialTime: const TimeOfDay(hour: 7, minute: 41),
|
|
);
|
|
|
|
final double minutesDisplayHeight = tester.getSize(find.text('41')).height;
|
|
final double amHeight = tester.getSize(find.text('AM')).height;
|
|
|
|
await tester.tap(find.text('OK')); // dismiss the dialog
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify that the time display is not affected by text scale.
|
|
await mediaQueryBoilerplate(
|
|
tester,
|
|
false,
|
|
textScaleFactor: 2.0,
|
|
initialTime: const TimeOfDay(hour: 7, minute: 41),
|
|
);
|
|
|
|
final double amHeight2x = tester.getSize(find.text('AM')).height;
|
|
expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
|
|
expect(amHeight2x, greaterThanOrEqualTo(amHeight * 2));
|
|
|
|
await tester.tap(find.text('OK')); // dismiss the dialog
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify that text scale for AM/PM is at most 2x.
|
|
await mediaQueryBoilerplate(
|
|
tester,
|
|
false,
|
|
textScaleFactor: 3.0,
|
|
initialTime: const TimeOfDay(hour: 7, minute: 41),
|
|
);
|
|
|
|
expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
|
|
expect(tester.getSize(find.text('AM')).height, equals(amHeight2x));
|
|
});
|
|
|
|
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, 0.0));
|
|
expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800.0, 600.0));
|
|
});
|
|
|
|
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, 0.0));
|
|
expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800.0, 600.0));
|
|
});
|
|
|
|
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.0, 600.0));
|
|
});
|
|
});
|
|
}
|
|
|
|
void _testsInput() {
|
|
testWidgets('Initial entry mode is used', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
|
|
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);
|
|
await finishPicker(tester);
|
|
expect(result, equals(const TimeOfDay(hour: 7, minute: 0)));
|
|
});
|
|
|
|
testWidgets('Help text is used - Input', (WidgetTester tester) async {
|
|
const String helpText = 'help';
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, helpText: helpText);
|
|
expect(find.text(helpText), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Hour label text is used - Input', (WidgetTester tester) async {
|
|
const String hourLabelText = 'Custom hour label';
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, hourLabelText: hourLabelText);
|
|
expect(find.text(hourLabelText), findsOneWidget);
|
|
});
|
|
|
|
|
|
testWidgets('Minute label text is used - Input', (WidgetTester tester) async {
|
|
const String minuteLabelText = 'Custom minute label';
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, minuteLabelText: minuteLabelText);
|
|
expect(find.text(minuteLabelText), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Invalid error text is used - Input', (WidgetTester tester) async {
|
|
const String errorInvalidText = 'Custom validation error';
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, errorInvalidText: errorInvalidText);
|
|
// 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('Can switch from input to dial entry mode', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
|
|
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, true);
|
|
await tester.tap(find.byIcon(Icons.keyboard));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(TextField), findsWidgets);
|
|
});
|
|
|
|
testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.inputOnly);
|
|
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, true, entryMode: TimePickerEntryMode.dialOnly);
|
|
expect(find.byType(TextField), findsNothing);
|
|
expect(find.byIcon(Icons.keyboard), findsNothing);
|
|
});
|
|
|
|
testWidgets('Switching to dial entry mode triggers entry callback', (WidgetTester tester) async {
|
|
bool triggeredCallback = false;
|
|
|
|
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, onEntryModeChange: (TimePickerEntryMode mode) {
|
|
if (mode == TimePickerEntryMode.dial) {
|
|
triggeredCallback = true;
|
|
}
|
|
});
|
|
|
|
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 {
|
|
bool triggeredCallback = false;
|
|
|
|
await mediaQueryBoilerplate(tester, true, onEntryModeChange: (TimePickerEntryMode mode) {
|
|
if (mode == TimePickerEntryMode.input) {
|
|
triggeredCallback = true;
|
|
}
|
|
});
|
|
|
|
await tester.tap(find.byIcon(Icons.keyboard));
|
|
await tester.pumpAndSettle();
|
|
expect(triggeredCallback, true);
|
|
});
|
|
|
|
testWidgets('Can double tap hours (when selected) to enter input mode', (WidgetTester tester) async {
|
|
await mediaQueryBoilerplate(tester, false);
|
|
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, false);
|
|
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, false);
|
|
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, false);
|
|
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);
|
|
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);
|
|
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);
|
|
|
|
// 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);
|
|
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}' == '_StringFragment')).dy;
|
|
expect(hourFieldTop, separatorTop);
|
|
expect(minuteFieldTop, separatorTop);
|
|
});
|
|
|
|
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',
|
|
))!;
|
|
final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
|
|
final Offset min45 = Offset(center.dx - 50.0, 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('PM'));
|
|
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',
|
|
);
|
|
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('PM'));
|
|
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',
|
|
))!;
|
|
|
|
final TestRestorationData restorationData = await tester.getRestorationData();
|
|
// Switch to input mode from dial mode.
|
|
await tester.tap(find.byIcon(Icons.keyboard));
|
|
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 Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00
|
|
final Offset min45 = Offset(center.dx - 50.0, 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)));
|
|
});
|
|
|
|
testWidgets('Can switch between hour/minute fields using keyboard input action', (WidgetTester tester) async {
|
|
await startPicker(tester, (TimeOfDay? time) { }, entryMode: TimePickerEntryMode.input);
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
Future<void> mediaQueryBoilerplate(
|
|
WidgetTester tester,
|
|
bool alwaysUse24HourFormat, {
|
|
TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0),
|
|
double textScaleFactor = 1.0,
|
|
TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
|
|
String? helpText,
|
|
String? hourLabelText,
|
|
String? minuteLabelText,
|
|
String? errorInvalidText,
|
|
bool accessibleNavigation = false,
|
|
EntryModeChangeCallback? onEntryModeChange,
|
|
bool tapButton = true,
|
|
}) async {
|
|
await tester.pumpWidget(
|
|
Localizations(
|
|
locale: const Locale('en', 'US'),
|
|
delegates: const <LocalizationsDelegate<dynamic>>[
|
|
DefaultMaterialLocalizations.delegate,
|
|
DefaultWidgetsLocalizations.delegate,
|
|
],
|
|
child: MediaQuery(
|
|
data: MediaQueryData(
|
|
alwaysUse24HourFormat: alwaysUse24HourFormat,
|
|
textScaleFactor: textScaleFactor,
|
|
accessibleNavigation: accessibleNavigation,
|
|
size: tester.binding.window.physicalSize / tester.binding.window.devicePixelRatio,
|
|
),
|
|
child: Material(
|
|
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,
|
|
);
|
|
},
|
|
child: const Text('X'),
|
|
);
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (tapButton) {
|
|
await tester.tap(find.text('X'));
|
|
}
|
|
await tester.pumpAndSettle();
|
|
}
|