diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index f03fa509d62..6bda89e0a56 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -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 _readTokenFile(String fileName) { @@ -167,6 +168,7 @@ Future main(List 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(); diff --git a/dev/tools/gen_defaults/lib/time_picker_template.dart b/dev/tools/gen_defaults/lib/time_picker_template.dart new file mode 100644 index 00000000000..ee15f185199 --- /dev/null +++ b/dev/tools/gen_defaults/lib/time_picker_template.dart @@ -0,0 +1,349 @@ +// 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 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 states) { + return _dayPeriodForegroundColor.resolve(states); + }); + } + + MaterialStateProperty get _dayPeriodForegroundColor { + return MaterialStateProperty.resolveWith((Set 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 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 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 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 states) { + return _hourMinuteTextColor.resolve(states); + }); + } + + MaterialStateProperty get _hourMinuteTextColor { + return MaterialStateProperty.resolveWith((Set 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 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")}; + } +} +'''; +} diff --git a/examples/api/lib/material/time_picker/show_time_picker.0.dart b/examples/api/lib/material/time_picker/show_time_picker.0.dart new file mode 100644 index 00000000000..02401a6fcb9 --- /dev/null +++ b/examples/api/lib/material/time_picker/show_time_picker.0.dart @@ -0,0 +1,357 @@ +// 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 createState() => _ShowTimePickerAppState(); +} + +class _ShowTimePickerAppState extends State { + 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 setThemeMode; + final ValueChanged setUseMaterial3; + + @override + State createState() => _TimePickerOptionsState(); +} + +class _TimePickerOptionsState extends State { + 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: [ + Expanded( + child: GridView( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 350, + mainAxisSpacing: 4, + mainAxisExtent: 200 * MediaQuery.textScaleFactorOf(context), + crossAxisSpacing: 4, + ), + children: [ + EnumCard( + choices: TimePickerEntryMode.values, + value: entryMode, + onChanged: _entryModeChanged, + ), + EnumCard( + choices: ThemeMode.values, + value: widget.themeMode, + onChanged: _themeModeChanged, + ), + EnumCard( + choices: TextDirection.values, + value: textDirection, + onChanged: _textDirectionChanged, + ), + EnumCard( + choices: MaterialTapTargetSize.values, + value: tapTargetSize, + onChanged: _tapTargetSizeChanged, + ), + ChoiceCard( + choices: const [...Orientation.values, null], + value: orientation, + title: '$Orientation', + choiceLabels: { + for (final Orientation choice in Orientation.values) choice: choice.name, + null: 'from MediaQuery', + }, + onChanged: _orientationChanged, + ), + ChoiceCard( + choices: const [false, true], + value: use24HourTime, + onChanged: _use24HourTimeChanged, + title: 'Time Mode', + choiceLabels: const { + false: '12-hour am/pm time', + true: '24-hour time', + }, + ), + ChoiceCard( + choices: const [false, true], + value: widget.useMaterial3, + onChanged: widget.setUseMaterial3, + title: 'Material Version', + choiceLabels: const { + false: 'Material 2', + true: 'Material 3', + }, + ), + ], + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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 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 choices; + final Map choiceLabels; + final String title; + final ValueChanged 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: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text(title), + ), + for (final T choice in choices) + RadioSelection( + 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 extends StatelessWidget { + const EnumCard({ + super.key, + required this.value, + required this.choices, + required this.onChanged, + }); + + final T value; + final Iterable choices; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return ChoiceCard( + value: value, + choices: choices, + onChanged: onChanged, + choiceLabels: { + 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 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 onChanged; + final Widget child; + + @override + State> createState() => _RadioSelectionState(); +} + +class _RadioSelectionState extends State> { + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Radio( + groupValue: widget.groupValue, + value: widget.value, + onChanged: widget.onChanged, + ), + ), + GestureDetector(onTap: () => widget.onChanged(widget.value), child: widget.child), + ], + ); + } +} diff --git a/examples/api/test/material/time_picker/show_time_picker.0_test.dart b/examples/api/test/material/time_picker/show_time_picker.0_test.dart new file mode 100644 index 00000000000..0c69ab95274 --- /dev/null +++ b/examples/api/test/material/time_picker/show_time_picker.0_test.dart @@ -0,0 +1,67 @@ +// 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 options = [ + '$TimePickerEntryMode', + ... TimePickerEntryMode.values.map((TimePickerEntryMode value) => value.name), + '$ThemeMode', + ... ThemeMode.values.map((ThemeMode value) => value.name), + '$TextDirection', + ... TextDirection.values.map((TextDirection value) => value.name), + '$MaterialTapTargetSize', + ... MaterialTapTargetSize.values.map((MaterialTapTargetSize value) => value.name), + '$Orientation', + ... Orientation.values.map((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); + }); +} diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index bc9f517ef82..d12ffe82fd2 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -4,14 +4,15 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:ui'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'button_style.dart'; import 'color_scheme.dart'; import 'colors.dart'; -import 'constants.dart'; import 'curves.dart'; import 'debug.dart'; import 'dialog.dart'; @@ -40,30 +41,22 @@ const Duration _kDialAnimateDuration = Duration(milliseconds: 200); const double _kTwoPi = 2 * math.pi; const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); -enum _TimePickerMode { hour, minute } - -const double _kTimePickerHeaderLandscapeWidth = 264.0; -const double _kTimePickerHeaderControlHeight = 80.0; - -const double _kTimePickerWidthPortrait = 328.0; -const double _kTimePickerWidthLandscape = 528.0; - -const double _kTimePickerHeightInput = 226.0; -const double _kTimePickerHeightPortrait = 496.0; -const double _kTimePickerHeightLandscape = 316.0; - -const double _kTimePickerHeightPortraitCollapsed = 484.0; -const double _kTimePickerHeightLandscapeCollapsed = 304.0; - -const BorderRadius _kDefaultBorderRadius = BorderRadius.all(Radius.circular(4.0)); -const ShapeBorder _kDefaultShape = RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); +const double _kTimePickerHeaderLandscapeWidth = 216; +const double _kTimePickerInnerDialOffset = 28; +const double _kTimePickerDialMinRadius = 50; +const double _kTimePickerDialPadding = 28; /// Interactive input mode of the time picker dialog. /// -/// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and -/// the user taps or drags the time they wish to select. In -/// TimePickerEntryMode.input] mode, [TextField]s are displayed and the user -/// types in the time they wish to select. +/// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and the user +/// taps or drags the time they wish to select. In TimePickerEntryMode.input] +/// mode, [TextField]s are displayed and the user types in the time they wish to +/// select. +/// +/// See also: +/// +/// * [showTimePicker], a function that shows a [TimePickerDialog] and returns +/// the selected time as a [Future]. enum TimePickerEntryMode { /// User picks time from a clock dial. /// @@ -86,186 +79,217 @@ enum TimePickerEntryMode { inputOnly } -/// Provides properties for rendering time picker header fragments. -@immutable -class _TimePickerFragmentContext { - const _TimePickerFragmentContext({ - required this.selectedTime, - required this.mode, - required this.onTimeChange, - required this.onModeChange, - required this.onHourDoubleTapped, - required this.onMinuteDoubleTapped, - required this.use24HourDials, - }) : assert(selectedTime != null), - assert(mode != null), - assert(onTimeChange != null), - assert(onModeChange != null), - assert(use24HourDials != null); +// Whether the dial-mode time picker is currently selecting the hour or the +// minute. +enum _HourMinuteMode { hour, minute } - final TimeOfDay selectedTime; - final _TimePickerMode mode; - final ValueChanged onTimeChange; - final ValueChanged<_TimePickerMode> onModeChange; - final GestureTapCallback onHourDoubleTapped; - final GestureTapCallback onMinuteDoubleTapped; - final bool use24HourDials; +// Aspects of _TimePickerModel that can be depended upon. +enum _TimePickerAspect { + use24HourFormat, + useMaterial3, + entryMode, + hourMinuteMode, + onHourMinuteModeChanged, + onHourDoubleTapped, + onMinuteDoubleTapped, + hourDialType, + selectedTime, + onSelectedTimeChanged, + orientation, + theme, + defaultTheme, } -class _TimePickerHeader extends StatelessWidget { - const _TimePickerHeader({ - required this.selectedTime, - required this.mode, - required this.orientation, - required this.onModeChanged, - required this.onChanged, +class _TimePickerModel extends InheritedModel<_TimePickerAspect> { + const _TimePickerModel({ + required this.entryMode, + required this.hourMinuteMode, + required this.onHourMinuteModeChanged, required this.onHourDoubleTapped, required this.onMinuteDoubleTapped, - required this.use24HourDials, - required this.helpText, - }) : assert(selectedTime != null), - assert(mode != null), - assert(orientation != null), - assert(use24HourDials != null); + required this.selectedTime, + required this.onSelectedTimeChanged, + required this.use24HourFormat, + required this.useMaterial3, + required this.hourDialType, + required this.orientation, + required this.theme, + required this.defaultTheme, + required super.child, + }); - final TimeOfDay selectedTime; - final _TimePickerMode mode; - final Orientation orientation; - final ValueChanged<_TimePickerMode> onModeChanged; - final ValueChanged onChanged; + final TimePickerEntryMode entryMode; + final _HourMinuteMode hourMinuteMode; + final ValueChanged<_HourMinuteMode> onHourMinuteModeChanged; final GestureTapCallback onHourDoubleTapped; final GestureTapCallback onMinuteDoubleTapped; - final bool use24HourDials; - final String? helpText; + final TimeOfDay selectedTime; + final ValueChanged onSelectedTimeChanged; + final bool use24HourFormat; + final bool useMaterial3; + final _HourDialType hourDialType; + final Orientation orientation; + final TimePickerThemeData theme; + final _TimePickerDefaults defaultTheme; - void _handleChangeMode(_TimePickerMode value) { - if (value != mode) { - onModeChanged(value); + static _TimePickerModel of(BuildContext context, [_TimePickerAspect? aspect]) => InheritedModel.inheritFrom<_TimePickerModel>(context, aspect: aspect)!; + static TimePickerEntryMode entryModeOf(BuildContext context) => of(context, _TimePickerAspect.entryMode).entryMode; + static _HourMinuteMode hourMinuteModeOf(BuildContext context) => of(context, _TimePickerAspect.hourMinuteMode).hourMinuteMode; + static TimeOfDay selectedTimeOf(BuildContext context) => of(context, _TimePickerAspect.selectedTime).selectedTime; + static bool use24HourFormatOf(BuildContext context) => of(context, _TimePickerAspect.use24HourFormat).use24HourFormat; + static bool useMaterial3Of(BuildContext context) => of(context, _TimePickerAspect.useMaterial3).useMaterial3; + static _HourDialType hourDialTypeOf(BuildContext context) => of(context, _TimePickerAspect.hourDialType).hourDialType; + static Orientation orientationOf(BuildContext context) => of(context, _TimePickerAspect.orientation).orientation; + static TimePickerThemeData themeOf(BuildContext context) => of(context, _TimePickerAspect.theme).theme; + static _TimePickerDefaults defaultThemeOf(BuildContext context) => of(context, _TimePickerAspect.defaultTheme).defaultTheme; + + static void setSelectedTime(BuildContext context, TimeOfDay value) => of(context, _TimePickerAspect.onSelectedTimeChanged).onSelectedTimeChanged(value); + static void setHourMinuteMode(BuildContext context, _HourMinuteMode value) => of(context, _TimePickerAspect.onHourMinuteModeChanged).onHourMinuteModeChanged(value); + + @override + bool updateShouldNotifyDependent(_TimePickerModel oldWidget, Set<_TimePickerAspect> dependencies) { + if (use24HourFormat != oldWidget.use24HourFormat && dependencies.contains(_TimePickerAspect.use24HourFormat)) { + return true; } + if (useMaterial3 != oldWidget.useMaterial3 && dependencies.contains(_TimePickerAspect.useMaterial3)) { + return true; + } + if (entryMode != oldWidget.entryMode && dependencies.contains(_TimePickerAspect.entryMode)) { + return true; + } + if (hourMinuteMode != oldWidget.hourMinuteMode && dependencies.contains(_TimePickerAspect.hourMinuteMode)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged && dependencies.contains(_TimePickerAspect.onHourMinuteModeChanged)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onHourDoubleTapped && dependencies.contains(_TimePickerAspect.onHourDoubleTapped)) { + return true; + } + if (onHourMinuteModeChanged != oldWidget.onMinuteDoubleTapped && dependencies.contains(_TimePickerAspect.onMinuteDoubleTapped)) { + return true; + } + if (hourDialType != oldWidget.hourDialType && dependencies.contains(_TimePickerAspect.hourDialType)) { + return true; + } + if (selectedTime != oldWidget.selectedTime && dependencies.contains(_TimePickerAspect.selectedTime)) { + return true; + } + if (onSelectedTimeChanged != oldWidget.onSelectedTimeChanged && dependencies.contains(_TimePickerAspect.onSelectedTimeChanged)) { + return true; + } + if (orientation != oldWidget.orientation && dependencies.contains(_TimePickerAspect.orientation)) { + return true; + } + if (theme != oldWidget.theme && dependencies.contains(_TimePickerAspect.theme)) { + return true; + } + if (defaultTheme != oldWidget.defaultTheme && dependencies.contains(_TimePickerAspect.defaultTheme)) { + return true; + } + return false; } + @override + bool updateShouldNotify(_TimePickerModel oldWidget) { + return use24HourFormat != oldWidget.use24HourFormat + || useMaterial3 != oldWidget.useMaterial3 + || entryMode != oldWidget.entryMode + || hourMinuteMode != oldWidget.hourMinuteMode + || onHourMinuteModeChanged != oldWidget.onHourMinuteModeChanged + || onHourDoubleTapped != oldWidget.onHourDoubleTapped + || onMinuteDoubleTapped != oldWidget.onMinuteDoubleTapped + || hourDialType != oldWidget.hourDialType + || selectedTime != oldWidget.selectedTime + || onSelectedTimeChanged != oldWidget.onSelectedTimeChanged + || orientation != oldWidget.orientation + || theme != oldWidget.theme + || defaultTheme != oldWidget.defaultTheme; + } +} + +class _TimePickerHeader extends StatelessWidget { + const _TimePickerHeader({ required this.helpText }); + + final String helpText; + @override Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - final ThemeData themeData = Theme.of(context); final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat( - alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - ); - final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final String timePickerDialHelpText = themeData.useMaterial3 - ? localizations.timePickerDialHelpText - : localizations.timePickerDialHelpText.toUpperCase(); - - final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext( - selectedTime: selectedTime, - mode: mode, - onTimeChange: onChanged, - onModeChange: _handleChangeMode, - onHourDoubleTapped: onHourDoubleTapped, - onMinuteDoubleTapped: onMinuteDoubleTapped, - use24HourDials: use24HourDials, + alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context), ); - final EdgeInsets padding; - double? width; - final Widget controls; - - switch (orientation) { + final _HourDialType hourDialType = _TimePickerModel.hourDialTypeOf(context); + switch (_TimePickerModel.orientationOf(context)) { case Orientation.portrait: - // Keep width null because in portrait we don't cap the width. - padding = const EdgeInsets.symmetric(horizontal: 24.0); - controls = Column( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 16.0), - SizedBox( - height: kMinInteractiveDimension * 2, - child: Row( - children: [ - if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ - _DayPeriodControl( - selectedTime: selectedTime, - orientation: orientation, - onChanged: onChanged, - ), - const SizedBox(width: 12.0), + Padding(padding: EdgeInsetsDirectional.only(bottom: _TimePickerModel.useMaterial3Of(context) ? 20 : 24), + child: Text( + helpText, + style: _TimePickerModel.themeOf(context).helpTextStyle ?? _TimePickerModel.defaultThemeOf(context).helpTextStyle, + ), + ), + Row( + children: [ + if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) + const _DayPeriodControl(), + Expanded( + child: Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: [ + const Expanded(child: _HourControl()), + _StringFragment(timeOfDayFormat: timeOfDayFormat), + const Expanded(child: _MinuteControl()), + ], + ), + ), + if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) + ...[ + const SizedBox(width: 12), + const _DayPeriodControl(), ], - Expanded( + ], + ), + ], + ); + case Orientation.landscape: + return SizedBox( + width: _kTimePickerHeaderLandscapeWidth, + child: Stack( + children: [ + Text( + helpText, + style: _TimePickerModel.themeOf(context).helpTextStyle ?? _TimePickerModel.defaultThemeOf(context).helpTextStyle, + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) + const _DayPeriodControl(), + Padding( + padding: EdgeInsets.only(bottom: hourDialType == _HourDialType.twelveHour ? 12 : 0), child: Row( // Hour/minutes should not change positions in RTL locales. textDirection: TextDirection.ltr, children: [ - Expanded(child: _HourControl(fragmentContext: fragmentContext)), + const Expanded(child: _HourControl()), _StringFragment(timeOfDayFormat: timeOfDayFormat), - Expanded(child: _MinuteControl(fragmentContext: fragmentContext)), + const Expanded(child: _MinuteControl()), ], ), ), - if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ - const SizedBox(width: 12.0), - _DayPeriodControl( - selectedTime: selectedTime, - orientation: orientation, - onChanged: onChanged, - ), - ], + if (hourDialType == _HourDialType.twelveHour && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) + const _DayPeriodControl(), ], ), - ), - ], - ); - break; - case Orientation.landscape: - width = _kTimePickerHeaderLandscapeWidth; - padding = const EdgeInsets.symmetric(horizontal: 24.0); - controls = Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) - _DayPeriodControl( - selectedTime: selectedTime, - orientation: orientation, - onChanged: onChanged, - ), - SizedBox( - height: kMinInteractiveDimension * 2, - child: Row( - // Hour/minutes should not change positions in RTL locales. - textDirection: TextDirection.ltr, - children: [ - Expanded(child: _HourControl(fragmentContext: fragmentContext)), - _StringFragment(timeOfDayFormat: timeOfDayFormat), - Expanded(child: _MinuteControl(fragmentContext: fragmentContext)), - ], - ), - ), - if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) - _DayPeriodControl( - selectedTime: selectedTime, - orientation: orientation, - onChanged: onChanged, - ), ], ), ); - break; } - - return Container( - width: width, - padding: padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 16.0), - Text( - helpText ?? timePickerDialHelpText, - style: TimePickerTheme.of(context).helpTextStyle ?? themeData.textTheme.labelSmall, - ), - controls, - ], - ), - ); } } @@ -275,9 +299,7 @@ class _HourMinuteControl extends StatelessWidget { required this.onTap, required this.onDoubleTap, required this.isSelected, - }) : assert(text != null), - assert(onTap != null), - assert(isSelected != null); + }); final String text; final GestureTapCallback onTap; @@ -286,27 +308,37 @@ class _HourMinuteControl extends StatelessWidget { @override Widget build(BuildContext context) { - final ThemeData themeData = Theme.of(context); - final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); - final bool isDark = themeData.colorScheme.brightness == Brightness.dark; - final Color textColor = timePickerTheme.hourMinuteTextColor - ?? MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.selected) - ? themeData.colorScheme.primary - : themeData.colorScheme.onSurface; - }); - final Color backgroundColor = timePickerTheme.hourMinuteColor - ?? MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.selected) - ? themeData.colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12) - : themeData.colorScheme.onSurface.withOpacity(0.12); - }); - final TextStyle style = timePickerTheme.hourMinuteTextStyle ?? themeData.textTheme.displayMedium!; - final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape; + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final Color backgroundColor = timePickerTheme.hourMinuteColor ?? defaultTheme.hourMinuteColor; + final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? defaultTheme.hourMinuteShape; + + final Set states = { + if (isSelected) MaterialState.selected, + }; + final Color effectiveTextColor = MaterialStateProperty.resolveAs( + _TimePickerModel.themeOf(context).hourMinuteTextColor ?? _TimePickerModel.defaultThemeOf(context).hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = MaterialStateProperty.resolveAs( + timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle, + states, + ).copyWith(color: effectiveTextColor); + + final double height; + switch (_TimePickerModel.entryModeOf(context)) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + height = defaultTheme.hourMinuteSize.height; + break; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + height = defaultTheme.hourMinuteInputSize.height; + break; + } - final Set states = isSelected ? {MaterialState.selected} : {}; return SizedBox( - height: _kTimePickerHeaderControlHeight, + height: height, child: Material( color: MaterialStateProperty.resolveAs(backgroundColor, states), clipBehavior: Clip.antiAlias, @@ -317,8 +349,8 @@ class _HourMinuteControl extends StatelessWidget { child: Center( child: Text( text, - style: style.copyWith(color: MaterialStateProperty.resolveAs(textColor, states)), - textScaleFactor: 1.0, + style: effectiveStyle, + textScaleFactor: 1, ), ), ), @@ -326,39 +358,39 @@ class _HourMinuteControl extends StatelessWidget { ); } } + /// Displays the hour fragment. /// -/// When tapped changes time picker dial mode to [_TimePickerMode.hour]. +/// When tapped changes time picker dial mode to [_HourMinuteMode.hour]. class _HourControl extends StatelessWidget { - const _HourControl({ - required this.fragmentContext, - }); - - final _TimePickerFragmentContext fragmentContext; + const _HourControl(); @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final String formattedHour = localizations.formatHour( - fragmentContext.selectedTime, - alwaysUse24HourFormat: alwaysUse24HourFormat, + selectedTime, + alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context), ); TimeOfDay hoursFromSelected(int hoursToAdd) { - if (fragmentContext.use24HourDials) { - final int selectedHour = fragmentContext.selectedTime.hour; - return fragmentContext.selectedTime.replacing( - hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay, - ); - } else { - // Cycle 1 through 12 without changing day period. - final int periodOffset = fragmentContext.selectedTime.periodOffset; - final int hours = fragmentContext.selectedTime.hourOfPeriod; - return fragmentContext.selectedTime.replacing( - hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, - ); + switch (_TimePickerModel.hourDialTypeOf(context)) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + final int selectedHour = selectedTime.hour; + return selectedTime.replacing( + hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay, + ); + case _HourDialType.twelveHour: + // Cycle 1 through 12 without changing day period. + final int periodOffset = selectedTime.periodOffset; + final int hours = selectedTime.hourOfPeriod; + return selectedTime.replacing( + hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, + ); } } @@ -378,27 +410,27 @@ class _HourControl extends StatelessWidget { excludeSemantics: true, increasedValue: formattedNextHour, onIncrease: () { - fragmentContext.onTimeChange(nextHour); + _TimePickerModel.setSelectedTime(context, nextHour); }, decreasedValue: formattedPreviousHour, onDecrease: () { - fragmentContext.onTimeChange(previousHour); + _TimePickerModel.setSelectedTime(context, previousHour); }, child: _HourMinuteControl( - isSelected: fragmentContext.mode == _TimePickerMode.hour, + isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.hour, text: formattedHour, - onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context)!, - onDoubleTap: fragmentContext.onHourDoubleTapped, + onTap: Feedback.wrapForTap(() => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.hour), context)!, + onDoubleTap: _TimePickerModel.of(context, _TimePickerAspect.onHourDoubleTapped).onHourDoubleTapped, ), ); } } /// A passive fragment showing a string value. +/// +/// Used to display the appropriate separator between the input fields. class _StringFragment extends StatelessWidget { - const _StringFragment({ - required this.timeOfDayFormat, - }); + const _StringFragment({ required this.timeOfDayFormat }); final TimeOfDayFormat timeOfDayFormat; @@ -420,18 +452,39 @@ class _StringFragment extends StatelessWidget { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); - final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? theme.textTheme.displayMedium!; - final Color textColor = timePickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface; + final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + final Set states = {}; + + final Color effectiveTextColor = MaterialStateProperty.resolveAs( + timePickerTheme.hourMinuteTextColor ?? defaultTheme.hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = MaterialStateProperty.resolveAs( + timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle, + states, + ).copyWith(color: effectiveTextColor); + + final double height; + switch (_TimePickerModel.entryModeOf(context)) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + height = defaultTheme.hourMinuteSize.height; + break; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + height = defaultTheme.hourMinuteInputSize.height; + break; + } return ExcludeSemantics( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6.0), - child: Center( - child: Text( - _stringFragmentValue(timeOfDayFormat), - style: hourMinuteStyle.apply(color: MaterialStateProperty.resolveAs(textColor, {})), - textScaleFactor: 1.0, - ), + child: SizedBox( + width: timeOfDayFormat == TimeOfDayFormat.frenchCanadian ? 36 : 24, + height: height, + child: Text( + _stringFragmentValue(timeOfDayFormat), + style: effectiveStyle, + textScaleFactor: 1, + textAlign: TextAlign.center, ), ), ); @@ -440,24 +493,21 @@ class _StringFragment extends StatelessWidget { /// Displays the minute fragment. /// -/// When tapped changes time picker dial mode to [_TimePickerMode.minute]. +/// When tapped changes time picker dial mode to [_HourMinuteMode.minute]. class _MinuteControl extends StatelessWidget { - const _MinuteControl({ - required this.fragmentContext, - }); - - final _TimePickerFragmentContext fragmentContext; + const _MinuteControl(); @override Widget build(BuildContext context) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime); - final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing( - minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour, + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); + final String formattedMinute = localizations.formatMinute(selectedTime); + final TimeOfDay nextMinute = selectedTime.replacing( + minute: (selectedTime.minute + 1) % TimeOfDay.minutesPerHour, ); final String formattedNextMinute = localizations.formatMinute(nextMinute); - final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing( - minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour, + final TimeOfDay previousMinute = selectedTime.replacing( + minute: (selectedTime.minute - 1) % TimeOfDay.minutesPerHour, ); final String formattedPreviousMinute = localizations.formatMinute(previousMinute); @@ -466,43 +516,42 @@ class _MinuteControl extends StatelessWidget { value: '${localizations.timePickerMinuteModeAnnouncement} $formattedMinute', increasedValue: formattedNextMinute, onIncrease: () { - fragmentContext.onTimeChange(nextMinute); + _TimePickerModel.setSelectedTime(context, nextMinute); }, decreasedValue: formattedPreviousMinute, onDecrease: () { - fragmentContext.onTimeChange(previousMinute); + _TimePickerModel.setSelectedTime(context, previousMinute); }, child: _HourMinuteControl( - isSelected: fragmentContext.mode == _TimePickerMode.minute, + isSelected: _TimePickerModel.hourMinuteModeOf(context) == _HourMinuteMode.minute, text: formattedMinute, - onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context)!, - onDoubleTap: fragmentContext.onMinuteDoubleTapped, + onTap: Feedback.wrapForTap(() => _TimePickerModel.setHourMinuteMode(context, _HourMinuteMode.minute), context)!, + onDoubleTap: _TimePickerModel.of(context, _TimePickerAspect.onMinuteDoubleTapped).onMinuteDoubleTapped, ), ); } } - /// Displays the am/pm fragment and provides controls for switching between am /// and pm. class _DayPeriodControl extends StatelessWidget { - const _DayPeriodControl({ - required this.selectedTime, - required this.onChanged, - required this.orientation, - }); + const _DayPeriodControl({ this.onPeriodChanged }); - final TimeOfDay selectedTime; - final Orientation orientation; - final ValueChanged onChanged; + final ValueChanged? onPeriodChanged; - void _togglePeriod() { + void _togglePeriod(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; final TimeOfDay newTime = selectedTime.replacing(hour: newHour); - onChanged(newTime); + if (onPeriodChanged != null) { + onPeriodChanged!.call(newTime); + } else { + _TimePickerModel.setSelectedTime(context, newTime); + } } void _setAm(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); if (selectedTime.period == DayPeriod.am) { return; } @@ -517,10 +566,11 @@ class _DayPeriodControl extends StatelessWidget { case TargetPlatform.macOS: break; } - _togglePeriod(); + _togglePeriod(context); } void _setPm(BuildContext context) { + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); if (selectedTime.period == DayPeriod.pm) { return; } @@ -535,113 +585,72 @@ class _DayPeriodControl extends StatelessWidget { case TargetPlatform.macOS: break; } - _togglePeriod(); + _togglePeriod(context); } @override Widget build(BuildContext context) { final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); - final ColorScheme colorScheme = Theme.of(context).colorScheme; - final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); - final bool isDark = colorScheme.brightness == Brightness.dark; - final Color textColor = timePickerTheme.dayPeriodTextColor - ?? MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.selected) - ? colorScheme.primary - : colorScheme.onSurface.withOpacity(0.60); - }); - final Color backgroundColor = timePickerTheme.dayPeriodColor - ?? MaterialStateColor.resolveWith((Set states) { - // 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 states.contains(MaterialState.selected) - ? colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12) - : Colors.transparent; - }); + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final TimeOfDay selectedTime = _TimePickerModel.selectedTimeOf(context); final bool amSelected = selectedTime.period == DayPeriod.am; - final Set amStates = amSelected ? {MaterialState.selected} : {}; final bool pmSelected = !amSelected; - final Set pmStates = pmSelected ? {MaterialState.selected} : {}; - final TextStyle textStyle = timePickerTheme.dayPeriodTextStyle ?? Theme.of(context).textTheme.titleMedium!; - final TextStyle amStyle = textStyle.copyWith( - color: MaterialStateProperty.resolveAs(textColor, amStates), - ); - final TextStyle pmStyle = textStyle.copyWith( - color: MaterialStateProperty.resolveAs(textColor, pmStates), - ); - OutlinedBorder shape = timePickerTheme.dayPeriodShape ?? - const RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); - final BorderSide borderSide = timePickerTheme.dayPeriodBorderSide ?? BorderSide( - color: Color.alphaBlend(colorScheme.onBackground.withOpacity(0.38), colorScheme.surface), - ); - // Apply the custom borderSide. - shape = shape.copyWith( - side: borderSide, + final BorderSide resolvedSide = timePickerTheme.dayPeriodBorderSide ?? defaultTheme.dayPeriodBorderSide; + final OutlinedBorder resolvedShape = (timePickerTheme.dayPeriodShape ?? defaultTheme.dayPeriodShape) + .copyWith(side: resolvedSide); + + final Widget amButton = _AmPmButton( + selected: amSelected, + onPressed: () => _setAm(context), + label: materialLocalizations.anteMeridiemAbbreviation, ); - final double buttonTextScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 2.0); - - final Widget amButton = Material( - color: MaterialStateProperty.resolveAs(backgroundColor, amStates), - child: InkWell( - onTap: Feedback.wrapForTap(() => _setAm(context), context), - child: Semantics( - checked: amSelected, - inMutuallyExclusiveGroup: true, - button: true, - child: Center( - child: Text( - materialLocalizations.anteMeridiemAbbreviation, - style: amStyle, - textScaleFactor: buttonTextScaleFactor, - ), - ), - ), - ), + final Widget pmButton = _AmPmButton( + selected: pmSelected, + onPressed: () => _setPm(context), + label: materialLocalizations.postMeridiemAbbreviation, ); - final Widget pmButton = Material( - color: MaterialStateProperty.resolveAs(backgroundColor, pmStates), - child: InkWell( - onTap: Feedback.wrapForTap(() => _setPm(context), context), - child: Semantics( - checked: pmSelected, - inMutuallyExclusiveGroup: true, - button: true, - child: Center( - child: Text( - materialLocalizations.postMeridiemAbbreviation, - style: pmStyle, - textScaleFactor: buttonTextScaleFactor, - ), - ), - ), - ), - ); + Size dayPeriodSize; + final Orientation orientation; + switch (_TimePickerModel.entryModeOf(context)) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + orientation = _TimePickerModel.orientationOf(context); + switch (orientation) { + case Orientation.portrait: + dayPeriodSize = defaultTheme.dayPeriodPortraitSize; + break; + case Orientation.landscape: + dayPeriodSize = defaultTheme.dayPeriodLandscapeSize; + break; + } + break; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + orientation = Orientation.portrait; + dayPeriodSize = defaultTheme.dayPeriodInputSize; + break; + } final Widget result; switch (orientation) { case Orientation.portrait: - const double width = 52.0; result = _DayPeriodInputPadding( - minSize: const Size(width, kMinInteractiveDimension * 2), + minSize: dayPeriodSize, orientation: orientation, - child: SizedBox( - width: width, - height: _kTimePickerHeaderControlHeight, + child: SizedBox.fromSize( + size: dayPeriodSize, child: Material( clipBehavior: Clip.antiAlias, color: Colors.transparent, - shape: shape, + shape: resolvedShape, child: Column( children: [ Expanded(child: amButton), Container( - decoration: BoxDecoration( - border: Border(top: borderSide), - ), + decoration: BoxDecoration(border: Border(top: resolvedSide)), height: 1, ), Expanded(child: pmButton), @@ -653,21 +662,19 @@ class _DayPeriodControl extends StatelessWidget { break; case Orientation.landscape: result = _DayPeriodInputPadding( - minSize: const Size(0.0, kMinInteractiveDimension), + minSize: dayPeriodSize, orientation: orientation, child: SizedBox( - height: 40.0, + height: dayPeriodSize.height, child: Material( clipBehavior: Clip.antiAlias, color: Colors.transparent, - shape: shape, + shape: resolvedShape, child: Row( children: [ Expanded(child: amButton), Container( - decoration: BoxDecoration( - border: Border(left: borderSide), - ), + decoration: BoxDecoration(border: Border(left: resolvedSide)), width: 1, ), Expanded(child: pmButton), @@ -682,6 +689,48 @@ class _DayPeriodControl extends StatelessWidget { } } +class _AmPmButton extends StatelessWidget { + const _AmPmButton({ + required this.onPressed, + required this.selected, + required this.label, + }); + + final bool selected; + final VoidCallback onPressed; + final String label; + + @override + Widget build(BuildContext context) { + final Set states = { if (selected) MaterialState.selected }; + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final Color resolvedBackgroundColor = MaterialStateProperty.resolveAs(timePickerTheme.dayPeriodColor ?? defaultTheme.dayPeriodColor, states); + final Color resolvedTextColor = MaterialStateProperty.resolveAs(timePickerTheme.dayPeriodTextColor ?? defaultTheme.dayPeriodTextColor, states); + final TextStyle? resolvedTextStyle = MaterialStateProperty.resolveAs(timePickerTheme.dayPeriodTextStyle ?? defaultTheme.dayPeriodTextStyle, states)?.copyWith(color: resolvedTextColor); + final double buttonTextScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 2); + + return Material( + color: resolvedBackgroundColor, + child: InkWell( + onTap: Feedback.wrapForTap(onPressed, context), + child: Semantics( + checked: selected, + inMutuallyExclusiveGroup: true, + button: true, + child: Center( + child: Text( + label, + style: resolvedTextStyle, + textScaleFactor: buttonTextScaleFactor, + ), + ), + ), + ), + ); + } +} + /// A widget to pad the area around the [_DayPeriodControl]'s inner [Material]. class _DayPeriodInputPadding extends SingleChildRenderObjectWidget { const _DayPeriodInputPadding({ @@ -734,7 +783,7 @@ class _RenderInputPadding extends RenderShiftedBox { if (child != null) { return math.max(child!.getMinIntrinsicWidth(height), minSize.width); } - return 0.0; + return 0; } @override @@ -742,7 +791,7 @@ class _RenderInputPadding extends RenderShiftedBox { if (child != null) { return math.max(child!.getMinIntrinsicHeight(width), minSize.height); } - return 0.0; + return 0; } @override @@ -750,7 +799,7 @@ class _RenderInputPadding extends RenderShiftedBox { if (child != null) { return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); } - return 0.0; + return 0; } @override @@ -758,7 +807,7 @@ class _RenderInputPadding extends RenderShiftedBox { if (child != null) { return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); } - return 0.0; + return 0; } Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { @@ -797,9 +846,9 @@ class _RenderInputPadding extends RenderShiftedBox { return true; } - if (position.dx < 0.0 || + if (position.dx < 0 || position.dx > math.max(child!.size.width, minSize.width) || - position.dy < 0.0 || + position.dy < 0 || position.dy > math.max(child!.size.height, minSize.height)) { return false; } @@ -808,21 +857,20 @@ class _RenderInputPadding extends RenderShiftedBox { switch (orientation) { case Orientation.portrait: if (position.dy > newPosition.dy) { - newPosition += const Offset(0.0, 1.0); + newPosition += const Offset(0, 1); } else { - newPosition += const Offset(0.0, -1.0); + newPosition += const Offset(0, -1); } break; case Orientation.landscape: if (position.dx > newPosition.dx) { - newPosition += const Offset(1.0, 0.0); + newPosition += const Offset(1, 0); } else { - newPosition += const Offset(-1.0, 0.0); + newPosition += const Offset(-1, 0); } break; } - return result.addWithRawTransform( transform: MatrixUtils.forceToPoint(newPosition), position: newPosition, @@ -837,6 +885,7 @@ class _RenderInputPadding extends RenderShiftedBox { class _TappableLabel { _TappableLabel({ required this.value, + required this.inner, required this.painter, required this.onTap, }); @@ -844,6 +893,10 @@ class _TappableLabel { /// The value this label is displaying. final int value; + /// This value is part of the "inner" ring of values on the dial, used for 24 + /// hour input. + final bool inner; + /// Paints the text of the label. final TextPainter painter; @@ -854,73 +907,88 @@ class _TappableLabel { class _DialPainter extends CustomPainter { _DialPainter({ required this.primaryLabels, - required this.secondaryLabels, + required this.selectedLabels, required this.backgroundColor, - required this.accentColor, + required this.handColor, + required this.handWidth, required this.dotColor, + required this.dotRadius, + required this.centerRadius, required this.theta, + required this.radius, required this.textDirection, required this.selectedValue, }) : super(repaint: PaintingBinding.instance.systemFonts); final List<_TappableLabel> primaryLabels; - final List<_TappableLabel> secondaryLabels; + final List<_TappableLabel> selectedLabels; final Color backgroundColor; - final Color accentColor; + final Color handColor; + final double handWidth; final Color dotColor; + final double dotRadius; + final double centerRadius; final double theta; + final double radius; final TextDirection textDirection; final int selectedValue; - static const double _labelPadding = 28.0; - void dispose() { for (final _TappableLabel label in primaryLabels) { label.painter.dispose(); } - for (final _TappableLabel label in secondaryLabels) { + for (final _TappableLabel label in selectedLabels) { label.painter.dispose(); } primaryLabels.clear(); - secondaryLabels.clear(); + selectedLabels.clear(); } @override void paint(Canvas canvas, Size size) { - final double radius = size.shortestSide / 2.0; - final Offset center = Offset(size.width / 2.0, size.height / 2.0); + final double dialRadius = clampDouble(size.shortestSide / 2, _kTimePickerDialMinRadius + dotRadius, double.infinity); + final double labelRadius = clampDouble(dialRadius - _kTimePickerDialPadding, _kTimePickerDialMinRadius, double.infinity); + final double innerLabelRadius = clampDouble(labelRadius - _kTimePickerInnerDialOffset, 0, double.infinity); + final double handleRadius = clampDouble(labelRadius - (radius < 0.5 ? 1 : 0) * (labelRadius - innerLabelRadius), _kTimePickerDialMinRadius, double.infinity); + final Offset center = Offset(size.width / 2, size.height / 2); final Offset centerPoint = center; - canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); + canvas.drawCircle(centerPoint, dialRadius, Paint()..color = backgroundColor); - final double labelRadius = radius - _labelPadding; - Offset getOffsetForTheta(double theta) { - return center + Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta)); + Offset getOffsetForTheta(double theta, double radius) { + return center + Offset(radius * math.cos(theta), -radius * math.sin(theta)); } - void paintLabels(List<_TappableLabel>? labels) { - if (labels == null) { + void paintLabels(List<_TappableLabel> labels, double radius) { + if (labels.isEmpty) { return; } final double labelThetaIncrement = -_kTwoPi / labels.length; - double labelTheta = math.pi / 2.0; + double labelTheta = math.pi / 2; for (final _TappableLabel label in labels) { final TextPainter labelPainter = label.painter; - final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0); - labelPainter.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset); + final Offset labelOffset = Offset(-labelPainter.width / 2, -labelPainter.height / 2); + labelPainter.paint(canvas, getOffsetForTheta(labelTheta, radius) + labelOffset); labelTheta += labelThetaIncrement; } } - paintLabels(primaryLabels); + void paintInnerOuterLabels(List<_TappableLabel>? labels) { + if (labels == null) { + return; + } - final Paint selectorPaint = Paint() - ..color = accentColor; - final Offset focusedPoint = getOffsetForTheta(theta); - const double focusedRadius = _labelPadding - 4.0; - canvas.drawCircle(centerPoint, 4.0, selectorPaint); - canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint); - selectorPaint.strokeWidth = 2.0; + paintLabels(labels.where((_TappableLabel label) => !label.inner).toList(), labelRadius); + paintLabels(labels.where((_TappableLabel label) => label.inner).toList(), innerLabelRadius); + } + + paintInnerOuterLabels(primaryLabels); + + final Paint selectorPaint = Paint()..color = handColor; + final Offset focusedPoint = getOffsetForTheta(theta, handleRadius); + canvas.drawCircle(centerPoint, centerRadius, selectorPaint); + canvas.drawCircle(focusedPoint, dotRadius, selectorPaint); + selectorPaint.strokeWidth = handWidth; canvas.drawLine(centerPoint, focusedPoint, selectorPaint); // Add a dot inside the selector but only when it isn't over the labels. @@ -929,43 +997,52 @@ class _DialPainter extends CustomPainter { // labels. The values were derived by manually testing the dial. final double labelThetaIncrement = -_kTwoPi / primaryLabels.length; if (theta % labelThetaIncrement > 0.1 && theta % labelThetaIncrement < 0.45) { - canvas.drawCircle(focusedPoint, 2.0, selectorPaint..color = dotColor); + canvas.drawCircle(focusedPoint, 2, selectorPaint..color = dotColor); } final Rect focusedRect = Rect.fromCircle( - center: focusedPoint, radius: focusedRadius, + center: focusedPoint, + radius: dotRadius, ); canvas ..save() ..clipPath(Path()..addOval(focusedRect)); - paintLabels(secondaryLabels); + paintInnerOuterLabels(selectedLabels); canvas.restore(); } @override bool shouldRepaint(_DialPainter oldPainter) { return oldPainter.primaryLabels != primaryLabels - || oldPainter.secondaryLabels != secondaryLabels + || oldPainter.selectedLabels != selectedLabels || oldPainter.backgroundColor != backgroundColor - || oldPainter.accentColor != accentColor + || oldPainter.handColor != handColor || oldPainter.theta != theta; } } +// Which kind of hour dial being presented. +enum _HourDialType { + twentyFourHour, + twentyFourHourDoubleRing, + twelveHour, +} + class _Dial extends StatefulWidget { const _Dial({ required this.selectedTime, - required this.mode, - required this.use24HourDials, + required this.hourMinuteMode, + required this.hourDialType, required this.onChanged, required this.onHourSelected, }) : assert(selectedTime != null), - assert(mode != null), - assert(use24HourDials != null); + assert(hourMinuteMode != null), + assert(hourMinuteMode != null), + assert(hourDialType != null); final TimeOfDay selectedTime; - final _TimePickerMode mode; - final bool use24HourDials; + final _HourMinuteMode hourMinuteMode; + final _HourDialType hourDialType; final ValueChanged? onChanged; final VoidCallback? onHourSelected; @@ -974,103 +1051,174 @@ class _Dial extends StatefulWidget { } class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { + late ThemeData themeData; + late MaterialLocalizations localizations; + _DialPainter? painter; + late AnimationController _animationController; + late Tween _thetaTween; + late Animation _theta; + late Tween _radiusTween; + late Animation _radius; + bool _dragging = false; + @override void initState() { super.initState(); - _thetaController = AnimationController( + _animationController = AnimationController( duration: _kDialAnimateDuration, vsync: this, ); _thetaTween = Tween(begin: _getThetaForTime(widget.selectedTime)); - _theta = _thetaController + _radiusTween = Tween(begin: _getRadiusForTime(widget.selectedTime)); + _theta = _animationController .drive(CurveTween(curve: standardEasing)) .drive(_thetaTween) ..addListener(() => setState(() { /* _theta.value has changed */ })); + _radius = _animationController + .drive(CurveTween(curve: standardEasing)) + .drive(_radiusTween) + ..addListener(() => setState(() { /* _radius.value has changed */ })); } - late ThemeData themeData; - late MaterialLocalizations localizations; - late bool alwaysUse24HourFormat; - _DialPainter? painter; - @override void didChangeDependencies() { super.didChangeDependencies(); assert(debugCheckHasMediaQuery(context)); themeData = Theme.of(context); localizations = MaterialLocalizations.of(context); - alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); } @override void didUpdateWidget(_Dial oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) { + if (widget.hourMinuteMode != oldWidget.hourMinuteMode || widget.selectedTime != oldWidget.selectedTime) { if (!_dragging) { - _animateTo(_getThetaForTime(widget.selectedTime)); + _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); } } } @override void dispose() { - _thetaController.dispose(); + _animationController.dispose(); painter?.dispose(); super.dispose(); } - late Tween _thetaTween; - late Animation _theta; - late AnimationController _thetaController; - bool _dragging = false; - static double _nearest(double target, double a, double b) { return ((target - a).abs() < (target - b).abs()) ? a : b; } - void _animateTo(double targetTheta) { - final double currentTheta = _theta.value; - double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); - beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); - _thetaTween - ..begin = beginTheta - ..end = targetTheta; - _thetaController - ..value = 0.0 - ..forward(); + void _animateTo(double targetTheta, double targetRadius) { + void animateToValue({ + required double target, + required Animation animation, + required Tween tween, + required AnimationController controller, + required double min, + required double max, + }) { + double beginValue = _nearest(target, animation.value, max); + beginValue = _nearest(target, beginValue, min); + tween + ..begin = beginValue + ..end = target; + controller + ..value = 0 + ..forward(); + } + + animateToValue( + target: targetTheta, + animation: _theta, + tween: _thetaTween, + controller: _animationController, + min: _theta.value - _kTwoPi, + max: _theta.value + _kTwoPi, + ); + animateToValue( + target: targetRadius, + animation: _radius, + tween: _radiusTween, + controller: _animationController, + min: 0, + max: 1, + ); + } + + double _getRadiusForTime(TimeOfDay time) { + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + switch (widget.hourDialType) { + case _HourDialType.twentyFourHourDoubleRing: + return time.hour >= 12 ? 0 : 1; + case _HourDialType.twentyFourHour: + case _HourDialType.twelveHour: + return 1; + } + case _HourMinuteMode.minute: + return 1; + } } double _getThetaForTime(TimeOfDay time) { - final int hoursFactor = widget.use24HourDials ? TimeOfDay.hoursPerDay : TimeOfDay.hoursPerPeriod; - final double fraction = widget.mode == _TimePickerMode.hour - ? (time.hour / hoursFactor) % hoursFactor - : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; - return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi; + final int hoursFactor; + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + hoursFactor = TimeOfDay.hoursPerDay; + break; + case _HourDialType.twentyFourHourDoubleRing: + hoursFactor = TimeOfDay.hoursPerPeriod; + break; + case _HourDialType.twelveHour: + hoursFactor = TimeOfDay.hoursPerPeriod; + break; + } + final double fraction; + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + fraction = (time.hour / hoursFactor) % hoursFactor; + break; + case _HourMinuteMode.minute: + fraction = (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; + break; + } + return (math.pi / 2 - fraction * _kTwoPi) % _kTwoPi; } - TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false}) { - final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; - if (widget.mode == _TimePickerMode.hour) { - int newHour; - if (widget.use24HourDials) { - newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay; - } else { - newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; - newHour = newHour + widget.selectedTime.periodOffset; - } - return widget.selectedTime.replacing(hour: newHour); - } else { - int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour; - if (roundMinutes) { - // Round the minutes to nearest 5 minute interval. - minute = ((minute + 2) ~/ 5) * 5 % TimeOfDay.minutesPerHour; - } - return widget.selectedTime.replacing(minute: minute); + TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false, required double radius}) { + final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1; + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + int newHour; + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay; + break; + case _HourDialType.twentyFourHourDoubleRing: + newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + if (radius < 0.5) { + newHour = newHour + TimeOfDay.hoursPerPeriod; + } + break; + case _HourDialType.twelveHour: + newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + newHour = newHour + widget.selectedTime.periodOffset; + break; + } + return widget.selectedTime.replacing(hour: newHour); + case _HourMinuteMode.minute: + int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour; + if (roundMinutes) { + // Round the minutes to nearest 5 minute interval. + minute = ((minute + 2) ~/ 5) * 5 % TimeOfDay.minutesPerHour; + } + return widget.selectedTime.replacing(minute: minute); } } TimeOfDay _notifyOnChangedIfNeeded({ bool roundMinutes = false }) { - final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes); + final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes, radius: _radius.value); if (widget.onChanged == null) { return current; } @@ -1083,25 +1231,34 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { void _updateThetaForPan({ bool roundMinutes = false }) { setState(() { final Offset offset = _position! - _center!; - double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi; + final double labelRadius = _dialSize!.shortestSide / 2 - _kTimePickerDialPadding; + final double innerRadius = labelRadius - _kTimePickerInnerDialOffset; + double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2) % _kTwoPi; + final double radius = clampDouble((offset.distance - innerRadius) / _kTimePickerInnerDialOffset, 0, 1); if (roundMinutes) { - angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes)); + angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes, radius: radius)); } + // The controller doesn't animate during the pan gesture. _thetaTween ..begin = angle - ..end = angle; // The controller doesn't animate during the pan gesture. + ..end = angle; + _radiusTween + ..begin = radius + ..end = radius; }); } Offset? _position; Offset? _center; + Size? _dialSize; void _handlePanStart(DragStartDetails details) { assert(!_dragging); _dragging = true; final RenderBox box = context.findRenderObject()! as RenderBox; _position = box.globalToLocal(details.globalPosition); - _center = box.size.center(Offset.zero); + _dialSize = box.size; + _center = _dialSize!.center(Offset.zero); _updateThetaForPan(); _notifyOnChangedIfNeeded(); } @@ -1117,8 +1274,9 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { _dragging = false; _position = null; _center = null; - _animateTo(_getThetaForTime(widget.selectedTime)); - if (widget.mode == _TimePickerMode.hour) { + _dialSize = null; + _animateTo(_getThetaForTime(widget.selectedTime), _getRadiusForTime(widget.selectedTime)); + if (widget.hourMinuteMode == _HourMinuteMode.hour) { widget.onHourSelected?.call(); } } @@ -1127,36 +1285,60 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { final RenderBox box = context.findRenderObject()! as RenderBox; _position = box.globalToLocal(details.globalPosition); _center = box.size.center(Offset.zero); + _dialSize = box.size; _updateThetaForPan(roundMinutes: true); final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true); - if (widget.mode == _TimePickerMode.hour) { - if (widget.use24HourDials) { - _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); - } else { - _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod)); + if (widget.hourMinuteMode == _HourMinuteMode.hour) { + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); + break; + case _HourDialType.twelveHour: + _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod)); + break; } widget.onHourSelected?.call(); } else { _announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); } - _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value, roundMinutes: true))); + final TimeOfDay time = _getTimeForTheta(_theta.value, roundMinutes: true, radius: _radius.value); + _animateTo(_getThetaForTime(time), _getRadiusForTime(time)); _dragging = false; _position = null; _center = null; + _dialSize = null; } void _selectHour(int hour) { _announceToAccessibility(context, localizations.formatDecimal(hour)); final TimeOfDay time; - if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { - time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); - } else { - if (widget.selectedTime.period == DayPeriod.am) { - time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); - } else { - time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute); + + TimeOfDay getAmPmTime() { + switch (widget.selectedTime.period) { + case DayPeriod.am: + return TimeOfDay(hour: hour, minute: widget.selectedTime.minute); + case DayPeriod.pm: + return TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute); } } + + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); + break; + case _HourDialType.twelveHour: + time = getAmPmTime(); + break; + } + break; + case _HourMinuteMode.minute: + time = getAmPmTime(); + break; + } final double angle = _getThetaForTime(time); _thetaTween ..begin = angle @@ -1192,7 +1374,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { TimeOfDay(hour: 11, minute: 0), ]; - static const List _twentyFourHours = [ + // On M2, there's no inner ring of numbers. + static const List _twentyFourHoursM2 = [ TimeOfDay(hour: 0, minute: 0), TimeOfDay(hour: 2, minute: 0), TimeOfDay(hour: 4, minute: 0), @@ -1207,13 +1390,47 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { TimeOfDay(hour: 22, minute: 0), ]; - _TappableLabel _buildTappableLabel(TextTheme textTheme, Color color, int value, String label, VoidCallback onTap) { - final TextStyle style = textTheme.bodyLarge!.copyWith(color: color); - final double labelScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 2.0); + static const List _twentyFourHours = [ + TimeOfDay(hour: 0, minute: 0), + TimeOfDay(hour: 1, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 3, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 5, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 7, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 9, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 11, minute: 0), + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 13, minute: 0), + TimeOfDay(hour: 14, minute: 0), + TimeOfDay(hour: 15, minute: 0), + TimeOfDay(hour: 16, minute: 0), + TimeOfDay(hour: 17, minute: 0), + TimeOfDay(hour: 18, minute: 0), + TimeOfDay(hour: 19, minute: 0), + TimeOfDay(hour: 20, minute: 0), + TimeOfDay(hour: 21, minute: 0), + TimeOfDay(hour: 22, minute: 0), + TimeOfDay(hour: 23, minute: 0), + ]; + + _TappableLabel _buildTappableLabel({ + required TextStyle? textStyle, + required int selectedValue, + required int value, + required bool inner, + required String label, + required VoidCallback onTap, + }) { + final double labelScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 2); return _TappableLabel( value: value, + inner: inner, painter: TextPainter( - text: TextSpan(style: style, text: label), + text: TextSpan(style: textStyle, text: label), textDirection: TextDirection.ltr, textScaleFactor: labelScaleFactor, )..layout(), @@ -1221,33 +1438,63 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ); } - List<_TappableLabel> _build24HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[ - for (final TimeOfDay timeOfDay in _twentyFourHours) - _buildTappableLabel( - textTheme, - color, - timeOfDay.hour, - localizations.formatHour(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat), - () { - _selectHour(timeOfDay.hour); - }, - ), - ]; + List<_TappableLabel> _build24HourRing({ + required TextStyle? textStyle, + required int selectedValue, + }) { + return <_TappableLabel>[ + if (themeData.useMaterial3) + for (final TimeOfDay timeOfDay in _twentyFourHours) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: timeOfDay.hour >= 12, + value: timeOfDay.hour, + label: timeOfDay.hour != 0 + ? '${timeOfDay.hour}' + : localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + if (!themeData.useMaterial3) + for (final TimeOfDay timeOfDay in _twentyFourHoursM2) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.hour, + label: localizations.formatHour(timeOfDay, alwaysUse24HourFormat: true), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + } - List<_TappableLabel> _build12HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[ - for (final TimeOfDay timeOfDay in _amHours) - _buildTappableLabel( - textTheme, - color, - timeOfDay.hour, - localizations.formatHour(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat), - () { - _selectHour(timeOfDay.hour); - }, - ), - ]; + List<_TappableLabel> _build12HourRing({ + required TextStyle? textStyle, + required int selectedValue, + }) { + return <_TappableLabel>[ + for (final TimeOfDay timeOfDay in _amHours) + _buildTappableLabel( + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.hour, + label: localizations.formatHour(timeOfDay, alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)), + onTap: () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + } - List<_TappableLabel> _buildMinutes(TextTheme textTheme, Color color) { + List<_TappableLabel> _buildMinutes({ + required TextStyle? textStyle, + required int selectedValue, + }) { const List minuteMarkerValues = [ TimeOfDay(hour: 0, minute: 0), TimeOfDay(hour: 0, minute: 5), @@ -1266,11 +1513,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { return <_TappableLabel>[ for (final TimeOfDay timeOfDay in minuteMarkerValues) _buildTappableLabel( - textTheme, - color, - timeOfDay.minute, - localizations.formatMinute(timeOfDay), - () { + textStyle: textStyle, + selectedValue: selectedValue, + inner: false, + value: timeOfDay.minute, + label: localizations.formatMinute(timeOfDay), + onTap: () { _selectMinute(timeOfDay.minute); }, ), @@ -1280,42 +1528,79 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - final TimePickerThemeData pickerTheme = TimePickerTheme.of(context); - final Color backgroundColor = pickerTheme.dialBackgroundColor ?? themeData.colorScheme.onBackground.withOpacity(0.12); - final Color accentColor = pickerTheme.dialHandColor ?? themeData.colorScheme.primary; - final Color primaryLabelColor = MaterialStateProperty.resolveAs(pickerTheme.dialTextColor, {}) ?? themeData.colorScheme.onSurface; - final Color secondaryLabelColor = MaterialStateProperty.resolveAs(pickerTheme.dialTextColor, {MaterialState.selected}) ?? themeData.colorScheme.onPrimary; + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + final Color backgroundColor = timePickerTheme.dialBackgroundColor ?? defaultTheme.dialBackgroundColor; + final Color dialHandColor = timePickerTheme.dialHandColor ?? defaultTheme.dialHandColor; + final TextStyle labelStyle = timePickerTheme.dialTextStyle ?? defaultTheme.dialTextStyle; + final Color dialTextUnselectedColor = MaterialStateProperty + .resolveAs(timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, { }); + final Color dialTextSelectedColor = MaterialStateProperty + .resolveAs(timePickerTheme.dialTextColor ?? defaultTheme.dialTextColor, { MaterialState.selected }); + final TextStyle resolvedUnselectedLabelStyle = labelStyle.copyWith(color: dialTextUnselectedColor); + final TextStyle resolvedSelectedLabelStyle = labelStyle.copyWith(color: dialTextSelectedColor); + final Color dotColor = dialTextSelectedColor; + List<_TappableLabel> primaryLabels; - List<_TappableLabel> secondaryLabels; + List<_TappableLabel> selectedLabels; final int selectedDialValue; - switch (widget.mode) { - case _TimePickerMode.hour: - if (widget.use24HourDials) { - selectedDialValue = widget.selectedTime.hour; - primaryLabels = _build24HourRing(theme.textTheme, primaryLabelColor); - secondaryLabels = _build24HourRing(theme.textTheme, secondaryLabelColor); - } else { - selectedDialValue = widget.selectedTime.hourOfPeriod; - primaryLabels = _build12HourRing(theme.textTheme, primaryLabelColor); - secondaryLabels = _build12HourRing(theme.textTheme, secondaryLabelColor); + final double radiusValue; + switch (widget.hourMinuteMode) { + case _HourMinuteMode.hour: + switch (widget.hourDialType) { + case _HourDialType.twentyFourHour: + case _HourDialType.twentyFourHourDoubleRing: + selectedDialValue = widget.selectedTime.hour; + primaryLabels = _build24HourRing( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _build24HourRing( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = theme.useMaterial3 ? _radius.value : 1; + break; + case _HourDialType.twelveHour: + selectedDialValue = widget.selectedTime.hourOfPeriod; + primaryLabels = _build12HourRing( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _build12HourRing( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = 1; + break; } break; - case _TimePickerMode.minute: + case _HourMinuteMode.minute: selectedDialValue = widget.selectedTime.minute; - primaryLabels = _buildMinutes(theme.textTheme, primaryLabelColor); - secondaryLabels = _buildMinutes(theme.textTheme, secondaryLabelColor); + primaryLabels = _buildMinutes( + textStyle: resolvedUnselectedLabelStyle, + selectedValue: selectedDialValue, + ); + selectedLabels = _buildMinutes( + textStyle: resolvedSelectedLabelStyle, + selectedValue: selectedDialValue, + ); + radiusValue = 1; break; } - painter?.dispose(); painter = _DialPainter( selectedValue: selectedDialValue, primaryLabels: primaryLabels, - secondaryLabels: secondaryLabels, + selectedLabels: selectedLabels, backgroundColor: backgroundColor, - accentColor: accentColor, - dotColor: theme.colorScheme.surface, + handColor: dialHandColor, + handWidth: defaultTheme.handWidth, + dotColor: dotColor, + dotRadius: defaultTheme.dotRadius, + centerRadius: defaultTheme.centerRadius, theta: _theta.value, + radius: radiusValue, textDirection: Directionality.of(context), ); @@ -1336,23 +1621,18 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { class _TimePickerInput extends StatefulWidget { const _TimePickerInput({ required this.initialSelectedTime, - required this.helpText, required this.errorInvalidText, required this.hourLabelText, required this.minuteLabelText, + required this.helpText, required this.autofocusHour, required this.autofocusMinute, - required this.onChanged, this.restorationId, - }) : assert(initialSelectedTime != null), - assert(onChanged != null); + }); /// The time initially selected when the dialog is shown. final TimeOfDay initialSelectedTime; - /// Optionally provide your own help text to the time picker. - final String? helpText; - /// Optionally provide your own validation error text. final String? errorInvalidText; @@ -1362,12 +1642,12 @@ class _TimePickerInput extends StatefulWidget { /// Optionally provide your own minute label text. final String? minuteLabelText; + final String helpText; + final bool? autofocusHour; final bool? autofocusMinute; - final ValueChanged onChanged; - /// Restoration ID to save and restore the state of the time picker input /// widget. /// @@ -1412,8 +1692,8 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi } } else { if (newHour > 0 && newHour < 13) { - if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) - || (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { + if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) || + (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; } return newHour; @@ -1442,7 +1722,7 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi final int? newHour = _parseHour(value); if (newHour != null) { _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute); - widget.onChanged(_selectedTime.value); + _TimePickerModel.setSelectedTime(context, _selectedTime.value); FocusScope.of(context).requestFocus(); } } @@ -1459,14 +1739,14 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi final int? newMinute = _parseMinute(value); if (newMinute != null) { _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!)); - widget.onChanged(_selectedTime.value); + _TimePickerModel.setSelectedTime(context, _selectedTime.value); FocusScope.of(context).unfocus(); } } void _handleDayPeriodChanged(TimeOfDay value) { _selectedTime.value = value; - widget.onChanged(_selectedTime.value); + _TimePickerModel.setSelectedTime(context, _selectedTime.value); } String? _validateHour(String? value) { @@ -1494,35 +1774,32 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); - final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: _TimePickerModel.use24HourFormatOf(context)); final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; final ThemeData theme = Theme.of(context); - final TextStyle hourMinuteStyle = TimePickerTheme.of(context).hourMinuteTextStyle ?? theme.textTheme.displayMedium!; - final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final String timePickerInputHelpText = theme.useMaterial3 - ? localizations.timePickerInputHelpText - : localizations.timePickerInputHelpText.toUpperCase(); + final TimePickerThemeData timePickerTheme = _TimePickerModel.themeOf(context); + final _TimePickerDefaults defaultTheme = _TimePickerModel.defaultThemeOf(context); + final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? defaultTheme.hourMinuteTextStyle; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + padding: _TimePickerModel.useMaterial3Of(context) ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - widget.helpText ?? timePickerInputHelpText, - style: TimePickerTheme.of(context).helpTextStyle ?? theme.textTheme.labelSmall, + Padding(padding: EdgeInsetsDirectional.only(bottom: _TimePickerModel.useMaterial3Of(context) ? 20 : 24), + child: Text( + widget.helpText, + style: _TimePickerModel.themeOf(context).helpTextStyle ?? _TimePickerModel.defaultThemeOf(context).helpTextStyle, + ), ), - const SizedBox(height: 16.0), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ - _DayPeriodControl( - selectedTime: _selectedTime.value, - orientation: Orientation.portrait, - onChanged: _handleDayPeriodChanged, + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), ), - const SizedBox(width: 12.0), ], Expanded( child: Row( @@ -1534,19 +1811,20 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 8.0), - _HourTextField( - restorationId: 'hour_text_field', - selectedTime: _selectedTime.value, - style: hourMinuteStyle, - autofocus: widget.autofocusHour, - inputAction: TextInputAction.next, - validator: _validateHour, - onSavedSubmitted: _handleHourSavedSubmitted, - onChanged: _handleHourChanged, - hourLabelText: widget.hourLabelText, + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _HourTextField( + restorationId: 'hour_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusHour, + inputAction: TextInputAction.next, + validator: _validateHour, + onSavedSubmitted: _handleHourSavedSubmitted, + onChanged: _handleHourChanged, + hourLabelText: widget.hourLabelText, + ), ), - const SizedBox(height: 8.0), if (!hourHasError.value && !minuteHasError.value) ExcludeSemantics( child: Text( @@ -1559,27 +1837,24 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi ], ), ), - Container( - margin: const EdgeInsets.only(top: 8.0), - height: _kTimePickerHeaderControlHeight, - child: _StringFragment(timeOfDayFormat: timeOfDayFormat), - ), + _StringFragment(timeOfDayFormat: timeOfDayFormat), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 8.0), - _MinuteTextField( - restorationId: 'minute_text_field', - selectedTime: _selectedTime.value, - style: hourMinuteStyle, - autofocus: widget.autofocusMinute, - inputAction: TextInputAction.done, - validator: _validateMinute, - onSavedSubmitted: _handleMinuteSavedSubmitted, - minuteLabelText: widget.minuteLabelText, + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _MinuteTextField( + restorationId: 'minute_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusMinute, + inputAction: TextInputAction.done, + validator: _validateMinute, + onSavedSubmitted: _handleMinuteSavedSubmitted, + minuteLabelText: widget.minuteLabelText, + ), ), - const SizedBox(height: 8.0), if (!hourHasError.value && !minuteHasError.value) ExcludeSemantics( child: Text( @@ -1596,11 +1871,9 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi ), ), if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ - const SizedBox(width: 12.0), - _DayPeriodControl( - selectedTime: _selectedTime.value, - orientation: Orientation.portrait, - onChanged: _handleDayPeriodChanged, + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: _DayPeriodControl(onPeriodChanged: _handleDayPeriodChanged), ), ], ], @@ -1611,7 +1884,7 @@ class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixi style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.error), ) else - const SizedBox(height: 2.0), + const SizedBox(height: 2), ], ), ); @@ -1650,7 +1923,7 @@ class _HourTextField extends StatelessWidget { autofocus: autofocus, inputAction: inputAction, style: style, - semanticHintText: hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, + semanticHintText: hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, validator: validator, onSavedSubmitted: onSavedSubmitted, onChanged: onChanged, @@ -1732,9 +2005,12 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with Restora @override void initState() { super.initState(); - focusNode = FocusNode()..addListener(() { - setState(() { }); // Rebuild. - }); + focusNode = FocusNode() + ..addListener(() { + setState(() { + // Rebuild when focus changes. + }); + }); } @override @@ -1760,62 +2036,73 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with Restora String get _formattedValue { final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context); - return !widget.isHour ? localizations.formatMinute(widget.selectedTime) : localizations.formatHour( - widget.selectedTime, - alwaysUse24HourFormat: alwaysUse24HourFormat, - ); + return !widget.isHour + ? localizations.formatMinute(widget.selectedTime) + : localizations.formatHour( + widget.selectedTime, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); - final ColorScheme colorScheme = theme.colorScheme; + final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + final bool alwaysUse24HourFormat = MediaQuery.alwaysUse24HourFormatOf(context); - final InputDecorationTheme? inputDecorationTheme = timePickerTheme.inputDecorationTheme; - InputDecoration inputDecoration; - if (inputDecorationTheme != null) { - inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme); - } else { - inputDecoration = InputDecoration( - contentPadding: EdgeInsets.zero, - filled: true, - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), - ), - errorBorder: OutlineInputBorder( - borderSide: BorderSide(color: colorScheme.error, width: 2.0), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: colorScheme.primary, width: 2.0), - ), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide(color: colorScheme.error, width: 2.0), - ), - hintStyle: widget.style.copyWith(color: colorScheme.onSurface.withOpacity(0.36)), - // TODO(rami-a): Remove this logic once https://github.com/flutter/flutter/issues/54104 is fixed. - errorStyle: const TextStyle(fontSize: 0.0, height: 0.0), // Prevent the error text from appearing. - ); - } - final Color unfocusedFillColor = timePickerTheme.hourMinuteColor ?? colorScheme.onSurface.withOpacity(0.12); + final InputDecorationTheme inputDecorationTheme = timePickerTheme.inputDecorationTheme ?? defaultTheme.inputDecorationTheme; + InputDecoration inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme); // If screen reader is in use, make the hint text say hours/minutes. // Otherwise, remove the hint text when focused because the centered cursor // appears odd above the hint text. - // - // TODO(rami-a): Once https://github.com/flutter/flutter/issues/67571 is - // resolved, remove the window check for semantics being enabled on web. final String? hintText = MediaQuery.accessibleNavigationOf(context) || WidgetsBinding.instance.window.semanticsEnabled ? widget.semanticHintText : (focusNode.hasFocus ? null : _formattedValue); + + // Because the fill color is specified in both the inputDecorationTheme and + // the TimePickerTheme, if there's one in the user's input decoration theme, + // use that. If not, but there's one in the user's + // timePickerTheme.hourMinuteColor, use that, and otherwise use the default. + // We ignore the value in the fillColor of the input decoration in the + // default theme here, but it's the same as the hourMinuteColor. + final Color startingFillColor = + timePickerTheme.inputDecorationTheme?.fillColor ?? + timePickerTheme.hourMinuteColor ?? + defaultTheme.hourMinuteColor; + final Color fillColor; + if (theme.useMaterial3) { + fillColor = MaterialStateProperty.resolveAs( + startingFillColor, + { + if (focusNode.hasFocus) MaterialState.focused, + if (focusNode.hasFocus) MaterialState.selected, + }, + ); + } else { + fillColor = focusNode.hasFocus ? Colors.transparent : startingFillColor; + } + inputDecoration = inputDecoration.copyWith( hintText: hintText, - fillColor: focusNode.hasFocus ? Colors.transparent : inputDecorationTheme?.fillColor ?? unfocusedFillColor, + fillColor: fillColor, ); - return SizedBox( - height: _kTimePickerHeaderControlHeight, + final Set states = { + if (focusNode.hasFocus) MaterialState.focused, + if (focusNode.hasFocus) MaterialState.selected, + }; + final Color effectiveTextColor = MaterialStateProperty.resolveAs( + timePickerTheme.hourMinuteTextColor ?? defaultTheme.hourMinuteTextColor, + states, + ); + final TextStyle effectiveStyle = MaterialStateProperty.resolveAs(widget.style, states) + .copyWith(color: effectiveTextColor); + + return SizedBox.fromSize( + size: alwaysUse24HourFormat ? defaultTheme.hourMinuteInputSize24Hour : defaultTheme.hourMinuteInputSize, child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + data: MediaQuery.of(context).copyWith(textScaleFactor: 1), child: UnmanagedRestorationScope( bucket: bucket, child: TextFormField( @@ -1830,7 +2117,7 @@ class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with Restora textAlign: TextAlign.center, textInputAction: widget.inputAction, keyboardType: TextInputType.number, - style: widget.style.copyWith(color: timePickerTheme.hourMinuteTextColor ?? colorScheme.onSurface), + style: effectiveStyle, controller: controller.value, decoration: inputDecoration, validator: widget.validator, @@ -1869,15 +2156,13 @@ class TimePickerDialog extends StatefulWidget { this.minuteLabelText, this.restorationId, this.initialEntryMode = TimePickerEntryMode.dial, + this.orientation, this.onEntryModeChanged, }) : assert(initialTime != null); /// The time initially selected when the dialog is shown. final TimeOfDay initialTime; - /// The entry mode for the picker. Whether it's text input or a dial. - final TimePickerEntryMode initialEntryMode; - /// Optionally provide your own text for the cancel button. /// /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. @@ -1914,6 +2199,22 @@ class TimePickerDialog extends StatefulWidget { /// Flutter. final String? restorationId; + /// The entry mode for the picker. Whether it's text input or a dial. + final TimePickerEntryMode initialEntryMode; + + /// The optional [orientation] parameter sets the [Orientation] to use when + /// displaying the dialog. + /// + /// By default, the orientation is derived from the [MediaQueryData.size] of + /// the ambient [MediaQuery]. If the aspect of the size is tall, then + /// [Orientation.portrait] is used, if the size is wide, then + /// [Orientation.landscape] is used. + /// + /// Use this parameter to override the default and force the dialog to appear + /// in either portrait or landscape mode regardless of the aspect of the + /// [MediaQueryData.size]. + final Orientation? orientation; + /// Callback called when the selected entry mode is changed. final EntryModeChangeCallback? onEntryModeChanged; @@ -1921,124 +2222,421 @@ class TimePickerDialog extends StatefulWidget { State createState() => _TimePickerDialogState(); } -// A restorable [TimePickerEntryMode] value. -// -// This serializes each entry as a unique `int` value. -class _RestorableTimePickerEntryMode extends RestorableValue { - _RestorableTimePickerEntryMode( - TimePickerEntryMode defaultValue, - ) : _defaultValue = defaultValue; - - final TimePickerEntryMode _defaultValue; - - @override - TimePickerEntryMode createDefaultValue() => _defaultValue; - - @override - void didUpdateValue(TimePickerEntryMode? oldValue) { - assert(debugIsSerializableForRestoration(value.index)); - notifyListeners(); - } - - @override - TimePickerEntryMode fromPrimitives(Object? data) => TimePickerEntryMode.values[data! as int]; - - @override - Object? toPrimitives() => value.index; -} - -// A restorable [_RestorableTimePickerEntryMode] value. -// -// This serializes each entry as a unique `int` value. -class _RestorableTimePickerMode extends RestorableValue<_TimePickerMode> { - _RestorableTimePickerMode( - _TimePickerMode defaultValue, - ) : _defaultValue = defaultValue; - - final _TimePickerMode _defaultValue; - - @override - _TimePickerMode createDefaultValue() => _defaultValue; - - @override - void didUpdateValue(_TimePickerMode? oldValue) { - assert(debugIsSerializableForRestoration(value.index)); - notifyListeners(); - } - - @override - _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; - - @override - Object? toPrimitives() => value.index; -} - -// A restorable [AutovalidateMode] value. -// -// This serializes each entry as a unique `int` value. -class _RestorableAutovalidateMode extends RestorableValue { - _RestorableAutovalidateMode( - AutovalidateMode defaultValue, - ) : _defaultValue = defaultValue; - - final AutovalidateMode _defaultValue; - - @override - AutovalidateMode createDefaultValue() => _defaultValue; - - @override - void didUpdateValue(AutovalidateMode? oldValue) { - assert(debugIsSerializableForRestoration(value.index)); - notifyListeners(); - } - - @override - AutovalidateMode fromPrimitives(Object? data) => AutovalidateMode.values[data! as int]; - - @override - Object? toPrimitives() => value.index; -} - -// A restorable [_RestorableTimePickerEntryMode] value. -// -// This serializes each entry as a unique `int` value. -// -// This value can be null. -class _RestorableTimePickerModeN extends RestorableValue<_TimePickerMode?> { - _RestorableTimePickerModeN( - _TimePickerMode? defaultValue, - ) : _defaultValue = defaultValue; - - final _TimePickerMode? _defaultValue; - - @override - _TimePickerMode? createDefaultValue() => _defaultValue; - - @override - void didUpdateValue(_TimePickerMode? oldValue) { - assert(debugIsSerializableForRestoration(value?.index)); - notifyListeners(); - } - - @override - _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; - - @override - Object? toPrimitives() => value?.index; -} - class _TimePickerDialogState extends State with RestorationMixin { + late final RestorableEnum _entryMode = RestorableEnum(widget.initialEntryMode, values: TimePickerEntryMode.values); + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); final GlobalKey _formKey = GlobalKey(); + final RestorableEnum _autovalidateMode = RestorableEnum(AutovalidateMode.disabled, values: AutovalidateMode.values); + late final RestorableEnumN _orientation = RestorableEnumN(widget.orientation, values: Orientation.values); - late final _RestorableTimePickerEntryMode _entryMode = _RestorableTimePickerEntryMode(widget.initialEntryMode); - final _RestorableTimePickerMode _mode = _RestorableTimePickerMode(_TimePickerMode.hour); - final _RestorableTimePickerModeN _lastModeAnnounced = _RestorableTimePickerModeN(null); - final _RestorableAutovalidateMode _autovalidateMode = _RestorableAutovalidateMode(AutovalidateMode.disabled); + // Base sizes + static const Size _kTimePickerPortraitSize = Size(310, 468); + static const Size _kTimePickerLandscapeSize = Size(524, 342); + static const Size _kTimePickerLandscapeSizeM2 = Size(508, 300); + static const Size _kTimePickerInputSize = Size(312, 216); + + // Absolute minimum dialog sizes, which is the point at which it begins + // scrolling to fit everything in. + static const Size _kTimePickerMinPortraitSize = Size(238, 326); + static const Size _kTimePickerMinLandscapeSize = Size(416, 248); + static const Size _kTimePickerMinInputSize = Size(312, 196); + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(_entryMode, 'entry_mode'); + registerForRestoration(_autovalidateMode, 'autovalidate_mode'); + registerForRestoration(_orientation, 'orientation'); + } + + void _handleTimeChanged(TimeOfDay value) { + if (value != _selectedTime.value) { + setState(() { + _selectedTime.value = value; + }); + } + } + + void _handleEntryModeChanged(TimePickerEntryMode value) { + if (value != _entryMode.value) { + setState(() { + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + _autovalidateMode.value = AutovalidateMode.disabled; + break; + case TimePickerEntryMode.input: + _formKey.currentState!.save(); + break; + case TimePickerEntryMode.dialOnly: + break; + case TimePickerEntryMode.inputOnly: + break; + } + _entryMode.value = value; + widget.onEntryModeChanged?.call(value); + }); + } + } + + void _toggleEntryMode() { + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + _handleEntryModeChanged(TimePickerEntryMode.input); + break; + case TimePickerEntryMode.input: + _handleEntryModeChanged(TimePickerEntryMode.dial); + break; + case TimePickerEntryMode.dialOnly: + case TimePickerEntryMode.inputOnly: + FlutterError('Can not change entry mode from $_entryMode'); + break; + } + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleOk() { + if (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) { + final FormState form = _formKey.currentState!; + if (!form.validate()) { + setState(() { + _autovalidateMode.value = AutovalidateMode.always; + }); + return; + } + form.save(); + } + Navigator.pop(context, _selectedTime.value); + } + + Size _minDialogSize(BuildContext context, {required bool useMaterial3}) { + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + switch (orientation) { + case Orientation.portrait: + return _kTimePickerMinPortraitSize; + case Orientation.landscape: + return _kTimePickerMinLandscapeSize; + } + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); + final double timePickerWidth; + switch(timeOfDayFormat) { + case TimeOfDayFormat.HH_colon_mm: + case TimeOfDayFormat.HH_dot_mm: + case TimeOfDayFormat.frenchCanadian: + case TimeOfDayFormat.H_colon_mm: + final _TimePickerDefaults defaultTheme = useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + timePickerWidth = _kTimePickerMinInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; + break; + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.h_colon_mm_space_a: + timePickerWidth = _kTimePickerMinInputSize.width; + break; + } + return Size(timePickerWidth, _kTimePickerMinInputSize.height); + } + } + + Size _dialogSize(BuildContext context, {required bool useMaterial3}) { + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + // Constrain the textScaleFactor to prevent layout issues. Since only some + // parts of the time picker scale up with textScaleFactor, we cap the factor + // to 1.1 as that provides enough space to reasonably fit all the content. + final double textScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 1.1); + + final Size timePickerSize; + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + switch (orientation) { + case Orientation.portrait: + timePickerSize = _kTimePickerPortraitSize; + break; + case Orientation.landscape: + timePickerSize = Size( + _kTimePickerLandscapeSize.width * textScaleFactor, + useMaterial3 ? _kTimePickerLandscapeSize.height : _kTimePickerLandscapeSizeM2.height + ); + break; + } + break; + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); + final double timePickerWidth; + switch(timeOfDayFormat) { + case TimeOfDayFormat.HH_colon_mm: + case TimeOfDayFormat.HH_dot_mm: + case TimeOfDayFormat.frenchCanadian: + case TimeOfDayFormat.H_colon_mm: + final _TimePickerDefaults defaultTheme = useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + timePickerWidth = _kTimePickerInputSize.width - defaultTheme.dayPeriodPortraitSize.width - 12; + break; + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.h_colon_mm_space_a: + timePickerWidth = _kTimePickerInputSize.width; + break; + } + timePickerSize = Size(timePickerWidth, _kTimePickerInputSize.height); + break; + } + return Size(timePickerSize.width, timePickerSize.height * textScaleFactor); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + final TimePickerThemeData pickerTheme = TimePickerTheme.of(context); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + final ShapeBorder shape = pickerTheme.shape ?? defaultTheme.shape; + final Color entryModeIconColor = pickerTheme.entryModeIconColor ?? defaultTheme.entryModeIconColor; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + + final Widget actions = Padding( + padding: EdgeInsetsDirectional.only(start: theme.useMaterial3 ? 0 : 4), + child: Row( + children: [ + if (_entryMode.value == TimePickerEntryMode.dial || _entryMode.value == TimePickerEntryMode.input) + IconButton( + // In material3 mode, we want to use the color as part of the + // button style which applies its own opacity. In material2 mode, + // we want to use the color as the color, which already includes + // the opacity. + color: theme.useMaterial3 ? null : entryModeIconColor, + style: theme.useMaterial3 ? IconButton.styleFrom(foregroundColor: entryModeIconColor) : null, + onPressed: _toggleEntryMode, + icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard_outlined : Icons.access_time), + tooltip: _entryMode.value == TimePickerEntryMode.dial + ? MaterialLocalizations.of(context).inputTimeModeButtonLabel + : MaterialLocalizations.of(context).dialModeButtonLabel, + ), + Expanded( + child: Container( + alignment: AlignmentDirectional.centerEnd, + constraints: const BoxConstraints(minHeight: 36), + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: [ + TextButton( + onPressed: _handleCancel, + child: Text(widget.cancelText ?? + (theme.useMaterial3 + ? localizations.cancelButtonLabel + : localizations.cancelButtonLabel.toUpperCase())), + ), + TextButton( + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ], + ), + ); + + final Offset tapTargetSizeOffset; + switch (theme.materialTapTargetSize) { + case MaterialTapTargetSize.padded: + tapTargetSizeOffset = Offset.zero; + break; + case MaterialTapTargetSize.shrinkWrap: + // _dialogSize returns "padded" sizes. + tapTargetSizeOffset = const Offset(0, -12); + break; + } + + final Size dialogSize = _dialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; + final Size minDialogSize = _minDialogSize(context, useMaterial3: theme.useMaterial3) + tapTargetSizeOffset; + return Dialog( + shape: shape, + elevation: pickerTheme.elevation ?? defaultTheme.elevation, + backgroundColor: pickerTheme.backgroundColor ?? defaultTheme.backgroundColor, + insetPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) ? 0 : 24, + ), + child: Padding( + padding: pickerTheme.padding ?? defaultTheme.padding, + child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + final Size constrainedSize = constraints.constrain(dialogSize); + final Size allowedSize = Size( + constrainedSize.width < minDialogSize.width ? minDialogSize.width : constrainedSize.width, + constrainedSize.height < minDialogSize.height ? minDialogSize.height : constrainedSize.height, + ); + return SingleChildScrollView( + restorationId: 'time_picker_scroll_view_horizontal', + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + restorationId: 'time_picker_scroll_view_vertical', + child: AnimatedContainer( + width: allowedSize.width, + height: allowedSize.height, + duration: _kDialogSizeAnimationDuration, + curve: Curves.easeIn, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Form( + key: _formKey, + autovalidateMode: _autovalidateMode.value, + child: _TimePicker( + time: widget.initialTime, + onTimeChanged: _handleTimeChanged, + helpText: widget.helpText, + cancelText: widget.cancelText, + confirmText: widget.confirmText, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + restorationId: 'time_picker', + entryMode: _entryMode.value, + orientation: widget.orientation, + onEntryModeChanged: _handleEntryModeChanged, + ), + ), + ), + actions, + ], + ), + ), + ), + ); + }), + ), + ); + } +} + +// The _TimePicker widget is constructed so that in the future we could expose +// this as a public API for embedding time pickers into other non-dialog +// widgets, once we're sure we want to support that. + +/// A Time Picker widget that can be embedded into another widget. +class _TimePicker extends StatefulWidget { + /// Creates a const Material Design time picker. + const _TimePicker({ + required this.time, + required this.onTimeChanged, + this.helpText, + this.cancelText, + this.confirmText, + this.errorInvalidText, + this.hourLabelText, + this.minuteLabelText, + this.restorationId, + this.entryMode = TimePickerEntryMode.dial, + this.orientation, + this.onEntryModeChanged, + }); + + /// Optionally provide your own text for the help text at the top of the + /// control. + /// + /// If null, the widget uses [MaterialLocalizations.timePickerDialHelpText] + /// when the [entryMode] is [TimePickerEntryMode.dial], and + /// [MaterialLocalizations.timePickerInputHelpText] when the [entryMode] is + /// [TimePickerEntryMode.input]. + final String? helpText; + + /// Optionally provide your own text for the cancel button. + /// + /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. + final String? cancelText; + + /// Optionally provide your own text for the confirm button. + /// + /// If null, the button uses [MaterialLocalizations.okButtonLabel]. + final String? confirmText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + /// Restoration ID to save and restore the state of the [TimePickerDialog]. + /// + /// If it is non-null, the time picker will persist and restore the + /// dialog's state. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// The initial entry mode for the picker. Whether it's text input or a dial. + final TimePickerEntryMode entryMode; + + /// The currently selected time of day. + final TimeOfDay time; + + final ValueChanged? onTimeChanged; + + /// The optional [orientation] parameter sets the [Orientation] to use when + /// displaying the dialog. + /// + /// By default, the orientation is derived from the [MediaQueryData.size] of + /// the ambient [MediaQuery]. If the aspect of the size is tall, then + /// [Orientation.portrait] is used, if the size is wide, then + /// [Orientation.landscape] is used. + /// + /// Use this parameter to override the default and force the dialog to appear + /// in either portrait or landscape mode regardless of the aspect of the + /// [MediaQueryData.size]. + final Orientation? orientation; + + /// Callback called when the selected entry mode is changed. + final EntryModeChangeCallback? onEntryModeChanged; + + @override + State<_TimePicker> createState() => _TimePickerState(); +} + +class _TimePickerState extends State<_TimePicker> with RestorationMixin { + Timer? _vibrateTimer; + late MaterialLocalizations localizations; + final RestorableEnum<_HourMinuteMode> _hourMinuteMode = + RestorableEnum<_HourMinuteMode>(_HourMinuteMode.hour, values: _HourMinuteMode.values); + final RestorableEnumN<_HourMinuteMode> _lastModeAnnounced = + RestorableEnumN<_HourMinuteMode>(null, values: _HourMinuteMode.values); final RestorableBoolN _autofocusHour = RestorableBoolN(null); final RestorableBoolN _autofocusMinute = RestorableBoolN(null); final RestorableBool _announcedInitialTime = RestorableBool(false); + late final RestorableEnumN _orientation = + RestorableEnumN(widget.orientation, values: Orientation.values); + RestorableTimeOfDay get selectedTime => _selectedTime; + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.time); - late final VoidCallback _entryModeListener; + @override + void dispose() { + _vibrateTimer?.cancel(); + _vibrateTimer = null; + super.dispose(); + } @override void didChangeDependencies() { @@ -2049,10 +2647,18 @@ class _TimePickerDialogState extends State with RestorationMix } @override - void initState() { - super.initState(); - _entryModeListener = () => widget.onEntryModeChanged?.call(_entryMode.value); - _entryMode.addListener(_entryModeListener); + void didUpdateWidget (_TimePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.orientation != widget.orientation) { + _orientation.value = widget.orientation; + } + if (oldWidget.time != widget.time) { + _selectedTime.value = widget.time; + } + } + + void _setEntryMode(TimePickerEntryMode mode){ + widget.onEntryModeChanged?.call(mode); } @override @@ -2060,22 +2666,15 @@ class _TimePickerDialogState extends State with RestorationMix @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - registerForRestoration(_entryMode, 'entry_mode'); - registerForRestoration(_mode, 'mode'); + registerForRestoration(_hourMinuteMode, 'hour_minute_mode'); registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); - registerForRestoration(_autovalidateMode, 'autovalidateMode'); registerForRestoration(_autofocusHour, 'autofocus_hour'); registerForRestoration(_autofocusMinute, 'autofocus_minute'); registerForRestoration(_announcedInitialTime, 'announced_initial_time'); registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(_orientation, 'orientation'); } - RestorableTimeOfDay get selectedTime => _selectedTime; - late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); - - Timer? _vibrateTimer; - late MaterialLocalizations localizations; - void _vibrate() { switch (Theme.of(context).platform) { case TargetPlatform.android: @@ -2094,50 +2693,50 @@ class _TimePickerDialogState extends State with RestorationMix } } - void _handleModeChanged(_TimePickerMode mode) { + void _handleHourMinuteModeChanged(_HourMinuteMode mode) { _vibrate(); setState(() { - _mode.value = mode; + _hourMinuteMode.value = mode; _announceModeOnce(); }); } void _handleEntryModeToggle() { setState(() { - switch (_entryMode.value) { + TimePickerEntryMode newMode = widget.entryMode; + switch (widget.entryMode) { case TimePickerEntryMode.dial: - _autovalidateMode.value = AutovalidateMode.disabled; - _entryMode.value = TimePickerEntryMode.input; + newMode = TimePickerEntryMode.input; break; case TimePickerEntryMode.input: - _formKey.currentState!.save(); _autofocusHour.value = false; _autofocusMinute.value = false; - _entryMode.value = TimePickerEntryMode.dial; + newMode = TimePickerEntryMode.dial; break; case TimePickerEntryMode.dialOnly: case TimePickerEntryMode.inputOnly: - FlutterError('Can not change entry mode from $_entryMode'); + FlutterError('Can not change entry mode from ${widget.entryMode}'); break; } + _setEntryMode(newMode); }); } void _announceModeOnce() { - if (_lastModeAnnounced.value == _mode.value) { + if (_lastModeAnnounced.value == _hourMinuteMode.value) { // Already announced it. return; } - switch (_mode.value) { - case _TimePickerMode.hour: + switch (_hourMinuteMode.value) { + case _HourMinuteMode.hour: _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement); break; - case _TimePickerMode.minute: + case _HourMinuteMode.minute: _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement); break; } - _lastModeAnnounced.value = _mode.value; + _lastModeAnnounced.value = _hourMinuteMode.value; } void _announceInitialTimeOnce() { @@ -2148,7 +2747,7 @@ class _TimePickerDialogState extends State with RestorationMix final MaterialLocalizations localizations = MaterialLocalizations.of(context); _announceToAccessibility( context, - localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)), + localizations.formatTimeOfDay(_selectedTime.value, alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)), ); _announcedInitialTime.value = true; } @@ -2157,6 +2756,7 @@ class _TimePickerDialogState extends State with RestorationMix _vibrate(); setState(() { _selectedTime.value = value; + widget.onTimeChanged?.call(value); }); } @@ -2172,160 +2772,94 @@ class _TimePickerDialogState extends State with RestorationMix void _handleHourSelected() { setState(() { - _mode.value = _TimePickerMode.minute; + _hourMinuteMode.value = _HourMinuteMode.minute; }); } - void _handleCancel() { - Navigator.pop(context); - } - - void _handleOk() { - if (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) { - final FormState form = _formKey.currentState!; - if (!form.validate()) { - setState(() { _autovalidateMode.value = AutovalidateMode.always; }); - return; - } - form.save(); - } - Navigator.pop(context, _selectedTime.value); - } - - Size _dialogSize(BuildContext context) { - final Orientation orientation = MediaQuery.orientationOf(context); - final ThemeData theme = Theme.of(context); - // Constrain the textScaleFactor to prevent layout issues. Since only some - // parts of the time picker scale up with textScaleFactor, we cap the factor - // to 1.1 as that provides enough space to reasonably fit all the content. - final double textScaleFactor = math.min(MediaQuery.textScaleFactorOf(context), 1.1); - - final double timePickerWidth; - final double timePickerHeight; - switch (_entryMode.value) { - case TimePickerEntryMode.dial: - case TimePickerEntryMode.dialOnly: - switch (orientation) { - case Orientation.portrait: - timePickerWidth = _kTimePickerWidthPortrait; - timePickerHeight = theme.materialTapTargetSize == MaterialTapTargetSize.padded - ? _kTimePickerHeightPortrait - : _kTimePickerHeightPortraitCollapsed; - break; - case Orientation.landscape: - timePickerWidth = _kTimePickerWidthLandscape * textScaleFactor; - timePickerHeight = theme.materialTapTargetSize == MaterialTapTargetSize.padded - ? _kTimePickerHeightLandscape - : _kTimePickerHeightLandscapeCollapsed; - break; - } - break; - case TimePickerEntryMode.input: - case TimePickerEntryMode.inputOnly: - timePickerWidth = _kTimePickerWidthPortrait; - timePickerHeight = _kTimePickerHeightInput; - break; - } - return Size(timePickerWidth, timePickerHeight * textScaleFactor); - } - @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context)); - final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; final ThemeData theme = Theme.of(context); - final ShapeBorder shape = TimePickerTheme.of(context).shape ?? _kDefaultShape; - final Orientation orientation = MediaQuery.orientationOf(context); - - final Widget actions = Row( - children: [ - const SizedBox(width: 10.0), - if (_entryMode.value == TimePickerEntryMode.dial || _entryMode.value == TimePickerEntryMode.input) - IconButton( - color: TimePickerTheme.of(context).entryModeIconColor ?? theme.colorScheme.onSurface.withOpacity( - theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6, - ), - onPressed: _handleEntryModeToggle, - icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time), - tooltip: _entryMode.value == TimePickerEntryMode.dial - ? MaterialLocalizations.of(context).inputTimeModeButtonLabel - : MaterialLocalizations.of(context).dialModeButtonLabel, - ), - Expanded( - child: Container( - alignment: AlignmentDirectional.centerEnd, - constraints: const BoxConstraints(minHeight: 52.0), - padding: const EdgeInsets.symmetric(horizontal: 8), - child: OverflowBar( - spacing: 8, - overflowAlignment: OverflowBarAlignment.end, - children: [ - TextButton( - onPressed: _handleCancel, - child: Text(widget.cancelText ?? ( - theme.useMaterial3 - ? localizations.cancelButtonLabel - : localizations.cancelButtonLabel.toUpperCase() - )), - ), - TextButton( - onPressed: _handleOk, - child: Text(widget.confirmText ?? localizations.okButtonLabel), - ), - ], - ), - ), - ), - ], - ); + final _TimePickerDefaults defaultTheme = theme.useMaterial3 ? _TimePickerDefaultsM3(context) : _TimePickerDefaultsM2(context); + final Orientation orientation = _orientation.value ?? MediaQuery.orientationOf(context); + final HourFormat timeOfDayHour = hourFormat(of: timeOfDayFormat); + final _HourDialType hourMode; + switch (timeOfDayHour) { + case HourFormat.HH: + case HourFormat.H: + hourMode = theme.useMaterial3 ? _HourDialType.twentyFourHourDoubleRing : _HourDialType.twentyFourHour; + break; + case HourFormat.h: + hourMode = _HourDialType.twelveHour; + break; + } + final String helpText; final Widget picker; - switch (_entryMode.value) { + switch (widget.entryMode) { case TimePickerEntryMode.dial: case TimePickerEntryMode.dialOnly: + helpText = widget.helpText ?? (theme.useMaterial3 + ? localizations.timePickerDialHelpText + : localizations.timePickerDialHelpText.toUpperCase()); + + final EdgeInsetsGeometry dialPadding; + switch (orientation) { + case Orientation.portrait: + dialPadding = const EdgeInsets.only(left: 12, right: 12, top: 36); + break; + case Orientation.landscape: + switch (theme.materialTapTargetSize) { + case MaterialTapTargetSize.padded: + dialPadding = const EdgeInsetsDirectional.only(start: 64); + break; + case MaterialTapTargetSize.shrinkWrap: + dialPadding = const EdgeInsetsDirectional.only(start: 64); + break; + } + break; + } final Widget dial = Padding( - padding: orientation == Orientation.portrait ? const EdgeInsets.symmetric(horizontal: 36, vertical: 24) : const EdgeInsets.all(24), + padding: dialPadding, child: ExcludeSemantics( - child: AspectRatio( - aspectRatio: 1.0, - child: _Dial( - mode: _mode.value, - use24HourDials: use24HourDials, - selectedTime: _selectedTime.value, - onChanged: _handleTimeChanged, - onHourSelected: _handleHourSelected, + child: SizedBox.fromSize( + size: defaultTheme.dialSize, + child: AspectRatio( + aspectRatio: 1, + child: _Dial( + hourMinuteMode: _hourMinuteMode.value, + hourDialType: hourMode, + selectedTime: _selectedTime.value, + onChanged: _handleTimeChanged, + onHourSelected: _handleHourSelected, + ), ), ), ), ); - final Widget header = _TimePickerHeader( - selectedTime: _selectedTime.value, - mode: _mode.value, - orientation: orientation, - onModeChanged: _handleModeChanged, - onChanged: _handleTimeChanged, - onHourDoubleTapped: _handleHourDoubleTapped, - onMinuteDoubleTapped: _handleMinuteDoubleTapped, - use24HourDials: use24HourDials, - helpText: widget.helpText, - ); - switch (orientation) { case Orientation.portrait: picker = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - header, + Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: _TimePickerHeader(helpText: helpText), + ), Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ // Dial grows and shrinks with the available space. - Expanded(child: dial), - actions, + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: dial, + ), + ), ], ), ), @@ -2336,14 +2870,17 @@ class _TimePickerDialogState extends State with RestorationMix picker = Column( children: [ Expanded( - child: Row( - children: [ - header, - Expanded(child: dial), - ], + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.useMaterial3 ? 0 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TimePickerHeader(helpText: helpText), + Expanded(child: dial), + ], + ), ), ), - actions, ], ); break; @@ -2351,58 +2888,43 @@ class _TimePickerDialogState extends State with RestorationMix break; case TimePickerEntryMode.input: case TimePickerEntryMode.inputOnly: - picker = Form( - key: _formKey, - autovalidateMode: _autovalidateMode.value, - child: SingleChildScrollView( - restorationId: 'time_picker_scroll_view', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _TimePickerInput( - initialSelectedTime: _selectedTime.value, - helpText: widget.helpText, - errorInvalidText: widget.errorInvalidText, - hourLabelText: widget.hourLabelText, - minuteLabelText: widget.minuteLabelText, - autofocusHour: _autofocusHour.value, - autofocusMinute: _autofocusMinute.value, - onChanged: _handleTimeChanged, - restorationId: 'time_picker_input', - ), - actions, - ], + final String helpText = widget.helpText ?? (theme.useMaterial3 + ? localizations.timePickerInputHelpText + : localizations.timePickerInputHelpText.toUpperCase()); + + picker = Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TimePickerInput( + initialSelectedTime: _selectedTime.value, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + helpText: helpText, + autofocusHour: _autofocusHour.value, + autofocusMinute: _autofocusMinute.value, + restorationId: 'time_picker_input', ), - ), + ], ); - break; } - - final Size dialogSize = _dialogSize(context); - return Dialog( - shape: shape, - backgroundColor: TimePickerTheme.of(context).backgroundColor ?? theme.colorScheme.surface, - insetPadding: EdgeInsets.symmetric( - horizontal: 16.0, - vertical: (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) ? 0.0 : 24.0, - ), - child: AnimatedContainer( - width: dialogSize.width, - height: dialogSize.height, - duration: _kDialogSizeAnimationDuration, - curve: Curves.easeIn, - child: picker, - ), + return _TimePickerModel( + entryMode: widget.entryMode, + selectedTime: _selectedTime.value, + hourMinuteMode: _hourMinuteMode.value, + orientation: orientation, + onHourMinuteModeChanged: _handleHourMinuteModeChanged, + onHourDoubleTapped: _handleHourDoubleTapped, + onMinuteDoubleTapped: _handleMinuteDoubleTapped, + hourDialType: hourMode, + onSelectedTimeChanged: _handleTimeChanged, + useMaterial3: theme.useMaterial3, + use24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + theme: TimePickerTheme.of(context), + defaultTheme: defaultTheme, + child: picker, ); } - - @override - void dispose() { - _vibrateTimer?.cancel(); - _vibrateTimer = null; - _entryMode.removeListener(_entryModeListener); - super.dispose(); - } } /// Shows a dialog containing a Material Design time picker. @@ -2410,8 +2932,7 @@ class _TimePickerDialogState extends State with RestorationMix /// The returned Future resolves to the time selected by the user when the user /// closes the dialog. If the user cancels the dialog, null is returned. /// -/// {@tool snippet} -/// Show a dialog with [initialTime] equal to the current time. +/// {@tool snippet} Show a dialog with [initialTime] equal to the current time. /// /// ```dart /// Future selectedTime = showTimePicker( @@ -2421,29 +2942,35 @@ class _TimePickerDialogState extends State with RestorationMix /// ``` /// {@end-tool} /// -/// The [context], [useRootNavigator] and [routeSettings] arguments are passed to -/// [showDialog], the documentation for which discusses how it is used. +/// The [context], [useRootNavigator] and [routeSettings] arguments are passed +/// to [showDialog], the documentation for which discusses how it is used. /// -/// The [builder] parameter can be used to wrap the dialog widget -/// to add inherited widgets like [Localizations.override], -/// [Directionality], or [MediaQuery]. +/// The [builder] parameter can be used to wrap the dialog widget to add +/// inherited widgets like [Localizations.override], [Directionality], or +/// [MediaQuery]. /// -/// The `initialEntryMode` parameter can be used to -/// determine the initial time entry selection of the picker (either a clock -/// dial or text input). +/// The `initialEntryMode` parameter can be used to determine the initial time +/// entry selection of the picker (either a clock dial or text input). /// /// Optional strings for the [helpText], [cancelText], [errorInvalidText], /// [hourLabelText], [minuteLabelText] and [confirmText] can be provided to /// override the default values. /// +/// The optional [orientation] parameter sets the [Orientation] to use when +/// displaying the dialog. By default, the orientation is derived from the +/// [MediaQueryData.size] of the ambient [MediaQuery]: wide sizes use the +/// landscape orientation, and tall sizes use the portrait orientation. Use this +/// parameter to override the default and force the dialog to appear in either +/// portrait or landscape mode. +/// /// {@macro flutter.widgets.RawDialogRoute} /// /// By default, the time picker gets its colors from the overall theme's /// [ColorScheme]. The time picker can be further customized by providing a /// [TimePickerThemeData] to the overall theme. /// -/// {@tool snippet} -/// Show a dialog with the text direction overridden to be [TextDirection.rtl]. +/// {@tool snippet} Show a dialog with the text direction overridden to be +/// [TextDirection.rtl]. /// /// ```dart /// Future selectedTimeRTL = showTimePicker( @@ -2459,8 +2986,8 @@ class _TimePickerDialogState extends State with RestorationMix /// ``` /// {@end-tool} /// -/// {@tool snippet} -/// Show a dialog with time unconditionally displayed in 24 hour format. +/// {@tool snippet} Show a dialog with time unconditionally displayed in 24 hour +/// format. /// /// ```dart /// Future selectedTime24Hour = showTimePicker( @@ -2476,14 +3003,21 @@ class _TimePickerDialogState extends State with RestorationMix /// ``` /// {@end-tool} /// +/// {@tool dartpad} +/// This example illustrates how to open a time picker, and allows exploring +/// some of the variations in the types of time pickers that may be shown. +/// +/// ** See code in examples/api/lib/material/time_picker/show_time_picker.0.dart ** +/// {@end-tool} +/// /// See also: /// -/// * [showDatePicker], which shows a dialog that contains a Material Design -/// date picker. -/// * [TimePickerThemeData], which allows you to customize the colors, -/// typography, and shape of the time picker. -/// * [DisplayFeatureSubScreen], which documents the specifics of how -/// [DisplayFeature]s can split the screen into sub-screens. +/// * [showDatePicker], which shows a dialog that contains a Material Design +/// date picker. +/// * [TimePickerThemeData], which allows you to customize the colors, +/// typography, and shape of the time picker. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. Future showTimePicker({ required BuildContext context, required TimeOfDay initialTime, @@ -2499,6 +3033,7 @@ Future showTimePicker({ RouteSettings? routeSettings, EntryModeChangeCallback? onEntryModeChanged, Offset? anchorPoint, + Orientation? orientation, }) async { assert(context != null); assert(initialTime != null); @@ -2515,6 +3050,7 @@ Future showTimePicker({ errorInvalidText: errorInvalidText, hourLabelText: hourLabelText, minuteLabelText: minuteLabelText, + orientation: orientation, onEntryModeChanged: onEntryModeChanged, ); return showDialog( @@ -2531,3 +3067,664 @@ Future showTimePicker({ void _announceToAccessibility(BuildContext context, String message) { SemanticsService.announce(message, Directionality.of(context)); } + +// An abstract base class for the M2 and M3 defaults below, so that their return +// types can be non-nullable. +abstract class _TimePickerDefaults extends TimePickerThemeData { + @override + Color get backgroundColor; + + @override + ButtonStyle get cancelButtonStyle; + + @override + ButtonStyle get confirmButtonStyle; + + @override + BorderSide get dayPeriodBorderSide; + + @override + Color get dayPeriodColor; + + @override + OutlinedBorder get dayPeriodShape; + + Size get dayPeriodInputSize; + Size get dayPeriodLandscapeSize; + Size get dayPeriodPortraitSize; + + @override + Color get dayPeriodTextColor; + + @override + TextStyle get dayPeriodTextStyle; + + @override + Color get dialBackgroundColor; + + @override + Color get dialHandColor; + + // Sizes that are generated from the tokens, but these aren't ones we're ready + // to expose in the theme. + Size get dialSize; + double get handWidth; + double get dotRadius; + double get centerRadius; + + @override + Color get dialTextColor; + + @override + TextStyle get dialTextStyle; + + @override + double get elevation; + + @override + Color get entryModeIconColor; + + @override + TextStyle get helpTextStyle; + + @override + Color get hourMinuteColor; + + @override + ShapeBorder get hourMinuteShape; + + Size get hourMinuteSize; + Size get hourMinuteSize24Hour; + Size get hourMinuteInputSize; + Size get hourMinuteInputSize24Hour; + + @override + Color get hourMinuteTextColor; + + @override + TextStyle get hourMinuteTextStyle; + + @override + InputDecorationTheme get inputDecorationTheme; + + @override + EdgeInsetsGeometry get padding; + + @override + ShapeBorder get shape; +} + +// These theme defaults are not auto-generated: they match the values for the +// Material 2 spec, which are not expected to change. +class _TimePickerDefaultsM2 extends _TimePickerDefaults { + _TimePickerDefaultsM2(this.context) : super(); + + final BuildContext context; + + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + static const OutlinedBorder _kDefaultShape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))); + + @override + Color get backgroundColor { + return _colors.surface; + } + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + BorderSide get dayPeriodBorderSide { + return BorderSide( + color: Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface), + ); + } + + @override + Color get dayPeriodColor { + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return _colors.primary.withOpacity(_colors.brightness == Brightness.dark ? 0.24 : 0.12); + } + // 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 _kDefaultShape; + } + + @override + Size get dayPeriodPortraitSize { + return const Size(52, 80); + } + + @override + Size get dayPeriodLandscapeSize { + return const Size(0, 40); + } + + @override + Size get dayPeriodInputSize { + return const Size(52, 70); + } + + @override + Color get dayPeriodTextColor { + return MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? _colors.primary : _colors.onSurface.withOpacity(0.60); + }); + } + + @override + TextStyle get dayPeriodTextStyle { + return _textTheme.titleMedium!.copyWith(color: dayPeriodTextColor); + } + + @override + Color get dialBackgroundColor { + return _colors.onSurface.withOpacity(_colors.brightness == Brightness.dark ? 0.12 : 0.08); + } + + @override + Color get dialHandColor { + return _colors.primary; + } + + @override + Size get dialSize { + return const Size.square(280); + } + + @override + double get handWidth { + return 2; + } + + @override + double get dotRadius { + return 22; + } + + @override + double get centerRadius { + return 4; + } + + @override + Color get dialTextColor { + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return _colors.surface; + } + return _colors.onSurface; + }); + } + + @override + TextStyle get dialTextStyle { + return _textTheme.bodyLarge!; + } + + @override + double get elevation { + return 6; + } + + @override + Color get entryModeIconColor { + return _colors.onSurface.withOpacity(_colors.brightness == Brightness.dark ? 1.0 : 0.6); + } + + @override + TextStyle get helpTextStyle { + return _textTheme.labelSmall!; + } + + @override + Color get hourMinuteColor { + return MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.selected) + ? _colors.primary.withOpacity(_colors.brightness == Brightness.dark ? 0.24 : 0.12) + : _colors.onSurface.withOpacity(0.12); + }); + } + + @override + ShapeBorder get hourMinuteShape { + return _kDefaultShape; + } + + @override + Size get hourMinuteSize { + return const Size(96, 80); + } + + @override + Size get hourMinuteSize24Hour { + return const Size(114, 80); + } + + @override + Size get hourMinuteInputSize { + return const Size(96, 70); + } + + @override + Size get hourMinuteInputSize24Hour { + return const Size(114, 70); + } + + @override + Color get hourMinuteTextColor { + return MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.selected) ? _colors.primary : _colors.onSurface; + }); + } + + @override + TextStyle get hourMinuteTextStyle { + return _textTheme.displayMedium!; + } + + Color get _hourMinuteInputColor { + return MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.selected) + ? Colors.transparent + : _colors.onSurface.withOpacity(0.12); + }); + } + + @override + InputDecorationTheme get inputDecorationTheme { + return InputDecorationTheme( + contentPadding: EdgeInsets.zero, + filled: true, + fillColor: _hourMinuteInputColor, + focusColor: Colors.transparent, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: _colors.error, width: 2), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: _colors.primary, width: 2), + ), + focusedErrorBorder: OutlineInputBorder( + 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 + EdgeInsetsGeometry get padding { + return const EdgeInsets.fromLTRB(8, 18, 8, 8); + } + + @override + ShapeBorder get shape { + return _kDefaultShape; + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - TimePicker + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_143 + +// Generated version v0_143 +class _TimePickerDefaultsM3 extends _TimePickerDefaults { + _TimePickerDefaultsM3(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 _colors.surface; + } + + @override + ButtonStyle get cancelButtonStyle { + return TextButton.styleFrom(); + } + + @override + ButtonStyle get confirmButtonStyle { + return TextButton.styleFrom(); + } + + @override + BorderSide get dayPeriodBorderSide { + return BorderSide(color: _colors.outline); + } + + @override + Color get dayPeriodColor { + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return _colors.tertiaryContainer; + } + // 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 const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))).copyWith(side: dayPeriodBorderSide); + } + + @override + Size get dayPeriodPortraitSize { + return const Size(52, 80); + } + + @override + Size get dayPeriodLandscapeSize { + return const Size(216, 38); + } + + @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 states) { + return _dayPeriodForegroundColor.resolve(states); + }); + } + + MaterialStateProperty get _dayPeriodForegroundColor { + return MaterialStateProperty.resolveWith((Set states) { + Color? textColor; + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + textColor = _colors.onTertiaryContainer; + } else { + // not pressed + if (states.contains(MaterialState.focused)) { + textColor = _colors.onTertiaryContainer; + } else { + // not focused + if (states.contains(MaterialState.hovered)) { + textColor = _colors.onTertiaryContainer; + } + } + } + } else { + // unselected + if (states.contains(MaterialState.pressed)) { + textColor = _colors.onSurfaceVariant; + } else { + // not pressed + if (states.contains(MaterialState.focused)) { + textColor = _colors.onSurfaceVariant; + } else { + // not focused + if (states.contains(MaterialState.hovered)) { + textColor = _colors.onSurfaceVariant; + } + } + } + } + return textColor ?? _colors.onTertiaryContainer; + }); + } + + @override + TextStyle get dayPeriodTextStyle { + return _textTheme.titleMedium!.copyWith(color: dayPeriodTextColor); + } + + @override + Color get dialBackgroundColor { + return _colors.onSurfaceVariant.withOpacity(_colors.brightness == Brightness.dark ? 0.12 : 0.08); + } + + @override + Color get dialHandColor { + return _colors.primary; + } + + @override + Size get dialSize { + return const Size.square(256.0); + } + + @override + double get handWidth { + return const Size(2, double.infinity).width; + } + + @override + double get dotRadius { + return const Size.square(48.0).width / 2; + } + + @override + double get centerRadius { + return const Size.square(8.0).width / 2; + } + + @override + Color get dialTextColor { + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + return _colors.onPrimary; + } + return _colors.onSurface; + }); + } + + @override + TextStyle get dialTextStyle { + return _textTheme.bodyLarge!; + } + + @override + double get elevation { + return 6.0; + } + + @override + Color get entryModeIconColor { + return _colors.onSurface; + } + + @override + TextStyle get helpTextStyle { + return MaterialStateTextStyle.resolveWith((Set states) { + final TextStyle textStyle = _textTheme.labelMedium!; + return textStyle.copyWith(color: _colors.onSurfaceVariant); + }); + } + + @override + EdgeInsetsGeometry get padding { + return const EdgeInsets.all(24); + } + + @override + Color get hourMinuteColor { + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + Color overlayColor = _colors.primaryContainer; + if (states.contains(MaterialState.pressed)) { + overlayColor = _colors.onPrimaryContainer; + } else if (states.contains(MaterialState.focused)) { + const double focusOpacity = 0.12; + overlayColor = _colors.onPrimaryContainer.withOpacity(focusOpacity); + } else if (states.contains(MaterialState.hovered)) { + const double hoverOpacity = 0.08; + overlayColor = _colors.onPrimaryContainer.withOpacity(hoverOpacity); + } + return Color.alphaBlend(overlayColor, _colors.primaryContainer); + } else { + Color overlayColor = _colors.surfaceVariant; + if (states.contains(MaterialState.pressed)) { + overlayColor = _colors.onSurface; + } else if (states.contains(MaterialState.focused)) { + const double focusOpacity = 0.12; + overlayColor = _colors.onSurface.withOpacity(focusOpacity); + } else if (states.contains(MaterialState.hovered)) { + const double hoverOpacity = 0.08; + overlayColor = _colors.onSurface.withOpacity(hoverOpacity); + } + return Color.alphaBlend(overlayColor, _colors.surfaceVariant); + } + }); + } + + @override + ShapeBorder get hourMinuteShape { + return const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))); + } + + @override + Size get hourMinuteSize { + return const Size(96, 80); + } + + @override + Size get hourMinuteSize24Hour { + return Size(const Size(114, double.infinity).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 states) { + return _hourMinuteTextColor.resolve(states); + }); + } + + MaterialStateProperty get _hourMinuteTextColor { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.selected)) { + if (states.contains(MaterialState.pressed)) { + return _colors.onPrimaryContainer; + } + if (states.contains(MaterialState.focused)) { + return _colors.onPrimaryContainer; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onPrimaryContainer; + } + return _colors.onPrimaryContainer; + } else { + // unselected + if (states.contains(MaterialState.pressed)) { + return _colors.onSurface; + } + if (states.contains(MaterialState.focused)) { + return _colors.onSurface; + } + if (states.contains(MaterialState.hovered)) { + return _colors.onSurface; + } + return _colors.onSurface; + } + }); + } + + @override + TextStyle get hourMinuteTextStyle { + return MaterialStateTextStyle.resolveWith((Set states) { + return _textTheme.displayLarge!.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. It's a BorderRadiusGeometry, so we have to + // resolve it before we can use it. + final BorderRadius selectorRadius = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ) + .borderRadius + .resolve(Directionality.of(context)); + 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 const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + } +} + +// END GENERATED TOKEN PROPERTIES - TimePicker diff --git a/packages/flutter/lib/src/material/time_picker_theme.dart b/packages/flutter/lib/src/material/time_picker_theme.dart index ea6cc946d2f..1f44f352442 100644 --- a/packages/flutter/lib/src/material/time_picker_theme.dart +++ b/packages/flutter/lib/src/material/time_picker_theme.dart @@ -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.dayPeriodColor, - this.dialHandColor, - this.dialBackgroundColor, - this.dialTextColor, - this.entryModeIconColor, - this.hourMinuteTextStyle, - this.dayPeriodTextStyle, - this.helpTextStyle, - this.shape, - this.hourMinuteShape, - this.dayPeriodShape, + this.cancelButtonStyle, + this.confirmButtonStyle, this.dayPeriodBorderSide, + this.dayPeriodColor, + this.dayPeriodShape, + this.dayPeriodTextColor, + this.dayPeriodTextStyle, + this.dialBackgroundColor, + this.dialHandColor, + this.dialTextColor, + this.dialTextStyle, + this.elevation, + this.entryModeIconColor, + this.helpTextStyle, + this.hourMinuteColor, + this.hourMinuteShape, + this.hourMinuteTextColor, + this.hourMinuteTextStyle, this.inputDecorationTheme, + this.padding, + this.shape, }); /// The background color of a time picker. @@ -60,41 +69,25 @@ class TimePickerThemeData with Diagnosticable { /// [ColorScheme.background]. final Color? backgroundColor; - /// The color of the header text that represents hours and minutes. - /// - /// 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. - /// - /// 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 style of the cancel button of a [TimePickerDialog]. + final ButtonStyle? cancelButtonStyle; - /// The background color of the hour and minutes header segments. - /// - /// 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 style of the conform (OK) button of a [TimePickerDialog]. + final ButtonStyle? confirmButtonStyle; - /// The color of the day period text that represents AM/PM. + /// The color and weight of the day period's outline. /// - /// If [dayPeriodTextColor] 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.withOpacity(0.60)` when - /// it's not selected. - final Color? dayPeriodTextColor; + /// ```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 AM/PM toggle. /// @@ -111,69 +104,6 @@ class TimePickerThemeData with Diagnosticable { /// [Dialog]'s color to be used. final Color? dayPeriodColor; - /// The color of the time picker dial's hand. - /// - /// If this is null, the time picker defaults to the overall theme's - /// [ColorScheme.primary]. - final Color? dialHandColor; - - /// The background color of the time picker dial. - /// - /// If this is null, the time picker defaults to the overall theme's - /// [ColorScheme.primary]. - final Color? dialBackgroundColor; - - /// The color of the dial text that represents specific hours and minutes. - /// - /// If [dialTextColor] 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 color is null then the dial's text colors are based on the - /// theme's [ThemeData.colorScheme]. - final Color? dialTextColor; - - /// 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, - /// ) - /// ``` - 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. - /// - /// If this is null, the time picker defaults to - /// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`. - final ShapeBorder? shape; - - /// The shape of the hour and minute controls that the time picker uses. - /// - /// If this is null, the time picker defaults to - /// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`. - final ShapeBorder? hourMinuteShape; - /// The shape of the day period that the time picker uses. /// /// If this is null, the time picker defaults to: @@ -186,64 +116,180 @@ class TimePickerThemeData with Diagnosticable { /// ``` final OutlinedBorder? dayPeriodShape; - /// The color and weight of the day period's outline. + /// The color of the day period text that represents AM/PM. + /// + /// If [dayPeriodTextColor] is a [MaterialStateColor], then the effective + /// text color can depend on the [MaterialState.selected] state, i.e. if the + /// text is selected or not. + /// + /// By default the overall theme's [ColorScheme.primary] color is used when + /// the text is selected and `ColorScheme.onSurface.withOpacity(0.60)` when + /// it's not selected. + final Color? dayPeriodTextColor; + + /// 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; + + /// 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? dialBackgroundColor; + + /// 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? dialHandColor; + + /// The color of the dial text that represents specific hours and minutes. + /// + /// If [dialTextColor] 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 color is null then the dial's text colors are based on the + /// 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 - /// BorderSide( - /// color: Color.alphaBlend( - /// Theme.of(context).colorScheme.onBackground.withOpacity(0.38), - /// Theme.of(context).colorScheme.surface, - /// ), - /// ), + /// Theme.of(context).colorScheme.onSurface.withOpacity( + /// Theme.of(context).colorScheme.brightness == Brightness.dark ? 1.0 : 0.6, + /// ) /// ``` - final BorderSide? dayPeriodBorderSide; + final Color? entryModeIconColor; + + /// 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 background color of the hour and minute header segments. + /// + /// 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. + /// + /// If this is null, the time picker defaults to + /// `RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))`. + final ShapeBorder? hourMinuteShape; + + /// The color of the header text that represents hours and minutes. + /// + /// 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. + /// + /// 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; + + /// 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; /// 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, - Color? dayPeriodColor, - Color? dialHandColor, - Color? dialBackgroundColor, - Color? dialTextColor, - Color? entryModeIconColor, - TextStyle? hourMinuteTextStyle, - TextStyle? dayPeriodTextStyle, - TextStyle? helpTextStyle, - ShapeBorder? shape, - ShapeBorder? hourMinuteShape, - OutlinedBorder? dayPeriodShape, + ButtonStyle? cancelButtonStyle, + ButtonStyle? confirmButtonStyle, + ButtonStyle? dayPeriodButtonStyle, BorderSide? dayPeriodBorderSide, + Color? dayPeriodColor, + OutlinedBorder? dayPeriodShape, + Color? dayPeriodTextColor, + TextStyle? dayPeriodTextStyle, + Color? dialBackgroundColor, + Color? dialHandColor, + Color? dialTextColor, + TextStyle? dialTextStyle, + double? elevation, + Color? entryModeIconColor, + TextStyle? helpTextStyle, + Color? hourMinuteColor, + ShapeBorder? hourMinuteShape, + 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, - dayPeriodColor: dayPeriodColor ?? this.dayPeriodColor, - dialHandColor: dialHandColor ?? this.dialHandColor, - dialBackgroundColor: dialBackgroundColor ?? this.dialBackgroundColor, - dialTextColor: dialTextColor ?? this.dialTextColor, - entryModeIconColor: entryModeIconColor ?? this.entryModeIconColor, - hourMinuteTextStyle: hourMinuteTextStyle ?? this.hourMinuteTextStyle, - dayPeriodTextStyle: dayPeriodTextStyle ?? this.dayPeriodTextStyle, - helpTextStyle: helpTextStyle ?? this.helpTextStyle, - shape: shape ?? this.shape, - hourMinuteShape: hourMinuteShape ?? this.hourMinuteShape, - dayPeriodShape: dayPeriodShape ?? this.dayPeriodShape, + cancelButtonStyle: cancelButtonStyle ?? this.cancelButtonStyle, + confirmButtonStyle: confirmButtonStyle ?? this.confirmButtonStyle, dayPeriodBorderSide: dayPeriodBorderSide ?? this.dayPeriodBorderSide, + dayPeriodColor: dayPeriodColor ?? this.dayPeriodColor, + 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, + helpTextStyle: helpTextStyle ?? this.helpTextStyle, + hourMinuteColor: hourMinuteColor ?? this.hourMinuteColor, + hourMinuteShape: hourMinuteShape ?? this.hourMinuteShape, + 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), - dayPeriodColor: Color.lerp(a?.dayPeriodColor, b?.dayPeriodColor, t), - dialHandColor: Color.lerp(a?.dialHandColor, b?.dialHandColor, t), - dialBackgroundColor: Color.lerp(a?.dialBackgroundColor, b?.dialBackgroundColor, t), - dialTextColor: Color.lerp(a?.dialTextColor, b?.dialTextColor, 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), - hourMinuteShape: ShapeBorder.lerp(a?.hourMinuteShape, b?.hourMinuteShape, t), - dayPeriodShape: ShapeBorder.lerp(a?.dayPeriodShape, b?.dayPeriodShape, t) as OutlinedBorder?, + 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), + 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), + helpTextStyle: TextStyle.lerp(a?.helpTextStyle, b?.helpTextStyle, t), + hourMinuteColor: Color.lerp(a?.hourMinuteColor, b?.hourMinuteColor, t), + hourMinuteShape: ShapeBorder.lerp(a?.hourMinuteShape, b?.hourMinuteShape, t), + 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([ backgroundColor, - hourMinuteTextColor, - hourMinuteColor, - dayPeriodTextColor, - dayPeriodColor, - dialHandColor, - dialBackgroundColor, - dialTextColor, - entryModeIconColor, - hourMinuteTextStyle, - dayPeriodTextStyle, - helpTextStyle, - shape, - hourMinuteShape, - dayPeriodShape, + cancelButtonStyle, + confirmButtonStyle, dayPeriodBorderSide, + dayPeriodColor, + dayPeriodShape, + dayPeriodTextColor, + dayPeriodTextStyle, + dialBackgroundColor, + dialHandColor, + dialTextColor, + dialTextStyle, + elevation, + entryModeIconColor, + helpTextStyle, + hourMinuteColor, + hourMinuteShape, + 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.dayPeriodColor == dayPeriodColor - && other.dialHandColor == dialHandColor - && other.dialBackgroundColor == dialBackgroundColor - && other.dialTextColor == dialTextColor - && other.entryModeIconColor == entryModeIconColor - && other.hourMinuteTextStyle == hourMinuteTextStyle - && other.dayPeriodTextStyle == dayPeriodTextStyle - && other.helpTextStyle == helpTextStyle - && other.shape == shape - && other.hourMinuteShape == hourMinuteShape - && other.dayPeriodShape == dayPeriodShape + && other.cancelButtonStyle == cancelButtonStyle + && other.confirmButtonStyle == confirmButtonStyle && other.dayPeriodBorderSide == dayPeriodBorderSide - && other.inputDecorationTheme == inputDecorationTheme; + && other.dayPeriodColor == dayPeriodColor + && 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.helpTextStyle == helpTextStyle + && other.hourMinuteColor == hourMinuteColor + && other.hourMinuteShape == hourMinuteShape + && 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(ColorProperty('dayPeriodColor', dayPeriodColor, defaultValue: null)); - properties.add(ColorProperty('dialHandColor', dialHandColor, defaultValue: null)); - properties.add(ColorProperty('dialBackgroundColor', dialBackgroundColor, defaultValue: null)); - properties.add(ColorProperty('dialTextColor', dialTextColor, defaultValue: null)); - properties.add(ColorProperty('entryModeIconColor', entryModeIconColor, defaultValue: null)); - properties.add(DiagnosticsProperty('hourMinuteTextStyle', hourMinuteTextStyle, defaultValue: null)); - properties.add(DiagnosticsProperty('dayPeriodTextStyle', dayPeriodTextStyle, defaultValue: null)); - properties.add(DiagnosticsProperty('helpTextStyle', helpTextStyle, defaultValue: null)); - properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); - properties.add(DiagnosticsProperty('hourMinuteShape', hourMinuteShape, defaultValue: null)); - properties.add(DiagnosticsProperty('dayPeriodShape', dayPeriodShape, defaultValue: null)); + properties.add(DiagnosticsProperty('cancelButtonStyle', cancelButtonStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('confirmButtonStyle', confirmButtonStyle, defaultValue: null)); properties.add(DiagnosticsProperty('dayPeriodBorderSide', dayPeriodBorderSide, defaultValue: null)); + properties.add(ColorProperty('dayPeriodColor', dayPeriodColor, defaultValue: null)); + properties.add(DiagnosticsProperty('dayPeriodShape', dayPeriodShape, defaultValue: null)); + properties.add(ColorProperty('dayPeriodTextColor', dayPeriodTextColor, defaultValue: null)); + properties.add(DiagnosticsProperty('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('dialTextStyle', dialTextStyle, defaultValue: null)); + properties.add(DoubleProperty('elevation', elevation, defaultValue: null)); + properties.add(ColorProperty('entryModeIconColor', entryModeIconColor, defaultValue: null)); + properties.add(DiagnosticsProperty('helpTextStyle', helpTextStyle, defaultValue: null)); + properties.add(ColorProperty('hourMinuteColor', hourMinuteColor, defaultValue: null)); + properties.add(DiagnosticsProperty('hourMinuteShape', hourMinuteShape, defaultValue: null)); + properties.add(ColorProperty('hourMinuteTextColor', hourMinuteTextColor, defaultValue: null)); + properties.add(DiagnosticsProperty('hourMinuteTextStyle', hourMinuteTextStyle, defaultValue: null)); properties.add(DiagnosticsProperty('inputDecorationTheme', inputDecorationTheme, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, defaultValue: null)); + properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); } } diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index 1d1fb78120b..2b5a322fed8 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart @@ -12,9 +12,1583 @@ import 'package:flutter_test/flutter_test.dart'; import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; +void main() { + 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; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); // 12:00 AM + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx + 50, center.dy)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 3, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 0))); + + center = (await startPicker(tester, (TimeOfDay? time) { + result = time; + }, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await tester.tapAt(Offset(center.dx - 50, center.dy)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 0))); + }); + + testWidgets('drag-select an hour', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final 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; + + gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, 0); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour0); + await gesture.moveBy(hour3 - hour0); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, 3); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour3); + await gesture.moveBy(hour6 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, equals(6)); + + expect( + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType), + equals(center), + ); + gesture = await tester.startGesture(hour6); + await gesture.moveBy(hour9 - hour6); + await gesture.up(); + await finishPicker(tester); + expect(result.hour, equals(9)); + }); + + testWidgets('tap-select switches from hour to minute', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final 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)); + await tester.tapAt(min45); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('drag-select switches from hour to minute', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final 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); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 50)); + gesture = await tester.startGesture(hour6); + await gesture.moveBy(hour3 - hour6); + await gesture.up(); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); + }); + + testWidgets('tap-select rounds down to nearest 5 minute increment', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final 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)); + await tester.tapAt(min46); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('tap-select rounds up to nearest 5 minute increment', (WidgetTester tester) async { + late TimeOfDay result; + + final Offset center = (await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, materialType: materialType))!; + final 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)); + await tester.tapAt(min48); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 50))); + }); + }); + + group('Dial Haptic Feedback (${materialType.name})', () { + const Duration kFastFeedbackInterval = Duration(milliseconds: 10); + const Duration kSlowFeedbackInterval = Duration(milliseconds: 200); + late FeedbackTester feedback; + + setUp(() { + feedback = FeedbackTester(); + }); + + tearDown(() { + feedback.dispose(); + }); + + testWidgets('tap-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await tester.pump(kFastFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await tester.pump(kSlowFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy + 50)); + await tester.pump(kSlowFeedbackInterval); + await tester.tapAt(Offset(center.dx, center.dy - 50)); + await finishPicker(tester); + expect(feedback.hapticCount, 3); + }); + + testWidgets('drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + final 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); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + final 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); + await tester.pump(kFastFeedbackInterval); + await gesture.moveBy(hour3 - hour0); + await tester.pump(kFastFeedbackInterval); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 1); + }); + + testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { + final Offset center = (await startPicker(tester, (TimeOfDay? time) {}, materialType: materialType))!; + final 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); + await tester.pump(kSlowFeedbackInterval); + await gesture.moveBy(hour3 - hour0); + await tester.pump(kSlowFeedbackInterval); + await gesture.moveBy(hour0 - hour3); + await gesture.up(); + await finishPicker(tester); + expect(feedback.hapticCount, 3); + }); + }); + + group('Dialog (${materialType.name})', () { + testWidgets('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, materialType: materialType); + const List labels12To11 = ['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final List primaryLabels = dialPainter.primaryLabels as List; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); + + // ignore: avoid_dynamic_calls + final List selectedLabels = dialPainter.selectedLabels as List; + // ignore: avoid_dynamic_calls + expect(selectedLabels.map((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, alwaysUse24HourFormat: true, materialType: materialType); + + final List labels00To22 = List.generate(12, (int index) { + return (index * 2).toString().padLeft(2, '0'); + }); + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final List primaryLabels = dialPainter.primaryLabels as List; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22); + + // ignore: avoid_dynamic_calls + final List selectedLabels = dialPainter.selectedLabels as List; + // ignore: avoid_dynamic_calls + expect(selectedLabels.map((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 List labels00To23 = List.generate(24, (int index) { + return index == 0 ? '00' : index.toString(); + }); + final List inner0To23 = List.generate(24, (int index) => index >= 12); + + final CustomPaint dialPaint = tester.widget(findDialPaint); + final dynamic dialPainter = dialPaint.painter; + // ignore: avoid_dynamic_calls + final List primaryLabels = dialPainter.primaryLabels as List; + // ignore: avoid_dynamic_calls + expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); + // ignore: avoid_dynamic_calls + expect(primaryLabels.map((dynamic tp) => tp.inner as bool), inner0To23); + + // ignore: avoid_dynamic_calls + final List selectedLabels = dialPainter.selectedLabels as List; + // ignore: avoid_dynamic_calls + expect(selectedLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); + // ignore: avoid_dynamic_calls + expect(selectedLabels.map((dynamic tp) => tp.inner as bool), inner0To23); + }); + 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, materialType: materialType); + + 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, 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); + + 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(); + tester.binding.window.clearDevicePixelRatioTestValue(); + }); + + testWidgets('builder parameter', (WidgetTester tester) async { + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: child!, + ); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + final double ltrOkRight = tester.getBottomRight(find.text(okString)).dx; + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + // Verify that the time picker is being laid out RTL. + // We expect the left edge of the 'OK' button in the RTL + // layout to match the gap between right edge of the 'OK' + // button and the right edge of the 800 wide window. + expect(tester.getBottomLeft(find.text(okString)).dx, 800 - ltrOkRight); + }); + + testWidgets('uses root navigator by default', (WidgetTester tester) async { + final PickerObserver rootObserver = PickerObserver(); + final PickerObserver nestedObserver = PickerObserver(); + + await tester.pumpWidget(MaterialApp( + navigatorObservers: [rootObserver], + home: Navigator( + observers: [nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + }, + child: const Text('Show Picker'), + ); + }, + ); + }, + ), + )); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.pickerCount, 1); + expect(nestedObserver.pickerCount, 0); + }); + + testWidgets('uses nested navigator if useRootNavigator is false', (WidgetTester tester) async { + final PickerObserver rootObserver = PickerObserver(); + final PickerObserver nestedObserver = PickerObserver(); + + await tester.pumpWidget(MaterialApp( + navigatorObservers: [rootObserver], + home: Navigator( + observers: [nestedObserver], + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + showTimePicker( + context: context, + useRootNavigator: false, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + }, + child: const Text('Show Picker'), + ); + }, + ); + }, + ), + )); + + // Open the dialog. + await tester.tap(find.byType(ElevatedButton)); + + expect(rootObserver.pickerCount, 0); + expect(nestedObserver.pickerCount, 1); + }); + + testWidgets('optional text parameters are utilized', (WidgetTester tester) async { + const String cancelText = 'Custom Cancel'; + const String confirmText = 'Custom OK'; + const String helperText = 'Custom Help'; + await tester.pumpWidget(MaterialApp( + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () async { + await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + cancelText: cancelText, + confirmText: confirmText, + helpText: helperText, + ); + }, + ); + }, + ), + ), + ), + )); + + // Open the picker. + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.text(cancelText), findsOneWidget); + expect(find.text(confirmText), findsOneWidget); + expect(find.text(helperText), findsOneWidget); + }); + + testWidgets('OK Cancel button 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( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('X'), + onPressed: () { + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + builder: (BuildContext context, Widget? child) { + return Directionality( + textDirection: textDirection, + child: child!, + ); + }, + ); + }, + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + 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(); + + 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, + initialTime: const TimeOfDay(hour: 7, minute: 41), + materialType: materialType, + ); + + final double minutesDisplayHeight = tester.getSize(find.text('41')).height; + final double amHeight = tester.getSize(find.text(amString)).height; + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // Verify that the time display is not affected by text scale. + await mediaQueryBoilerplate( + tester, + textScaleFactor: 2, + initialTime: const TimeOfDay(hour: 7, minute: 41), + materialType: materialType, + ); + + final double amHeight2x = tester.getSize(find.text(amString)).height; + expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); + expect(amHeight2x, greaterThanOrEqualTo(amHeight * 2)); + + await tester.tap(find.text(okString)); // dismiss the dialog + await tester.pumpAndSettle(); + + // Verify that text scale for AM/PM is at most 2x. + await mediaQueryBoilerplate( + tester, + 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(amString)).height, equals(amHeight2x)); + }); + + group('showTimePicker avoids overlapping display features', () { + testWidgets('positioning with anchorPoint', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: [ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + anchorPoint: const Offset(1000, 0), + ); + + await tester.pumpAndSettle(); + // Should take the right side of the screen + expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); + }); + + testWidgets('positioning with Directionality', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: [ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: Directionality( + textDirection: TextDirection.rtl, + child: child!, + ), + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + // By default it should place the dialog on the right screen + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410, 0)); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800, 600)); + }); + + testWidgets('positioning with defaults', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return MediaQuery( + // Display has a vertical hinge down the middle + data: const MediaQueryData( + size: Size(800, 600), + displayFeatures: [ + DisplayFeature( + bounds: Rect.fromLTRB(390, 0, 410, 600), + type: DisplayFeatureType.hinge, + state: DisplayFeatureState.unknown, + ), + ], + ), + child: child!, + ); + }, + home: const Center(child: Text('Test')), + ), + ); + final BuildContext context = tester.element(find.text('Test')); + + // By default it should place the dialog on the left screen + showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + ); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.byType(TimePickerDialog)), Offset.zero); + expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(390, 600)); + }); + }); + + group('Works for various view sizes', () { + for (final Size size in const [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.tap], + flags: [ + SemanticsFlag.isButton, + SemanticsFlag.isChecked, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.hasCheckedState, + SemanticsFlag.isFocusable, + ], + ), + ); + expect( + semantics, + includesNodeWith( + label: pmString, + actions: [SemanticsAction.tap], + flags: [ + 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.tap], + flags: [SemanticsFlag.isTextField, SemanticsFlag.isMultiline], + ), + ); + expect( + semantics, + includesNodeWith( + label: 'Minute', + value: '00', + actions: [SemanticsAction.tap], + flags: [SemanticsFlag.isTextField, SemanticsFlag.isMultiline], + ), + ); + + semantics.dispose(); + }); + + testWidgets('can increment and decrement hours', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future actAndExpect({ + required String initialValue, + required SemanticsAction action, + required String finalValue, + }) async { + final SemanticsNode elevenHours = semantics + .nodesWith( + value: 'Select hours $initialValue', + ancestor: tester.renderObject(_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 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', + ); + + 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)); + + 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(); + }); + }); + + group('Time picker - Input (${materialType.name})', () { + testWidgets('Initial entry mode is used', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + expect(find.byType(TextField), findsNWidgets(2)); + }); + + testWidgets('Initial time is the default', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, entryMode: TimePickerEntryMode.input, materialType: materialType); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 7, minute: 0))); + }); + + testWidgets('Help text is used - Input', (WidgetTester tester) async { + const String helpText = 'help'; + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + helpText: helpText, + materialType: materialType, + ); + expect(find.text(helpText), findsOneWidget); + }); + + testWidgets('Help text is used in Material3 - Input', (WidgetTester tester) async { + const 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, + 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, + 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, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + errorInvalidText: errorInvalidText, + materialType: materialType, + ); + // Input invalid time (hour) to force validation error + await tester.enterText(find.byType(TextField).first, '88'); + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of( + tester.element(find.byType(TextButton).first), + ); + // Tap the ok button to trigger the validation error with custom translation + await tester.tap(find.text(materialLocalizations.okButtonLabel)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text(errorInvalidText), findsOneWidget); + }); + + testWidgets('Can switch from input to dial entry mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + materialType: materialType, + ); + await tester.tap(find.byIcon(Icons.access_time)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Can switch from dial to input entry mode', (WidgetTester tester) async { + await mediaQueryBoilerplate(tester, alwaysUse24HourFormat: true, materialType: materialType); + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.inputOnly, + materialType: materialType, + ); + expect(find.byType(TextField), findsWidgets); + expect(find.byIcon(Icons.access_time), findsNothing); + }); + + testWidgets('Can not switch out of dialOnly mode', (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.dialOnly, + materialType: materialType, + ); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(Icons.keyboard_outlined), findsNothing); + }); + + testWidgets('Switching to dial entry mode triggers entry callback', (WidgetTester tester) async { + bool triggeredCallback = false; + + await mediaQueryBoilerplate( + tester, + alwaysUse24HourFormat: true, + entryMode: TimePickerEntryMode.input, + onEntryModeChange: (TimePickerEntryMode mode) { + if (mode == TimePickerEntryMode.dial) { + triggeredCallback = true; + } + }, + materialType: materialType, + ); + + await tester.tap(find.byIcon(Icons.access_time)); + await tester.pumpAndSettle(); + expect(triggeredCallback, true); + }); + + testWidgets('Switching to input entry mode triggers entry callback', (WidgetTester tester) async { + bool triggeredCallback = false; + + await mediaQueryBoilerplate(tester, alwaysUse24HourFormat: true, onEntryModeChange: (TimePickerEntryMode mode) { + if (mode == TimePickerEntryMode.input) { + triggeredCallback = true; + } + }, materialType: materialType); + + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pumpAndSettle(); + expect(triggeredCallback, true); + }); + + testWidgets('Can double tap hours (when selected) to enter input mode', (WidgetTester tester) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder hourFinder = find.ancestor( + of: find.text('7'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Double tap the hour. + await tester.tap(hourFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(hourFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not double tap hours (when not selected) to enter input mode', (WidgetTester tester) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder hourFinder = find.ancestor( + of: find.text('7'), + matching: find.byType(InkWell), + ); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Switch to minutes mode. + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + // Double tap the hour. + await tester.tap(hourFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(hourFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Can double tap minutes (when selected) to enter input mode', (WidgetTester tester) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Switch to minutes mode. + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + // Double tap the minutes. + await tester.tap(minuteFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsWidgets); + }); + + testWidgets('Can not double tap minutes (when not selected) to enter input mode', (WidgetTester tester) async { + await mediaQueryBoilerplate(tester, materialType: materialType); + final Finder minuteFinder = find.ancestor( + of: find.text('00'), + matching: find.byType(InkWell), + ); + + expect(find.byType(TextField), findsNothing); + + // Double tap the minutes. + await tester.tap(minuteFinder); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(minuteFinder); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Entered text returns time', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, entryMode: TimePickerEntryMode.input, materialType: materialType); + await tester.enterText(find.byType(TextField).first, '9'); + await tester.enterText(find.byType(TextField).last, '12'); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + }); + + testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async { + late TimeOfDay result; + await startPicker(tester, (TimeOfDay? time) { + result = time!; + }, entryMode: TimePickerEntryMode.input, materialType: materialType); + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '15'); + await tester.tap(find.byIcon(Icons.access_time)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); + }); + + testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async { + TimeOfDay? result; + await startPicker(tester, (TimeOfDay? time) { + result = time; + }, entryMode: TimePickerEntryMode.input, materialType: materialType); + + // Invalid hour. + await tester.enterText(find.byType(TextField).first, '88'); + await tester.enterText(find.byType(TextField).last, '15'); + await finishPicker(tester); + expect(result, null); + + // Invalid minute. + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '95'); + await finishPicker(tester); + expect(result, null); + + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '15'); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); + }); + + // Fixes regression that was reverted in https://github.com/flutter/flutter/pull/64094#pullrequestreview-469836378. + testWidgets('Ensure hour/minute fields are top-aligned with the separator', (WidgetTester tester) async { + await startPicker(tester, (TimeOfDay? time) {}, + entryMode: TimePickerEntryMode.input, materialType: materialType); + final double hourFieldTop = + tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField')).dy; + final double minuteFieldTop = + tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField')).dy; + final double separatorTop = + tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_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; + }, + restorationId: 'restorable_time_picker', + 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)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + // Setting to PM adds 12 hours (18:45) + await tester.tap(find.text(pmString)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 18, minute: 45))); + + // Test restoring from before PM was selected (6:45) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + + testWidgets('Time Picker state restoration test - input mode', (WidgetTester tester) async { + TimeOfDay? result; + await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + entryMode: TimePickerEntryMode.input, + restorationId: 'restorable_time_picker', + materialType: materialType, + ); + await tester.enterText(find.byType(TextField).first, '9'); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + final TestRestorationData restorationData = await tester.getRestorationData(); + await tester.restartAndRestore(); + + // Setting to PM adds 12 hours (21:12) + await tester.tap(find.text(pmString)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 21, minute: 12))); + + // Restoring from before PM was set (9:12) + await tester.restoreFrom(restorationData); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + }); + + testWidgets('Time Picker state restoration test - switching modes', (WidgetTester tester) async { + TimeOfDay? result; + final Offset center = (await startPicker( + tester, + (TimeOfDay? time) { + result = time; + }, + restorationId: 'restorable_time_picker', + materialType: materialType, + ))!; + + final TestRestorationData restorationData = await tester.getRestorationData(); + // Switch to input mode from dial mode. + await tester.tap(find.byIcon(Icons.keyboard_outlined)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.restartAndRestore(); + + // Select time using input mode controls. + await tester.enterText(find.byType(TextField).first, '9'); + await tester.enterText(find.byType(TextField).last, '12'); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); + + // Restoring from dial mode. + await tester.restoreFrom(restorationData); + final 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)); + await tester.restartAndRestore(); + await tester.tapAt(min45); + await tester.pump(const Duration(milliseconds: 50)); + await finishPicker(tester); + expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); + }); + }); + } +} + +final Finder findDialPaint = find.descendant( + of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), + matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), +); + +class PickerObserver extends NavigatorObserver { + int pickerCount = 0; + + @override + void didPush(Route route, Route? previousRoute) { + if (route is DialogRoute) { + pickerCount++; + } + super.didPush(route, previousRoute); + } +} + +Future mediaQueryBoilerplate( + WidgetTester tester, { + bool alwaysUse24HourFormat = false, + TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0), + double textScaleFactor = 1, + TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + String? helpText, + String? hourLabelText, + String? minuteLabelText, + String? errorInvalidText, + bool accessibleNavigation = false, + EntryModeChangeCallback? onEntryModeChange, + bool tapButton = true, + required MaterialType materialType, + Orientation? orientation, +}) async { + await tester.pumpWidget( + Builder(builder: (BuildContext context) { + return Theme( + data: Theme.of(context).copyWith(useMaterial3: materialType == MaterialType.material3), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const >[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MediaQuery( + data: MediaQueryData( + alwaysUse24HourFormat: alwaysUse24HourFormat, + textScaleFactor: textScaleFactor, + accessibleNavigation: accessibleNavigation, + size: tester.binding.window.physicalSize / tester.binding.window.devicePixelRatio, + ), + child: Material( + child: Center( + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute(builder: (BuildContext context) { + return TextButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: initialTime, + initialEntryMode: entryMode, + helpText: helpText, + hourLabelText: hourLabelText, + minuteLabelText: minuteLabelText, + errorInvalidText: errorInvalidText, + onEntryModeChanged: onEntryModeChange, + orientation: orientation, + ); + }, + child: const Text('X'), + ); + }); + }, + ), + ), + ), + ), + ), + ), + ); + }), + ); + if (tapButton) { + await tester.tap(find.text('X')); + } + await tester.pumpAndSettle(); +} + final Finder _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'); +final Finder _timePicker = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePicker'); class _TimePickerLauncher extends StatefulWidget { const _TimePickerLauncher({ @@ -40,8 +1614,8 @@ class _TimePickerLauncherState extends State<_TimePickerLauncher> with Restorati onPresent: (NavigatorState navigator, Object? arguments) { return navigator.restorablePush( _timePickerRoute, - arguments: { - 'entryMode': widget.entryMode.index, + arguments: { + 'entry_mode': widget.entryMode.name, }, ); }, @@ -52,7 +1626,9 @@ class _TimePickerLauncherState extends State<_TimePickerLauncher> with Restorati Object? arguments, ) { final Map args = arguments! as Map; - final TimePickerEntryMode entryMode = TimePickerEntryMode.values[args['entryMode'] as int]; + final TimePickerEntryMode entryMode = TimePickerEntryMode.values.firstWhere( + (TimePickerEntryMode element) => element.name == args['entry_mode'], + ); return DialogRoute( context: context, builder: (BuildContext context) { @@ -101,15 +1677,24 @@ class _TimePickerLauncherState extends State<_TimePickerLauncher> with Restorati } } +// The version of material design layout, etc. to test. Corresponds to +// useMaterial3 true/false in the ThemeData, but used an enum here so that it +// wasn't just a boolean, for easier identification of the name of the mode in +// tests. +enum MaterialType { + material2, + material3, +} + Future startPicker( - WidgetTester tester, - ValueChanged onChanged, { - TimePickerEntryMode entryMode = TimePickerEntryMode.dial, - String? restorationId, - bool useMaterial3 = false, - }) async { + WidgetTester tester, + ValueChanged onChanged, { + TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + String? restorationId, + required MaterialType materialType, +}) async { await tester.pumpWidget(MaterialApp( - theme: ThemeData(useMaterial3: useMaterial3), + theme: ThemeData(useMaterial3: materialType == MaterialType.material3), restorationScopeId: 'app', locale: const Locale('en', 'US'), home: _TimePickerLauncher( @@ -120,1306 +1705,15 @@ Future startPicker( )); await tester.tap(find.text('X')); await tester.pumpAndSettle(const Duration(seconds: 1)); - return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey('time-picker-dial'))) : null; + return entryMode == TimePickerEntryMode.dial + ? tester.getCenter(find.byKey(const ValueKey('time-picker-dial'))) + : null; } Future finishPicker(WidgetTester tester) async { - final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(tester.element(find.byType(ElevatedButton))); + 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); - }); - - testWidgets('tap-select an hour', (WidgetTester tester) async { - TimeOfDay? result; - - Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!; - await tester.tapAt(Offset(center.dx, center.dy - 50.0)); // 12:00 AM - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); - - center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!; - await tester.tapAt(Offset(center.dx + 50.0, center.dy)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 3, minute: 0))); - - center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!; - await tester.tapAt(Offset(center.dx, center.dy + 50.0)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 0))); - - center = (await startPicker(tester, (TimeOfDay? time) { result = time; }))!; - await tester.tapAt(Offset(center.dx, center.dy + 50.0)); - await tester.tapAt(Offset(center.dx - 50, center.dy)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 9, minute: 0))); - }); - - testWidgets('drag-select an hour', (WidgetTester tester) async { - late TimeOfDay result; - - final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!; - final Offset hour0 = Offset(center.dx, center.dy - 50.0); // 12:00 AM - final Offset hour3 = Offset(center.dx + 50.0, center.dy); - final Offset hour6 = Offset(center.dx, center.dy + 50.0); - final Offset hour9 = Offset(center.dx - 50.0, center.dy); - - TestGesture gesture; - - gesture = await tester.startGesture(hour3); - await gesture.moveBy(hour0 - hour3); - await gesture.up(); - await finishPicker(tester); - expect(result.hour, 0); - - expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center)); - gesture = await tester.startGesture(hour0); - await gesture.moveBy(hour3 - hour0); - await gesture.up(); - await finishPicker(tester); - expect(result.hour, 3); - - expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center)); - gesture = await tester.startGesture(hour3); - await gesture.moveBy(hour6 - hour3); - await gesture.up(); - await finishPicker(tester); - expect(result.hour, equals(6)); - - expect(await startPicker(tester, (TimeOfDay? time) { result = time!; }), equals(center)); - gesture = await tester.startGesture(hour6); - await gesture.moveBy(hour9 - hour6); - await gesture.up(); - await finishPicker(tester); - expect(result.hour, equals(9)); - }); - - testWidgets('tap-select switches from hour to minute', (WidgetTester tester) async { - late TimeOfDay result; - - final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!; - final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 - final Offset min45 = Offset(center.dx - 50.0, center.dy); // 45 mins (or 9:00 hours) - - await tester.tapAt(hour6); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tapAt(min45); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); - }); - - testWidgets('drag-select switches from hour to minute', (WidgetTester tester) async { - late TimeOfDay result; - - final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!; - final Offset hour3 = Offset(center.dx + 50.0, center.dy); - final Offset hour6 = Offset(center.dx, center.dy + 50.0); - final Offset hour9 = Offset(center.dx - 50.0, center.dy); - - TestGesture gesture = await tester.startGesture(hour6); - await gesture.moveBy(hour9 - hour6); - await gesture.up(); - await tester.pump(const Duration(milliseconds: 50)); - gesture = await tester.startGesture(hour6); - await gesture.moveBy(hour3 - hour6); - await gesture.up(); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); - }); - - testWidgets('tap-select rounds down to nearest 5 minute increment', (WidgetTester tester) async { - late TimeOfDay result; - - final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!; - final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 - final Offset min46 = Offset(center.dx - 50.0, center.dy - 5); // 46 mins - - await tester.tapAt(hour6); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tapAt(min46); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); - }); - - testWidgets('tap-select rounds up to nearest 5 minute increment', (WidgetTester tester) async { - late TimeOfDay result; - - final Offset center = (await startPicker(tester, (TimeOfDay? time) { result = time!; }))!; - final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 - final Offset min48 = Offset(center.dx - 50.0, center.dy - 15); // 48 mins - - await tester.tapAt(hour6); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tapAt(min48); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 50))); - }); - - group('haptic feedback', () { - const Duration kFastFeedbackInterval = Duration(milliseconds: 10); - const Duration kSlowFeedbackInterval = Duration(milliseconds: 200); - late FeedbackTester feedback; - - setUp(() { - feedback = FeedbackTester(); - }); - - tearDown(() { - feedback.dispose(); - }); - - testWidgets('tap-select vibrates once', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - await tester.tapAt(Offset(center.dx, center.dy - 50.0)); - await finishPicker(tester); - expect(feedback.hapticCount, 1); - }); - - testWidgets('quick successive tap-selects vibrate once', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - await tester.tapAt(Offset(center.dx, center.dy - 50.0)); - await tester.pump(kFastFeedbackInterval); - await tester.tapAt(Offset(center.dx, center.dy + 50.0)); - await finishPicker(tester); - expect(feedback.hapticCount, 1); - }); - - testWidgets('slow successive tap-selects vibrate once per tap', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - await tester.tapAt(Offset(center.dx, center.dy - 50.0)); - await tester.pump(kSlowFeedbackInterval); - await tester.tapAt(Offset(center.dx, center.dy + 50.0)); - await tester.pump(kSlowFeedbackInterval); - await tester.tapAt(Offset(center.dx, center.dy - 50.0)); - await finishPicker(tester); - expect(feedback.hapticCount, 3); - }); - - testWidgets('drag-select vibrates once', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - final Offset hour0 = Offset(center.dx, center.dy - 50.0); - final Offset hour3 = Offset(center.dx + 50.0, center.dy); - - final TestGesture gesture = await tester.startGesture(hour3); - await gesture.moveBy(hour0 - hour3); - await gesture.up(); - await finishPicker(tester); - expect(feedback.hapticCount, 1); - }); - - testWidgets('quick drag-select vibrates once', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - final Offset hour0 = Offset(center.dx, center.dy - 50.0); - final Offset hour3 = Offset(center.dx + 50.0, center.dy); - - final TestGesture gesture = await tester.startGesture(hour3); - await gesture.moveBy(hour0 - hour3); - await tester.pump(kFastFeedbackInterval); - await gesture.moveBy(hour3 - hour0); - await tester.pump(kFastFeedbackInterval); - await gesture.moveBy(hour0 - hour3); - await gesture.up(); - await finishPicker(tester); - expect(feedback.hapticCount, 1); - }); - - testWidgets('slow drag-select vibrates once', (WidgetTester tester) async { - final Offset center = (await startPicker(tester, (TimeOfDay? time) { }))!; - final Offset hour0 = Offset(center.dx, center.dy - 50.0); - final Offset hour3 = Offset(center.dx + 50.0, center.dy); - - final TestGesture gesture = await tester.startGesture(hour3); - await gesture.moveBy(hour0 - hour3); - await tester.pump(kSlowFeedbackInterval); - await gesture.moveBy(hour3 - hour0); - await tester.pump(kSlowFeedbackInterval); - await gesture.moveBy(hour0 - hour3); - await gesture.up(); - await finishPicker(tester); - expect(feedback.hapticCount, 3); - }); - }); - - const List labels12To11 = ['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; - const List labels00To22 = ['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22']; - - testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, false); - - final CustomPaint dialPaint = tester.widget(findDialPaint); - final dynamic dialPainter = dialPaint.painter; - // ignore: avoid_dynamic_calls - final List primaryLabels = dialPainter.primaryLabels as List; - // ignore: avoid_dynamic_calls - expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); - - // ignore: avoid_dynamic_calls - final List secondaryLabels = dialPainter.secondaryLabels as List; - // ignore: avoid_dynamic_calls - expect(secondaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); - }); - - testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true); - - final CustomPaint dialPaint = tester.widget(findDialPaint); - final dynamic dialPainter = dialPaint.painter; - // ignore: avoid_dynamic_calls - final List primaryLabels = dialPainter.primaryLabels as List; - // ignore: avoid_dynamic_calls - expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22); - - // ignore: avoid_dynamic_calls - final List secondaryLabels = dialPainter.secondaryLabels as List; - // ignore: avoid_dynamic_calls - expect(secondaryLabels.map((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.tap], - flags: [ - SemanticsFlag.isButton, - SemanticsFlag.isChecked, - SemanticsFlag.isInMutuallyExclusiveGroup, - SemanticsFlag.hasCheckedState, - SemanticsFlag.isFocusable, - ], - ), - ); - expect( - semantics, - includesNodeWith( - label: 'PM', - actions: [SemanticsAction.tap], - flags: [ - 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.tap], - flags: [SemanticsFlag.isTextField, SemanticsFlag.isMultiline], - ), - ); - expect( - semantics, - includesNodeWith( - label: 'Minute', - value: '00', - actions: [SemanticsAction.tap], - flags: [SemanticsFlag.isTextField, SemanticsFlag.isMultiline], - ), - ); - - semantics.dispose(); - }); - - testWidgets('can increment and decrement hours', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - - Future actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue }) async { - final SemanticsNode elevenHours = semantics.nodesWith( - value: 'Select hours $initialValue', - ancestor: tester.renderObject(_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 actAndExpect({ required String initialValue, required SemanticsAction action, required String finalValue }) async { - final SemanticsNode elevenHours = semantics.nodesWith( - value: 'Select minutes $initialValue', - ancestor: tester.renderObject(_minuteControl).debugSemantics, - ).single; - tester.binding.pipelineOwner.semanticsOwner!.performAction(elevenHours.id, action); - await tester.pumpAndSettle(); - expect( - find.descendant(of: _minuteControl, matching: find.text(finalValue)), - findsOneWidget, - ); - } - - await mediaQueryBoilerplate(tester, false, initialTime: const TimeOfDay(hour: 11, minute: 58)); - await actAndExpect( - initialValue: '58', - action: SemanticsAction.increase, - finalValue: '59', - ); - await actAndExpect( - initialValue: '59', - action: SemanticsAction.increase, - finalValue: '00', - ); - - // Ensure we preserve hour period as we roll over. - final dynamic pickerState = tester.state(_timePickerDialog); - // ignore: avoid_dynamic_calls - expect(pickerState.selectedTime.value, const TimeOfDay(hour: 11, minute: 0)); - - await actAndExpect( - initialValue: '00', - action: SemanticsAction.decrease, - finalValue: '59', - ); - await actAndExpect( - initialValue: '59', - action: SemanticsAction.decrease, - finalValue: '58', - ); - - semantics.dispose(); - }); - - testWidgets('header touch regions are large enough', (WidgetTester tester) async { - // Ensure picker is displayed in portrait mode. - tester.binding.window.physicalSizeTestValue = const Size(400, 800); - tester.binding.window.devicePixelRatioTestValue = 1; - await mediaQueryBoilerplate(tester, false); - - final Size dayPeriodControlSize = tester.getSize(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl')); - expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48.0)); - // Height should be double the minimum size to account for both AM/PM stacked. - expect(dayPeriodControlSize.height, greaterThanOrEqualTo(48.0 * 2)); - - final Size hourSize = tester.getSize(find.ancestor( - of: find.text('7'), - matching: find.byType(InkWell), - )); - expect(hourSize.width, greaterThanOrEqualTo(48.0)); - expect(hourSize.height, greaterThanOrEqualTo(48.0)); - - final Size minuteSize = tester.getSize(find.ancestor( - of: find.text('00'), - matching: find.byType(InkWell), - )); - expect(minuteSize.width, greaterThanOrEqualTo(48.0)); - expect(minuteSize.height, greaterThanOrEqualTo(48.0)); - - tester.binding.window.clearPhysicalSizeTestValue(); - tester.binding.window.clearDevicePixelRatioTestValue(); - }); - - testWidgets('when change orientation, should reflect in render objects', (WidgetTester tester) async { - // portrait - tester.binding.window.physicalSizeTestValue = const Size(800, 800.5); - tester.binding.window.devicePixelRatioTestValue = 1; - await mediaQueryBoilerplate(tester, false); - - RenderObject render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding')); - expect((render as dynamic).orientation, Orientation.portrait); // ignore: avoid_dynamic_calls - - // landscape - tester.binding.window.physicalSizeTestValue = const Size(800.5, 800); - tester.binding.window.devicePixelRatioTestValue = 1; - await mediaQueryBoilerplate(tester, false, tapButton: false); - - render = tester.renderObject(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodInputPadding')); - expect((render as dynamic).orientation, Orientation.landscape); // ignore: avoid_dynamic_calls - - tester.binding.window.clearPhysicalSizeTestValue(); - tester.binding.window.clearDevicePixelRatioTestValue(); - }); - - testWidgets('builder parameter', (WidgetTester tester) async { - Widget buildFrame(TextDirection textDirection) { - return MaterialApp( - home: Material( - child: Center( - child: Builder( - builder: (BuildContext context) { - return ElevatedButton( - child: const Text('X'), - onPressed: () { - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - builder: (BuildContext context, Widget? child) { - return Directionality( - textDirection: textDirection, - child: child!, - ); - }, - ); - }, - ); - }, - ), - ), - ), - ); - } - - await tester.pumpWidget(buildFrame(TextDirection.ltr)); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - final double ltrOkRight = tester.getBottomRight(find.text('OK')).dx; - - await tester.tap(find.text('OK')); // dismiss the dialog - await tester.pumpAndSettle(); - - await tester.pumpWidget(buildFrame(TextDirection.rtl)); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - - // Verify that the time picker is being laid out RTL. - // We expect the left edge of the 'OK' button in the RTL - // layout to match the gap between right edge of the 'OK' - // button and the right edge of the 800 wide window. - expect(tester.getBottomLeft(find.text('OK')).dx, 800 - ltrOkRight); - }); - - testWidgets('uses root navigator by default', (WidgetTester tester) async { - final PickerObserver rootObserver = PickerObserver(); - final PickerObserver nestedObserver = PickerObserver(); - - await tester.pumpWidget(MaterialApp( - navigatorObservers: [rootObserver], - home: Navigator( - observers: [nestedObserver], - onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute( - builder: (BuildContext context) { - return ElevatedButton( - onPressed: () { - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - ); - }, - child: const Text('Show Picker'), - ); - }, - ); - }, - ), - )); - - // Open the dialog. - await tester.tap(find.byType(ElevatedButton)); - - expect(rootObserver.pickerCount, 1); - expect(nestedObserver.pickerCount, 0); - }); - - testWidgets('uses nested navigator if useRootNavigator is false', (WidgetTester tester) async { - final PickerObserver rootObserver = PickerObserver(); - final PickerObserver nestedObserver = PickerObserver(); - - await tester.pumpWidget(MaterialApp( - navigatorObservers: [rootObserver], - home: Navigator( - observers: [nestedObserver], - onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute( - builder: (BuildContext context) { - return ElevatedButton( - onPressed: () { - showTimePicker( - context: context, - useRootNavigator: false, - initialTime: const TimeOfDay(hour: 7, minute: 0), - ); - }, - child: const Text('Show Picker'), - ); - }, - ); - }, - ), - )); - - // Open the dialog. - await tester.tap(find.byType(ElevatedButton)); - - expect(rootObserver.pickerCount, 0); - expect(nestedObserver.pickerCount, 1); - }); - - testWidgets('optional text parameters are utilized', (WidgetTester tester) async { - const String cancelText = 'Custom Cancel'; - const String confirmText = 'Custom OK'; - const String helperText = 'Custom Help'; - await tester.pumpWidget(MaterialApp( - home: Material( - child: Center( - child: Builder( - builder: (BuildContext context) { - return ElevatedButton( - child: const Text('X'), - onPressed: () async { - await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - cancelText: cancelText, - confirmText: confirmText, - helpText: helperText, - ); - }, - ); - }, - ), - ), - ), - )); - - // Open the picker. - await tester.tap(find.text('X')); - await tester.pumpAndSettle(const Duration(seconds: 1)); - - expect(find.text(cancelText), findsOneWidget); - expect(find.text(confirmText), findsOneWidget); - expect(find.text(helperText), findsOneWidget); - }); - - testWidgets('OK Cancel button layout', (WidgetTester tester) async { - Widget buildFrame(TextDirection textDirection) { - return MaterialApp( - home: Material( - child: Center( - child: Builder( - builder: (BuildContext context) { - return ElevatedButton( - child: const Text('X'), - onPressed: () { - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - builder: (BuildContext context, Widget? child) { - return Directionality( - textDirection: textDirection, - child: child!, - ); - }, - ); - }, - ); - }, - ), - ), - ), - ); - } - - await tester.pumpWidget(buildFrame(TextDirection.ltr)); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('OK')).dx, 638); - expect(tester.getBottomLeft(find.text('OK')).dx, 610); - expect(tester.getBottomRight(find.text('CANCEL')).dx, 576); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - - await tester.pumpWidget(buildFrame(TextDirection.rtl)); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('OK')).dx, 162); - expect(tester.getBottomRight(find.text('OK')).dx, 190); - expect(tester.getBottomLeft(find.text('CANCEL')).dx, 224); - await tester.tap(find.text('OK')); - await tester.pumpAndSettle(); - }); - - testWidgets('text scale affects certain elements and not others', (WidgetTester tester) async { - await mediaQueryBoilerplate( - tester, - false, - initialTime: const TimeOfDay(hour: 7, minute: 41), - ); - - final double minutesDisplayHeight = tester.getSize(find.text('41')).height; - final double amHeight = tester.getSize(find.text('AM')).height; - - await tester.tap(find.text('OK')); // dismiss the dialog - await tester.pumpAndSettle(); - - // Verify that the time display is not affected by text scale. - await mediaQueryBoilerplate( - tester, - false, - textScaleFactor: 2.0, - initialTime: const TimeOfDay(hour: 7, minute: 41), - ); - - final double amHeight2x = tester.getSize(find.text('AM')).height; - expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); - expect(amHeight2x, greaterThanOrEqualTo(amHeight * 2)); - - await tester.tap(find.text('OK')); // dismiss the dialog - await tester.pumpAndSettle(); - - // Verify that text scale for AM/PM is at most 2x. - await mediaQueryBoilerplate( - tester, - false, - textScaleFactor: 3.0, - initialTime: const TimeOfDay(hour: 7, minute: 41), - ); - - expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); - expect(tester.getSize(find.text('AM')).height, equals(amHeight2x)); - }); - - group('showTimePicker avoids overlapping display features', () { - testWidgets('positioning with anchorPoint', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (BuildContext context, Widget? child) { - return MediaQuery( - // Display has a vertical hinge down the middle - data: const MediaQueryData( - size: Size(800, 600), - displayFeatures: [ - DisplayFeature( - bounds: Rect.fromLTRB(390, 0, 410, 600), - type: DisplayFeatureType.hinge, - state: DisplayFeatureState.unknown, - ), - ], - ), - child: child!, - ); - }, - home: const Center(child: Text('Test')), - ), - ); - final BuildContext context = tester.element(find.text('Test')); - - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - anchorPoint: const Offset(1000, 0), - ); - - await tester.pumpAndSettle(); - // Should take the right side of the screen - expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410.0, 0.0)); - expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800.0, 600.0)); - }); - - testWidgets('positioning with Directionality', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (BuildContext context, Widget? child) { - return MediaQuery( - // Display has a vertical hinge down the middle - data: const MediaQueryData( - size: Size(800, 600), - displayFeatures: [ - DisplayFeature( - bounds: Rect.fromLTRB(390, 0, 410, 600), - type: DisplayFeatureType.hinge, - state: DisplayFeatureState.unknown, - ), - ], - ), - child: Directionality( - textDirection: TextDirection.rtl, - child: child!, - ), - ); - }, - home: const Center(child: Text('Test')), - ), - ); - final BuildContext context = tester.element(find.text('Test')); - - // By default it should place the dialog on the right screen - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - ); - - await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.byType(TimePickerDialog)), const Offset(410.0, 0.0)); - expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(800.0, 600.0)); - }); - - testWidgets('positioning with defaults', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (BuildContext context, Widget? child) { - return MediaQuery( - // Display has a vertical hinge down the middle - data: const MediaQueryData( - size: Size(800, 600), - displayFeatures: [ - DisplayFeature( - bounds: Rect.fromLTRB(390, 0, 410, 600), - type: DisplayFeatureType.hinge, - state: DisplayFeatureState.unknown, - ), - ], - ), - child: child!, - ); - }, - home: const Center(child: Text('Test')), - ), - ); - final BuildContext context = tester.element(find.text('Test')); - - // By default it should place the dialog on the left screen - showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - ); - - await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.byType(TimePickerDialog)), Offset.zero); - expect(tester.getBottomRight(find.byType(TimePickerDialog)), const Offset(390.0, 600.0)); - }); - }); -} - -void _testsInput() { - testWidgets('Initial entry mode is used', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input); - expect(find.byType(TextField), findsNWidgets(2)); - }); - - testWidgets('Initial time is the default', (WidgetTester tester) async { - late TimeOfDay result; - await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 7, minute: 0))); - }); - - testWidgets('Help text is used - Input', (WidgetTester tester) async { - const String helpText = 'help'; - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, helpText: helpText); - expect(find.text(helpText), findsOneWidget); - }); - - testWidgets('Hour label text is used - Input', (WidgetTester tester) async { - const String hourLabelText = 'Custom hour label'; - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, hourLabelText: hourLabelText); - expect(find.text(hourLabelText), findsOneWidget); - }); - - - testWidgets('Minute label text is used - Input', (WidgetTester tester) async { - const String minuteLabelText = 'Custom minute label'; - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, minuteLabelText: minuteLabelText); - expect(find.text(minuteLabelText), findsOneWidget); - }); - - testWidgets('Invalid error text is used - Input', (WidgetTester tester) async { - const String errorInvalidText = 'Custom validation error'; - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, errorInvalidText: errorInvalidText); - // Input invalid time (hour) to force validation error - await tester.enterText(find.byType(TextField).first, '88'); - final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(tester.element(find.byType(TextButton).first)); - // Tap the ok button to trigger the validation error with custom translation - await tester.tap(find.text(materialLocalizations.okButtonLabel)); - await tester.pumpAndSettle(const Duration(seconds: 1)); - expect(find.text(errorInvalidText), findsOneWidget); - }); - - testWidgets('Can switch from input to dial entry mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input); - await tester.tap(find.byIcon(Icons.access_time)); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsNothing); - }); - - testWidgets('Can switch from dial to input entry mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true); - await tester.tap(find.byIcon(Icons.keyboard)); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsWidgets); - }); - - testWidgets('Can not switch out of inputOnly mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.inputOnly); - expect(find.byType(TextField), findsWidgets); - expect(find.byIcon(Icons.access_time), findsNothing); - }); - - testWidgets('Can not switch out of dialOnly mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.dialOnly); - expect(find.byType(TextField), findsNothing); - expect(find.byIcon(Icons.keyboard), findsNothing); - }); - - testWidgets('Switching to dial entry mode triggers entry callback', (WidgetTester tester) async { - bool triggeredCallback = false; - - await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, onEntryModeChange: (TimePickerEntryMode mode) { - if (mode == TimePickerEntryMode.dial) { - triggeredCallback = true; - } - }); - - await tester.tap(find.byIcon(Icons.access_time)); - await tester.pumpAndSettle(); - expect(triggeredCallback, true); - }); - - testWidgets('Switching to input entry mode triggers entry callback', (WidgetTester tester) async { - bool triggeredCallback = false; - - await mediaQueryBoilerplate(tester, true, onEntryModeChange: (TimePickerEntryMode mode) { - if (mode == TimePickerEntryMode.input) { - triggeredCallback = true; - } - }); - - await tester.tap(find.byIcon(Icons.keyboard)); - await tester.pumpAndSettle(); - expect(triggeredCallback, true); - }); - - testWidgets('Can double tap hours (when selected) to enter input mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, false); - final Finder hourFinder = find.ancestor( - of: find.text('7'), - matching: find.byType(InkWell), - ); - - expect(find.byType(TextField), findsNothing); - - // Double tap the hour. - await tester.tap(hourFinder); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(hourFinder); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsWidgets); - }); - - testWidgets('Can not double tap hours (when not selected) to enter input mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, false); - final Finder hourFinder = find.ancestor( - of: find.text('7'), - matching: find.byType(InkWell), - ); - final Finder minuteFinder = find.ancestor( - of: find.text('00'), - matching: find.byType(InkWell), - ); - - expect(find.byType(TextField), findsNothing); - - // Switch to minutes mode. - await tester.tap(minuteFinder); - await tester.pumpAndSettle(); - - // Double tap the hour. - await tester.tap(hourFinder); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(hourFinder); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsNothing); - }); - - testWidgets('Can double tap minutes (when selected) to enter input mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, false); - final Finder minuteFinder = find.ancestor( - of: find.text('00'), - matching: find.byType(InkWell), - ); - - expect(find.byType(TextField), findsNothing); - - // Switch to minutes mode. - await tester.tap(minuteFinder); - await tester.pumpAndSettle(); - - // Double tap the minutes. - await tester.tap(minuteFinder); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(minuteFinder); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsWidgets); - }); - - testWidgets('Can not double tap minutes (when not selected) to enter input mode', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, false); - final Finder minuteFinder = find.ancestor( - of: find.text('00'), - matching: find.byType(InkWell), - ); - - expect(find.byType(TextField), findsNothing); - - // Double tap the minutes. - await tester.tap(minuteFinder); - await tester.pump(const Duration(milliseconds: 100)); - await tester.tap(minuteFinder); - await tester.pumpAndSettle(); - - expect(find.byType(TextField), findsNothing); - }); - - testWidgets('Entered text returns time', (WidgetTester tester) async { - late TimeOfDay result; - await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input); - await tester.enterText(find.byType(TextField).first, '9'); - await tester.enterText(find.byType(TextField).last, '12'); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); - }); - - testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async { - late TimeOfDay result; - await startPicker(tester, (TimeOfDay? time) { result = time!; }, entryMode: TimePickerEntryMode.input); - await tester.enterText(find.byType(TextField).first, '8'); - await tester.enterText(find.byType(TextField).last, '15'); - await tester.tap(find.byIcon(Icons.access_time)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); - }); - - testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async { - TimeOfDay? result; - await startPicker(tester, (TimeOfDay? time) { result = time; }, entryMode: TimePickerEntryMode.input); - - // Invalid hour. - await tester.enterText(find.byType(TextField).first, '88'); - await tester.enterText(find.byType(TextField).last, '15'); - await finishPicker(tester); - expect(result, null); - - // Invalid minute. - await tester.enterText(find.byType(TextField).first, '8'); - await tester.enterText(find.byType(TextField).last, '95'); - await finishPicker(tester); - expect(result, null); - - await tester.enterText(find.byType(TextField).first, '8'); - await tester.enterText(find.byType(TextField).last, '15'); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 8, minute: 15))); - }); - - // Fixes regression that was reverted in https://github.com/flutter/flutter/pull/64094#pullrequestreview-469836378. - testWidgets('Ensure hour/minute fields are top-aligned with the separator', (WidgetTester tester) async { - await startPicker(tester, (TimeOfDay? time) { }, entryMode: TimePickerEntryMode.input); - final double hourFieldTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourTextField')).dy; - final double minuteFieldTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteTextField')).dy; - final double separatorTop = tester.getTopLeft(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_StringFragment')).dy; - expect(hourFieldTop, separatorTop); - expect(minuteFieldTop, separatorTop); - }); - - testWidgets('Time Picker state restoration test - dial mode', (WidgetTester tester) async { - TimeOfDay? result; - final Offset center = (await startPicker( - tester, - (TimeOfDay? time) { result = time; }, - restorationId: 'restorable_time_picker', - ))!; - final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 - final Offset min45 = Offset(center.dx - 50.0, center.dy); // 45 mins (or 9:00 hours) - - await tester.tapAt(hour6); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - await tester.tapAt(min45); - await tester.pump(const Duration(milliseconds: 50)); - final TestRestorationData restorationData = await tester.getRestorationData(); - await tester.restartAndRestore(); - // Setting to PM adds 12 hours (18:45) - await tester.tap(find.text('PM')); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 18, minute: 45))); - - // Test restoring from before PM was selected (6:45) - await tester.restoreFrom(restorationData); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); - }); - - testWidgets('Time Picker state restoration test - input mode', (WidgetTester tester) async { - TimeOfDay? result; - await startPicker( - tester, - (TimeOfDay? time) { result = time; }, - entryMode: TimePickerEntryMode.input, - restorationId: 'restorable_time_picker', - ); - await tester.enterText(find.byType(TextField).first, '9'); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - - await tester.enterText(find.byType(TextField).last, '12'); - await tester.pump(const Duration(milliseconds: 50)); - final TestRestorationData restorationData = await tester.getRestorationData(); - await tester.restartAndRestore(); - - // Setting to PM adds 12 hours (21:12) - await tester.tap(find.text('PM')); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 21, minute: 12))); - - // Restoring from before PM was set (9:12) - await tester.restoreFrom(restorationData); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); - }); - - testWidgets('Time Picker state restoration test - switching modes', (WidgetTester tester) async { - TimeOfDay? result; - final Offset center = (await startPicker( - tester, - (TimeOfDay? time) { result = time; }, - restorationId: 'restorable_time_picker', - ))!; - - final TestRestorationData restorationData = await tester.getRestorationData(); - // Switch to input mode from dial mode. - await tester.tap(find.byIcon(Icons.keyboard)); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - - // Select time using input mode controls. - await tester.enterText(find.byType(TextField).first, '9'); - await tester.enterText(find.byType(TextField).last, '12'); - await tester.pump(const Duration(milliseconds: 50)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 9, minute: 12))); - - // Restoring from dial mode. - await tester.restoreFrom(restorationData); - final Offset hour6 = Offset(center.dx, center.dy + 50.0); // 6:00 - final Offset min45 = Offset(center.dx - 50.0, center.dy); // 45 mins (or 9:00 hours) - - await tester.tapAt(hour6); - await tester.pump(const Duration(milliseconds: 50)); - await tester.restartAndRestore(); - await tester.tapAt(min45); - await tester.pump(const Duration(milliseconds: 50)); - await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 6, minute: 45))); - }); - - testWidgets('Can switch between hour/minute fields using keyboard input action', (WidgetTester tester) async { - await startPicker(tester, (TimeOfDay? time) { }, entryMode: TimePickerEntryMode.input); - - final Finder hourFinder = find.byType(TextField).first; - final TextField hourField = tester.widget(hourFinder); - await tester.tap(hourFinder); - expect(hourField.focusNode!.hasFocus, isTrue); - - await tester.enterText(find.byType(TextField).first, '08'); - final Finder minuteFinder = find.byType(TextField).last; - final TextField minuteField = tester.widget(minuteFinder); - expect(hourField.focusNode!.hasFocus, isFalse); - expect(minuteField.focusNode!.hasFocus, isTrue); - - expect(tester.testTextInput.setClientArgs!['inputAction'], equals('TextInputAction.done')); - await tester.testTextInput.receiveAction(TextInputAction.done); - expect(hourField.focusNode!.hasFocus, isFalse); - expect(minuteField.focusNode!.hasFocus, isFalse); - }); -} - -final Finder findDialPaint = find.descendant( - of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'), - matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), -); - -class PickerObserver extends NavigatorObserver { - int pickerCount = 0; - - @override - void didPush(Route route, Route? previousRoute) { - if (route is DialogRoute) { - pickerCount++; - } - super.didPush(route, previousRoute); - } -} - -Future mediaQueryBoilerplate( - WidgetTester tester, - bool alwaysUse24HourFormat, { - TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0), - double textScaleFactor = 1.0, - TimePickerEntryMode entryMode = TimePickerEntryMode.dial, - String? helpText, - String? hourLabelText, - String? minuteLabelText, - String? errorInvalidText, - bool accessibleNavigation = false, - EntryModeChangeCallback? onEntryModeChange, - bool tapButton = true, -}) async { - await tester.pumpWidget( - Localizations( - locale: const Locale('en', 'US'), - delegates: const >[ - DefaultMaterialLocalizations.delegate, - DefaultWidgetsLocalizations.delegate, - ], - child: MediaQuery( - data: MediaQueryData( - alwaysUse24HourFormat: alwaysUse24HourFormat, - textScaleFactor: textScaleFactor, - accessibleNavigation: accessibleNavigation, - size: tester.binding.window.physicalSize / tester.binding.window.devicePixelRatio, - ), - child: Material( - child: Directionality( - textDirection: TextDirection.ltr, - child: Navigator( - onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute(builder: (BuildContext context) { - return TextButton( - onPressed: () { - showTimePicker( - context: context, - initialTime: initialTime, - initialEntryMode: entryMode, - helpText: helpText, - hourLabelText: hourLabelText, - minuteLabelText: minuteLabelText, - errorInvalidText: errorInvalidText, - onEntryModeChanged: onEntryModeChange, - ); - }, - child: const Text('X'), - ); - }); - }, - ), - ), - ), - ), - ), - ); - if (tapButton) { - await tester.tap(find.text('X')); - } - await tester.pumpAndSettle(); -} diff --git a/packages/flutter/test/material/time_picker_theme_test.dart b/packages/flutter/test/material/time_picker_theme_test.dart index a437b72c4d2..12dc98c9088 100644 --- a/packages/flutter/test/material/time_picker_theme_test.dart +++ b/packages/flutter/test/material/time_picker_theme_test.dart @@ -75,21 +75,21 @@ void main() { expect(description, [ '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()', 'dialBackgroundColor: Color(0xffffffff)', + 'dialHandColor: Color(0xffffffff)', 'dialTextColor: Color(0xffffffff)', 'entryModeIconColor: Color(0xffffffff)', - 'hourMinuteTextStyle: TextStyle()', - 'dayPeriodTextStyle: TextStyle()', 'helpTextStyle: TextStyle()', - '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()', + '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(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 secondaryLabels = dialPainter.secondaryLabels as List; + final List selectedLabels = dialPainter.selectedLabels as List; 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 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 secondaryLabels = dialPainter.secondaryLabels as List; + final List selectedLabels = dialPainter.selectedLabels as List; 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), diff --git a/packages/flutter_localizations/test/material/time_picker_test.dart b/packages/flutter_localizations/test/material/time_picker_test.dart index 4c5cd1d8e33..5f850379c9e 100644 --- a/packages/flutter_localizations/test/material/time_picker_test.dart +++ b/packages/flutter_localizations/test/material/time_picker_test.dart @@ -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? onChanged; - final Locale locale; - final TimePickerEntryMode entryMode; - - @override - Widget build(BuildContext context) { - return MaterialApp( - locale: locale, - supportedLocales: [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 startPicker( - WidgetTester tester, - ValueChanged 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 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 locales = [ @@ -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 secondaryLabels = dialPainter.secondaryLabels as List; + final List selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls - secondaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!), + selectedLabels.map((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!), labels12To11, ); }); @@ -375,11 +320,72 @@ void main() { ); // ignore: avoid_dynamic_calls - final List secondaryLabels = dialPainter.secondaryLabels as List; + final List selectedLabels = dialPainter.selectedLabels as List; expect( // ignore: avoid_dynamic_calls - secondaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text! as TextSpan).text!), + selectedLabels.map((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? onChanged; + final Locale locale; + final TimePickerEntryMode entryMode; + + @override + Widget build(BuildContext context) { + return MaterialApp( + locale: locale, + supportedLocales: [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 startPicker( + WidgetTester tester, + ValueChanged 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 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)); +}