Unverified Commit fae458b9 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Convert TimePicker to Material 3 (#116396)

* Make some minor changes in preparation for updating the Time Picker to M3

* Revert OutlineInputBorder.borderRadius type change

* Revert more OutlineInputBorder.borderRadius changes.

* Convert TimePicker to Material 3

* Add example test

* Revert OutlineInputBorder.borderRadius type change

* Fix test

* Review Changes

* Merge changes

* Some sizing and elevation fixes

* Fix localization tests
parent a59dd83d
......@@ -49,6 +49,7 @@ import 'package:gen_defaults/surface_tint.dart';
import 'package:gen_defaults/switch_template.dart';
import 'package:gen_defaults/tabs_template.dart';
import 'package:gen_defaults/text_field_template.dart';
import 'package:gen_defaults/time_picker_template.dart';
import 'package:gen_defaults/typography_template.dart';
Map<String, dynamic> _readTokenFile(String fileName) {
......@@ -167,6 +168,7 @@ Future<void> main(List<String> args) async {
SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile();
SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile();
SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile();
TimePickerTemplate('TimePicker', '$materialLib/time_picker.dart', tokens).updateFile();
TextFieldTemplate('TextField', '$materialLib/text_field.dart', tokens).updateFile();
TabsTemplate('Tabs', '$materialLib/tabs.dart', tokens).updateFile();
TypographyTemplate('Typography', '$materialLib/typography.dart', tokens).updateFile();
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'template.dart';
class TimePickerTemplate extends TokenTemplate {
const TimePickerTemplate(super.blockName, super.fileName, super.tokens, {
super.colorSchemePrefix = '_colors.',
super.textThemePrefix = '_textTheme.'
});
static const String tokenGroup = 'md.comp.time-picker';
static const String hourMinuteComponent = '$tokenGroup.time-selector';
static const String dayPeriodComponent = '$tokenGroup.period-selector';
static const String dialComponent = '$tokenGroup.clock-dial';
static const String variant = '';
@override
String generate() => '''
// Generated version ${tokens["version"]}
class _${blockName}DefaultsM3 extends _TimePickerDefaults {
_${blockName}DefaultsM3(this.context);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
late final TextTheme _textTheme = Theme.of(context).textTheme;
@override
Color get backgroundColor {
return ${componentColor("$tokenGroup.container")};
}
@override
ButtonStyle get cancelButtonStyle {
return TextButton.styleFrom();
}
@override
ButtonStyle get confirmButtonStyle {
return TextButton.styleFrom();
}
@override
BorderSide get dayPeriodBorderSide {
return ${border('$dayPeriodComponent.outline')};
}
@override
Color get dayPeriodColor {
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return ${componentColor("$dayPeriodComponent.selected.container")};
}
// The unselected day period should match the overall picker dialog color.
// Making it transparent enables that without being redundant and allows
// the optional elevation overlay for dark mode to be visible.
return Colors.transparent;
});
}
@override
OutlinedBorder get dayPeriodShape {
return ${shape("$dayPeriodComponent.container")}.copyWith(side: dayPeriodBorderSide);
}
@override
Size get dayPeriodPortraitSize {
return ${size('$dayPeriodComponent.vertical.container')};
}
@override
Size get dayPeriodLandscapeSize {
return ${size('$dayPeriodComponent.horizontal.container')};
}
@override
Size get dayPeriodInputSize {
// Input size is eight pixels smaller than the portrait size in the spec,
// but there's not token for it yet.
return Size(dayPeriodPortraitSize.width, dayPeriodPortraitSize.height - 8);
}
@override
Color get dayPeriodTextColor {
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
return _dayPeriodForegroundColor.resolve(states);
});
}
MaterialStateProperty<Color> get _dayPeriodForegroundColor {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
Color? textColor;
if (states.contains(MaterialState.selected)) {
if (states.contains(MaterialState.pressed)) {
textColor = ${componentColor("$dayPeriodComponent.selected.pressed.label-text")};
} else {
// not pressed
if (states.contains(MaterialState.focused)) {
textColor = ${componentColor("$dayPeriodComponent.selected.focus.label-text")};
} else {
// not focused
if (states.contains(MaterialState.hovered)) {
textColor = ${componentColor("$dayPeriodComponent.selected.hover.label-text")};
}
}
}
} else {
// unselected
if (states.contains(MaterialState.pressed)) {
textColor = ${componentColor("$dayPeriodComponent.unselected.pressed.label-text")};
} else {
// not pressed
if (states.contains(MaterialState.focused)) {
textColor = ${componentColor("$dayPeriodComponent.unselected.focus.label-text")};
} else {
// not focused
if (states.contains(MaterialState.hovered)) {
textColor = ${componentColor("$dayPeriodComponent.unselected.hover.label-text")};
}
}
}
}
return textColor ?? ${componentColor("$dayPeriodComponent.selected.label-text")};
});
}
@override
TextStyle get dayPeriodTextStyle {
return ${textStyle("$dayPeriodComponent.label-text")}!.copyWith(color: dayPeriodTextColor);
}
@override
Color get dialBackgroundColor {
return ${componentColor(dialComponent)}.withOpacity(_colors.brightness == Brightness.dark ? 0.12 : 0.08);
}
@override
Color get dialHandColor {
return ${componentColor('$dialComponent.selector.handle.container')};
}
@override
Size get dialSize {
return ${size("$dialComponent.container")};
}
@override
double get handWidth {
return ${size("$dialComponent.selector.track.container")}.width;
}
@override
double get dotRadius {
return ${size("$dialComponent.selector.handle.container")}.width / 2;
}
@override
double get centerRadius {
return ${size("$dialComponent.selector.center.container")}.width / 2;
}
@override
Color get dialTextColor {
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return ${componentColor('$dialComponent.selected.label-text')};
}
return ${componentColor('$dialComponent.unselected.label-text')};
});
}
@override
TextStyle get dialTextStyle {
return ${textStyle('$dialComponent.label-text')}!;
}
@override
double get elevation {
return ${elevation("$tokenGroup.container")};
}
@override
Color get entryModeIconColor {
return _colors.onSurface;
}
@override
TextStyle get helpTextStyle {
return MaterialStateTextStyle.resolveWith((Set<MaterialState> states) {
final TextStyle textStyle = ${textStyle('$tokenGroup.headline')}!;
return textStyle.copyWith(color: ${componentColor('$tokenGroup.headline')});
});
}
@override
EdgeInsetsGeometry get padding {
return const EdgeInsets.all(24);
}
@override
Color get hourMinuteColor {
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
Color overlayColor = ${componentColor('$hourMinuteComponent.selected.container')};
if (states.contains(MaterialState.pressed)) {
overlayColor = ${componentColor('$hourMinuteComponent.selected.pressed.state-layer')};
} else if (states.contains(MaterialState.focused)) {
const double focusOpacity = ${opacity('$hourMinuteComponent.focus.state-layer.opacity')};
overlayColor = ${componentColor('$hourMinuteComponent.selected.focus.state-layer')}.withOpacity(focusOpacity);
} else if (states.contains(MaterialState.hovered)) {
const double hoverOpacity = ${opacity('$hourMinuteComponent.hover.state-layer.opacity')};
overlayColor = ${componentColor('$hourMinuteComponent.selected.hover.state-layer')}.withOpacity(hoverOpacity);
}
return Color.alphaBlend(overlayColor, ${componentColor('$hourMinuteComponent.selected.container')});
} else {
Color overlayColor = ${componentColor('$hourMinuteComponent.unselected.container')};
if (states.contains(MaterialState.pressed)) {
overlayColor = ${componentColor('$hourMinuteComponent.unselected.pressed.state-layer')};
} else if (states.contains(MaterialState.focused)) {
const double focusOpacity = ${opacity('$hourMinuteComponent.focus.state-layer.opacity')};
overlayColor = ${componentColor('$hourMinuteComponent.unselected.focus.state-layer')}.withOpacity(focusOpacity);
} else if (states.contains(MaterialState.hovered)) {
const double hoverOpacity = ${opacity('$hourMinuteComponent.hover.state-layer.opacity')};
overlayColor = ${componentColor('$hourMinuteComponent.unselected.hover.state-layer')}.withOpacity(hoverOpacity);
}
return Color.alphaBlend(overlayColor, ${componentColor('$hourMinuteComponent.unselected.container')});
}
});
}
@override
ShapeBorder get hourMinuteShape {
return ${shape('$hourMinuteComponent.container')};
}
@override
Size get hourMinuteSize {
return ${size('$hourMinuteComponent.container')};
}
@override
Size get hourMinuteSize24Hour {
return Size(${size('$hourMinuteComponent.24h-vertical.container')}.width, hourMinuteSize.height);
}
@override
Size get hourMinuteInputSize {
// Input size is eight pixels smaller than the regular size in the spec, but
// there's not token for it yet.
return Size(hourMinuteSize.width, hourMinuteSize.height - 8);
}
@override
Size get hourMinuteInputSize24Hour {
// Input size is eight pixels smaller than the regular size in the spec, but
// there's not token for it yet.
return Size(hourMinuteSize24Hour.width, hourMinuteSize24Hour.height - 8);
}
@override
Color get hourMinuteTextColor {
return MaterialStateColor.resolveWith((Set<MaterialState> states) {
return _hourMinuteTextColor.resolve(states);
});
}
MaterialStateProperty<Color> get _hourMinuteTextColor {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
if (states.contains(MaterialState.pressed)) {
return ${componentColor("$hourMinuteComponent.selected.pressed.label-text")};
}
if (states.contains(MaterialState.focused)) {
return ${componentColor("$hourMinuteComponent.selected.focus.label-text")};
}
if (states.contains(MaterialState.hovered)) {
return ${componentColor("$hourMinuteComponent.selected.hover.label-text")};
}
return ${componentColor("$hourMinuteComponent.selected.label-text")};
} else {
// unselected
if (states.contains(MaterialState.pressed)) {
return ${componentColor("$hourMinuteComponent.unselected.pressed.label-text")};
}
if (states.contains(MaterialState.focused)) {
return ${componentColor("$hourMinuteComponent.unselected.focus.label-text")};
}
if (states.contains(MaterialState.hovered)) {
return ${componentColor("$hourMinuteComponent.unselected.hover.label-text")};
}
return ${componentColor("$hourMinuteComponent.unselected.label-text")};
}
});
}
@override
TextStyle get hourMinuteTextStyle {
return MaterialStateTextStyle.resolveWith((Set<MaterialState> states) {
return ${textStyle('$hourMinuteComponent.label-text')}!.copyWith(color: _hourMinuteTextColor.resolve(states));
});
}
@override
InputDecorationTheme get inputDecorationTheme {
// This is NOT correct, but there's no token for
// 'time-input.container.shape', so this is using the radius from the shape
// for the hour/minute selector.
final BorderRadiusGeometry selectorRadius = ${shape('$hourMinuteComponent.container')}.borderRadius;
return InputDecorationTheme(
contentPadding: EdgeInsets.zero,
filled: true,
// This should be derived from a token, but there isn't one for 'time-input'.
fillColor: hourMinuteColor,
// This should be derived from a token, but there isn't one for 'time-input'.
focusColor: _colors.primaryContainer,
enabledBorder: OutlineInputBorder(
borderRadius: selectorRadius,
borderSide: const BorderSide(color: Colors.transparent),
),
errorBorder: OutlineInputBorder(
borderRadius: selectorRadius,
borderSide: BorderSide(color: _colors.error, width: 2),
),
focusedBorder: OutlineInputBorder(
borderRadius: selectorRadius,
borderSide: BorderSide(color: _colors.primary, width: 2),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: selectorRadius,
borderSide: BorderSide(color: _colors.error, width: 2),
),
hintStyle: hourMinuteTextStyle.copyWith(color: _colors.onSurface.withOpacity(0.36)),
// Prevent the error text from appearing.
// TODO(rami-a): Remove this workaround once
// https://github.com/flutter/flutter/issues/54104
// is fixed.
errorStyle: const TextStyle(fontSize: 0, height: 0),
);
}
@override
ShapeBorder get shape {
return ${shape("$tokenGroup.container")};
}
}
''';
}
// 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.
/// Flutter code sample for [showTimePicker].
import 'package:flutter/material.dart';
void main() {
runApp(const ShowTimePickerApp());
}
class ShowTimePickerApp extends StatefulWidget {
const ShowTimePickerApp({super.key});
@override
State<ShowTimePickerApp> createState() => _ShowTimePickerAppState();
}
class _ShowTimePickerAppState extends State<ShowTimePickerApp> {
ThemeMode themeMode = ThemeMode.dark;
bool useMaterial3 = true;
void setThemeMode(ThemeMode mode) {
setState(() {
themeMode = mode;
});
}
void setUseMaterial3(bool? value) {
setState(() {
useMaterial3 = value!;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.light(useMaterial3: useMaterial3),
darkTheme: ThemeData.dark(useMaterial3: useMaterial3),
themeMode: themeMode,
home: TimePickerOptions(
themeMode: themeMode,
useMaterial3: useMaterial3,
setThemeMode: setThemeMode,
setUseMaterial3: setUseMaterial3,
),
);
}
}
class TimePickerOptions extends StatefulWidget {
const TimePickerOptions({
super.key,
required this.themeMode,
required this.useMaterial3,
required this.setThemeMode,
required this.setUseMaterial3,
});
final ThemeMode themeMode;
final bool useMaterial3;
final ValueChanged<ThemeMode> setThemeMode;
final ValueChanged<bool?> setUseMaterial3;
@override
State<TimePickerOptions> createState() => _TimePickerOptionsState();
}
class _TimePickerOptionsState extends State<TimePickerOptions> {
TimeOfDay? selectedTime;
TimePickerEntryMode entryMode = TimePickerEntryMode.dial;
Orientation? orientation;
TextDirection textDirection = TextDirection.ltr;
MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.padded;
bool use24HourTime = false;
void _entryModeChanged(TimePickerEntryMode? value) {
if (value != entryMode) {
setState(() {
entryMode = value!;
});
}
}
void _orientationChanged(Orientation? value) {
if (value != orientation) {
setState(() {
orientation = value;
});
}
}
void _textDirectionChanged(TextDirection? value) {
if (value != textDirection) {
setState(() {
textDirection = value!;
});
}
}
void _tapTargetSizeChanged(MaterialTapTargetSize? value) {
if (value != tapTargetSize) {
setState(() {
tapTargetSize = value!;
});
}
}
void _use24HourTimeChanged(bool? value) {
if (value != use24HourTime) {
setState(() {
use24HourTime = value!;
});
}
}
void _themeModeChanged(ThemeMode? value) {
widget.setThemeMode(value!);
}
@override
Widget build(BuildContext context) {
return Material(
child: Column(
children: <Widget>[
Expanded(
child: GridView(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 350,
mainAxisSpacing: 4,
mainAxisExtent: 200 * MediaQuery.textScaleFactorOf(context),
crossAxisSpacing: 4,
),
children: <Widget>[
EnumCard<TimePickerEntryMode>(
choices: TimePickerEntryMode.values,
value: entryMode,
onChanged: _entryModeChanged,
),
EnumCard<ThemeMode>(
choices: ThemeMode.values,
value: widget.themeMode,
onChanged: _themeModeChanged,
),
EnumCard<TextDirection>(
choices: TextDirection.values,
value: textDirection,
onChanged: _textDirectionChanged,
),
EnumCard<MaterialTapTargetSize>(
choices: MaterialTapTargetSize.values,
value: tapTargetSize,
onChanged: _tapTargetSizeChanged,
),
ChoiceCard<Orientation?>(
choices: const <Orientation?>[...Orientation.values, null],
value: orientation,
title: '$Orientation',
choiceLabels: <Orientation?, String>{
for (final Orientation choice in Orientation.values) choice: choice.name,
null: 'from MediaQuery',
},
onChanged: _orientationChanged,
),
ChoiceCard<bool>(
choices: const <bool>[false, true],
value: use24HourTime,
onChanged: _use24HourTimeChanged,
title: 'Time Mode',
choiceLabels: const <bool, String>{
false: '12-hour am/pm time',
true: '24-hour time',
},
),
ChoiceCard<bool>(
choices: const <bool>[false, true],
value: widget.useMaterial3,
onChanged: widget.setUseMaterial3,
title: 'Material Version',
choiceLabels: const <bool, String>{
false: 'Material 2',
true: 'Material 3',
},
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: ElevatedButton(
child: const Text('Open time picker'),
onPressed: () async {
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: selectedTime ?? TimeOfDay.now(),
initialEntryMode: entryMode,
orientation: orientation,
builder: (BuildContext context, Widget? child) {
// We just wrap these environmental changes around the
// child in this builder so that we can apply the
// options selected above. In regular usage, this is
// rarely necessary, because the default values are
// usually used as-is.
return Theme(
data: Theme.of(context).copyWith(
materialTapTargetSize: tapTargetSize,
),
child: Directionality(
textDirection: textDirection,
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
alwaysUse24HourFormat: use24HourTime,
),
child: child!,
),
),
);
},
);
setState(() {
selectedTime = time;
});
},
),
),
if (selectedTime != null) Text('Selected time: ${selectedTime!.format(context)}'),
],
),
),
],
),
);
}
}
// This is a simple card that presents a set of radio buttons (inside of a
// RadioSelection, defined below) for the user to select from.
class ChoiceCard<T extends Object?> extends StatelessWidget {
const ChoiceCard({
super.key,
required this.value,
required this.choices,
required this.onChanged,
required this.choiceLabels,
required this.title,
});
final T value;
final Iterable<T> choices;
final Map<T, String> choiceLabels;
final String title;
final ValueChanged<T?> onChanged;
@override
Widget build(BuildContext context) {
return Card(
// If the card gets too small, let it scroll both directions.
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(title),
),
for (final T choice in choices)
RadioSelection<T>(
value: choice,
groupValue: value,
onChanged: onChanged,
child: Text(choiceLabels[choice]!),
),
],
),
),
),
),
);
}
}
// This aggregates a ChoiceCard so that it presents a set of radio buttons for
// the allowed enum values for the user to select from.
class EnumCard<T extends Enum> extends StatelessWidget {
const EnumCard({
super.key,
required this.value,
required this.choices,
required this.onChanged,
});
final T value;
final Iterable<T> choices;
final ValueChanged<T?> onChanged;
@override
Widget build(BuildContext context) {
return ChoiceCard<T>(
value: value,
choices: choices,
onChanged: onChanged,
choiceLabels: <T, String>{
for (final T choice in choices) choice: choice.name,
},
title: value.runtimeType.toString());
}
}
// A button that has a radio button on one side and a label child. Tapping on
// the label or the radio button selects the item.
class RadioSelection<T extends Object?> extends StatefulWidget {
const RadioSelection({
super.key,
required this.value,
required this.groupValue,
required this.onChanged,
required this.child,
});
final T value;
final T? groupValue;
final ValueChanged<T?> onChanged;
final Widget child;
@override
State<RadioSelection<T>> createState() => _RadioSelectionState<T>();
}
class _RadioSelectionState<T extends Object?> extends State<RadioSelection<T>> {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: Radio<T>(
groupValue: widget.groupValue,
value: widget.value,
onChanged: widget.onChanged,
),
),
GestureDetector(onTap: () => widget.onChanged(widget.value), child: widget.child),
],
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/time_picker/show_time_picker.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Can open and modify time picker', (WidgetTester tester) async {
const String openPicker = 'Open time picker';
final List<String> options = <String>[
'$TimePickerEntryMode',
... TimePickerEntryMode.values.map<String>((TimePickerEntryMode value) => value.name),
'$ThemeMode',
... ThemeMode.values.map<String>((ThemeMode value) => value.name),
'$TextDirection',
... TextDirection.values.map<String>((TextDirection value) => value.name),
'$MaterialTapTargetSize',
... MaterialTapTargetSize.values.map<String>((MaterialTapTargetSize value) => value.name),
'$Orientation',
... Orientation.values.map<String>((Orientation value) => value.name),
'Time Mode',
'12-hour am/pm time',
'24-hour time',
'Material Version',
'Material 2',
'Material 3',
openPicker,
];
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: example.ShowTimePickerApp(),
),
),
);
for (final String option in options) {
expect(find.text(option), findsOneWidget, reason: 'Unable to find $option widget in example.');
}
// Open time picker
await tester.tap(find.text(openPicker));
await tester.pumpAndSettle();
expect(find.text('Select time'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('OK'), findsOneWidget);
// Close time picker
await tester.tapAt(const Offset(1, 1));
await tester.pumpAndSettle();
expect(find.text('Select time'), findsNothing);
expect(find.text('Cancel'), findsNothing);
expect(find.text('OK'), findsNothing);
// Change an option.
await tester.tap(find.text('Material 2'));
await tester.pumpAndSettle();
await tester.tap(find.text(openPicker));
await tester.pumpAndSettle();
expect(find.text('SELECT TIME'), findsOneWidget);
expect(find.text('CANCEL'), findsOneWidget);
expect(find.text('OK'), findsOneWidget);
});
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -2,10 +2,14 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'button_style.dart';
import 'input_decorator.dart';
import 'material_state.dart';
import 'theme.dart';
// Examples can assume:
......@@ -36,22 +40,27 @@ class TimePickerThemeData with Diagnosticable {
/// [ThemeData.timePickerTheme].
const TimePickerThemeData({
this.backgroundColor,
this.hourMinuteTextColor,
this.hourMinuteColor,
this.dayPeriodTextColor,
this.cancelButtonStyle,
this.confirmButtonStyle,
this.dayPeriodBorderSide,
this.dayPeriodColor,
this.dialHandColor,
this.dayPeriodShape,
this.dayPeriodTextColor,
this.dayPeriodTextStyle,
this.dialBackgroundColor,
this.dialHandColor,
this.dialTextColor,
this.dialTextStyle,
this.elevation,
this.entryModeIconColor,
this.hourMinuteTextStyle,
this.dayPeriodTextStyle,
this.helpTextStyle,
this.shape,
this.hourMinuteColor,
this.hourMinuteShape,
this.dayPeriodShape,
this.dayPeriodBorderSide,
this.hourMinuteTextColor,
this.hourMinuteTextStyle,
this.inputDecorationTheme,
this.padding,
this.shape,
});
/// The background color of a time picker.
......@@ -60,19 +69,29 @@ class TimePickerThemeData with Diagnosticable {
/// [ColorScheme.background].
final Color? backgroundColor;
/// The color of the header text that represents hours and minutes.
/// The style of the cancel button of a [TimePickerDialog].
final ButtonStyle? cancelButtonStyle;
/// The style of the conform (OK) button of a [TimePickerDialog].
final ButtonStyle? confirmButtonStyle;
/// The color and weight of the day period's outline.
///
/// If [hourMinuteTextColor] is a [MaterialStateColor], then the effective
/// text color can depend on the [MaterialState.selected] state, i.e. if the
/// text is selected or not.
/// If this is null, the time picker defaults to:
///
/// By default the overall theme's [ColorScheme.primary] color is used when
/// the text is selected and [ColorScheme.onSurface] when it's not selected.
final Color? hourMinuteTextColor;
/// ```dart
/// BorderSide(
/// color: Color.alphaBlend(
/// Theme.of(context).colorScheme.onBackground.withOpacity(0.38),
/// Theme.of(context).colorScheme.surface,
/// ),
/// ),
/// ```
final BorderSide? dayPeriodBorderSide;
/// The background color of the hour and minutes header segments.
/// The background color of the AM/PM toggle.
///
/// If [hourMinuteColor] is a [MaterialStateColor], then the effective
/// If [dayPeriodColor] is a [MaterialStateColor], then the effective
/// background color can depend on the [MaterialState.selected] state, i.e.
/// if the segment is selected or not.
///
......@@ -81,9 +100,21 @@ class TimePickerThemeData with Diagnosticable {
/// brightness is [Brightness.light] and
/// `ColorScheme.primary.withOpacity(0.24)` is used when the overall theme's
/// brightness is [Brightness.dark].
/// If the segment is not selected, the overall theme's
/// `ColorScheme.onSurface.withOpacity(0.12)` is used.
final Color? hourMinuteColor;
/// If the segment is not selected, [Colors.transparent] is used to allow the
/// [Dialog]'s color to be used.
final Color? dayPeriodColor;
/// The shape of the day period that the time picker uses.
///
/// If this is null, the time picker defaults to:
///
/// ```dart
/// const RoundedRectangleBorder(
/// borderRadius: BorderRadius.all(Radius.circular(4.0)),
/// side: BorderSide(),
/// )
/// ```
final OutlinedBorder? dayPeriodShape;
/// The color of the day period text that represents AM/PM.
///
......@@ -96,32 +127,25 @@ class TimePickerThemeData with Diagnosticable {
/// it's not selected.
final Color? dayPeriodTextColor;
/// The background color of the AM/PM toggle.
///
/// If [dayPeriodColor] is a [MaterialStateColor], then the effective
/// background color can depend on the [MaterialState.selected] state, i.e.
/// if the segment is selected or not.
/// Used to configure the [TextStyle]s for the day period control.
///
/// By default, if the segment is selected, the overall theme's
/// `ColorScheme.primary.withOpacity(0.12)` is used when the overall theme's
/// brightness is [Brightness.light] and
/// `ColorScheme.primary.withOpacity(0.24)` is used when the overall theme's
/// brightness is [Brightness.dark].
/// If the segment is not selected, [Colors.transparent] is used to allow the
/// [Dialog]'s color to be used.
final Color? dayPeriodColor;
/// If this is null, the time picker defaults to the overall theme's
/// [TextTheme.titleMedium].
final TextStyle? dayPeriodTextStyle;
/// The color of the time picker dial's hand.
/// The background color of the time picker dial when the entry mode is
/// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly].
///
/// If this is null, the time picker defaults to the overall theme's
/// [ColorScheme.primary].
final Color? dialHandColor;
final Color? dialBackgroundColor;
/// The background color of the time picker dial.
/// The color of the time picker dial's hand when the entry mode is
/// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly].
///
/// If this is null, the time picker defaults to the overall theme's
/// [ColorScheme.primary].
final Color? dialBackgroundColor;
final Color? dialHandColor;
/// The color of the dial text that represents specific hours and minutes.
///
......@@ -133,10 +157,24 @@ class TimePickerThemeData with Diagnosticable {
/// theme's [ThemeData.colorScheme].
final Color? dialTextColor;
/// The [TextStyle] for the numbers on the time selection dial.
///
/// If [dialTextStyle]'s [TextStyle.color] is a [MaterialStateColor], then the
/// effective text color can depend on the [MaterialState.selected] state,
/// i.e. if the text is selected or not.
///
/// If this style is null then the dial's text style is based on the theme's
/// [ThemeData.textTheme].
final TextStyle? dialTextStyle;
/// The Material elevation for the time picker dialog.
final double? elevation;
/// The color of the entry mode [IconButton].
///
/// If this is null, the time picker defaults to:
///
///
/// ```dart
/// Theme.of(context).colorScheme.onSurface.withOpacity(
/// Theme.of(context).colorScheme.brightness == Brightness.dark ? 1.0 : 0.6,
......@@ -144,29 +182,26 @@ class TimePickerThemeData with Diagnosticable {
/// ```
final Color? entryModeIconColor;
/// Used to configure the [TextStyle]s for the hour/minute controls.
///
/// If this is null, the time picker defaults to the overall theme's
/// [TextTheme.headline3].
final TextStyle? hourMinuteTextStyle;
/// Used to configure the [TextStyle]s for the day period control.
///
/// If this is null, the time picker defaults to the overall theme's
/// [TextTheme.titleMedium].
final TextStyle? dayPeriodTextStyle;
/// Used to configure the [TextStyle]s for the helper text in the header.
///
/// If this is null, the time picker defaults to the overall theme's
/// [TextTheme.labelSmall].
final TextStyle? helpTextStyle;
/// The shape of the [Dialog] that the time picker is presented in.
/// The background color of the hour and minute header segments.
///
/// If this is null, the time picker defaults to
/// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`.
final ShapeBorder? shape;
/// If [hourMinuteColor] is a [MaterialStateColor], then the effective
/// background color can depend on the [MaterialState.selected] state, i.e.
/// if the segment is selected or not.
///
/// By default, if the segment is selected, the overall theme's
/// `ColorScheme.primary.withOpacity(0.12)` is used when the overall theme's
/// brightness is [Brightness.light] and
/// `ColorScheme.primary.withOpacity(0.24)` is used when the overall theme's
/// brightness is [Brightness.dark].
/// If the segment is not selected, the overall theme's
/// `ColorScheme.onSurface.withOpacity(0.12)` is used.
final Color? hourMinuteColor;
/// The shape of the hour and minute controls that the time picker uses.
///
......@@ -174,76 +209,87 @@ class TimePickerThemeData with Diagnosticable {
/// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`.
final ShapeBorder? hourMinuteShape;
/// The shape of the day period that the time picker uses.
/// The color of the header text that represents hours and minutes.
///
/// If this is null, the time picker defaults to:
/// If [hourMinuteTextColor] is a [MaterialStateColor], then the effective
/// text color can depend on the [MaterialState.selected] state, i.e. if the
/// text is selected or not.
///
/// ```dart
/// const RoundedRectangleBorder(
/// borderRadius: BorderRadius.all(Radius.circular(4.0)),
/// side: BorderSide(),
/// )
/// ```
final OutlinedBorder? dayPeriodShape;
/// By default the overall theme's [ColorScheme.primary] color is used when
/// the text is selected and [ColorScheme.onSurface] when it's not selected.
final Color? hourMinuteTextColor;
/// The color and weight of the day period's outline.
///
/// If this is null, the time picker defaults to:
/// Used to configure the [TextStyle]s for the hour/minute controls.
///
/// ```dart
/// BorderSide(
/// color: Color.alphaBlend(
/// Theme.of(context).colorScheme.onBackground.withOpacity(0.38),
/// Theme.of(context).colorScheme.surface,
/// ),
/// ),
/// ```
final BorderSide? dayPeriodBorderSide;
/// If this is null, the time picker defaults to the overall theme's
/// [TextTheme.headline3].
final TextStyle? hourMinuteTextStyle;
/// The input decoration theme for the [TextField]s in the time picker.
///
/// If this is null, the time picker provides its own defaults.
final InputDecorationTheme? inputDecorationTheme;
/// The padding around the time picker dialog when the entry mode is
/// [TimePickerEntryMode.dial] or [TimePickerEntryMode.dialOnly].
final EdgeInsetsGeometry? padding;
/// The shape of the [Dialog] that the time picker is presented in.
///
/// If this is null, the time picker defaults to
/// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`.
final ShapeBorder? shape;
/// Creates a copy of this object with the given fields replaced with the
/// new values.
TimePickerThemeData copyWith({
Color? backgroundColor,
Color? hourMinuteTextColor,
Color? hourMinuteColor,
Color? dayPeriodTextColor,
ButtonStyle? cancelButtonStyle,
ButtonStyle? confirmButtonStyle,
ButtonStyle? dayPeriodButtonStyle,
BorderSide? dayPeriodBorderSide,
Color? dayPeriodColor,
Color? dialHandColor,
OutlinedBorder? dayPeriodShape,
Color? dayPeriodTextColor,
TextStyle? dayPeriodTextStyle,
Color? dialBackgroundColor,
Color? dialHandColor,
Color? dialTextColor,
TextStyle? dialTextStyle,
double? elevation,
Color? entryModeIconColor,
TextStyle? hourMinuteTextStyle,
TextStyle? dayPeriodTextStyle,
TextStyle? helpTextStyle,
ShapeBorder? shape,
Color? hourMinuteColor,
ShapeBorder? hourMinuteShape,
OutlinedBorder? dayPeriodShape,
BorderSide? dayPeriodBorderSide,
Color? hourMinuteTextColor,
TextStyle? hourMinuteTextStyle,
InputDecorationTheme? inputDecorationTheme,
EdgeInsetsGeometry? padding,
ShapeBorder? shape,
}) {
return TimePickerThemeData(
backgroundColor: backgroundColor ?? this.backgroundColor,
hourMinuteTextColor: hourMinuteTextColor ?? this.hourMinuteTextColor,
hourMinuteColor: hourMinuteColor ?? this.hourMinuteColor,
dayPeriodTextColor: dayPeriodTextColor ?? this.dayPeriodTextColor,
cancelButtonStyle: cancelButtonStyle ?? this.cancelButtonStyle,
confirmButtonStyle: confirmButtonStyle ?? this.confirmButtonStyle,
dayPeriodBorderSide: dayPeriodBorderSide ?? this.dayPeriodBorderSide,
dayPeriodColor: dayPeriodColor ?? this.dayPeriodColor,
dialHandColor: dialHandColor ?? this.dialHandColor,
dayPeriodShape: dayPeriodShape ?? this.dayPeriodShape,
dayPeriodTextColor: dayPeriodTextColor ?? this.dayPeriodTextColor,
dayPeriodTextStyle: dayPeriodTextStyle ?? this.dayPeriodTextStyle,
dialBackgroundColor: dialBackgroundColor ?? this.dialBackgroundColor,
dialHandColor: dialHandColor ?? this.dialHandColor,
dialTextColor: dialTextColor ?? this.dialTextColor,
dialTextStyle: dialTextStyle ?? this.dialTextStyle,
elevation: elevation ?? this.elevation,
entryModeIconColor: entryModeIconColor ?? this.entryModeIconColor,
hourMinuteTextStyle: hourMinuteTextStyle ?? this.hourMinuteTextStyle,
dayPeriodTextStyle: dayPeriodTextStyle ?? this.dayPeriodTextStyle,
helpTextStyle: helpTextStyle ?? this.helpTextStyle,
shape: shape ?? this.shape,
hourMinuteColor: hourMinuteColor ?? this.hourMinuteColor,
hourMinuteShape: hourMinuteShape ?? this.hourMinuteShape,
dayPeriodShape: dayPeriodShape ?? this.dayPeriodShape,
dayPeriodBorderSide: dayPeriodBorderSide ?? this.dayPeriodBorderSide,
hourMinuteTextColor: hourMinuteTextColor ?? this.hourMinuteTextColor,
hourMinuteTextStyle: hourMinuteTextStyle ?? this.hourMinuteTextStyle,
inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme,
padding: padding ?? this.padding,
shape: shape ?? this.shape,
);
}
......@@ -268,45 +314,55 @@ class TimePickerThemeData with Diagnosticable {
}
return TimePickerThemeData(
backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t),
hourMinuteTextColor: Color.lerp(a?.hourMinuteTextColor, b?.hourMinuteTextColor, t),
hourMinuteColor: Color.lerp(a?.hourMinuteColor, b?.hourMinuteColor, t),
dayPeriodTextColor: Color.lerp(a?.dayPeriodTextColor, b?.dayPeriodTextColor, t),
cancelButtonStyle: ButtonStyle.lerp(a?.cancelButtonStyle, b?.cancelButtonStyle, t),
confirmButtonStyle: ButtonStyle.lerp(a?.confirmButtonStyle, b?.confirmButtonStyle, t),
dayPeriodBorderSide: lerpedBorderSide,
dayPeriodColor: Color.lerp(a?.dayPeriodColor, b?.dayPeriodColor, t),
dialHandColor: Color.lerp(a?.dialHandColor, b?.dialHandColor, t),
dayPeriodShape: ShapeBorder.lerp(a?.dayPeriodShape, b?.dayPeriodShape, t) as OutlinedBorder?,
dayPeriodTextColor: Color.lerp(a?.dayPeriodTextColor, b?.dayPeriodTextColor, t),
dayPeriodTextStyle: TextStyle.lerp(a?.dayPeriodTextStyle, b?.dayPeriodTextStyle, t),
dialBackgroundColor: Color.lerp(a?.dialBackgroundColor, b?.dialBackgroundColor, t),
dialHandColor: Color.lerp(a?.dialHandColor, b?.dialHandColor, t),
dialTextColor: Color.lerp(a?.dialTextColor, b?.dialTextColor, t),
dialTextStyle: TextStyle.lerp(a?.dialTextStyle, b?.dialTextStyle, t),
elevation: lerpDouble(a?.elevation, b?.elevation, t),
entryModeIconColor: Color.lerp(a?.entryModeIconColor, b?.entryModeIconColor, t),
hourMinuteTextStyle: TextStyle.lerp(a?.hourMinuteTextStyle, b?.hourMinuteTextStyle, t),
dayPeriodTextStyle: TextStyle.lerp(a?.dayPeriodTextStyle, b?.dayPeriodTextStyle, t),
helpTextStyle: TextStyle.lerp(a?.helpTextStyle, b?.helpTextStyle, t),
shape: ShapeBorder.lerp(a?.shape, b?.shape, t),
hourMinuteColor: Color.lerp(a?.hourMinuteColor, b?.hourMinuteColor, t),
hourMinuteShape: ShapeBorder.lerp(a?.hourMinuteShape, b?.hourMinuteShape, t),
dayPeriodShape: ShapeBorder.lerp(a?.dayPeriodShape, b?.dayPeriodShape, t) as OutlinedBorder?,
dayPeriodBorderSide: lerpedBorderSide,
hourMinuteTextColor: Color.lerp(a?.hourMinuteTextColor, b?.hourMinuteTextColor, t),
hourMinuteTextStyle: TextStyle.lerp(a?.hourMinuteTextStyle, b?.hourMinuteTextStyle, t),
inputDecorationTheme: t < 0.5 ? a?.inputDecorationTheme : b?.inputDecorationTheme,
padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t),
shape: ShapeBorder.lerp(a?.shape, b?.shape, t),
);
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll(<Object?>[
backgroundColor,
hourMinuteTextColor,
hourMinuteColor,
dayPeriodTextColor,
cancelButtonStyle,
confirmButtonStyle,
dayPeriodBorderSide,
dayPeriodColor,
dialHandColor,
dayPeriodShape,
dayPeriodTextColor,
dayPeriodTextStyle,
dialBackgroundColor,
dialHandColor,
dialTextColor,
dialTextStyle,
elevation,
entryModeIconColor,
hourMinuteTextStyle,
dayPeriodTextStyle,
helpTextStyle,
shape,
hourMinuteColor,
hourMinuteShape,
dayPeriodShape,
dayPeriodBorderSide,
hourMinuteTextColor,
hourMinuteTextStyle,
inputDecorationTheme,
);
padding,
shape,
]);
@override
bool operator ==(Object other) {
......@@ -318,44 +374,54 @@ class TimePickerThemeData with Diagnosticable {
}
return other is TimePickerThemeData
&& other.backgroundColor == backgroundColor
&& other.hourMinuteTextColor == hourMinuteTextColor
&& other.hourMinuteColor == hourMinuteColor
&& other.dayPeriodTextColor == dayPeriodTextColor
&& other.cancelButtonStyle == cancelButtonStyle
&& other.confirmButtonStyle == confirmButtonStyle
&& other.dayPeriodBorderSide == dayPeriodBorderSide
&& other.dayPeriodColor == dayPeriodColor
&& other.dialHandColor == dialHandColor
&& other.dayPeriodShape == dayPeriodShape
&& other.dayPeriodTextColor == dayPeriodTextColor
&& other.dayPeriodTextStyle == dayPeriodTextStyle
&& other.dialBackgroundColor == dialBackgroundColor
&& other.dialHandColor == dialHandColor
&& other.dialTextColor == dialTextColor
&& other.dialTextStyle == dialTextStyle
&& other.elevation == elevation
&& other.entryModeIconColor == entryModeIconColor
&& other.hourMinuteTextStyle == hourMinuteTextStyle
&& other.dayPeriodTextStyle == dayPeriodTextStyle
&& other.helpTextStyle == helpTextStyle
&& other.shape == shape
&& other.hourMinuteColor == hourMinuteColor
&& other.hourMinuteShape == hourMinuteShape
&& other.dayPeriodShape == dayPeriodShape
&& other.dayPeriodBorderSide == dayPeriodBorderSide
&& other.inputDecorationTheme == inputDecorationTheme;
&& other.hourMinuteTextColor == hourMinuteTextColor
&& other.hourMinuteTextStyle == hourMinuteTextStyle
&& other.inputDecorationTheme == inputDecorationTheme
&& other.padding == padding
&& other.shape == shape;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(ColorProperty('backgroundColor', backgroundColor, defaultValue: null));
properties.add(ColorProperty('hourMinuteTextColor', hourMinuteTextColor, defaultValue: null));
properties.add(ColorProperty('hourMinuteColor', hourMinuteColor, defaultValue: null));
properties.add(ColorProperty('dayPeriodTextColor', dayPeriodTextColor, defaultValue: null));
properties.add(DiagnosticsProperty<ButtonStyle>('cancelButtonStyle', cancelButtonStyle, defaultValue: null));
properties.add(DiagnosticsProperty<ButtonStyle>('confirmButtonStyle', confirmButtonStyle, defaultValue: null));
properties.add(DiagnosticsProperty<BorderSide>('dayPeriodBorderSide', dayPeriodBorderSide, defaultValue: null));
properties.add(ColorProperty('dayPeriodColor', dayPeriodColor, defaultValue: null));
properties.add(ColorProperty('dialHandColor', dialHandColor, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('dayPeriodShape', dayPeriodShape, defaultValue: null));
properties.add(ColorProperty('dayPeriodTextColor', dayPeriodTextColor, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('dayPeriodTextStyle', dayPeriodTextStyle, defaultValue: null));
properties.add(ColorProperty('dialBackgroundColor', dialBackgroundColor, defaultValue: null));
properties.add(ColorProperty('dialHandColor', dialHandColor, defaultValue: null));
properties.add(ColorProperty('dialTextColor', dialTextColor, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle?>('dialTextStyle', dialTextStyle, defaultValue: null));
properties.add(DoubleProperty('elevation', elevation, defaultValue: null));
properties.add(ColorProperty('entryModeIconColor', entryModeIconColor, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('hourMinuteTextStyle', hourMinuteTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('dayPeriodTextStyle', dayPeriodTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('helpTextStyle', helpTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null));
properties.add(ColorProperty('hourMinuteColor', hourMinuteColor, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('hourMinuteShape', hourMinuteShape, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('dayPeriodShape', dayPeriodShape, defaultValue: null));
properties.add(DiagnosticsProperty<BorderSide>('dayPeriodBorderSide', dayPeriodBorderSide, defaultValue: null));
properties.add(ColorProperty('hourMinuteTextColor', hourMinuteTextColor, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('hourMinuteTextStyle', hourMinuteTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<InputDecorationTheme>('inputDecorationTheme', inputDecorationTheme, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null));
}
}
......
......@@ -12,162 +12,56 @@ 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,
bool useMaterial3 = false,
}) async {
await tester.pumpWidget(MaterialApp(
theme: ThemeData(useMaterial3: useMaterial3),
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('Material3 has sentence case labels', (WidgetTester tester) async {
await startPicker(tester, (TimeOfDay? time) {
expect(find.text('Select time'), findsOneWidget);
expect(find.text('Enter time'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
}, useMaterial3: true);
});
for (final MaterialType materialType in MaterialType.values) {
final String selectTimeString;
final String enterTimeString;
final String cancelString;
const String okString = 'OK';
const String amString = 'AM';
const String pmString = 'PM';
switch (materialType) {
case MaterialType.material2:
selectTimeString = 'SELECT TIME';
enterTimeString = 'ENTER TIME';
cancelString = 'CANCEL';
break;
case MaterialType.material3:
selectTimeString = 'Select time';
enterTimeString = 'Enter time';
cancelString = 'Cancel';
break;
}
group('Dial (${materialType.name})', () {
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
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; }))!;
await tester.tapAt(Offset(center.dx + 50.0, center.dy));
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; }))!;
await tester.tapAt(Offset(center.dx, center.dy + 50.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; }))!;
await tester.tapAt(Offset(center.dx, center.dy + 50.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)));
......@@ -176,11 +70,13 @@ void _tests() {
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);
final Offset center = (await startPicker(tester, (TimeOfDay? time) {
result = time!;
}, materialType: materialType))!;
final Offset hour0 = Offset(center.dx, center.dy - 50); // 12:00 AM
final Offset hour3 = Offset(center.dx + 50, center.dy);
final Offset hour6 = Offset(center.dx, center.dy + 50);
final Offset hour9 = Offset(center.dx - 50, center.dy);
TestGesture gesture;
......@@ -190,21 +86,36 @@ void _tests() {
await finishPicker(tester);
expect(result.hour, 0);
expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center));
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!; }), equals(center));
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!; }), equals(center));
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();
......@@ -215,9 +126,11 @@ void _tests() {
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)
final Offset center = (await startPicker(tester, (TimeOfDay? time) {
result = time!;
}, materialType: materialType))!;
final Offset hour6 = Offset(center.dx, center.dy + 50); // 6:00
final Offset min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours)
await tester.tapAt(hour6);
await tester.pump(const Duration(milliseconds: 50));
......@@ -229,10 +142,12 @@ void _tests() {
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);
final Offset center = (await startPicker(tester, (TimeOfDay? time) {
result = time!;
}, materialType: materialType))!;
final Offset hour3 = Offset(center.dx + 50, center.dy);
final Offset hour6 = Offset(center.dx, center.dy + 50);
final Offset hour9 = Offset(center.dx - 50, center.dy);
TestGesture gesture = await tester.startGesture(hour6);
await gesture.moveBy(hour9 - hour6);
......@@ -248,9 +163,11 @@ void _tests() {
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
final Offset center = (await startPicker(tester, (TimeOfDay? time) {
result = time!;
}, materialType: materialType))!;
final Offset hour6 = Offset(center.dx, center.dy + 50); // 6:00
final Offset min46 = Offset(center.dx - 50, center.dy - 5); // 46 mins
await tester.tapAt(hour6);
await tester.pump(const Duration(milliseconds: 50));
......@@ -262,9 +179,11 @@ void _tests() {
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
final Offset center = (await startPicker(tester, (TimeOfDay? time) {
result = time!;
}, materialType: materialType))!;
final Offset hour6 = Offset(center.dx, center.dy + 50); // 6:00
final Offset min48 = Offset(center.dx - 50, center.dy - 15); // 48 mins
await tester.tapAt(hour6);
await tester.pump(const Duration(milliseconds: 50));
......@@ -272,8 +191,9 @@ void _tests() {
await finishPicker(tester);
expect(result, equals(const TimeOfDay(hour: 6, minute: 50)));
});
});
group('haptic feedback', () {
group('Dial Haptic Feedback (${materialType.name})', () {
const Duration kFastFeedbackInterval = Duration(milliseconds: 10);
const Duration kSlowFeedbackInterval = Duration(milliseconds: 200);
late FeedbackTester feedback;
......@@ -287,36 +207,36 @@ void _tests() {
});
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));
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) { }))!;
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
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.0));
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) { }))!;
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
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.0));
await tester.tapAt(Offset(center.dx, center.dy + 50));
await tester.pump(kSlowFeedbackInterval);
await tester.tapAt(Offset(center.dx, center.dy - 50.0));
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) { }))!;
final Offset hour0 = Offset(center.dx, center.dy - 50.0);
final Offset hour3 = Offset(center.dx + 50.0, center.dy);
final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!;
final Offset hour0 = Offset(center.dx, center.dy - 50);
final Offset hour3 = Offset(center.dx + 50, center.dy);
final TestGesture gesture = await tester.startGesture(hour3);
await gesture.moveBy(hour0 - hour3);
......@@ -326,9 +246,9 @@ void _tests() {
});
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 Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!;
final Offset hour0 = Offset(center.dx, center.dy - 50);
final Offset hour3 = Offset(center.dx + 50, center.dy);
final TestGesture gesture = await tester.startGesture(hour3);
await gesture.moveBy(hour0 - hour3);
......@@ -342,9 +262,9 @@ void _tests() {
});
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 Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!;
final Offset hour0 = Offset(center.dx, center.dy - 50);
final Offset hour3 = Offset(center.dx + 50, center.dy);
final TestGesture gesture = await tester.startGesture(hour3);
await gesture.moveBy(hour0 - hour3);
......@@ -358,11 +278,23 @@ void _tests() {
});
});
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'];
group('Dialog (${materialType.name})', () {
testWidgets('Widgets have correct label capitalization', (WidgetTester tester) async {
await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType);
expect(find.text(selectTimeString), findsOneWidget);
expect(find.text(cancelString), findsOneWidget);
});
testWidgets('Widgets have correct label capitalization in input mode', (WidgetTester tester) async {
await startPicker(tester, (TimeOfDay? time) {},
entryMode: TimePickerEntryMode.input, materialType: materialType);
expect(find.text(enterTimeString), findsOneWidget);
expect(find.text(cancelString), findsOneWidget);
});
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
await mediaQueryBoilerplate(tester, materialType: materialType);
const List<String> 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;
......@@ -372,271 +304,94 @@ void _tests() {
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>;
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
// ignore: avoid_dynamic_calls
expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
expect(selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
});
switch (materialType) {
case MaterialType.material2:
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true);
await mediaQueryBoilerplate(tester, alwaysUse24HourFormat: true, materialType: materialType);
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);
final List<String> 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 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);
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));
// ignore: avoid_dynamic_calls
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
// ignore: avoid_dynamic_calls
expect(selectedLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
});
break;
case MaterialType.material3:
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, alwaysUse24HourFormat: true, materialType: materialType);
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 List<String> labels00To23 = List<String>.generate(24, (int index) {
return index == 0 ? '00' : index.toString();
});
final List<bool> inner0To23 = List<bool>.generate(24, (int index) => index >= 12);
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));
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), labels00To23);
// ignore: avoid_dynamic_calls
expect(primaryLabels.map<bool>((dynamic tp) => tp.inner as bool), inner0To23);
tester.binding.window.clearPhysicalSizeTestValue();
tester.binding.window.clearDevicePixelRatioTestValue();
// ignore: avoid_dynamic_calls
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
// ignore: avoid_dynamic_calls
expect(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);
});
break;
}
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);
await mediaQueryBoilerplate(tester, materialType: materialType);
RenderObject render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'));
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);
await mediaQueryBoilerplate(tester, tapButton: false, materialType: materialType);
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('setting orientation should override MediaQuery orientation', (WidgetTester tester) async {
// portrait media query
tester.binding.window.physicalSizeTestValue = const Size(800, 800.5);
tester.binding.window.devicePixelRatioTestValue = 1;
await mediaQueryBoilerplate(tester, orientation: Orientation.landscape, materialType: materialType);
render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding'));
final RenderObject 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();
......@@ -675,9 +430,9 @@ void _tests() {
await tester.pumpWidget(buildFrame(TextDirection.ltr));
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
final double ltrOkRight = tester.getBottomRight(find.text('OK')).dx;
final double ltrOkRight = tester.getBottomRight(find.text(okString)).dx;
await tester.tap(find.text('OK')); // dismiss the dialog
await tester.tap(find.text(okString)); // dismiss the dialog
await tester.pumpAndSettle();
await tester.pumpWidget(buildFrame(TextDirection.rtl));
......@@ -688,7 +443,7 @@ void _tests() {
// 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);
expect(tester.getBottomLeft(find.text(okString)).dx, 800 - ltrOkRight);
});
testWidgets('uses root navigator by default', (WidgetTester tester) async {
......@@ -794,9 +549,10 @@ void _tests() {
expect(find.text(helperText), findsOneWidget);
});
testWidgets('OK Cancel button layout', (WidgetTester tester) async {
testWidgets('OK Cancel button and helpText layout', (WidgetTester tester) async {
Widget buildFrame(TextDirection textDirection) {
return MaterialApp(
theme: ThemeData.light().copyWith(useMaterial3: materialType == MaterialType.material3),
home: Material(
child: Center(
child: Builder(
......@@ -826,60 +582,84 @@ void _tests() {
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'));
switch (materialType) {
case MaterialType.material2:
expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(154, 155)));
expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(281, 165)));
break;
case MaterialType.material3:
expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(138, 129)));
expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(292.0, 143.0)));
break;
}
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.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'));
switch (materialType) {
case MaterialType.material2:
expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(519, 155)));
expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(646, 165)));
break;
case MaterialType.material3:
expect(tester.getTopLeft(find.text(selectTimeString)), equals(const Offset(508, 129)));
expect(tester.getBottomRight(find.text(selectTimeString)), equals(const Offset(662, 143)));
break;
}
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('text scale affects certain elements and not others', (WidgetTester tester) async {
await mediaQueryBoilerplate(
tester,
false,
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('AM')).height;
final double amHeight = tester.getSize(find.text(amString)).height;
await tester.tap(find.text('OK')); // dismiss the dialog
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,
false,
textScaleFactor: 2.0,
textScaleFactor: 2,
initialTime: const TimeOfDay(hour: 7, minute: 41),
materialType: materialType,
);
final double amHeight2x = tester.getSize(find.text('AM')).height;
final double amHeight2x = tester.getSize(find.text(amString)).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.tap(find.text(okString)); // dismiss the dialog
await tester.pumpAndSettle();
// Verify that text scale for AM/PM is at most 2x.
await mediaQueryBoilerplate(
tester,
false,
textScaleFactor: 3.0,
textScaleFactor: 3,
initialTime: const TimeOfDay(hour: 7, minute: 41),
materialType: materialType,
);
expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
expect(tester.getSize(find.text('AM')).height, equals(amHeight2x));
expect(tester.getSize(find.text(amString)).height, equals(amHeight2x));
});
group('showTimePicker avoids overlapping display features', () {
......@@ -915,8 +695,8 @@ void _tests() {
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));
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 {
......@@ -953,8 +733,8 @@ void _tests() {
);
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));
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 {
......@@ -975,63 +755,390 @@ void _tests() {
),
child: child!,
);
},
home: const Center(child: Text('Test')),
),
},
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 size in const <Size>[Size(100, 100), Size(300, 300), Size(800, 600)]) {
testWidgets('Draws dial without overflows at $size', (WidgetTester tester) async {
tester.binding.window.physicalSizeTestValue = size;
await mediaQueryBoilerplate(tester, entryMode: TimePickerEntryMode.input, materialType: materialType);
await tester.pumpAndSettle();
expect(tester.takeException(), isNot(throwsAssertionError));
tester.binding.window.clearPhysicalSizeTestValue();
});
testWidgets('Draws input without overflows at $size', (WidgetTester tester) async {
tester.binding.window.physicalSizeTestValue = size;
await mediaQueryBoilerplate(tester, materialType: materialType);
await tester.pumpAndSettle();
expect(tester.takeException(), isNot(throwsAssertionError));
tester.binding.window.clearPhysicalSizeTestValue();
});
}
});
});
group('Time picker - A11y and Semantics (${materialType.name})', () {
testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await mediaQueryBoilerplate(tester, materialType: materialType);
expect(
semantics,
includesNodeWith(
label: amString,
actions: <SemanticsAction>[SemanticsAction.tap],
flags: <SemanticsFlag>[
SemanticsFlag.isButton,
SemanticsFlag.isChecked,
SemanticsFlag.isInMutuallyExclusiveGroup,
SemanticsFlag.hasCheckedState,
SemanticsFlag.isFocusable,
],
),
);
expect(
semantics,
includesNodeWith(
label: pmString,
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, alwaysUse24HourFormat: true, materialType: materialType);
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: cancelString));
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('provides semantics information for text fields', (WidgetTester tester) async {
final SemanticsTester 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],
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,
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 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,
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',
);
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),
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, materialType: materialType);
final Size dayPeriodControlSize = tester.getSize(
find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'),
);
expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48));
expect(dayPeriodControlSize.height, greaterThanOrEqualTo(80));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.byType(TimePickerDialog)), Offset.zero);
expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(390.0, 600.0));
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));
tester.binding.window.clearPhysicalSizeTestValue();
tester.binding.window.clearDevicePixelRatioTestValue();
});
});
}
void _testsInput() {
group('Time picker - Input (${materialType.name})', () {
testWidgets('Initial entry mode is used', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
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);
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 String helpText = 'help';
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, helpText: helpText);
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 String 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 String hourLabelText = 'Custom hour label';
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, hourLabelText: hourLabelText);
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 String minuteLabelText = 'Custom minute label';
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, minuteLabelText: minuteLabelText);
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 String errorInvalidText = 'Custom validation error';
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, errorInvalidText: errorInvalidText);
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));
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));
......@@ -1039,39 +1146,60 @@ void _testsInput() {
});
testWidgets('Can switch from input to dial entry mode', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
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, true);
await tester.tap(find.byIcon(Icons.keyboard));
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, true, entryMode: TimePickerEntryMode.inputOnly);
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, true, entryMode: TimePickerEntryMode.dialOnly);
await mediaQueryBoilerplate(
tester,
alwaysUse24HourFormat: true,
entryMode: TimePickerEntryMode.dialOnly,
materialType: materialType,
);
expect(find.byType(TextField), findsNothing);
expect(find.byIcon(Icons.keyboard), findsNothing);
expect(find.byIcon(Icons.keyboard_outlined), 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) {
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();
......@@ -1081,19 +1209,19 @@ void _testsInput() {
testWidgets('Switching to input entry mode triggers entry callback', (WidgetTester tester) async {
bool triggeredCallback = false;
await mediaQueryBoilerplate(tester, true, onEntryModeChange: (TimePickerEntryMode mode) {
await mediaQueryBoilerplate(tester, alwaysUse24HourFormat: true, onEntryModeChange: (TimePickerEntryMode mode) {
if (mode == TimePickerEntryMode.input) {
triggeredCallback = true;
}
});
}, materialType: materialType);
await tester.tap(find.byIcon(Icons.keyboard));
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, false);
await mediaQueryBoilerplate(tester, materialType: materialType);
final Finder hourFinder = find.ancestor(
of: find.text('7'),
matching: find.byType(InkWell),
......@@ -1111,7 +1239,7 @@ void _testsInput() {
});
testWidgets('Can not double tap hours (when not selected) to enter input mode', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
await mediaQueryBoilerplate(tester, materialType: materialType);
final Finder hourFinder = find.ancestor(
of: find.text('7'),
matching: find.byType(InkWell),
......@@ -1137,7 +1265,7 @@ void _testsInput() {
});
testWidgets('Can double tap minutes (when selected) to enter input mode', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
await mediaQueryBoilerplate(tester, materialType: materialType);
final Finder minuteFinder = find.ancestor(
of: find.text('00'),
matching: find.byType(InkWell),
......@@ -1159,7 +1287,7 @@ void _testsInput() {
});
testWidgets('Can not double tap minutes (when not selected) to enter input mode', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false);
await mediaQueryBoilerplate(tester, materialType: materialType);
final Finder minuteFinder = find.ancestor(
of: find.text('00'),
matching: find.byType(InkWell),
......@@ -1178,7 +1306,9 @@ void _testsInput() {
testWidgets('Entered text returns time', (WidgetTester tester) async {
late TimeOfDay result;
await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input);
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);
......@@ -1187,7 +1317,9 @@ void _testsInput() {
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 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));
......@@ -1197,7 +1329,9 @@ void _testsInput() {
testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async {
TimeOfDay? result;
await startPicker(tester, (TimeOfDay? time) { result = time; }, entryMode: TimePickerEntryMode.input);
await startPicker(tester, (TimeOfDay? time) {
result = time;
}, entryMode: TimePickerEntryMode.input, materialType: materialType);
// Invalid hour.
await tester.enterText(find.byType(TextField).first, '88');
......@@ -1219,23 +1353,53 @@ void _testsInput() {
// 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;
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}' == '_StringFragment')).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);
});
});
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; },
(TimeOfDay? time) {
result = time;
},
restorationId: 'restorable_time_picker',
materialType: materialType,
))!;
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)
final Offset hour6 = Offset(center.dx, center.dy + 50); // 6:00
final Offset min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours)
await tester.tapAt(hour6);
await tester.pump(const Duration(milliseconds: 50));
......@@ -1245,7 +1409,7 @@ void _testsInput() {
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.tap(find.text(pmString));
await tester.pump(const Duration(milliseconds: 50));
await tester.restartAndRestore();
await finishPicker(tester);
......@@ -1261,9 +1425,12 @@ void _testsInput() {
TimeOfDay? result;
await startPicker(
tester,
(TimeOfDay? time) { result = time; },
(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));
......@@ -1275,7 +1442,7 @@ void _testsInput() {
await tester.restartAndRestore();
// Setting to PM adds 12 hours (21:12)
await tester.tap(find.text('PM'));
await tester.tap(find.text(pmString));
await tester.pump(const Duration(milliseconds: 50));
await tester.restartAndRestore();
......@@ -1292,13 +1459,16 @@ void _testsInput() {
TimeOfDay? result;
final Offset center = (await startPicker(
tester,
(TimeOfDay? time) { result = time; },
(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));
await tester.tap(find.byIcon(Icons.keyboard_outlined));
await tester.pump(const Duration(milliseconds: 50));
await tester.restartAndRestore();
......@@ -1311,8 +1481,8 @@ void _testsInput() {
// 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)
final Offset hour6 = Offset(center.dx, center.dy + 50); // 6:00
final Offset min45 = Offset(center.dx - 50, center.dy); // 45 mins (or 9:00 hours)
await tester.tapAt(hour6);
await tester.pump(const Duration(milliseconds: 50));
......@@ -1322,26 +1492,8 @@ void _testsInput() {
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(
......@@ -1362,10 +1514,10 @@ class PickerObserver extends NavigatorObserver {
}
Future<void> mediaQueryBoilerplate(
WidgetTester tester,
bool alwaysUse24HourFormat, {
WidgetTester tester, {
bool alwaysUse24HourFormat = false,
TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0),
double textScaleFactor = 1.0,
double textScaleFactor = 1,
TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
String? helpText,
String? hourLabelText,
......@@ -1374,9 +1526,14 @@ Future<void> mediaQueryBoilerplate(
bool accessibleNavigation = false,
EntryModeChangeCallback? onEntryModeChange,
bool tapButton = true,
required MaterialType materialType,
Orientation? orientation,
}) async {
await tester.pumpWidget(
Localizations(
Builder(builder: (BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(useMaterial3: materialType == MaterialType.material3),
child: Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
......@@ -1390,6 +1547,7 @@ Future<void> mediaQueryBoilerplate(
size: tester.binding.window.physicalSize / tester.binding.window.devicePixelRatio,
),
child: Material(
child: Center(
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
......@@ -1406,6 +1564,7 @@ Future<void> mediaQueryBoilerplate(
minuteLabelText: minuteLabelText,
errorInvalidText: errorInvalidText,
onEntryModeChanged: onEntryModeChange,
orientation: orientation,
);
},
child: const Text('X'),
......@@ -1417,9 +1576,144 @@ Future<void> mediaQueryBoilerplate(
),
),
),
),
);
}),
);
if (tapButton) {
await tester.tap(find.text('X'));
}
await tester.pumpAndSettle();
}
final Finder _hourControl = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl');
final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_MinuteControl');
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,
});
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, String>{
'entry_mode': widget.entryMode.name,
},
);
},
);
static Route<TimeOfDay> _timePickerRoute(
BuildContext context,
Object? arguments,
) {
final Map<dynamic, dynamic> args = arguments! as Map<dynamic, dynamic>;
final TimePickerEntryMode entryMode = TimePickerEntryMode.values.firstWhere(
(TimePickerEntryMode element) => element.name == args['entry_mode'],
);
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();
}
},
);
},
),
),
);
}
}
// 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,
required MaterialType materialType,
}) async {
await tester.pumpWidget(MaterialApp(
theme: ThemeData(useMaterial3: materialType == MaterialType.material3),
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));
}
......@@ -75,21 +75,21 @@ void main() {
expect(description, <String>[
'backgroundColor: Color(0xffffffff)',
'hourMinuteTextColor: Color(0xffffffff)',
'hourMinuteColor: Color(0xffffffff)',
'dayPeriodTextColor: Color(0xffffffff)',
'dayPeriodBorderSide: BorderSide',
'dayPeriodColor: Color(0xffffffff)',
'dialHandColor: Color(0xffffffff)',
'dayPeriodShape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)',
'dayPeriodTextColor: Color(0xffffffff)',
'dayPeriodTextStyle: TextStyle(<all styles inherited>)',
'dialBackgroundColor: Color(0xffffffff)',
'dialHandColor: Color(0xffffffff)',
'dialTextColor: Color(0xffffffff)',
'entryModeIconColor: Color(0xffffffff)',
'hourMinuteTextStyle: TextStyle(<all styles inherited>)',
'dayPeriodTextStyle: TextStyle(<all styles inherited>)',
'helpTextStyle: TextStyle(<all styles inherited>)',
'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)',
'hourMinuteColor: Color(0xffffffff)',
'hourMinuteShape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)',
'dayPeriodShape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)',
'dayPeriodBorderSide: BorderSide',
'hourMinuteTextColor: Color(0xffffffff)',
'hourMinuteTextStyle: TextStyle(<all styles inherited>)',
'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)'
]);
});
......@@ -104,10 +104,11 @@ void main() {
expect(dialogMaterial.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))));
final RenderBox dial = tester.firstRenderObject<RenderBox>(find.byType(CustomPaint));
debugPrint('Color: ${defaultTheme.colorScheme.onSurface.withOpacity(0.08)}');
expect(
dial,
paints
..circle(color: defaultTheme.colorScheme.onBackground.withOpacity(0.12)) // Dial background color.
..circle(color: defaultTheme.colorScheme.onSurface.withOpacity(0.08)) // Dial background color.
..circle(color: Color(defaultTheme.colorScheme.primary.value)), // Dial hand color.
);
......@@ -162,10 +163,10 @@ void main() {
.copyWith(color: defaultTheme.colorScheme.onSurface),
);
// ignore: avoid_dynamic_calls
final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
expect(
// ignore: avoid_dynamic_calls
secondaryLabels.first.painter.text.style,
selectedLabels.first.painter.text.style,
Typography.material2014().englishLike.bodyLarge!
.merge(Typography.material2014().white.bodyLarge)
.copyWith(color: defaultTheme.colorScheme.onPrimary),
......@@ -186,7 +187,7 @@ void main() {
expect(pmMaterial.color, Colors.transparent);
final Color expectedBorderColor = Color.alphaBlend(
defaultTheme.colorScheme.onBackground.withOpacity(0.38),
defaultTheme.colorScheme.onSurface.withOpacity(0.38),
defaultTheme.colorScheme.surface,
);
final Material dayPeriodMaterial = _dayPeriodMaterial(tester);
......@@ -220,7 +221,7 @@ void main() {
final InputDecoration hourDecoration = _textField(tester, '7').decoration!;
expect(hourDecoration.filled, true);
expect(hourDecoration.fillColor, defaultTheme.colorScheme.onSurface.withOpacity(0.12));
expect(hourDecoration.fillColor, MaterialStateColor.resolveWith((Set<MaterialState> states) => defaultTheme.colorScheme.onSurface.withOpacity(0.12)));
expect(hourDecoration.enabledBorder, const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)));
expect(hourDecoration.errorBorder, OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2)));
expect(hourDecoration.focusedBorder, OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.primary, width: 2)));
......@@ -307,10 +308,10 @@ void main() {
.copyWith(color: _unselectedColor),
);
// ignore: avoid_dynamic_calls
final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
expect(
// ignore: avoid_dynamic_calls
secondaryLabels.first.painter.text.style,
selectedLabels.first.painter.text.style,
Typography.material2014().englishLike.bodyLarge!
.merge(Typography.material2014().white.bodyLarge)
.copyWith(color: _selectedColor),
......
......@@ -6,62 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
class _TimePickerLauncher extends StatelessWidget {
const _TimePickerLauncher({
this.onChanged,
required this.locale,
this.entryMode = TimePickerEntryMode.dial,
});
final ValueChanged<TimeOfDay?>? onChanged;
final Locale locale;
final TimePickerEntryMode entryMode;
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: locale,
supportedLocales: <Locale>[locale],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: Material(
child: Center(
child: Builder(
builder: (BuildContext context) {
return ElevatedButton(
child: const Text('X'),
onPressed: () async {
onChanged?.call(await showTimePicker(
context: context,
initialEntryMode: entryMode,
initialTime: const TimeOfDay(hour: 7, minute: 0),
));
},
);
}
),
),
),
);
}
}
Future<Offset> startPicker(
WidgetTester tester,
ValueChanged<TimeOfDay?> onChanged, {
Locale locale = const Locale('en', 'US'),
}) async {
await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: locale,));
await tester.tap(find.text('X'));
await tester.pumpAndSettle(const Duration(seconds: 1));
return tester.getCenter(find.byKey(const Key('time-picker-dial')));
}
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() {
testWidgets('can localize the header in all known formats - portrait', (WidgetTester tester) async {
// Ensure picker is displayed in portrait mode.
......@@ -213,13 +157,13 @@ void main() {
});
testWidgets('can localize input mode in all known formats', (WidgetTester tester) async {
final Finder hourControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField');
final Finder minuteControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField');
final Finder dayPeriodControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl');
final Finder stringFragmentTextFinder = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_StringFragment'),
matching: find.byType(Text),
).first;
final Finder hourControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField');
final Finder minuteControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField');
final Finder dayPeriodControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl');
// TODO(yjbanov): also test `HH.mm` (in_ID), `a h:mm` (ko_KR) and `HH:mm น.` (th_TH) when we have .arb files for them
final List<Locale> locales = <Locale>[
......@@ -276,6 +220,7 @@ void main() {
expect(dayPeriodControlFinder, findsNothing);
}
await finishPicker(tester);
expect(tester.takeException(), isNot(throwsFlutterError));
}
});
......@@ -353,10 +298,10 @@ void main() {
);
// ignore: avoid_dynamic_calls
final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
expect(
// ignore: avoid_dynamic_calls
secondaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!),
selectedLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!),
labels12To11,
);
});
......@@ -375,11 +320,72 @@ void main() {
);
// ignore: avoid_dynamic_calls
final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
final List<dynamic> selectedLabels = dialPainter.selectedLabels as List<dynamic>;
expect(
// ignore: avoid_dynamic_calls
secondaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!),
selectedLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!),
labels00To22TwoDigit,
);
});
}
class _TimePickerLauncher extends StatelessWidget {
const _TimePickerLauncher({
this.onChanged,
required this.locale,
this.entryMode = TimePickerEntryMode.dial,
});
final ValueChanged<TimeOfDay?>? onChanged;
final Locale locale;
final TimePickerEntryMode entryMode;
@override
Widget build(BuildContext context) {
return MaterialApp(
locale: locale,
supportedLocales: <Locale>[locale],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: Material(
child: Center(
child: Builder(
builder: (BuildContext context) {
return ElevatedButton(
child: const Text('X'),
onPressed: () async {
onChanged?.call(await showTimePicker(
context: context,
initialEntryMode: entryMode,
initialTime: const TimeOfDay(hour: 7, minute: 0),
));
},
);
}
),
),
),
);
}
}
Future<Offset> startPicker(
WidgetTester tester,
ValueChanged<TimeOfDay?> onChanged, {
Locale locale = const Locale('en', 'US'),
}) async {
await tester.pumpWidget(
_TimePickerLauncher(
onChanged: onChanged,
locale: locale,
),
);
await tester.tap(find.text('X'));
await tester.pumpAndSettle(const Duration(seconds: 1));
return tester.getCenter(find.byKey(const Key('time-picker-dial')));
}
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));
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment