From e676024d37beb1505590769c19b51c1e81345cf7 Mon Sep 17 00:00:00 2001 From: rami-a <2364772+rami-a@users.noreply.github.com> Date: Mon, 22 Jun 2020 20:03:04 -0400 Subject: [PATCH] [Material] Redesign Time Picker (#59191) --- packages/flutter/lib/material.dart | 1 + .../flutter/lib/src/material/theme_data.dart | 15 + .../flutter/lib/src/material/time_picker.dart | 2121 +++++++++-------- .../lib/src/material/time_picker_theme.dart | 381 +++ .../test/material/theme_data_test.dart | 4 + .../test/material/time_picker_test.dart | 549 ++--- .../test/material/time_picker_theme_test.dart | 426 ++++ .../test/material/time_picker_test.dart | 210 +- 8 files changed, 2416 insertions(+), 1291 deletions(-) create mode 100644 packages/flutter/lib/src/material/time_picker_theme.dart create mode 100644 packages/flutter/test/material/time_picker_theme_test.dart diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index ac012eca7fc..571163ffc59 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -124,6 +124,7 @@ export 'src/material/theme.dart'; export 'src/material/theme_data.dart'; export 'src/material/time.dart'; export 'src/material/time_picker.dart'; +export 'src/material/time_picker_theme.dart'; export 'src/material/toggle_buttons.dart'; export 'src/material/toggle_buttons_theme.dart'; export 'src/material/toggleable.dart'; diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 92edc65bebb..d9a94a4b91c 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -35,6 +35,7 @@ import 'slider_theme.dart'; import 'snack_bar_theme.dart'; import 'tab_bar_theme.dart'; import 'text_theme.dart'; +import 'time_picker_theme.dart'; import 'toggle_buttons_theme.dart'; import 'tooltip_theme.dart'; import 'typography.dart'; @@ -269,6 +270,7 @@ class ThemeData with Diagnosticable { DividerThemeData dividerTheme, ButtonBarThemeData buttonBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme, + TimePickerThemeData timePickerTheme, bool fixTextFieldOutlineLabel, }) { assert(colorScheme?.brightness == null || brightness == null || colorScheme.brightness == brightness); @@ -380,6 +382,7 @@ class ThemeData with Diagnosticable { dividerTheme ??= const DividerThemeData(); buttonBarTheme ??= const ButtonBarThemeData(); bottomNavigationBarTheme ??= const BottomNavigationBarThemeData(); + timePickerTheme ??= const TimePickerThemeData(); fixTextFieldOutlineLabel ??= false; @@ -448,6 +451,7 @@ class ThemeData with Diagnosticable { dividerTheme: dividerTheme, buttonBarTheme: buttonBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme, + timePickerTheme: timePickerTheme, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel, ); } @@ -527,6 +531,7 @@ class ThemeData with Diagnosticable { @required this.dividerTheme, @required this.buttonBarTheme, @required this.bottomNavigationBarTheme, + @required this.timePickerTheme, @required this.fixTextFieldOutlineLabel, }) : assert(visualDensity != null), assert(primaryColor != null), @@ -589,6 +594,7 @@ class ThemeData with Diagnosticable { assert(dividerTheme != null), assert(buttonBarTheme != null), assert(bottomNavigationBarTheme != null), + assert(timePickerTheme != null), assert(fixTextFieldOutlineLabel != null); /// Create a [ThemeData] based on the colors in the given [colorScheme] and @@ -1036,6 +1042,9 @@ class ThemeData with Diagnosticable { /// widgets. final BottomNavigationBarThemeData bottomNavigationBarTheme; + /// A theme for customizing the appearance and layout of time picker widgets. + final TimePickerThemeData timePickerTheme; + /// A temporary flag to allow apps to opt-in to a /// [small fix](https://github.com/flutter/flutter/issues/54028) for the Y /// coordinate of the floating label in a [TextField] [OutlineInputBorder]. @@ -1117,6 +1126,7 @@ class ThemeData with Diagnosticable { DividerThemeData dividerTheme, ButtonBarThemeData buttonBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme, + TimePickerThemeData timePickerTheme, bool fixTextFieldOutlineLabel, }) { cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); @@ -1185,6 +1195,7 @@ class ThemeData with Diagnosticable { dividerTheme: dividerTheme ?? this.dividerTheme, buttonBarTheme: buttonBarTheme ?? this.buttonBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme, + timePickerTheme: timePickerTheme ?? this.timePickerTheme, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel ?? this.fixTextFieldOutlineLabel, ); } @@ -1331,6 +1342,7 @@ class ThemeData with Diagnosticable { dividerTheme: DividerThemeData.lerp(a.dividerTheme, b.dividerTheme, t), buttonBarTheme: ButtonBarThemeData.lerp(a.buttonBarTheme, b.buttonBarTheme, t), bottomNavigationBarTheme: BottomNavigationBarThemeData.lerp(a.bottomNavigationBarTheme, b.bottomNavigationBarTheme, t), + timePickerTheme: TimePickerThemeData.lerp(a.timePickerTheme, b.timePickerTheme, t), fixTextFieldOutlineLabel: t < 0.5 ? a.fixTextFieldOutlineLabel : b.fixTextFieldOutlineLabel, ); } @@ -1405,6 +1417,7 @@ class ThemeData with Diagnosticable { && other.dividerTheme == dividerTheme && other.buttonBarTheme == buttonBarTheme && other.bottomNavigationBarTheme == bottomNavigationBarTheme + && other.timePickerTheme == timePickerTheme && other.fixTextFieldOutlineLabel == fixTextFieldOutlineLabel; } @@ -1478,6 +1491,7 @@ class ThemeData with Diagnosticable { dividerTheme, buttonBarTheme, bottomNavigationBarTheme, + timePickerTheme, fixTextFieldOutlineLabel, ]; return hashList(values); @@ -1547,6 +1561,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('bannerTheme', bannerTheme, defaultValue: defaultData.bannerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('dividerTheme', dividerTheme, defaultValue: defaultData.dividerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('buttonBarTheme', buttonBarTheme, defaultValue: defaultData.buttonBarTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('timePickerTheme', timePickerTheme, defaultValue: defaultData.timePickerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('bottomNavigationBarTheme', bottomNavigationBarTheme, defaultValue: defaultData.bottomNavigationBarTheme, level: DiagnosticLevel.debug)); } } diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index d43266445aa..39004d2b450 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -12,325 +12,283 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'button_bar.dart'; +import 'button_theme.dart'; +import 'color_scheme.dart'; import 'colors.dart'; +import 'constants.dart'; +import 'curves.dart'; import 'debug.dart'; import 'dialog.dart'; import 'feedback.dart'; import 'flat_button.dart'; +import 'icon_button.dart'; +import 'icons.dart'; import 'ink_well.dart'; +import 'input_border.dart'; +import 'input_decorator.dart'; import 'material.dart'; import 'material_localizations.dart'; +import 'material_state.dart'; +import 'text_form_field.dart'; import 'text_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; import 'time.dart'; +import 'time_picker_theme.dart'; // Examples can assume: // BuildContext context; +const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200); const Duration _kDialAnimateDuration = Duration(milliseconds: 200); const double _kTwoPi = 2 * math.pi; const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); enum _TimePickerMode { hour, minute } -const double _kTimePickerHeaderPortraitHeight = 96.0; -const double _kTimePickerHeaderLandscapeWidth = 168.0; - +const double _kTimePickerHeaderLandscapeWidth = 264.0; +const double _kTimePickerHeaderControlHeight = 80.0; const double _kTimePickerWidthPortrait = 328.0; -const double _kTimePickerWidthLandscape = 512.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 BoxConstraints _kMinTappableRegion = BoxConstraints(minWidth: 48, minHeight: 48); +const BorderRadius _kDefaultBorderRadius = BorderRadius.all(Radius.circular(4.0)); +const ShapeBorder _kDefaultShape = RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); -enum _TimePickerHeaderId { - hour, - colon, - minute, - period, // AM/PM picker - dot, - hString, // French Canadian "h" literal +/// 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. +enum TimePickerEntryMode { + /// Tapping/dragging on a clock dial. + dial, + + /// Text input. + input, } /// Provides properties for rendering time picker header fragments. @immutable class _TimePickerFragmentContext { const _TimePickerFragmentContext({ - @required this.headerTextTheme, - @required this.textDirection, @required this.selectedTime, @required this.mode, - @required this.activeColor, - @required this.activeStyle, - @required this.inactiveColor, - @required this.inactiveStyle, @required this.onTimeChange, @required this.onModeChange, - @required this.targetPlatform, @required this.use24HourDials, - }) : assert(headerTextTheme != null), - assert(textDirection != null), - assert(selectedTime != null), + }) : assert(selectedTime != null), assert(mode != null), - assert(activeColor != null), - assert(activeStyle != null), - assert(inactiveColor != null), - assert(inactiveStyle != null), assert(onTimeChange != null), assert(onModeChange != null), - assert(targetPlatform != null), assert(use24HourDials != null); - final TextTheme headerTextTheme; - final TextDirection textDirection; final TimeOfDay selectedTime; final _TimePickerMode mode; - final Color activeColor; - final TextStyle activeStyle; - final Color inactiveColor; - final TextStyle inactiveStyle; final ValueChanged onTimeChange; final ValueChanged<_TimePickerMode> onModeChange; - final TargetPlatform targetPlatform; final bool use24HourDials; } -/// Contains the [widget] and layout properties of an atom of time information, -/// such as am/pm indicator, hour, minute and string literals appearing in the -/// formatted time string. -class _TimePickerHeaderFragment { - const _TimePickerHeaderFragment({ - @required this.layoutId, - @required this.widget, - this.startMargin = 0.0, - }) : assert(layoutId != null), - assert(widget != null), - assert(startMargin != null); - - /// Identifier used by the custom layout to refer to the widget. - final _TimePickerHeaderId layoutId; - - /// The widget that renders a piece of time information. - final Widget widget; - - /// Horizontal distance from the fragment appearing at the start of this - /// fragment. - /// - /// This value contributes to the total horizontal width of all fragments - /// appearing on the same line, unless it is the first fragment on the line, - /// in which case this value is ignored. - final double startMargin; -} - -/// An unbreakable part of the time picker header. -/// -/// When the picker is laid out vertically, [fragments] of the piece are laid -/// out on the same line, with each piece getting its own line. -class _TimePickerHeaderPiece { - /// Creates a time picker header piece. - /// - /// All arguments must be non-null. If the piece does not contain a pivot - /// fragment, use the value -1 as a convention. - const _TimePickerHeaderPiece(this.pivotIndex, this.fragments, { this.bottomMargin = 0.0 }) - : assert(pivotIndex != null), - assert(fragments != null), - assert(bottomMargin != null); - - /// Index into the [fragments] list, pointing at the fragment that's centered - /// horizontally. - final int pivotIndex; - - /// Fragments this piece is made of. - final List<_TimePickerHeaderFragment> fragments; - - /// Vertical distance between this piece and the next piece. - /// - /// This property applies only when the header is laid out vertically. - final double bottomMargin; -} - -/// Describes how the time picker header must be formatted. -/// -/// A [_TimePickerHeaderFormat] is made of multiple [_TimePickerHeaderPiece]s. -/// A piece is made of multiple [_TimePickerHeaderFragment]s. A fragment has a -/// widget used to render some time information and contains some layout -/// properties. -/// -/// ## Layout rules -/// -/// Pieces are laid out such that all fragments inside the same piece are laid -/// out horizontally. Pieces are laid out horizontally if portrait orientation, -/// and vertically in landscape orientation. -/// -/// One of the pieces is identified as a _centerpiece_. It is a piece that is -/// positioned in the center of the header, with all other pieces positioned -/// to the left or right of it. -class _TimePickerHeaderFormat { - const _TimePickerHeaderFormat(this.centerpieceIndex, this.pieces) - : assert(centerpieceIndex != null), - assert(pieces != null); - - /// Index into the [pieces] list pointing at the piece that contains the - /// pivot fragment. - final int centerpieceIndex; - - /// Pieces that constitute a time picker header. - final List<_TimePickerHeaderPiece> pieces; -} - -/// Displays the am/pm fragment and provides controls for switching between am -/// and pm. -class _DayPeriodControl extends StatelessWidget { - const _DayPeriodControl({ - @required this.fragmentContext, +class _TimePickerHeader extends StatelessWidget { + const _TimePickerHeader({ + @required this.selectedTime, + @required this.mode, @required this.orientation, - }); + @required this.onModeChanged, + @required this.onChanged, + @required this.use24HourDials, + @required this.helpText, + }) : assert(selectedTime != null), + assert(mode != null), + assert(orientation != null), + assert(use24HourDials != null); - final _TimePickerFragmentContext fragmentContext; + final TimeOfDay selectedTime; + final _TimePickerMode mode; final Orientation orientation; + final ValueChanged<_TimePickerMode> onModeChanged; + final ValueChanged onChanged; + final bool use24HourDials; + final String helpText; - void _togglePeriod() { - final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; - final TimeOfDay newTime = fragmentContext.selectedTime.replacing(hour: newHour); - fragmentContext.onTimeChange(newTime); - } - - void _setAm(BuildContext context) { - if (fragmentContext.selectedTime.period == DayPeriod.am) { - return; - } - switch (fragmentContext.targetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation); - break; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - break; - } - _togglePeriod(); - } - - void _setPm(BuildContext context) { - if (fragmentContext.selectedTime.period == DayPeriod.pm) { - return; - } - switch (fragmentContext.targetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation); - break; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - break; - } - _togglePeriod(); + void _handleChangeMode(_TimePickerMode value) { + if (value != mode) + onModeChanged(value); } @override Widget build(BuildContext context) { - final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); - final TextTheme headerTextTheme = fragmentContext.headerTextTheme; - final TimeOfDay selectedTime = fragmentContext.selectedTime; - final Color activeColor = fragmentContext.activeColor; - final Color inactiveColor = fragmentContext.inactiveColor; - final bool amSelected = selectedTime.period == DayPeriod.am; - final TextStyle amStyle = headerTextTheme.subtitle1.copyWith( - color: amSelected ? activeColor: inactiveColor - ); - final TextStyle pmStyle = headerTextTheme.subtitle1.copyWith( - color: !amSelected ? activeColor: inactiveColor - ); - final bool layoutPortrait = orientation == Orientation.portrait; - - final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0); - - final Widget amButton = ConstrainedBox( - constraints: _kMinTappableRegion, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: Feedback.wrapForTap(() => _setAm(context), context), - child: Padding( - padding: layoutPortrait ? const EdgeInsets.only(bottom: 2.0) : const EdgeInsets.only(right: 4.0), - child: Align( - alignment: layoutPortrait ? Alignment.bottomCenter : Alignment.centerRight, - widthFactor: 1, - heightFactor: 1, - child: Semantics( - selected: amSelected, - child: Text( - materialLocalizations.anteMeridiemAbbreviation, - style: amStyle, - textScaleFactor: buttonTextScaleFactor, - ), - ), - ), - ), - ), - ), + assert(debugCheckHasMediaQuery(context)); + final ThemeData themeData = Theme.of(context); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat( + alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat, ); - final Widget pmButton = ConstrainedBox( - constraints: _kMinTappableRegion, - child: Material( - type: MaterialType.transparency, - textStyle: pmStyle, - child: InkWell( - onTap: Feedback.wrapForTap(() => _setPm(context), context), - child: Padding( - padding: layoutPortrait ? const EdgeInsets.only(top: 2.0) : const EdgeInsets.only(left: 4.0), - child: Align( - alignment: orientation == Orientation.portrait ? Alignment.topCenter : Alignment.centerLeft, - widthFactor: 1, - heightFactor: 1, - child: Semantics( - selected: !amSelected, - child: Text( - materialLocalizations.postMeridiemAbbreviation, - style: pmStyle, - textScaleFactor: buttonTextScaleFactor, - ), - ), - ), - ), - ), - ), + final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext( + selectedTime: selectedTime, + mode: mode, + onTimeChange: onChanged, + onModeChange: _handleChangeMode, + use24HourDials: use24HourDials, ); + EdgeInsets padding; + double width; + Widget controls; + switch (orientation) { case Orientation.portrait: - return Column( - mainAxisSize: MainAxisSize.min, + // Keep width null because in portrait we don't cap the width. + padding = const EdgeInsets.symmetric(horizontal: 24.0); + controls = Column( children: [ - amButton, - pmButton, + const SizedBox(height: 16.0), + Container( + 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), + ], + Expanded(child: _HourControl(fragmentContext: fragmentContext)), + _StringFragment(timeOfDayFormat: timeOfDayFormat), + Expanded(child: _MinuteControl(fragmentContext: fragmentContext)), + if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ + const SizedBox(width: 12.0), + _DayPeriodControl( + selectedTime: selectedTime, + orientation: orientation, + onChanged: onChanged, + ), + ] + ], + ), + ), ], ); - + break; case Orientation.landscape: - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - amButton, - pmButton, - ], + 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, + ), + Container( + height: kMinInteractiveDimension * 2, + child: Row( + 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 null; + + return Container( + width: width, + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16.0), + Text( + // TODO(rami-a): localize 'SELECT TIME.' + helpText ?? 'SELECT TIME', + style: TimePickerTheme.of(context).helpTextStyle ?? themeData.textTheme.overline, + ), + controls, + ], + ), + ); } } +class _HourMinuteControl extends StatelessWidget { + const _HourMinuteControl({ + @required this.text, + @required this.onTap, + @required this.isSelected, + }) : assert(text != null), + assert(onTap != null), + assert(isSelected != null); + + final String text; + final GestureTapCallback onTap; + final bool isSelected; + + @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.headline2; + final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape; + + final Set states = isSelected ? {MaterialState.selected} : {}; + return Container( + height: _kTimePickerHeaderControlHeight, + child: Material( + color: MaterialStateProperty.resolveAs(backgroundColor, states), + clipBehavior: Clip.antiAlias, + shape: shape, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + text, + style: style.copyWith(color: MaterialStateProperty.resolveAs(textColor, states)), + textScaleFactor: 1.0, + ), + ), + ), + ), + ); + } +} /// Displays the hour fragment. /// /// When tapped changes time picker dial mode to [_TimePickerMode.hour]. @@ -346,9 +304,6 @@ class _HourControl extends StatelessWidget { assert(debugCheckHasMediaQuery(context)); final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour - ? fragmentContext.activeStyle - : fragmentContext.inactiveStyle; final String formattedHour = localizations.formatHour( fragmentContext.selectedTime, alwaysUse24HourFormat: alwaysUse24HourFormat, @@ -393,20 +348,10 @@ class _HourControl extends StatelessWidget { onDecrease: () { fragmentContext.onTimeChange(previousHour); }, - child: ConstrainedBox( - constraints: _kMinTappableRegion, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context), - child: Text( - formattedHour, - style: hourStyle, - textAlign: TextAlign.end, - textScaleFactor: 1.0, - ), - ), - ), + child: _HourMinuteControl( + isSelected: fragmentContext.mode == _TimePickerMode.hour, + text: formattedHour, + onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context), ), ); } @@ -415,17 +360,44 @@ class _HourControl extends StatelessWidget { /// A passive fragment showing a string value. class _StringFragment extends StatelessWidget { const _StringFragment({ - @required this.fragmentContext, - @required this.value, + @required this.timeOfDayFormat, }); - final _TimePickerFragmentContext fragmentContext; - final String value; + final TimeOfDayFormat timeOfDayFormat; + + String _stringFragmentValue(TimeOfDayFormat timeOfDayFormat) { + switch (timeOfDayFormat) { + case TimeOfDayFormat.h_colon_mm_space_a: + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.H_colon_mm: + case TimeOfDayFormat.HH_colon_mm: + return ':'; + case TimeOfDayFormat.HH_dot_mm: + return '.'; + case TimeOfDayFormat.frenchCanadian: + return 'h'; + } + return ''; + } @override Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? theme.textTheme.headline2; + final Color textColor = timePickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface; + return ExcludeSemantics( - child: Text(value, style: fragmentContext.inactiveStyle, textScaleFactor: 1.0), + 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, + ), + ), + ), ); } } @@ -443,9 +415,6 @@ class _MinuteControl extends StatelessWidget { @override Widget build(BuildContext context) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute - ? fragmentContext.activeStyle - : fragmentContext.inactiveStyle; final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime); final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing( minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour, @@ -468,412 +437,335 @@ class _MinuteControl extends StatelessWidget { onDecrease: () { fragmentContext.onTimeChange(previousMinute); }, - child: ConstrainedBox( - constraints: _kMinTappableRegion, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context), - child: Text(formattedMinute, style: minuteStyle, textAlign: TextAlign.start, textScaleFactor: 1.0), - ), - ), + child: _HourMinuteControl( + isSelected: fragmentContext.mode == _TimePickerMode.minute, + text: formattedMinute, + onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context), ), ); } } -/// Provides time picker header layout configuration for the given -/// [timeOfDayFormat] passing [context] to each widget in the -/// configuration. -/// -/// The [timeOfDayFormat] and [context] arguments must not be null. -_TimePickerHeaderFormat _buildHeaderFormat( - TimeOfDayFormat timeOfDayFormat, - _TimePickerFragmentContext context, - Orientation orientation, -) { - // Creates an hour fragment. - _TimePickerHeaderFragment hour() { - return _TimePickerHeaderFragment( - layoutId: _TimePickerHeaderId.hour, - widget: _HourControl(fragmentContext: context), - ); - } - // Creates a minute fragment. - _TimePickerHeaderFragment minute() { - return _TimePickerHeaderFragment( - layoutId: _TimePickerHeaderId.minute, - widget: _MinuteControl(fragmentContext: context), - ); - } - - // Creates a string fragment. - _TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) { - return _TimePickerHeaderFragment( - layoutId: layoutId, - widget: _StringFragment( - fragmentContext: context, - value: value, - ), - ); - } - - // Creates an am/pm fragment. - _TimePickerHeaderFragment dayPeriod() { - return _TimePickerHeaderFragment( - layoutId: _TimePickerHeaderId.period, - widget: _DayPeriodControl(fragmentContext: context, orientation: orientation), - ); - } - - // Convenience function for creating a time header format with up to two pieces. - _TimePickerHeaderFormat format( - _TimePickerHeaderPiece piece1, [ - _TimePickerHeaderPiece piece2, - ]) { - final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[]; - switch (context.textDirection) { - case TextDirection.ltr: - pieces.add(piece1); - if (piece2 != null) - pieces.add(piece2); - break; - case TextDirection.rtl: - if (piece2 != null) - pieces.add(piece2); - pieces.add(piece1); - break; - } - int centerpieceIndex; - for (int i = 0; i < pieces.length; i += 1) { - if (pieces[i].pivotIndex >= 0) { - centerpieceIndex = i; - } - } - assert(centerpieceIndex != null); - return _TimePickerHeaderFormat(centerpieceIndex, pieces); - } - - // Convenience function for creating a time header piece with up to three fragments. - _TimePickerHeaderPiece piece({ - int pivotIndex = -1, - double bottomMargin = 0.0, - _TimePickerHeaderFragment fragment1, - _TimePickerHeaderFragment fragment2, - _TimePickerHeaderFragment fragment3, - }) { - final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[ - fragment1, - if (fragment2 != null) ...<_TimePickerHeaderFragment>[ - fragment2, - if (fragment3 != null) fragment3, - ], - ]; - return _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin); - } - - switch (timeOfDayFormat) { - case TimeOfDayFormat.h_colon_mm_space_a: - return format( - piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.colon, ':'), - fragment3: minute(), - ), - piece( - fragment1: dayPeriod(), - ), - ); - case TimeOfDayFormat.H_colon_mm: - return format(piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.colon, ':'), - fragment3: minute(), - )); - case TimeOfDayFormat.HH_dot_mm: - return format(piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.dot, '.'), - fragment3: minute(), - )); - case TimeOfDayFormat.a_space_h_colon_mm: - return format( - piece( - fragment1: dayPeriod(), - ), - piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.colon, ':'), - fragment3: minute(), - ), - ); - case TimeOfDayFormat.frenchCanadian: - return format(piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.hString, 'h'), - fragment3: minute(), - )); - case TimeOfDayFormat.HH_colon_mm: - return format(piece( - pivotIndex: 1, - fragment1: hour(), - fragment2: string(_TimePickerHeaderId.colon, ':'), - fragment3: minute(), - )); - } - - return null; -} - -class _TimePickerHeaderLayout extends MultiChildLayoutDelegate { - _TimePickerHeaderLayout(this.orientation, this.format) - : assert(orientation != null), - assert(format != null); - - final Orientation orientation; - final _TimePickerHeaderFormat format; - - @override - void performLayout(Size size) { - final BoxConstraints constraints = BoxConstraints.loose(size); - - switch (orientation) { - case Orientation.portrait: - _layoutHorizontally(size, constraints); - break; - case Orientation.landscape: - _layoutVertically(size, constraints); - break; - } - } - - void _layoutHorizontally(Size size, BoxConstraints constraints) { - final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[]; - final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; - int pivotIndex = 0; - for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) { - final _TimePickerHeaderPiece piece = format.pieces[pieceIndex]; - for (final _TimePickerHeaderFragment fragment in piece.fragments) { - childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints); - fragmentsFlattened.add(fragment); - } - - if (pieceIndex == format.centerpieceIndex) - pivotIndex += format.pieces[format.centerpieceIndex].pivotIndex; - else if (pieceIndex < format.centerpieceIndex) - pivotIndex += piece.fragments.length; - } - - _positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex); - } - - void _layoutVertically(Size size, BoxConstraints constraints) { - final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; - final List pieceHeights = []; - double height = 0.0; - double margin = 0.0; - for (final _TimePickerHeaderPiece piece in format.pieces) { - double pieceHeight = 0.0; - for (final _TimePickerHeaderFragment fragment in piece.fragments) { - final Size childSize = childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints); - pieceHeight = math.max(pieceHeight, childSize.height); - } - pieceHeights.add(pieceHeight); - height += pieceHeight + margin; - // Delay application of margin until next piece because margin of the - // bottom-most piece should not contribute to the size. - margin = piece.bottomMargin; - } - - final _TimePickerHeaderPiece centerpiece = format.pieces[format.centerpieceIndex]; - double y = (size.height - height) / 2.0; - for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) { - final double pieceVerticalCenter = y + pieceHeights[pieceIndex] / 2.0; - if (pieceIndex != format.centerpieceIndex) - _positionPiece(size.width, pieceVerticalCenter, childSizes, format.pieces[pieceIndex].fragments); - else - _positionPivoted(size.width, pieceVerticalCenter, childSizes, centerpiece.fragments, centerpiece.pivotIndex); - - y += pieceHeights[pieceIndex] + format.pieces[pieceIndex].bottomMargin; - } - } - - void _positionPivoted(double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) { - double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0; - for (final _TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) { - tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin; - } - - double x = width / 2.0 + tailWidth; - x = math.min(x, width); - for (int i = fragments.length - 1; i >= 0; i -= 1) { - final _TimePickerHeaderFragment fragment = fragments[i]; - final Size childSize = childSizes[fragment.layoutId]; - x -= childSize.width; - positionChild(fragment.layoutId, Offset(x, y - childSize.height / 2.0)); - x -= fragment.startMargin; - } - } - - void _positionPiece(double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) { - double pieceWidth = 0.0; - double nextMargin = 0.0; - for (final _TimePickerHeaderFragment fragment in fragments) { - final Size childSize = childSizes[fragment.layoutId]; - pieceWidth += childSize.width + nextMargin; - // Delay application of margin until next element because margin of the - // left-most fragment should not contribute to the size. - nextMargin = fragment.startMargin; - } - double x = (width + pieceWidth) / 2.0; - for (int i = fragments.length - 1; i >= 0; i -= 1) { - final _TimePickerHeaderFragment fragment = fragments[i]; - final Size childSize = childSizes[fragment.layoutId]; - x -= childSize.width; - positionChild(fragment.layoutId, Offset(x, centeredAroundY - childSize.height / 2.0)); - x -= fragment.startMargin; - } - } - - @override - bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format; -} - -class _TimePickerHeader extends StatelessWidget { - const _TimePickerHeader({ +/// 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.mode, - @required this.orientation, - @required this.onModeChanged, @required this.onChanged, - @required this.use24HourDials, - }) : assert(selectedTime != null), - assert(mode != null), - assert(orientation != null), - assert(use24HourDials != null); + @required this.orientation, + }); final TimeOfDay selectedTime; - final _TimePickerMode mode; final Orientation orientation; - final ValueChanged<_TimePickerMode> onModeChanged; final ValueChanged onChanged; - final bool use24HourDials; - void _handleChangeMode(_TimePickerMode value) { - if (value != mode) - onModeChanged(value); + void _togglePeriod() { + final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + final TimeOfDay newTime = selectedTime.replacing(hour: newHour); + onChanged(newTime); } - TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) { - // These font sizes aren't listed in the spec explicitly. I worked them out - // by measuring the text using a screen ruler and comparing them to the - // screen shots of the time picker in the spec. - assert(orientation != null); - switch (orientation) { - case Orientation.portrait: - return headerTextTheme.headline2.copyWith(fontSize: 60.0); - case Orientation.landscape: - return headerTextTheme.headline3.copyWith(fontSize: 50.0); + void _setAm(BuildContext context) { + if (selectedTime.period == DayPeriod.am) { + return; } - return null; + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + _togglePeriod(); + } + + void _setPm(BuildContext context) { + if (selectedTime.period == DayPeriod.pm) { + return; + } + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + _togglePeriod(); } @override Widget build(BuildContext context) { - assert(debugCheckHasMediaQuery(context)); - final ThemeData themeData = Theme.of(context); - final MediaQueryData media = MediaQuery.of(context); - final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context) - .timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); - - EdgeInsets padding; - double height; - double width; - - assert(orientation != null); - switch (orientation) { - case Orientation.portrait: - height = _kTimePickerHeaderPortraitHeight; - padding = const EdgeInsets.symmetric(horizontal: 24.0); - break; - case Orientation.landscape: - width = _kTimePickerHeaderLandscapeWidth; - padding = const EdgeInsets.symmetric(horizontal: 16.0); - break; - } - - Color backgroundColor; - switch (themeData.brightness) { - case Brightness.light: - backgroundColor = themeData.primaryColor; - break; - case Brightness.dark: - backgroundColor = themeData.backgroundColor; - break; - } - - Color activeColor; - Color inactiveColor; - switch (themeData.primaryColorBrightness) { - case Brightness.light: - activeColor = Colors.black87; - inactiveColor = Colors.black54; - break; - case Brightness.dark: - activeColor = Colors.white; - inactiveColor = Colors.white70; - break; - } - - final TextTheme headerTextTheme = themeData.primaryTextTheme; - final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme); - final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext( - headerTextTheme: headerTextTheme, - textDirection: Directionality.of(context), - selectedTime: selectedTime, - mode: mode, - activeColor: activeColor, - activeStyle: baseHeaderStyle.copyWith(color: activeColor), - inactiveColor: inactiveColor, - inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor), - onTimeChange: onChanged, - onModeChange: _handleChangeMode, - targetPlatform: themeData.platform, - use24HourDials: use24HourDials, + 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 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.subtitle1; + 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 _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext, orientation); + final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0); - return Container( - width: width, - height: height, - padding: padding, - color: backgroundColor, - child: CustomMultiChildLayout( - delegate: _TimePickerHeaderLayout(orientation, format), - children: format.pieces - .expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments) - .map((_TimePickerHeaderFragment fragment) { - return LayoutId( - id: fragment.layoutId, - child: fragment.widget, - ); - }) - .toList(), + final Widget amButton = Material( + color: MaterialStateProperty.resolveAs(backgroundColor, amStates), + child: InkWell( + onTap: Feedback.wrapForTap(() => _setAm(context), context), + child: Semantics( + selected: amSelected, + child: Center( + child: Text( + materialLocalizations.anteMeridiemAbbreviation, + style: amStyle, + textScaleFactor: buttonTextScaleFactor, + ), + ), + ), ), ); + + final Widget pmButton = Material( + color: MaterialStateProperty.resolveAs(backgroundColor, pmStates), + child: InkWell( + onTap: Feedback.wrapForTap(() => _setPm(context), context), + child: Semantics( + selected: pmSelected, + child: Center( + child: Text( + materialLocalizations.postMeridiemAbbreviation, + style: pmStyle, + textScaleFactor: buttonTextScaleFactor, + ), + ), + ), + ), + ); + + Widget result; + switch (orientation) { + case Orientation.portrait: + const double width = 52.0; + result = _DayPeriodInputPadding( + minSize: const Size(width, kMinInteractiveDimension * 2), + orientation: orientation, + child: Container( + width: width, + height: _kTimePickerHeaderControlHeight, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + shape: shape, + child: Column( + children: [ + Expanded(child: amButton), + Container( + decoration: BoxDecoration( + border: Border(top: borderSide), + ), + height: 1, + ), + Expanded(child: pmButton), + ], + ), + ), + ), + ); + break; + case Orientation.landscape: + result = _DayPeriodInputPadding( + minSize: const Size(0.0, kMinInteractiveDimension), + orientation: orientation, + child: Container( + height: 40.0, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + shape: shape, + child: Row( + children: [ + Expanded(child: amButton), + Container( + decoration: BoxDecoration( + border: Border(left: borderSide), + ), + width: 1, + ), + Expanded(child: pmButton), + ], + ), + ), + ), + ); + break; + } + return result; } } -enum _DialRing { - outer, - inner, +/// A widget to pad the area around the [_DayPeriodControl]'s inner [Material]. +class _DayPeriodInputPadding extends SingleChildRenderObjectWidget { + const _DayPeriodInputPadding({ + Key key, + Widget child, + this.minSize, + this.orientation, + }) : super(key: key, child: child); + + final Size minSize; + final Orientation orientation; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize, orientation); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, this.orientation, [RenderBox child]) : super(child); + + final Orientation orientation; + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) + return; + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child.getMinIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child.getMinIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child.getMaxIntrinsicWidth(height), minSize.width); + } + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child.getMaxIntrinsicHeight(width), minSize.height); + } + return 0.0; + } + + @override + void performLayout() { + if (child != null) { + child.layout(constraints, parentUsesSize: true); + final double width = math.max(child.size.width, minSize.width); + final double height = math.max(child.size.height, minSize.height); + size = constraints.constrain(Size(width, height)); + final BoxParentData childParentData = child.parentData as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child.size as Offset); + } else { + size = Size.zero; + } + } + + @override + bool hitTest(BoxHitTestResult result, { Offset position }) { + if (super.hitTest(result, position: position)) { + return true; + } + + if (position.dx < 0.0 || + position.dx > math.max(child.size.width, minSize.width) || + position.dy < 0.0 || + position.dy > math.max(child.size.height, minSize.height)) { + return false; + } + + Offset newPosition = child.size.center(Offset.zero); + switch (orientation) { + case Orientation.portrait: + if (position.dy > newPosition.dy) { + newPosition += const Offset(0.0, 1.0); + } else { + newPosition += const Offset(0.0, -1.0); + } + break; + case Orientation.landscape: + if (position.dx > newPosition.dx) { + newPosition += const Offset(1.0, 0.0); + } else { + newPosition += const Offset(-1.0, 0.0); + } + break; + } + + + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(newPosition), + position: newPosition, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == newPosition); + return child.hitTest(result, position: newPosition); + }, + ); + } } class _TappableLabel { @@ -895,29 +787,27 @@ class _TappableLabel { class _DialPainter extends CustomPainter { _DialPainter({ - @required this.primaryOuterLabels, - @required this.primaryInnerLabels, - @required this.secondaryOuterLabels, - @required this.secondaryInnerLabels, + @required this.primaryLabels, + @required this.secondaryLabels, @required this.backgroundColor, @required this.accentColor, + @required this.dotColor, @required this.theta, - @required this.activeRing, @required this.textDirection, @required this.selectedValue, }) : super(repaint: PaintingBinding.instance.systemFonts); - final List<_TappableLabel> primaryOuterLabels; - final List<_TappableLabel> primaryInnerLabels; - final List<_TappableLabel> secondaryOuterLabels; - final List<_TappableLabel> secondaryInnerLabels; + final List<_TappableLabel> primaryLabels; + final List<_TappableLabel> secondaryLabels; final Color backgroundColor; final Color accentColor; + final Color dotColor; final double theta; - final _DialRing activeRing; final TextDirection textDirection; final int selectedValue; + static const double _labelPadding = 28.0; + @override void paint(Canvas canvas, Size size) { final double radius = size.shortestSide / 2.0; @@ -925,24 +815,12 @@ class _DialPainter extends CustomPainter { final Offset centerPoint = center; canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); - const double labelPadding = 24.0; - final double outerLabelRadius = radius - labelPadding; - final double innerLabelRadius = radius - labelPadding * 2.5; - Offset getOffsetForTheta(double theta, _DialRing ring) { - double labelRadius; - switch (ring) { - case _DialRing.outer: - labelRadius = outerLabelRadius; - break; - case _DialRing.inner: - labelRadius = innerLabelRadius; - break; - } - return center + Offset(labelRadius * math.cos(theta), - -labelRadius * math.sin(theta)); + final double labelRadius = radius - _labelPadding; + Offset getOffsetForTheta(double theta) { + return center + Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta)); } - void paintLabels(List<_TappableLabel> labels, _DialRing ring) { + void paintLabels(List<_TappableLabel> labels) { if (labels == null) return; final double labelThetaIncrement = -_kTwoPi / labels.length; @@ -951,121 +829,48 @@ class _DialPainter extends CustomPainter { 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, ring) + labelOffset); + labelPainter.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset); labelTheta += labelThetaIncrement; } } - paintLabels(primaryOuterLabels, _DialRing.outer); - paintLabels(primaryInnerLabels, _DialRing.inner); + paintLabels(primaryLabels); final Paint selectorPaint = Paint() ..color = accentColor; - final Offset focusedPoint = getOffsetForTheta(theta, activeRing); - const double focusedRadius = labelPadding - 4.0; + 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; canvas.drawLine(centerPoint, focusedPoint, selectorPaint); + // Add a dot inside the selector but only when it isn't over the labels. + // This checks that the selector's theta is between two labels. A remainder + // between 0.1 and 0.45 indicates that the selector is roughly not above any + // 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); + } + final Rect focusedRect = Rect.fromCircle( center: focusedPoint, radius: focusedRadius, ); canvas ..save() ..clipPath(Path()..addOval(focusedRect)); - paintLabels(secondaryOuterLabels, _DialRing.outer); - paintLabels(secondaryInnerLabels, _DialRing.inner); + paintLabels(secondaryLabels); canvas.restore(); } - static const double _semanticNodeSizeScale = 1.5; - - @override - SemanticsBuilderCallback get semanticsBuilder => _buildSemantics; - - /// Creates semantics nodes for the hour/minute labels painted on the dial. - /// - /// The nodes are positioned on top of the text and their size is - /// [_semanticNodeSizeScale] bigger than those of the text boxes to provide - /// bigger tap area. - List _buildSemantics(Size size) { - final double radius = size.shortestSide / 2.0; - final Offset center = Offset(size.width / 2.0, size.height / 2.0); - const double labelPadding = 24.0; - final double outerLabelRadius = radius - labelPadding; - final double innerLabelRadius = radius - labelPadding * 2.5; - - Offset getOffsetForTheta(double theta, _DialRing ring) { - double labelRadius; - switch (ring) { - case _DialRing.outer: - labelRadius = outerLabelRadius; - break; - case _DialRing.inner: - labelRadius = innerLabelRadius; - break; - } - return center + Offset(labelRadius * math.cos(theta), - -labelRadius * math.sin(theta)); - } - - final List nodes = []; - - void paintLabels(List<_TappableLabel> labels, _DialRing ring) { - if (labels == null) - return; - final double labelThetaIncrement = -_kTwoPi / labels.length; - final double ordinalOffset = ring == _DialRing.inner ? 12.0 : 0.0; - double labelTheta = math.pi / 2.0; - - for (int i = 0; i < labels.length; i++) { - final _TappableLabel label = labels[i]; - final TextPainter labelPainter = label.painter; - final double width = labelPainter.width * _semanticNodeSizeScale; - final double height = labelPainter.height * _semanticNodeSizeScale; - final Offset nodeOffset = getOffsetForTheta(labelTheta, ring) + Offset(-width / 2.0, -height / 2.0); - final TextSpan textSpan = labelPainter.text as TextSpan; - final CustomPainterSemantics node = CustomPainterSemantics( - rect: Rect.fromLTRB( - nodeOffset.dx - 24.0 + width / 2, - nodeOffset.dy - 24.0 + height / 2, - nodeOffset.dx + 24.0 + width / 2, - nodeOffset.dy + 24.0 + height / 2, - ), - properties: SemanticsProperties( - sortKey: OrdinalSortKey(i.toDouble() + ordinalOffset), - selected: label.value == selectedValue, - value: textSpan?.text, - textDirection: textDirection, - onTap: label.onTap, - ), - tags: const { - // Used by tests to find this node. - SemanticsTag('dial-label'), - }, - ); - nodes.add(node); - labelTheta += labelThetaIncrement; - } - } - - paintLabels(primaryOuterLabels, _DialRing.outer); - paintLabels(primaryInnerLabels, _DialRing.inner); - - return nodes; - } - @override bool shouldRepaint(_DialPainter oldPainter) { - return oldPainter.primaryOuterLabels != primaryOuterLabels - || oldPainter.primaryInnerLabels != primaryInnerLabels - || oldPainter.secondaryOuterLabels != secondaryOuterLabels - || oldPainter.secondaryInnerLabels != secondaryInnerLabels + return oldPainter.primaryLabels != primaryLabels + || oldPainter.secondaryLabels != secondaryLabels || oldPainter.backgroundColor != backgroundColor || oldPainter.accentColor != accentColor - || oldPainter.theta != theta - || oldPainter.activeRing != activeRing; + || oldPainter.theta != theta; } } @@ -1094,14 +899,13 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override void initState() { super.initState(); - _updateDialRingFromWidget(); _thetaController = AnimationController( duration: _kDialAnimateDuration, vsync: this, ); _thetaTween = Tween(begin: _getThetaForTime(widget.selectedTime)); _theta = _thetaController - .drive(CurveTween(curve: Curves.fastOutSlowIn)) + .drive(CurveTween(curve: standardEasing)) .drive(_thetaTween) ..addListener(() => setState(() { /* _theta.value has changed */ })); } @@ -1122,23 +926,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override void didUpdateWidget(_Dial oldWidget) { super.didUpdateWidget(oldWidget); - _updateDialRingFromWidget(); if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) { if (!_dragging) _animateTo(_getThetaForTime(widget.selectedTime)); } } - void _updateDialRingFromWidget() { - if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { - _activeRing = widget.selectedTime.hour >= 1 && widget.selectedTime.hour <= 12 - ? _DialRing.inner - : _DialRing.outer; - } else { - _activeRing = _DialRing.outer; - } - } - @override void dispose() { _thetaController.dispose(); @@ -1167,36 +960,36 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { } double _getThetaForTime(TimeOfDay time) { + final int hoursFactor = widget.use24HourDials ? TimeOfDay.hoursPerDay : TimeOfDay.hoursPerPeriod; final double fraction = widget.mode == _TimePickerMode.hour - ? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod + ? (time.hour / hoursFactor) % hoursFactor : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi; } - TimeOfDay _getTimeForTheta(double theta) { + TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false}) { final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; if (widget.mode == _TimePickerMode.hour) { - int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + int newHour; if (widget.use24HourDials) { - if (_activeRing == _DialRing.outer) { - if (newHour != 0) - newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; - } else if (newHour == 0) { - newHour = TimeOfDay.hoursPerPeriod; - } + 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 { - return widget.selectedTime.replacing( - minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour - ); + 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() { - final TimeOfDay current = _getTimeForTheta(_theta.value); + TimeOfDay _notifyOnChangedIfNeeded({ bool roundMinutes = false }) { + final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes); if (widget.onChanged == null) return current; if (current != widget.selectedTime) @@ -1204,27 +997,21 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { return current; } - void _updateThetaForPan() { + void _updateThetaForPan({ bool roundMinutes = false }) { setState(() { final Offset offset = _position - _center; - final double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi; + double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi; + if (roundMinutes) { + angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes)); + } _thetaTween ..begin = angle ..end = angle; // The controller doesn't animate during the pan gesture. - final RenderBox box = context.findRenderObject() as RenderBox; - final double radius = box.size.shortestSide / 2.0; - if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { - if (offset.distance * 1.5 < radius) - _activeRing = _DialRing.inner; - else - _activeRing = _DialRing.outer; - } }); } Offset _position; Offset _center; - _DialRing _activeRing = _DialRing.outer; void _handlePanStart(DragStartDetails details) { assert(!_dragging); @@ -1259,8 +1046,8 @@ 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); - _updateThetaForPan(); - final TimeOfDay newTime = _notifyOnChangedIfNeeded(); + _updateThetaForPan(roundMinutes: true); + final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true); if (widget.mode == _TimePickerMode.hour) { if (widget.use24HourDials) { _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); @@ -1273,7 +1060,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { } else { _announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); } - _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value))); + _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value, roundMinutes: true))); _dragging = false; _position = null; _center = null; @@ -1283,12 +1070,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { _announceToAccessibility(context, localizations.formatDecimal(hour)); TimeOfDay time; if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { - _activeRing = hour >= 1 && hour <= 12 - ? _DialRing.inner - : _DialRing.outer; time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); } else { - _activeRing = _DialRing.outer; if (widget.selectedTime.period == DayPeriod.am) { time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); } else { @@ -1330,19 +1113,19 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { TimeOfDay(hour: 11, minute: 0), ]; - static const List _pmHours = [ + static const List _twentyFourHours = [ TimeOfDay(hour: 0, minute: 0), - TimeOfDay(hour: 13, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 12, 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(TextTheme textTheme, int value, String label, VoidCallback onTap) { @@ -1359,8 +1142,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ); } - List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) => <_TappableLabel>[ - for (final TimeOfDay timeOfDay in _amHours) + List<_TappableLabel> _build24HourRing(TextTheme textTheme) => <_TappableLabel>[ + for (final TimeOfDay timeOfDay in _twentyFourHours) _buildTappableLabel( textTheme, timeOfDay.hour, @@ -1371,19 +1154,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ), ]; - List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) => <_TappableLabel>[ - for (final TimeOfDay timeOfDay in _pmHours) - _buildTappableLabel( - textTheme, - timeOfDay.hour, - localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), - () { - _selectHour(timeOfDay.hour); - }, - ), - ]; - - List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) => <_TappableLabel>[ + List<_TappableLabel> _build12HourRing(TextTheme textTheme) => <_TappableLabel>[ for (final TimeOfDay timeOfDay in _amHours) _buildTappableLabel( textTheme, @@ -1426,42 +1197,29 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { - Color backgroundColor; - switch (themeData.brightness) { - case Brightness.light: - backgroundColor = Colors.grey[200]; - break; - case Brightness.dark: - backgroundColor = themeData.backgroundColor; - break; - } - final ThemeData theme = Theme.of(context); - List<_TappableLabel> primaryOuterLabels; - List<_TappableLabel> primaryInnerLabels; - List<_TappableLabel> secondaryOuterLabels; - List<_TappableLabel> secondaryInnerLabels; + 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; + List<_TappableLabel> primaryLabels; + List<_TappableLabel> secondaryLabels; int selectedDialValue; switch (widget.mode) { case _TimePickerMode.hour: if (widget.use24HourDials) { selectedDialValue = widget.selectedTime.hour; - primaryOuterLabels = _build24HourOuterRing(theme.textTheme); - secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme); - primaryInnerLabels = _build24HourInnerRing(theme.textTheme); - secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme); + primaryLabels = _build24HourRing(theme.textTheme); + secondaryLabels = _build24HourRing(theme.accentTextTheme); } else { selectedDialValue = widget.selectedTime.hourOfPeriod; - primaryOuterLabels = _build12HourOuterRing(theme.textTheme); - secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme); + primaryLabels = _build12HourRing(theme.textTheme); + secondaryLabels = _build12HourRing(theme.accentTextTheme); } break; case _TimePickerMode.minute: selectedDialValue = widget.selectedTime.minute; - primaryOuterLabels = _buildMinutes(theme.textTheme); - primaryInnerLabels = null; - secondaryOuterLabels = _buildMinutes(theme.accentTextTheme); - secondaryInnerLabels = null; + primaryLabels = _buildMinutes(theme.textTheme); + secondaryLabels = _buildMinutes(theme.accentTextTheme); break; } @@ -1475,14 +1233,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { key: const ValueKey('time-picker-dial'), painter: _DialPainter( selectedValue: selectedDialValue, - primaryOuterLabels: primaryOuterLabels, - primaryInnerLabels: primaryInnerLabels, - secondaryOuterLabels: secondaryOuterLabels, - secondaryInnerLabels: secondaryInnerLabels, + primaryLabels: primaryLabels, + secondaryLabels: secondaryLabels, backgroundColor: backgroundColor, - accentColor: themeData.accentColor, + accentColor: accentColor, + dotColor: theme.colorScheme.surface, theta: _theta.value, - activeRing: _activeRing, textDirection: Directionality.of(context), ), ), @@ -1490,6 +1246,342 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { } } +class _TimePickerInput extends StatefulWidget { + const _TimePickerInput({ + Key key, + @required this.initialSelectedTime, + @required this.helpText, + @required this.onChanged, + }) : assert(initialSelectedTime != null), + assert(onChanged != null), + super(key: key); + + /// 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; + + final ValueChanged onChanged; + + @override + _TimePickerInputState createState() => _TimePickerInputState(); +} + +class _TimePickerInputState extends State<_TimePickerInput> { + TimeOfDay _selectedTime; + bool hourHasError = false; + bool minuteHasError = false; + + @override + void initState() { + super.initState(); + _selectedTime = widget.initialSelectedTime; + } + + int _parseHour(String value) { + if (value == null) { + return null; + } + + int newHour = int.tryParse(value); + if (newHour == null) { + return null; + } + + if (MediaQuery.of(context).alwaysUse24HourFormat) { + if (newHour >= 0 && newHour < 24) { + return newHour; + } + } else { + if (newHour > 0 && newHour < 13) { + if ((_selectedTime.period == DayPeriod.pm && newHour != 12) + || (_selectedTime.period == DayPeriod.am && newHour == 12)) { + newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + } + return newHour; + } + } + return null; + } + + int _parseMinute(String value) { + if (value == null) { + return null; + } + + final int newMinute = int.tryParse(value); + if (newMinute == null) { + return null; + } + + if (newMinute >= 0 && newMinute < 60) { + return newMinute; + } + return null; + } + + void _handleHourSavedSubmitted(String value) { + final int newHour = _parseHour(value); + if (newHour != null) { + _selectedTime = TimeOfDay(hour: newHour, minute: _selectedTime.minute); + widget.onChanged(_selectedTime); + } + } + + void _handleHourChanged(String value) { + final int newHour = _parseHour(value); + if (newHour != null && value.length == 2) { + // If a valid hour is typed, move focus to the minute TextField. + FocusScope.of(context).nextFocus(); + } + } + + void _handleMinuteSavedSubmitted(String value) { + final int newMinute = _parseMinute(value); + if (newMinute != null) { + _selectedTime = TimeOfDay(hour: _selectedTime.hour, minute: int.parse(value)); + widget.onChanged(_selectedTime); + } + } + + void _handleDayPeriodChanged(TimeOfDay value) { + _selectedTime = value; + widget.onChanged(_selectedTime); + } + + String _validateHour(String value) { + final int newHour = _parseHour(value); + setState(() { + hourHasError = newHour == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newHour == null ? '' : null; + } + + String _validateMinute(String value) { + final int newMinute = _parseMinute(value); + setState(() { + minuteHasError = newMinute == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newMinute == null ? '' : null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final MediaQueryData media = MediaQuery.of(context); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); + final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; + final ThemeData theme = Theme.of(context); + final TextStyle hourMinuteStyle = TimePickerTheme.of(context).hourMinuteTextStyle ?? theme.textTheme.headline2; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + // TODO(rami-a): localize 'ENTER TIME' + widget.helpText ?? 'ENTER TIME', + style: TimePickerTheme.of(context).helpTextStyle ?? theme.textTheme.overline, + ), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ + _DayPeriodControl( + selectedTime: _selectedTime, + orientation: Orientation.portrait, + onChanged: _handleDayPeriodChanged, + ), + const SizedBox(width: 12.0), + ], + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + _HourMinuteTextField( + selectedTime: _selectedTime, + isHour: true, + style: hourMinuteStyle, + validator: _validateHour, + onSavedSubmitted: _handleHourSavedSubmitted, + onChanged: _handleHourChanged, + ), + const SizedBox(height: 8.0), + if (!hourHasError && !minuteHasError) + ExcludeSemantics( + // TODO(rami-a): localize 'Hour' + child: Text('Hour', style: theme.textTheme.caption, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + )), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: _StringFragment(timeOfDayFormat: timeOfDayFormat), + ), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + _HourMinuteTextField( + selectedTime: _selectedTime, + isHour: false, + style: hourMinuteStyle, + validator: _validateMinute, + onSavedSubmitted: _handleMinuteSavedSubmitted, + ), + const SizedBox(height: 8.0), + if (!hourHasError && !minuteHasError) + ExcludeSemantics( + // TODO(rami-a): localize 'Minute' + child: Text('Minute', style: theme.textTheme.caption, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + )), + if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ + const SizedBox(width: 12.0), + _DayPeriodControl( + selectedTime: _selectedTime, + orientation: Orientation.portrait, + onChanged: _handleDayPeriodChanged, + ), + ], + ], + ), + if (hourHasError || minuteHasError) + Text( + // TODO(rami-a): localize 'Enter a valid time' + 'Enter a valid time', + style: theme.textTheme.bodyText2.copyWith(color: theme.colorScheme.error), + ) + else + const SizedBox(height: 2.0), + ], + ), + ); + } +} + +class _HourMinuteTextField extends StatefulWidget { + const _HourMinuteTextField({ + Key key, + @required this.selectedTime, + @required this.isHour, + @required this.style, + @required this.validator, + @required this.onSavedSubmitted, + this.onChanged, + }) : super(key: key); + + final TimeOfDay selectedTime; + final bool isHour; + final TextStyle style; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final ValueChanged onChanged; + + @override + _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); +} + +class _HourMinuteTextFieldState extends State<_HourMinuteTextField> { + TextEditingController controller; + FocusNode focusNode; + + @override + void initState() { + super.initState(); + focusNode = FocusNode()..addListener(() { + setState(() { }); // Rebuild. + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + controller ??= TextEditingController(text: _formattedValue); + } + + String get _formattedValue { + final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + 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 ColorScheme colorScheme = theme.colorScheme; + + final InputDecorationTheme inputDecorationTheme = TimePickerTheme.of(context).inputDecorationTheme; + InputDecoration inputDecoration; + if (inputDecorationTheme != null) { + inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme); + } else { + inputDecoration = InputDecoration( + contentPadding: const EdgeInsetsDirectional.only(bottom: 16.0, start: 3.0), + filled: true, + fillColor: focusNode.hasFocus ? colorScheme.surface : colorScheme.onSurface.withOpacity(0.12), + 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. + ); + } + inputDecoration = inputDecoration.copyWith( + // Remove the hint text when focused because the centered cursor appears + // odd above the hint text. + hintText: focusNode.hasFocus ? null : _formattedValue, + ); + + return Column( + children: [ + SizedBox( + height: _kTimePickerHeaderControlHeight, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: TextFormField( + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: widget.style.copyWith(color: colorScheme.onSurface), + controller: controller, + decoration: inputDecoration, + validator: widget.validator, + onEditingComplete: () => widget.onSavedSubmitted(controller.text), + onSaved: widget.onSavedSubmitted, + onFieldSubmitted: widget.onSavedSubmitted, + onChanged: widget.onChanged, + ), + ), + ), + ], + ); + } +} + /// A material design time picker designed to appear inside a popup dialog. /// /// Pass this widget to [showDialog]. The value returned by [showDialog] is the @@ -1503,21 +1595,45 @@ class _TimePickerDialog extends StatefulWidget { const _TimePickerDialog({ Key key, @required this.initialTime, + @required this.cancelText, + @required this.confirmText, + @required this.helpText, + this.initialEntryMode = TimePickerEntryMode.dial, }) : assert(initialTime != null), super(key: key); /// 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]. + 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 help text to the header of the time picker. + final String helpText; + @override _TimePickerDialogState createState() => _TimePickerDialogState(); } class _TimePickerDialogState extends State<_TimePickerDialog> { + final GlobalKey _formKey = GlobalKey(); + @override void initState() { super.initState(); _selectedTime = widget.initialTime; + _entryMode = widget.initialEntryMode; + _autoValidate = false; } @override @@ -1528,8 +1644,10 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { _announceModeOnce(); } + TimePickerEntryMode _entryMode; _TimePickerMode _mode = _TimePickerMode.hour; _TimePickerMode _lastModeAnnounced; + bool _autoValidate; TimeOfDay get selectedTime => _selectedTime; TimeOfDay _selectedTime; @@ -1563,6 +1681,21 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { }); } + void _handleEntryModeToggle() { + setState(() { + switch (_entryMode) { + case TimePickerEntryMode.dial: + _autoValidate = false; + _entryMode = TimePickerEntryMode.input; + break; + case TimePickerEntryMode.input: + _formKey.currentState.save(); + _entryMode = TimePickerEntryMode.dial; + break; + } + }); + } + void _announceModeOnce() { if (_lastModeAnnounced == _mode) { // Already announced it. @@ -1613,9 +1746,52 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { } void _handleOk() { + if (_entryMode == TimePickerEntryMode.input) { + final FormState form = _formKey.currentState; + if (!form.validate()) { + setState(() { _autoValidate = true; }); + return; + } + form.save(); + } Navigator.pop(context, _selectedTime); } + Size _dialogSize(BuildContext context) { + final Orientation orientation = MediaQuery.of(context).orientation; + 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.of(context).textScaleFactor, 1.1); + + double timePickerWidth; + double timePickerHeight; + switch (_entryMode) { + case TimePickerEntryMode.dial: + 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: + timePickerWidth = _kTimePickerWidthPortrait; + timePickerHeight = _kTimePickerHeightInput; + break; + } + return Size(timePickerWidth, timePickerHeight * textScaleFactor); + } + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); @@ -1623,113 +1799,144 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); 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 = media.orientation; - final Widget picker = Padding( - padding: const EdgeInsets.all(16.0), - child: AspectRatio( - aspectRatio: 1.0, - child: _Dial( - mode: _mode, - use24HourDials: use24HourDials, - selectedTime: _selectedTime, - onChanged: _handleTimeChanged, - onHourSelected: _handleHourSelected, - ), - ), - ); - - final Widget actions = ButtonBar( + final Widget actions = Row( children: [ - FlatButton( - child: Text(localizations.cancelButtonLabel), - onPressed: _handleCancel, + const SizedBox(width: 10.0), + IconButton( + color: TimePickerTheme.of(context).entryModeIconColor ?? theme.colorScheme.onSurface.withOpacity( + theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6, + ), + onPressed: _handleEntryModeToggle, + icon: Icon(_entryMode == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time), + // TODO(rami-a): Localize these strings. + tooltip: _entryMode == TimePickerEntryMode.dial + ? 'Switch to text input mode' + : 'Switch to dial picker mode', ), - FlatButton( - child: Text(localizations.okButtonLabel), - onPressed: _handleOk, + Expanded( + // TODO(rami-a): Move away from ButtonBar to avoid https://github.com/flutter/flutter/issues/53378. + child: ButtonBar( + layoutBehavior: ButtonBarLayoutBehavior.constrained, + children: [ + FlatButton( + onPressed: _handleCancel, + child: Text(widget.cancelText ?? localizations.cancelButtonLabel), + ), + FlatButton( + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), ), ], ); - final Dialog dialog = Dialog( - child: OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - final Widget header = _TimePickerHeader( - selectedTime: _selectedTime, - mode: _mode, - orientation: orientation, - onModeChanged: _handleModeChanged, - onChanged: _handleTimeChanged, - use24HourDials: use24HourDials, - ); + Widget picker; + switch (_entryMode) { + case TimePickerEntryMode.dial: + final Widget dial = Padding( + padding: orientation == Orientation.portrait ? const EdgeInsets.symmetric(horizontal: 36, vertical: 24) : const EdgeInsets.all(24), + child: ExcludeSemantics( + child: AspectRatio( + aspectRatio: 1.0, + child: _Dial( + mode: _mode, + use24HourDials: use24HourDials, + selectedTime: _selectedTime, + onChanged: _handleTimeChanged, + onHourSelected: _handleHourSelected, + ), + ), + ), + ); - final Widget pickerAndActions = Container( - color: theme.dialogBackgroundColor, + final Widget header = _TimePickerHeader( + selectedTime: _selectedTime, + mode: _mode, + orientation: orientation, + onModeChanged: _handleModeChanged, + onChanged: _handleTimeChanged, + use24HourDials: use24HourDials, + helpText: widget.helpText, + ); + + switch (orientation) { + case Orientation.portrait: + picker = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Dial grows and shrinks with the available space. + Expanded(child: dial), + actions, + ], + ), + ), + ], + ); + break; + case Orientation.landscape: + picker = Column( + children: [ + Expanded( + child: Row( + children: [ + header, + Expanded(child: dial), + ], + ), + ), + actions, + ], + ); + break; + } + break; + case TimePickerEntryMode.input: + picker = Form( + key: _formKey, + autovalidate: _autoValidate, + child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Expanded(child: picker), // picker grows and shrinks with the available space + _TimePickerInput( + initialSelectedTime: _selectedTime, + helpText: widget.helpText, + onChanged: _handleTimeChanged, + ), actions, ], ), - ); + ), + ); + break; + } - double timePickerHeightPortrait; - double timePickerHeightLandscape; - switch (theme.materialTapTargetSize) { - case MaterialTapTargetSize.padded: - timePickerHeightPortrait = _kTimePickerHeightPortrait; - timePickerHeightLandscape = _kTimePickerHeightLandscape; - break; - case MaterialTapTargetSize.shrinkWrap: - timePickerHeightPortrait = _kTimePickerHeightPortraitCollapsed; - timePickerHeightLandscape = _kTimePickerHeightLandscapeCollapsed; - break; - } - - assert(orientation != null); - switch (orientation) { - case Orientation.portrait: - return SizedBox( - width: _kTimePickerWidthPortrait, - height: timePickerHeightPortrait, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - header, - Expanded( - child: pickerAndActions, - ), - ], - ), - ); - case Orientation.landscape: - return SizedBox( - width: _kTimePickerWidthLandscape, - height: timePickerHeightLandscape, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - header, - Flexible( - child: pickerAndActions, - ), - ], - ), - ); - } - return null; - } + 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 == TimePickerEntryMode.input ? 0.0 : 24.0, ), - ); - - return Theme( - data: theme.copyWith( - dialogBackgroundColor: Colors.transparent, + child: AnimatedContainer( + width: dialogSize.width, + height: dialogSize.height, + duration: _kDialogSizeAnimationDuration, + curve: Curves.easeIn, + child: picker, ), - child: dialog, ); } @@ -1764,6 +1971,13 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { /// to add inherited widgets like [Localizations.override], /// [Directionality], or [MediaQuery]. /// +/// The [entryMode] 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], and [confirmText] can be +/// provided to override the default values. +/// /// {@tool snippet} /// Show a dialog with the text direction overridden to be [TextDirection.rtl]. /// @@ -1807,14 +2021,25 @@ Future showTimePicker({ @required TimeOfDay initialTime, TransitionBuilder builder, bool useRootNavigator = true, + TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial, + String cancelText, + String confirmText, + String helpText, RouteSettings routeSettings, }) async { assert(context != null); assert(initialTime != null); assert(useRootNavigator != null); + assert(initialEntryMode != null); assert(debugCheckHasMaterialLocalizations(context)); - final Widget dialog = _TimePickerDialog(initialTime: initialTime); + final Widget dialog = _TimePickerDialog( + initialTime: initialTime, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + helpText: helpText, + ); return await showDialog( context: context, useRootNavigator: useRootNavigator, diff --git a/packages/flutter/lib/src/material/time_picker_theme.dart b/packages/flutter/lib/src/material/time_picker_theme.dart new file mode 100644 index 00000000000..28ed0b7be91 --- /dev/null +++ b/packages/flutter/lib/src/material/time_picker_theme.dart @@ -0,0 +1,381 @@ +// 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. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'input_decorator.dart'; +import 'theme.dart'; + +/// Defines the visual properties of the widget displayed with [showTimePicker]. +/// +/// Descendant widgets obtain the current [TimePickerThemeData] object using +/// `TimePickerTheme.of(context)`. Instances of [TimePickerThemeData] +/// can be customized with [TimePickerThemeData.copyWith]. +/// +/// Typically a [TimePickerThemeData] is specified as part of the overall +/// [Theme] with [ThemeData.timePickerTheme]. +/// +/// All [TimePickerThemeData] properties are `null` by default. When null, +/// [showTimePicker] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +/// * [TimePickerTheme], which describes the actual configuration of a time +/// picker theme. +@immutable +class TimePickerThemeData with Diagnosticable { + + /// Creates a theme that can be used for [TimePickerTheme] or + /// [ThemeData.timePickerTheme]. + const TimePickerThemeData({ + this.backgroundColor, + this.hourMinuteTextColor, + this.hourMinuteColor, + this.dayPeriodTextColor, + this.dayPeriodColor, + this.dialHandColor, + this.dialBackgroundColor, + this.entryModeIconColor, + this.hourMinuteTextStyle, + this.dayPeriodTextStyle, + this.helpTextStyle, + this.shape, + this.hourMinuteShape, + this.dayPeriodShape, + this.dayPeriodBorderSide, + this.inputDecorationTheme, + }); + + /// The background color of a time picker. + /// + /// If this is null, the time picker defaults to the overall theme's + /// [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 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 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; + + /// The background color of the AM/PM toggle. + /// + /// If [dayPeriodColor] is a [MaterialStateColor], then the effective + /// background color can depend on the [MaterialState.selected] state, i.e. + /// if the segment is selected or not. + /// + /// By default, if the segment is selected, the overall theme's + /// `ColorScheme.primary.withOpacity(0.12)` is used when the overall theme's + /// brightness is [Brightness.light] and + /// `ColorScheme.primary.withOpacity(0.24)` is used when the overall theme's + /// brightness is [Brightness.dark]. + /// If the segment is not selected, [Colors.transparent] is used to allow the + /// [Dialog]'s color to be used. + final Color dayPeriodColor; + + /// 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 entry mode [IconButton]. + /// + /// If this is null, the time picker defaults to + /// ``` + /// 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.subtitle1]. + 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.overline]. + 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: + /// ``` + /// RoundedRectangleBorder( + /// borderRadius: BorderRadius.all(Radius.circular(4.0)), + /// side: BorderSide(), + /// ) + /// ``` + final OutlinedBorder dayPeriodShape; + + /// The color and weight of the day period's outline. + /// + /// If this is null, the time picker defaults to: + /// ``` + /// BorderSide( + /// color: Color.alphaBlend(colorScheme.onBackground.withOpacity(0.38), colorScheme.surface), + /// ) + /// ``` + final BorderSide dayPeriodBorderSide; + + /// 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; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + TimePickerThemeData copyWith({ + Color backgroundColor, + Color hourMinuteTextColor, + Color hourMinuteColor, + Color hourMinuteUnselectedTextColor, + Color hourMinuteUnselectedColor, + Color dayPeriodTextColor, + Color dayPeriodColor, + Color dialHandColor, + Color dialBackgroundColor, + Color entryModeIconColor, + TextStyle hourMinuteTextStyle, + TextStyle dayPeriodTextStyle, + TextStyle helpTextStyle, + ShapeBorder shape, + ShapeBorder hourMinuteShape, + OutlinedBorder dayPeriodShape, + BorderSide dayPeriodBorderSide, + InputDecorationTheme inputDecorationTheme, + }) { + 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, + 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, + dayPeriodBorderSide: dayPeriodBorderSide ?? this.dayPeriodBorderSide, + inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme, + ); + } + + /// Linearly interpolate between two time picker themes. + /// + /// The argument `t` must not be null. + /// + /// {@macro dart.ui.shadow.lerp} + static TimePickerThemeData lerp(TimePickerThemeData a, TimePickerThemeData b, double t) { + assert(t != null); + + // Workaround since BorderSide's lerp does not allow for null arguments. + BorderSide lerpedBorderSide; + if (a?.dayPeriodBorderSide == null && b?.dayPeriodBorderSide == null) { + lerpedBorderSide = null; + } else if (a?.dayPeriodBorderSide == null) { + lerpedBorderSide = b?.dayPeriodBorderSide; + } else if (b?.dayPeriodBorderSide == null) { + lerpedBorderSide = a?.dayPeriodBorderSide; + } else { + lerpedBorderSide = BorderSide.lerp(a?.dayPeriodBorderSide, b?.dayPeriodBorderSide, t); + } + 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), + 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, + dayPeriodBorderSide: lerpedBorderSide, + inputDecorationTheme: t < 0.5 ? a.inputDecorationTheme : b.inputDecorationTheme, + ); + } + + @override + int get hashCode { + return hashValues( + backgroundColor, + hourMinuteTextColor, + hourMinuteColor, + dayPeriodTextColor, + dayPeriodColor, + dialHandColor, + dialBackgroundColor, + entryModeIconColor, + hourMinuteTextStyle, + dayPeriodTextStyle, + helpTextStyle, + shape, + hourMinuteShape, + dayPeriodShape, + dayPeriodBorderSide, + inputDecorationTheme, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + 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.entryModeIconColor == entryModeIconColor + && other.hourMinuteTextStyle == hourMinuteTextStyle + && other.dayPeriodTextStyle == dayPeriodTextStyle + && other.helpTextStyle == helpTextStyle + && other.shape == shape + && other.hourMinuteShape == hourMinuteShape + && other.dayPeriodShape == dayPeriodShape + && other.dayPeriodBorderSide == dayPeriodBorderSide + && other.inputDecorationTheme == inputDecorationTheme; + } + + @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('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('dayPeriodBorderSide', dayPeriodBorderSide, defaultValue: null)); + properties.add(DiagnosticsProperty('inputDecorationTheme', inputDecorationTheme, defaultValue: null)); + } +} + +/// An inherited widget that defines the configuration for time pickers +/// displayed using [showTimePicker] in this widget's subtree. +/// +/// Values specified here are used for time picker properties that are not +/// given an explicit non-null value. +class TimePickerTheme extends InheritedTheme { + /// Creates a time picker theme that controls the configurations for + /// time pickers displayed in its widget subtree. + const TimePickerTheme({ + Key key, + @required this.data, + Widget child, + }) : assert(data != null), + super(key: key, child: child); + + /// The properties for descendant time picker widgets. + final TimePickerThemeData data; + + /// The [data] value of the closest [TimePickerTheme] ancestor. + /// + /// If there is no ancestor, it returns [ThemeData.timePickerTheme]. + /// Applications can assume that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TimePickerThemeData theme = TimePickerTheme.of(context); + /// ``` + static TimePickerThemeData of(BuildContext context) { + final TimePickerTheme timePickerTheme = context.dependOnInheritedWidgetOfExactType(); + return timePickerTheme?.data ?? Theme.of(context).timePickerTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + final TimePickerTheme ancestorTheme = context.findAncestorWidgetOfExactType(); + return identical(this, ancestorTheme) ? child : TimePickerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(TimePickerTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index ee667c660c1..6e69ceb6a16 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -282,6 +282,7 @@ void main() { dividerTheme: const DividerThemeData(color: Colors.black), buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.start), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed), + timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.black), fixTextFieldOutlineLabel: false, ); @@ -363,6 +364,7 @@ void main() { dividerTheme: const DividerThemeData(color: Colors.white), buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.end), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.shifting), + timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.white), fixTextFieldOutlineLabel: true, ); @@ -430,6 +432,7 @@ void main() { dividerTheme: otherTheme.dividerTheme, buttonBarTheme: otherTheme.buttonBarTheme, bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme, + timePickerTheme: otherTheme.timePickerTheme, fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel, ); @@ -499,6 +502,7 @@ void main() { expect(themeDataCopy.dividerTheme, equals(otherTheme.dividerTheme)); expect(themeDataCopy.buttonBarTheme, equals(otherTheme.buttonBarTheme)); expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme)); + expect(themeDataCopy.timePickerTheme, equals(otherTheme.timePickerTheme)); expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel)); }); diff --git a/packages/flutter/test/material/time_picker_test.dart b/packages/flutter/test/material/time_picker_test.dart index f5b47eab5d9..95e4e5e3cfd 100644 --- a/packages/flutter/test/material/time_picker_test.dart +++ b/packages/flutter/test/material/time_picker_test.dart @@ -6,15 +6,12 @@ @TestOn('!chrome') // entire file needs triage. import 'dart:async'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../rendering/mock_canvas.dart'; -import '../rendering/recording_canvas.dart'; import '../widgets/semantics_tester.dart'; import 'feedback_tester.dart'; @@ -23,10 +20,16 @@ final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widge final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePickerDialog'); class _TimePickerLauncher extends StatelessWidget { - const _TimePickerLauncher({ Key key, this.onChanged, this.locale }) : super(key: key); + const _TimePickerLauncher({ + Key key, + this.onChanged, + this.locale, + this.entryMode = TimePickerEntryMode.dial, + }) : super(key: key); final ValueChanged onChanged; final Locale locale; + final TimePickerEntryMode entryMode; @override Widget build(BuildContext context) { @@ -35,17 +38,18 @@ class _TimePickerLauncher extends StatelessWidget { home: Material( child: Center( child: Builder( - builder: (BuildContext context) { - return RaisedButton( - child: const Text('X'), - onPressed: () async { - onChanged(await showTimePicker( - context: context, - initialTime: const TimeOfDay(hour: 7, minute: 0), - )); - }, - ); - } + builder: (BuildContext context) { + return RaisedButton( + child: const Text('X'), + onPressed: () async { + onChanged(await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 7, minute: 0), + initialEntryMode: entryMode, + )); + }, + ); + } ), ), ), @@ -53,11 +57,15 @@ class _TimePickerLauncher extends StatelessWidget { } } -Future startPicker(WidgetTester tester, ValueChanged onChanged) async { - await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: const Locale('en', 'US'))); +Future startPicker( + WidgetTester tester, + ValueChanged onChanged, { + TimePickerEntryMode entryMode = TimePickerEntryMode.dial, + }) async { + await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: const Locale('en', 'US'), entryMode: entryMode)); await tester.tap(find.text('X')); await tester.pumpAndSettle(const Duration(seconds: 1)); - return tester.getCenter(find.byKey(const ValueKey('time-picker-dial'))); + return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey('time-picker-dial'))) : null; } Future finishPicker(WidgetTester tester) async { @@ -67,9 +75,13 @@ Future finishPicker(WidgetTester tester) async { } void main() { - group('Time picker', () { + group('Time picker - Dial', () { _tests(); }); + + group('Time picker - Input', () { + _testsInput(); + }); } void _tests() { @@ -170,6 +182,34 @@ void _tests() { expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); }); + testWidgets('tap-select rounds down to nearest 5 minute increment', (WidgetTester tester) async { + 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 { + 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); @@ -256,64 +296,18 @@ void _tests() { }); const List labels12To11 = ['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; - const List labels12To11TwoDigit = ['12', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11']; - const List labels00To23 = ['00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23']; - - Future mediaQueryBoilerplate( - WidgetTester tester, - bool alwaysUse24HourFormat, { - TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0), - double textScaleFactor = 1.0, - }) async { - await tester.pumpWidget( - Localizations( - locale: const Locale('en', 'US'), - delegates: const >[ - DefaultMaterialLocalizations.delegate, - DefaultWidgetsLocalizations.delegate, - ], - child: MediaQuery( - data: MediaQueryData( - alwaysUse24HourFormat: alwaysUse24HourFormat, - textScaleFactor: textScaleFactor, - ), - child: Material( - child: Directionality( - textDirection: TextDirection.ltr, - child: Navigator( - onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute(builder: (BuildContext context) { - return FlatButton( - onPressed: () { - showTimePicker(context: context, initialTime: initialTime); - }, - child: const Text('X'), - ); - }); - }, - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - } + 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; - final List primaryOuterLabels = dialPainter.primaryOuterLabels as List; - expect(primaryOuterLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); - expect(dialPainter.primaryInnerLabels, null); + final List primaryLabels = dialPainter.primaryLabels as List; + expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); - final List secondaryOuterLabels = dialPainter.secondaryOuterLabels as List; - expect(secondaryOuterLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); - expect(dialPainter.secondaryInnerLabels, null); + final List secondaryLabels = dialPainter.secondaryLabels as List; + expect(secondaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11); }); testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async { @@ -321,15 +315,11 @@ void _tests() { final CustomPaint dialPaint = tester.widget(findDialPaint); final dynamic dialPainter = dialPaint.painter; - final List primaryOuterLabels = dialPainter.primaryOuterLabels as List; - expect(primaryOuterLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); - final List primaryInnerLabels = dialPainter.primaryInnerLabels as List; - expect(primaryInnerLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11TwoDigit); + final List primaryLabels = dialPainter.primaryLabels as List; + expect(primaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22); - final List secondaryOuterLabels = dialPainter.secondaryOuterLabels as List; - expect(secondaryOuterLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To23); - final List secondaryInnerLabels = dialPainter.secondaryInnerLabels as List; - expect(secondaryInnerLabels.map((dynamic tp) => tp.painter.text.text as String), labels12To11TwoDigit); + final List secondaryLabels = dialPainter.secondaryLabels as List; + expect(secondaryLabels.map((dynamic tp) => tp.painter.text.text as String), labels00To22); }); testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async { @@ -347,10 +337,10 @@ void _tests() { await mediaQueryBoilerplate(tester, true); expect(semantics, isNot(includesNodeWith(label: ':'))); - expect(semantics.nodesWith(value: '00'), hasLength(2), - reason: '00 appears once in the header, then again in the dial'); - expect(semantics.nodesWith(value: '07'), hasLength(2), - reason: '07 appears once in the header, then again in the dial'); + expect(semantics.nodesWith(value: '00'), hasLength(1), + reason: '00 appears once in the header'); + expect(semantics.nodesWith(value: '07'), hasLength(1), + reason: '07 appears once in the header'); expect(semantics, includesNodeWith(label: 'CANCEL')); expect(semantics, includesNodeWith(label: 'OK')); @@ -361,82 +351,6 @@ void _tests() { semantics.dispose(); }); - testWidgets('provides semantics information for hours', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await mediaQueryBoilerplate(tester, true); - - final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey('time-picker-dial'))); - final CustomPainter dialPainter = dialPaint.painter; - final _CustomPainterSemanticsTester painterTester = _CustomPainterSemanticsTester(tester, dialPainter, semantics); - - painterTester.addLabel('00', 86.0, 0.0, 134.0, 48.0); - painterTester.addLabel('13', 129.0, 11.5, 177.0, 59.5); - painterTester.addLabel('14', 160.5, 43.0, 208.5, 91.0); - painterTester.addLabel('15', 172.0, 86.0, 220.0, 134.0); - painterTester.addLabel('16', 160.5, 129.0, 208.5, 177.0); - painterTester.addLabel('17', 129.0, 160.5, 177.0, 208.5); - painterTester.addLabel('18', 86.0, 172.0, 134.0, 220.0); - painterTester.addLabel('19', 43.0, 160.5, 91.0, 208.5); - painterTester.addLabel('20', 11.5, 129.0, 59.5, 177.0); - painterTester.addLabel('21', 0.0, 86.0, 48.0, 134.0); - painterTester.addLabel('22', 11.5, 43.0, 59.5, 91.0); - painterTester.addLabel('23', 43.0, 11.5, 91.0, 59.5); - painterTester.addLabel('12', 86.0, 36.0, 134.0, 84.0); - painterTester.addLabel('01', 111.0, 42.7, 159.0, 90.7); - painterTester.addLabel('02', 129.3, 61.0, 177.3, 109.0); - painterTester.addLabel('03', 136.0, 86.0, 184.0, 134.0); - painterTester.addLabel('04', 129.3, 111.0, 177.3, 159.0); - painterTester.addLabel('05', 111.0, 129.3, 159.0, 177.3); - painterTester.addLabel('06', 86.0, 136.0, 134.0, 184.0); - painterTester.addLabel('07', 61.0, 129.3, 109.0, 177.3); - painterTester.addLabel('08', 42.7, 111.0, 90.7, 159.0); - painterTester.addLabel('09', 36.0, 86.0, 84.0, 134.0); - painterTester.addLabel('10', 42.7, 61.0, 90.7, 109.0); - painterTester.addLabel('11', 61.0, 42.7, 109.0, 90.7); - - painterTester.assertExpectations(); - semantics.dispose(); - }); - - testWidgets('provides semantics information for minutes', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await mediaQueryBoilerplate(tester, true); - await tester.tap(_minuteControl); - await tester.pumpAndSettle(); - - final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey('time-picker-dial'))); - final CustomPainter dialPainter = dialPaint.painter; - final _CustomPainterSemanticsTester painterTester = _CustomPainterSemanticsTester(tester, dialPainter, semantics); - - painterTester.addLabel('00', 86.0, 0.0, 134.0, 48.0); - painterTester.addLabel('05', 129.0, 11.5, 177.0, 59.5); - painterTester.addLabel('10', 160.5, 43.0, 208.5, 91.0); - painterTester.addLabel('15', 172.0, 86.0, 220.0, 134.0); - painterTester.addLabel('20', 160.5, 129.0, 208.5, 177.0); - painterTester.addLabel('25', 129.0, 160.5, 177.0, 208.5); - painterTester.addLabel('30', 86.0, 172.0, 134.0, 220.0); - painterTester.addLabel('35', 43.0, 160.5, 91.0, 208.5); - painterTester.addLabel('40', 11.5, 129.0, 59.5, 177.0); - painterTester.addLabel('45', 0.0, 86.0, 48.0, 134.0); - painterTester.addLabel('50', 11.5, 43.0, 59.5, 91.0); - painterTester.addLabel('55', 43.0, 11.5, 91.0, 59.5); - - painterTester.assertExpectations(); - semantics.dispose(); - }); - - testWidgets('picks the right dial ring from widget configuration', (WidgetTester tester) async { - await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 12, minute: 0)); - dynamic dialPaint = tester.widget(findDialPaint); - expect('${dialPaint.painter.activeRing}', '_DialRing.inner'); - - await tester.pumpWidget(Container()); // make sure previous state isn't reused - - await mediaQueryBoilerplate(tester, true, initialTime: const TimeOfDay(hour: 0, minute: 0)); - dialPaint = tester.widget(findDialPaint); - expect('${dialPaint.painter.activeRing}', '_DialRing.outer'); - }); - testWidgets('can increment and decrement hours', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -550,21 +464,15 @@ void _tests() { }); 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 amSize = tester.getSize(find.ancestor( - of: find.text('AM'), - matching: find.byType(InkWell), - )); - expect(amSize.width, greaterThanOrEqualTo(48.0)); - expect(amSize.height, greaterThanOrEqualTo(48.0)); - - final Size pmSize = tester.getSize(find.ancestor( - of: find.text('PM'), - matching: find.byType(InkWell), - )); - expect(pmSize.width, greaterThanOrEqualTo(48.0)); - expect(pmSize.height, greaterThanOrEqualTo(48.0)); + 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'), @@ -579,6 +487,9 @@ void _tests() { )); expect(minuteSize.width, greaterThanOrEqualTo(48.0)); expect(minuteSize.height, greaterThanOrEqualTo(48.0)); + + tester.binding.window.physicalSizeTestValue = null; + tester.binding.window.devicePixelRatioTestValue = null; }); testWidgets('builder parameter', (WidgetTester tester) async { @@ -696,51 +607,158 @@ void _tests() { 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 RaisedButton( + 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); + }); + + // TODO(rami-a): Re-enable and fix test. testWidgets('text scale affects certain elements and not others', - (WidgetTester tester) async { - await mediaQueryBoilerplate( - tester, - false, - textScaleFactor: 1.0, - initialTime: const TimeOfDay(hour: 7, minute: 41), - ); - await tester.tap(find.text('X')); + (WidgetTester tester) async { + await mediaQueryBoilerplate( + tester, + false, + textScaleFactor: 1.0, + initialTime: const TimeOfDay(hour: 7, minute: 41), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + 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), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + 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), + ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); + expect(tester.getSize(find.text('AM')).height, equals(amHeight2x)); + }); +} + +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 { + 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('Can toggle 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); + }); - 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(); + testWidgets('Entered text returns time', (WidgetTester tester) async { + 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))); + }); - // 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), - ); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); + testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async { + 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))); + }); - expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); - expect(tester.getSize(find.text('AM')).height, equals(amHeight * 2)); + testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async { + TimeOfDay result; + await startPicker(tester, (TimeOfDay time) { result = time; }, entryMode: TimePickerEntryMode.input); - await tester.tap(find.text('OK')); // dismiss the dialog - await tester.pumpAndSettle(); + // Invalid hour. + await tester.enterText(find.byType(TextField).first, '88'); + await tester.enterText(find.byType(TextField).last, '15'); + await finishPicker(tester); + expect(result, null); - // 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), - ); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); + // Invalid minute. + await tester.enterText(find.byType(TextField).first, '8'); + await tester.enterText(find.byType(TextField).last, '150'); + await finishPicker(tester); + expect(result, null); - expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight)); - expect(tester.getSize(find.text('AM')).height, equals(amHeight * 2)); + 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))); }); } @@ -749,73 +767,6 @@ final Finder findDialPaint = find.descendant( matching: find.byWidgetPredicate((Widget w) => w is CustomPaint), ); -class _SemanticsNodeExpectation { - _SemanticsNodeExpectation(this.label, this.left, this.top, this.right, this.bottom); - - final String label; - final double left; - final double top; - final double right; - final double bottom; -} - -class _CustomPainterSemanticsTester { - _CustomPainterSemanticsTester(this.tester, this.painter, this.semantics); - - final WidgetTester tester; - final CustomPainter painter; - final SemanticsTester semantics; - final PaintPattern expectedLabels = paints; - final List<_SemanticsNodeExpectation> expectedNodes = <_SemanticsNodeExpectation>[]; - - void addLabel(String label, double left, double top, double right, double bottom) { - expectedNodes.add(_SemanticsNodeExpectation(label, left, top, right, bottom)); - } - - void assertExpectations() { - final TestRecordingCanvas canvasRecording = TestRecordingCanvas(); - painter.paint(canvasRecording, const Size(220.0, 220.0)); - final List paragraphs = canvasRecording.invocations - .where((RecordedInvocation recordedInvocation) { - return recordedInvocation.invocation.memberName == #drawParagraph; - }) - .map((RecordedInvocation recordedInvocation) { - return recordedInvocation.invocation.positionalArguments.first as ui.Paragraph; - }) - .toList(); - - final PaintPattern expectedLabels = paints; - int i = 0; - - for (final _SemanticsNodeExpectation expectation in expectedNodes) { - expect(semantics, includesNodeWith(value: expectation.label)); - final Iterable dialLabelNodes = semantics - .nodesWith(value: expectation.label) - .where((SemanticsNode node) => node.tags?.contains(const SemanticsTag('dial-label')) ?? false); - expect(dialLabelNodes, hasLength(1), reason: 'Expected exactly one label ${expectation.label}'); - final Rect rect = Rect.fromLTRB(expectation.left, expectation.top, expectation.right, expectation.bottom); - expect(dialLabelNodes.single.rect, within(distance: 1.0, from: rect), - reason: 'This is checking the node rectangle for label ${expectation.label}'); - - final ui.Paragraph paragraph = paragraphs[i++]; - - // The label text paragraph and the semantics node share the same center, - // but have different sizes. - final Offset center = dialLabelNodes.single.rect.center; - final Offset topLeft = center.translate( - -paragraph.width / 2.0, - -paragraph.height / 2.0, - ); - - expectedLabels.paragraph( - paragraph: paragraph, - offset: within(distance: 1.0, from: topLeft), - ); - } - expect(tester.renderObject(findDialPaint), expectedLabels); - } -} - class PickerObserver extends NavigatorObserver { int pickerCount = 0; @@ -827,3 +778,53 @@ class PickerObserver extends NavigatorObserver { 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, + }) async { + await tester.pumpWidget( + Localizations( + locale: const Locale('en', 'US'), + delegates: const >[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + child: MediaQuery( + data: MediaQueryData( + alwaysUse24HourFormat: alwaysUse24HourFormat, + textScaleFactor: textScaleFactor, + ), + child: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + onGenerateRoute: (RouteSettings settings) { + return MaterialPageRoute(builder: (BuildContext context) { + return FlatButton( + onPressed: () { + showTimePicker( + context: context, + initialTime: initialTime, + initialEntryMode: entryMode, + helpText: helpText, + ); + }, + child: const Text('X'), + ); + }); + }, + ), + ), + ), + ), + ), + ); + + 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 new file mode 100644 index 00000000000..0b9947815d6 --- /dev/null +++ b/packages/flutter/test/material/time_picker_theme_test.dart @@ -0,0 +1,426 @@ +// 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/painting.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; + +void main() { + test('TimePickerThemeData copyWith, ==, hashCode basics', () { + expect(const TimePickerThemeData(), const TimePickerThemeData().copyWith()); + expect(const TimePickerThemeData().hashCode, const TimePickerThemeData().copyWith().hashCode); + }); + + test('TimePickerThemeData null fields by default', () { + const TimePickerThemeData timePickerTheme = TimePickerThemeData(); + expect(timePickerTheme.backgroundColor, null); + expect(timePickerTheme.hourMinuteTextColor, null); + expect(timePickerTheme.hourMinuteColor, null); + expect(timePickerTheme.dayPeriodTextColor, null); + expect(timePickerTheme.dayPeriodColor, null); + expect(timePickerTheme.dialHandColor, null); + expect(timePickerTheme.dialBackgroundColor, null); + expect(timePickerTheme.dialHandColor, null); + expect(timePickerTheme.dialBackgroundColor, null); + expect(timePickerTheme.entryModeIconColor, null); + expect(timePickerTheme.hourMinuteTextStyle, null); + expect(timePickerTheme.dayPeriodTextStyle, null); + expect(timePickerTheme.helpTextStyle, null); + expect(timePickerTheme.shape, null); + expect(timePickerTheme.hourMinuteShape, null); + expect(timePickerTheme.dayPeriodShape, null); + expect(timePickerTheme.dayPeriodBorderSide, null); + expect(timePickerTheme.inputDecorationTheme, null); + }); + + testWidgets('Default TimePickerThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const TimePickerThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('TimePickerThemeData implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const TimePickerThemeData( + backgroundColor: Color(0xFFFFFFFF), + hourMinuteTextColor: Color(0xFFFFFFFF), + hourMinuteColor: Color(0xFFFFFFFF), + dayPeriodTextColor: Color(0xFFFFFFFF), + dayPeriodColor: Color(0xFFFFFFFF), + dialHandColor: Color(0xFFFFFFFF), + dialBackgroundColor: Color(0xFFFFFFFF), + entryModeIconColor: Color(0xFFFFFFFF), + hourMinuteTextStyle: TextStyle(), + dayPeriodTextStyle: TextStyle(), + helpTextStyle: TextStyle(), + shape: RoundedRectangleBorder(), + hourMinuteShape: RoundedRectangleBorder(), + dayPeriodShape: RoundedRectangleBorder(), + dayPeriodBorderSide: BorderSide(), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'backgroundColor: Color(0xffffffff)', + 'hourMinuteTextColor: Color(0xffffffff)', + 'hourMinuteColor: Color(0xffffffff)', + 'dayPeriodTextColor: Color(0xffffffff)', + 'dayPeriodColor: Color(0xffffffff)', + 'dialHandColor: Color(0xffffffff)', + 'dialBackgroundColor: Color(0xffffffff)', + 'entryModeIconColor: Color(0xffffffff)', + 'hourMinuteTextStyle: TextStyle()', + 'dayPeriodTextStyle: TextStyle()', + 'helpTextStyle: TextStyle()', + 'shape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.zero)', + 'hourMinuteShape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.zero)', + 'dayPeriodShape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.zero)', + 'dayPeriodBorderSide: BorderSide(Color(0xff000000), 1.0, BorderStyle.solid)', + ]); + }); + + testWidgets('Passing no TimePickerThemeData uses defaults', (WidgetTester tester) async { + final ThemeData defaultTheme = ThemeData.fallback(); + await tester.pumpWidget(const _TimePickerLauncher()); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, defaultTheme.colorScheme.surface); + expect(dialogMaterial.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); + + final RenderBox dial = tester.firstRenderObject(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: defaultTheme.colorScheme.onBackground.withOpacity(0.12)) // Dial background color. + ..circle(color: Color(defaultTheme.colorScheme.primary.value)), // Dial hand color. + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2014().englishLike.headline2 + .merge(Typography.material2014().black.headline2) + .copyWith(color: defaultTheme.colorScheme.primary), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2014().englishLike.headline2 + .merge(Typography.material2014().black.headline2) + .copyWith(color: defaultTheme.colorScheme.onSurface), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2014().englishLike.subtitle1 + .merge(Typography.material2014().black.subtitle1) + .copyWith(color: defaultTheme.colorScheme.primary), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2014().englishLike.subtitle1 + .merge(Typography.material2014().black.subtitle1) + .copyWith(color: defaultTheme.colorScheme.onSurface.withOpacity(0.6)), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'SELECT TIME'); + expect( + helperText.text.style, + Typography.material2014().englishLike.overline + .merge(Typography.material2014().black.overline), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, defaultTheme.colorScheme.primary.withOpacity(0.12)); + expect(hourMaterial.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, defaultTheme.colorScheme.onSurface.withOpacity(0.12)); + expect(minuteMaterial.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0)))); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, defaultTheme.colorScheme.primary.withOpacity(0.12)); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, Colors.transparent); + + final Color expectedBorderColor = Color.alphaBlend( + defaultTheme.colorScheme.onBackground.withOpacity(0.38), + defaultTheme.colorScheme.surface, + ); + final Material dayPeriodMaterial = _dayPeriodMaterial(tester); + expect( + dayPeriodMaterial.shape, + RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(4.0)), + side: BorderSide(color: expectedBorderColor), + ), + ); + + final Container dayPeriodDivider = _dayPeriodDivider(tester); + expect( + dayPeriodDivider.decoration, + BoxDecoration(border: Border(left: BorderSide(color: expectedBorderColor))), + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect( + entryModeIconButton.color, + defaultTheme.colorScheme.onSurface.withOpacity(0.6), + ); + }); + + + testWidgets('Passing no TimePickerThemeData uses defaults - input mode', (WidgetTester tester) async { + final ThemeData defaultTheme = ThemeData.fallback(); + await tester.pumpWidget(const _TimePickerLauncher(entryMode: TimePickerEntryMode.input)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration; + expect(hourDecoration.filled, true); + expect(hourDecoration.fillColor, 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))); + expect(hourDecoration.focusedErrorBorder, OutlineInputBorder(borderSide: BorderSide(color: defaultTheme.colorScheme.error, width: 2))); + expect( + hourDecoration.hintStyle, + Typography.material2014().englishLike.headline2 + .merge(defaultTheme.textTheme.headline2.copyWith(color: defaultTheme.colorScheme.onSurface.withOpacity(0.36))), + ); + }); + + testWidgets('Time picker uses values from TimePickerThemeData', (WidgetTester tester) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(); + final ThemeData theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget(_TimePickerLauncher(themeData: theme,)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final Material dialogMaterial = _dialogMaterial(tester); + expect(dialogMaterial.color, timePickerTheme.backgroundColor); + expect(dialogMaterial.shape, timePickerTheme.shape); + + final RenderBox dial = tester.firstRenderObject(find.byType(CustomPaint)); + expect( + dial, + paints + ..circle(color: Color(timePickerTheme.dialBackgroundColor.value)) // Dial background color. + ..circle(color: Color(timePickerTheme.dialHandColor.value)), // Dial hand color. + ); + + final RenderParagraph hourText = _textRenderParagraph(tester, '7'); + expect( + hourText.text.style, + Typography.material2014().englishLike.bodyText2 + .merge(Typography.material2014().black.bodyText2) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _selectedColor), + ); + + final RenderParagraph minuteText = _textRenderParagraph(tester, '15'); + expect( + minuteText.text.style, + Typography.material2014().englishLike.bodyText2 + .merge(Typography.material2014().black.bodyText2) + .merge(timePickerTheme.hourMinuteTextStyle) + .copyWith(color: _unselectedColor), + ); + + final RenderParagraph amText = _textRenderParagraph(tester, 'AM'); + expect( + amText.text.style, + Typography.material2014().englishLike.subtitle1 + .merge(Typography.material2014().black.subtitle1) + .merge(timePickerTheme.dayPeriodTextStyle) + .copyWith(color: _selectedColor), + ); + + final RenderParagraph pmText = _textRenderParagraph(tester, 'PM'); + expect( + pmText.text.style, + Typography.material2014().englishLike.subtitle1 + .merge(Typography.material2014().black.subtitle1) + .merge(timePickerTheme.dayPeriodTextStyle) + .copyWith(color: _unselectedColor), + ); + + final RenderParagraph helperText = _textRenderParagraph(tester, 'SELECT TIME'); + expect( + helperText.text.style, + Typography.material2014().englishLike.bodyText2 + .merge(Typography.material2014().black.bodyText2) + .merge(timePickerTheme.helpTextStyle), + ); + + final Material hourMaterial = _textMaterial(tester, '7'); + expect(hourMaterial.color, _selectedColor); + expect(hourMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material minuteMaterial = _textMaterial(tester, '15'); + expect(minuteMaterial.color, _unselectedColor); + expect(minuteMaterial.shape, timePickerTheme.hourMinuteShape); + + final Material amMaterial = _textMaterial(tester, 'AM'); + expect(amMaterial.color, _selectedColor); + + final Material pmMaterial = _textMaterial(tester, 'PM'); + expect(pmMaterial.color, _unselectedColor); + + final Material dayPeriodMaterial = _dayPeriodMaterial(tester); + expect( + dayPeriodMaterial.shape, + timePickerTheme.dayPeriodShape.copyWith(side: timePickerTheme.dayPeriodBorderSide), + ); + + final Container dayPeriodDivider = _dayPeriodDivider(tester); + expect( + dayPeriodDivider.decoration, + BoxDecoration(border: Border(left: timePickerTheme.dayPeriodBorderSide)), + ); + + final IconButton entryModeIconButton = _entryModeIconButton(tester); + expect( + entryModeIconButton.color, + timePickerTheme.entryModeIconColor, + ); + }); + + testWidgets('Time picker uses values from TimePickerThemeData - input mode', (WidgetTester tester) async { + final TimePickerThemeData timePickerTheme = _timePickerTheme(); + final ThemeData theme = ThemeData(timePickerTheme: timePickerTheme); + await tester.pumpWidget(_TimePickerLauncher(themeData: theme, entryMode: TimePickerEntryMode.input)); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final InputDecoration hourDecoration = _textField(tester, '7').decoration; + expect(hourDecoration.filled, timePickerTheme.inputDecorationTheme.filled); + expect(hourDecoration.fillColor, timePickerTheme.inputDecorationTheme.fillColor); + expect(hourDecoration.enabledBorder, timePickerTheme.inputDecorationTheme.enabledBorder); + expect(hourDecoration.errorBorder, timePickerTheme.inputDecorationTheme.errorBorder); + expect(hourDecoration.focusedBorder, timePickerTheme.inputDecorationTheme.focusedBorder); + expect(hourDecoration.focusedErrorBorder, timePickerTheme.inputDecorationTheme.focusedErrorBorder); + expect(hourDecoration.hintStyle, timePickerTheme.inputDecorationTheme.hintStyle); + }); +} + +final Color _selectedColor = Colors.green[100]; +final Color _unselectedColor = Colors.green[200]; + +TimePickerThemeData _timePickerTheme() { + Color getColor(Set states) { + return states.contains(MaterialState.selected) ? _selectedColor : _unselectedColor; + } + final MaterialStateColor materialStateColor = MaterialStateColor.resolveWith(getColor); + return TimePickerThemeData( + backgroundColor: Colors.orange, + hourMinuteTextColor: materialStateColor, + hourMinuteColor: materialStateColor, + dayPeriodTextColor: materialStateColor, + dayPeriodColor: materialStateColor, + dialHandColor: Colors.brown, + dialBackgroundColor: Colors.pinkAccent, + entryModeIconColor: Colors.red, + hourMinuteTextStyle: const TextStyle(fontSize: 8.0), + dayPeriodTextStyle: const TextStyle(fontSize: 8.0), + helpTextStyle: const TextStyle(fontSize: 8.0), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + hourMinuteShape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + dayPeriodShape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16.0))), + dayPeriodBorderSide: const BorderSide(color: Colors.blueAccent), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: Colors.purple, + enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.blue)), + errorBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.green)), + focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.yellow)), + focusedErrorBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.red)), + hintStyle: TextStyle(fontSize: 8), + ), + ); +} + +class _TimePickerLauncher extends StatelessWidget { + const _TimePickerLauncher({ + Key key, + this.themeData, + this.entryMode = TimePickerEntryMode.dial, + }) : super(key: key); + + final ThemeData themeData; + final TimePickerEntryMode entryMode; + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: themeData, + home: Material( + child: Center( + child: Builder( + builder: (BuildContext context) { + return RaisedButton( + child: const Text('X'), + onPressed: () async { + await showTimePicker( + context: context, + initialEntryMode: entryMode, + initialTime: const TimeOfDay(hour: 7, minute: 15), + ); + }, + ); + } + ), + ), + ), + ); + } +} + +Material _dialogMaterial(WidgetTester tester) { + return tester.widget(find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first); +} + +Material _textMaterial(WidgetTester tester, String text) { + return tester.widget(find.ancestor(of: find.text(text), matching: find.byType(Material)).first); +} + +TextField _textField(WidgetTester tester, String text) { + return tester.widget(find.ancestor(of: find.text(text), matching: find.byType(TextField)).first); +} + +Material _dayPeriodMaterial(WidgetTester tester) { + return tester.widget(find.descendant(of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), matching: find.byType(Material)).first); +} + +Container _dayPeriodDivider(WidgetTester tester) { + return tester.widget(find.descendant(of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), matching: find.byType(Container)).at(1)); +} + +IconButton _entryModeIconButton(WidgetTester tester) { + return tester.widget(find.descendant(of: find.byType(Dialog), matching: find.byType(IconButton)).first); +} + +RenderParagraph _textRenderParagraph(WidgetTester tester, String text) { + return tester.element(find.text(text).first).renderObject as RenderParagraph; +} \ No newline at end of file diff --git a/packages/flutter_localizations/test/material/time_picker_test.dart b/packages/flutter_localizations/test/material/time_picker_test.dart index 597e2412f96..47a7df722dc 100644 --- a/packages/flutter_localizations/test/material/time_picker_test.dart +++ b/packages/flutter_localizations/test/material/time_picker_test.dart @@ -43,7 +43,7 @@ class _TimePickerLauncher extends StatelessWidget { Future startPicker( WidgetTester tester, ValueChanged onChanged, { - Locale locale = const Locale('en', 'US'), + Locale locale = const Locale('en', 'US'), }) async { await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: locale,)); await tester.tap(find.text('X')); @@ -58,66 +58,151 @@ Future finishPicker(WidgetTester tester) async { } void main() { - testWidgets('can localize the header in all known formats', (WidgetTester tester) async { - // 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 Map> locales = >{ - const Locale('en', 'US'): const ['hour', 'string :', 'minute', 'period'], //'h:mm a' - const Locale('en', 'GB'): const ['hour', 'string :', 'minute'], //'HH:mm' - const Locale('es', 'ES'): const ['hour', 'string :', 'minute'], //'H:mm' - const Locale('fr', 'CA'): const ['hour', 'string h', 'minute'], //'HH \'h\' mm' - const Locale('zh', 'ZH'): const ['period', 'hour', 'string :', 'minute'], //'ah:mm' - }; + testWidgets('can localize the header in all known formats - portrait', (WidgetTester tester) async { + // Ensure picker is displayed in portrait mode. + tester.binding.window.physicalSizeTestValue = const Size(400, 800); + tester.binding.window.devicePixelRatioTestValue = 1; - for (final Locale locale in locales.keys) { + 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}' == '_HourControl'); + final Finder minuteControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteControl'); + 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 = [ + const Locale('en', 'US'), //'h:mm a' + const Locale('en', 'GB'), //'HH:mm' + const Locale('es', 'ES'), //'H:mm' + const Locale('fr', 'CA'), //'HH \'h\' mm' + const Locale('zh', 'ZH'), //'ah:mm' + ]; + + for (final Locale locale in locales) { final Offset center = await startPicker(tester, (TimeOfDay time) { }, locale: locale); - final List actual = []; - tester.element(find.byType(CustomMultiChildLayout)).visitChildren((Element child) { - final LayoutId layout = child.widget as LayoutId; - final String fragmentType = '${layout.child.runtimeType}'; - final dynamic widget = layout.child; - if (fragmentType == '_MinuteControl') { - actual.add('minute'); - } else if (fragmentType == '_DayPeriodControl') { - actual.add('period'); - } else if (fragmentType == '_HourControl') { - actual.add('hour'); - } else if (fragmentType == '_StringFragment') { - actual.add('string ${widget.value}'); - } else { - fail('Unsupported fragment type: $fragmentType'); - } - }); - expect(actual, locales[locale]); + final Text stringFragmentText = tester.widget(stringFragmentTextFinder); + final double hourLeftOffset = tester.getTopLeft(hourControlFinder).dx; + final double minuteLeftOffset = tester.getTopLeft(minuteControlFinder).dx; + final double stringFragmentLeftOffset = tester.getTopLeft(stringFragmentTextFinder).dx; + + if (locale == const Locale('en', 'US')) { + final double dayPeriodLeftOffset = tester.getTopLeft(dayPeriodControlFinder).dx; + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(minuteLeftOffset, lessThan(dayPeriodLeftOffset)); + } else if (locale == const Locale('en', 'GB')) { + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('es', 'ES')) { + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('fr', 'CA')) { + expect(stringFragmentText.data, 'h'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('zh', 'ZH')) { + final double dayPeriodLeftOffset = tester.getTopLeft(dayPeriodControlFinder).dx; + expect(stringFragmentText.data, ':'); + expect(dayPeriodLeftOffset, lessThan(hourLeftOffset)); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + } await tester.tapAt(Offset(center.dx, center.dy - 50.0)); await finishPicker(tester); } + + tester.binding.window.physicalSizeTestValue = null; + tester.binding.window.devicePixelRatioTestValue = null; }); - testWidgets('uses single-ring 12-hour dial for h hour format', (WidgetTester tester) async { - // Tap along the segment stretching from the center to the edge at - // 12:00 AM position. Because there's only one ring, no matter where you - // tap the time will be the same. See the 24-hour dial test that behaves - // differently. - for (int i = 1; i < 10; i++) { - TimeOfDay result; - final Offset center = await startPicker(tester, (TimeOfDay time) { result = time; }); - final Size size = tester.getSize(find.byKey(const Key('time-picker-dial'))); - final double dy = (size.height / 2.0 / 10) * i; - await tester.tapAt(Offset(center.dx, center.dy - dy)); + testWidgets('can localize the header in all known formats - landscape', (WidgetTester tester) async { + // Ensure picker is displayed in landscape mode. + tester.binding.window.physicalSizeTestValue = const Size(800, 400); + tester.binding.window.devicePixelRatioTestValue = 1; + + 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}' == '_HourControl'); + final Finder minuteControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_MinuteControl'); + 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 = [ + const Locale('en', 'US'), //'h:mm a' + const Locale('en', 'GB'), //'HH:mm' + const Locale('es', 'ES'), //'H:mm' + const Locale('fr', 'CA'), //'HH \'h\' mm' + const Locale('zh', 'ZH'), //'ah:mm' + ]; + + for (final Locale locale in locales) { + final Offset center = await startPicker(tester, (TimeOfDay time) { }, locale: locale); + final Text stringFragmentText = tester.widget(stringFragmentTextFinder); + final double hourLeftOffset = tester.getTopLeft(hourControlFinder).dx; + final double hourTopOffset = tester.getTopLeft(hourControlFinder).dy; + final double minuteLeftOffset = tester.getTopLeft(minuteControlFinder).dx; + final double stringFragmentLeftOffset = tester.getTopLeft(stringFragmentTextFinder).dx; + + if (locale == const Locale('en', 'US')) { + final double dayPeriodLeftOffset = tester.getTopLeft(dayPeriodControlFinder).dx; + final double dayPeriodTopOffset = tester.getTopLeft(dayPeriodControlFinder).dy; + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(hourLeftOffset, dayPeriodLeftOffset); + expect(hourTopOffset, lessThan(dayPeriodTopOffset)); + } else if (locale == const Locale('en', 'GB')) { + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('es', 'ES')) { + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('fr', 'CA')) { + expect(stringFragmentText.data, 'h'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(dayPeriodControlFinder, findsNothing); + } else if (locale == const Locale('zh', 'ZH')) { + final double dayPeriodLeftOffset = tester.getTopLeft(dayPeriodControlFinder).dx; + final double dayPeriodTopOffset = tester.getTopLeft(dayPeriodControlFinder).dy; + expect(stringFragmentText.data, ':'); + expect(hourLeftOffset, lessThan(stringFragmentLeftOffset)); + expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset)); + expect(hourLeftOffset, dayPeriodLeftOffset); + expect(hourTopOffset, greaterThan(dayPeriodTopOffset)); + } + await tester.tapAt(Offset(center.dx, center.dy - 50.0)); await finishPicker(tester); - expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); } + + tester.binding.window.physicalSizeTestValue = null; + tester.binding.window.devicePixelRatioTestValue = null; }); - testWidgets('uses two-ring 24-hour dial for H and HH hour formats', (WidgetTester tester) async { + testWidgets('uses single-ring 24-hour dial for all formats', (WidgetTester tester) async { const List locales = [ + Locale('en', 'US'), // h Locale('en', 'GB'), // HH Locale('es', 'ES'), // H ]; for (final Locale locale in locales) { // Tap along the segment stretching from the center to the edge at - // 12:00 AM position. There are two rings. At ~70% mark, the ring - // switches between inner ring and outer ring. + // 12:00 AM position. Because there's only one ring, no matter where you + // tap the time will be the same. for (int i = 1; i < 10; i++) { TimeOfDay result; final Offset center = await startPicker(tester, (TimeOfDay time) { result = time; }, locale: locale); @@ -125,14 +210,13 @@ void main() { final double dy = (size.height / 2.0 / 10) * i; await tester.tapAt(Offset(center.dx, center.dy - dy)); await finishPicker(tester); - expect(result, equals(TimeOfDay(hour: i < 7 ? 12 : 0, minute: 0))); + expect(result, equals(const TimeOfDay(hour: 0, minute: 0))); } } }); const List labels12To11 = ['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; - const List labels12To11TwoDigit = ['12', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11']; - const List labels00To23 = ['00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23']; + const List labels00To22TwoDigit = ['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22']; Future mediaQueryBoilerplate(WidgetTester tester, bool alwaysUse24HourFormat) async { await tester.pumpWidget( @@ -174,19 +258,17 @@ void main() { final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey('time-picker-dial'))); final dynamic dialPainter = dialPaint.painter; - final List primaryOuterLabels = dialPainter.primaryOuterLabels as List; + final List primaryLabels = dialPainter.primaryLabels as List; expect( - primaryOuterLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), + primaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), labels12To11, ); - expect(dialPainter.primaryInnerLabels, null); - final List secondaryOuterLabels = dialPainter.secondaryOuterLabels as List; + final List secondaryLabels = dialPainter.secondaryLabels as List; expect( - secondaryOuterLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), + secondaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), labels12To11, ); - expect(dialPainter.secondaryInnerLabels, null); }); testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async { @@ -194,26 +276,16 @@ void main() { final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey('time-picker-dial'))); final dynamic dialPainter = dialPaint.painter; - final List primaryOuterLabels = dialPainter.primaryOuterLabels as List; + final List primaryLabels = dialPainter.primaryLabels as List; expect( - primaryOuterLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), - labels00To23, - ); - final List primaryInnerLabels = dialPainter.primaryInnerLabels as List; - expect( - primaryInnerLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), - labels12To11TwoDigit, + primaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), + labels00To22TwoDigit, ); - final List secondaryOuterLabels = dialPainter.secondaryOuterLabels as List; + final List secondaryLabels = dialPainter.secondaryLabels as List; expect( - secondaryOuterLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), - labels00To23, - ); - final List secondaryInnerLabels = dialPainter.secondaryInnerLabels as List; - expect( - secondaryInnerLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), - labels12To11TwoDigit, + secondaryLabels.map((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), + labels00To22TwoDigit, ); }); }