Unverified Commit e676024d authored by rami-a's avatar rami-a Committed by GitHub

[Material] Redesign Time Picker (#59191)

parent 2cd205bb
...@@ -124,6 +124,7 @@ export 'src/material/theme.dart'; ...@@ -124,6 +124,7 @@ export 'src/material/theme.dart';
export 'src/material/theme_data.dart'; export 'src/material/theme_data.dart';
export 'src/material/time.dart'; export 'src/material/time.dart';
export 'src/material/time_picker.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.dart';
export 'src/material/toggle_buttons_theme.dart'; export 'src/material/toggle_buttons_theme.dart';
export 'src/material/toggleable.dart'; export 'src/material/toggleable.dart';
......
...@@ -35,6 +35,7 @@ import 'slider_theme.dart'; ...@@ -35,6 +35,7 @@ import 'slider_theme.dart';
import 'snack_bar_theme.dart'; import 'snack_bar_theme.dart';
import 'tab_bar_theme.dart'; import 'tab_bar_theme.dart';
import 'text_theme.dart'; import 'text_theme.dart';
import 'time_picker_theme.dart';
import 'toggle_buttons_theme.dart'; import 'toggle_buttons_theme.dart';
import 'tooltip_theme.dart'; import 'tooltip_theme.dart';
import 'typography.dart'; import 'typography.dart';
...@@ -269,6 +270,7 @@ class ThemeData with Diagnosticable { ...@@ -269,6 +270,7 @@ class ThemeData with Diagnosticable {
DividerThemeData dividerTheme, DividerThemeData dividerTheme,
ButtonBarThemeData buttonBarTheme, ButtonBarThemeData buttonBarTheme,
BottomNavigationBarThemeData bottomNavigationBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme,
TimePickerThemeData timePickerTheme,
bool fixTextFieldOutlineLabel, bool fixTextFieldOutlineLabel,
}) { }) {
assert(colorScheme?.brightness == null || brightness == null || colorScheme.brightness == brightness); assert(colorScheme?.brightness == null || brightness == null || colorScheme.brightness == brightness);
...@@ -380,6 +382,7 @@ class ThemeData with Diagnosticable { ...@@ -380,6 +382,7 @@ class ThemeData with Diagnosticable {
dividerTheme ??= const DividerThemeData(); dividerTheme ??= const DividerThemeData();
buttonBarTheme ??= const ButtonBarThemeData(); buttonBarTheme ??= const ButtonBarThemeData();
bottomNavigationBarTheme ??= const BottomNavigationBarThemeData(); bottomNavigationBarTheme ??= const BottomNavigationBarThemeData();
timePickerTheme ??= const TimePickerThemeData();
fixTextFieldOutlineLabel ??= false; fixTextFieldOutlineLabel ??= false;
...@@ -448,6 +451,7 @@ class ThemeData with Diagnosticable { ...@@ -448,6 +451,7 @@ class ThemeData with Diagnosticable {
dividerTheme: dividerTheme, dividerTheme: dividerTheme,
buttonBarTheme: buttonBarTheme, buttonBarTheme: buttonBarTheme,
bottomNavigationBarTheme: bottomNavigationBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme,
timePickerTheme: timePickerTheme,
fixTextFieldOutlineLabel: fixTextFieldOutlineLabel, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel,
); );
} }
...@@ -527,6 +531,7 @@ class ThemeData with Diagnosticable { ...@@ -527,6 +531,7 @@ class ThemeData with Diagnosticable {
@required this.dividerTheme, @required this.dividerTheme,
@required this.buttonBarTheme, @required this.buttonBarTheme,
@required this.bottomNavigationBarTheme, @required this.bottomNavigationBarTheme,
@required this.timePickerTheme,
@required this.fixTextFieldOutlineLabel, @required this.fixTextFieldOutlineLabel,
}) : assert(visualDensity != null), }) : assert(visualDensity != null),
assert(primaryColor != null), assert(primaryColor != null),
...@@ -589,6 +594,7 @@ class ThemeData with Diagnosticable { ...@@ -589,6 +594,7 @@ class ThemeData with Diagnosticable {
assert(dividerTheme != null), assert(dividerTheme != null),
assert(buttonBarTheme != null), assert(buttonBarTheme != null),
assert(bottomNavigationBarTheme != null), assert(bottomNavigationBarTheme != null),
assert(timePickerTheme != null),
assert(fixTextFieldOutlineLabel != null); assert(fixTextFieldOutlineLabel != null);
/// Create a [ThemeData] based on the colors in the given [colorScheme] and /// Create a [ThemeData] based on the colors in the given [colorScheme] and
...@@ -1036,6 +1042,9 @@ class ThemeData with Diagnosticable { ...@@ -1036,6 +1042,9 @@ class ThemeData with Diagnosticable {
/// widgets. /// widgets.
final BottomNavigationBarThemeData bottomNavigationBarTheme; 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 /// A temporary flag to allow apps to opt-in to a
/// [small fix](https://github.com/flutter/flutter/issues/54028) for the Y /// [small fix](https://github.com/flutter/flutter/issues/54028) for the Y
/// coordinate of the floating label in a [TextField] [OutlineInputBorder]. /// coordinate of the floating label in a [TextField] [OutlineInputBorder].
...@@ -1117,6 +1126,7 @@ class ThemeData with Diagnosticable { ...@@ -1117,6 +1126,7 @@ class ThemeData with Diagnosticable {
DividerThemeData dividerTheme, DividerThemeData dividerTheme,
ButtonBarThemeData buttonBarTheme, ButtonBarThemeData buttonBarTheme,
BottomNavigationBarThemeData bottomNavigationBarTheme, BottomNavigationBarThemeData bottomNavigationBarTheme,
TimePickerThemeData timePickerTheme,
bool fixTextFieldOutlineLabel, bool fixTextFieldOutlineLabel,
}) { }) {
cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault();
...@@ -1185,6 +1195,7 @@ class ThemeData with Diagnosticable { ...@@ -1185,6 +1195,7 @@ class ThemeData with Diagnosticable {
dividerTheme: dividerTheme ?? this.dividerTheme, dividerTheme: dividerTheme ?? this.dividerTheme,
buttonBarTheme: buttonBarTheme ?? this.buttonBarTheme, buttonBarTheme: buttonBarTheme ?? this.buttonBarTheme,
bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme, bottomNavigationBarTheme: bottomNavigationBarTheme ?? this.bottomNavigationBarTheme,
timePickerTheme: timePickerTheme ?? this.timePickerTheme,
fixTextFieldOutlineLabel: fixTextFieldOutlineLabel ?? this.fixTextFieldOutlineLabel, fixTextFieldOutlineLabel: fixTextFieldOutlineLabel ?? this.fixTextFieldOutlineLabel,
); );
} }
...@@ -1331,6 +1342,7 @@ class ThemeData with Diagnosticable { ...@@ -1331,6 +1342,7 @@ class ThemeData with Diagnosticable {
dividerTheme: DividerThemeData.lerp(a.dividerTheme, b.dividerTheme, t), dividerTheme: DividerThemeData.lerp(a.dividerTheme, b.dividerTheme, t),
buttonBarTheme: ButtonBarThemeData.lerp(a.buttonBarTheme, b.buttonBarTheme, t), buttonBarTheme: ButtonBarThemeData.lerp(a.buttonBarTheme, b.buttonBarTheme, t),
bottomNavigationBarTheme: BottomNavigationBarThemeData.lerp(a.bottomNavigationBarTheme, b.bottomNavigationBarTheme, 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, fixTextFieldOutlineLabel: t < 0.5 ? a.fixTextFieldOutlineLabel : b.fixTextFieldOutlineLabel,
); );
} }
...@@ -1405,6 +1417,7 @@ class ThemeData with Diagnosticable { ...@@ -1405,6 +1417,7 @@ class ThemeData with Diagnosticable {
&& other.dividerTheme == dividerTheme && other.dividerTheme == dividerTheme
&& other.buttonBarTheme == buttonBarTheme && other.buttonBarTheme == buttonBarTheme
&& other.bottomNavigationBarTheme == bottomNavigationBarTheme && other.bottomNavigationBarTheme == bottomNavigationBarTheme
&& other.timePickerTheme == timePickerTheme
&& other.fixTextFieldOutlineLabel == fixTextFieldOutlineLabel; && other.fixTextFieldOutlineLabel == fixTextFieldOutlineLabel;
} }
...@@ -1478,6 +1491,7 @@ class ThemeData with Diagnosticable { ...@@ -1478,6 +1491,7 @@ class ThemeData with Diagnosticable {
dividerTheme, dividerTheme,
buttonBarTheme, buttonBarTheme,
bottomNavigationBarTheme, bottomNavigationBarTheme,
timePickerTheme,
fixTextFieldOutlineLabel, fixTextFieldOutlineLabel,
]; ];
return hashList(values); return hashList(values);
...@@ -1547,6 +1561,7 @@ class ThemeData with Diagnosticable { ...@@ -1547,6 +1561,7 @@ class ThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<MaterialBannerThemeData>('bannerTheme', bannerTheme, defaultValue: defaultData.bannerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty<MaterialBannerThemeData>('bannerTheme', bannerTheme, defaultValue: defaultData.bannerTheme, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<DividerThemeData>('dividerTheme', dividerTheme, defaultValue: defaultData.dividerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty<DividerThemeData>('dividerTheme', dividerTheme, defaultValue: defaultData.dividerTheme, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<ButtonBarThemeData>('buttonBarTheme', buttonBarTheme, defaultValue: defaultData.buttonBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty<ButtonBarThemeData>('buttonBarTheme', buttonBarTheme, defaultValue: defaultData.buttonBarTheme, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<TimePickerThemeData>('timePickerTheme', timePickerTheme, defaultValue: defaultData.timePickerTheme, level: DiagnosticLevel.debug));
properties.add(DiagnosticsProperty<BottomNavigationBarThemeData>('bottomNavigationBarTheme', bottomNavigationBarTheme, defaultValue: defaultData.bottomNavigationBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty<BottomNavigationBarThemeData>('bottomNavigationBarTheme', bottomNavigationBarTheme, defaultValue: defaultData.bottomNavigationBarTheme, level: DiagnosticLevel.debug));
} }
} }
......
...@@ -12,325 +12,283 @@ import 'package:flutter/services.dart'; ...@@ -12,325 +12,283 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'button_bar.dart'; import 'button_bar.dart';
import 'button_theme.dart';
import 'color_scheme.dart';
import 'colors.dart'; import 'colors.dart';
import 'constants.dart';
import 'curves.dart';
import 'debug.dart'; import 'debug.dart';
import 'dialog.dart'; import 'dialog.dart';
import 'feedback.dart'; import 'feedback.dart';
import 'flat_button.dart'; import 'flat_button.dart';
import 'icon_button.dart';
import 'icons.dart';
import 'ink_well.dart'; import 'ink_well.dart';
import 'input_border.dart';
import 'input_decorator.dart';
import 'material.dart'; import 'material.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
import 'material_state.dart';
import 'text_form_field.dart';
import 'text_theme.dart'; import 'text_theme.dart';
import 'theme.dart'; import 'theme.dart';
import 'theme_data.dart'; import 'theme_data.dart';
import 'time.dart'; import 'time.dart';
import 'time_picker_theme.dart';
// Examples can assume: // Examples can assume:
// BuildContext context; // BuildContext context;
const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200);
const Duration _kDialAnimateDuration = Duration(milliseconds: 200); const Duration _kDialAnimateDuration = Duration(milliseconds: 200);
const double _kTwoPi = 2 * math.pi; const double _kTwoPi = 2 * math.pi;
const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); const Duration _kVibrateCommitDelay = Duration(milliseconds: 100);
enum _TimePickerMode { hour, minute } enum _TimePickerMode { hour, minute }
const double _kTimePickerHeaderPortraitHeight = 96.0; const double _kTimePickerHeaderLandscapeWidth = 264.0;
const double _kTimePickerHeaderLandscapeWidth = 168.0; const double _kTimePickerHeaderControlHeight = 80.0;
const double _kTimePickerWidthPortrait = 328.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 _kTimePickerHeightPortrait = 496.0;
const double _kTimePickerHeightLandscape = 316.0; const double _kTimePickerHeightLandscape = 316.0;
const double _kTimePickerHeightPortraitCollapsed = 484.0; const double _kTimePickerHeightPortraitCollapsed = 484.0;
const double _kTimePickerHeightLandscapeCollapsed = 304.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 { /// Interactive input mode of the time picker dialog.
hour, ///
colon, /// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and
minute, /// the user taps or drags the time they wish to select. In
period, // AM/PM picker /// TimePickerEntryMode.input] mode, [TextField]s are displayed and the user
dot, /// types in the time they wish to select.
hString, // French Canadian "h" literal enum TimePickerEntryMode {
/// Tapping/dragging on a clock dial.
dial,
/// Text input.
input,
} }
/// Provides properties for rendering time picker header fragments. /// Provides properties for rendering time picker header fragments.
@immutable @immutable
class _TimePickerFragmentContext { class _TimePickerFragmentContext {
const _TimePickerFragmentContext({ const _TimePickerFragmentContext({
@required this.headerTextTheme,
@required this.textDirection,
@required this.selectedTime, @required this.selectedTime,
@required this.mode, @required this.mode,
@required this.activeColor,
@required this.activeStyle,
@required this.inactiveColor,
@required this.inactiveStyle,
@required this.onTimeChange, @required this.onTimeChange,
@required this.onModeChange, @required this.onModeChange,
@required this.targetPlatform,
@required this.use24HourDials, @required this.use24HourDials,
}) : assert(headerTextTheme != null), }) : assert(selectedTime != null),
assert(textDirection != null),
assert(selectedTime != null),
assert(mode != null), assert(mode != null),
assert(activeColor != null),
assert(activeStyle != null),
assert(inactiveColor != null),
assert(inactiveStyle != null),
assert(onTimeChange != null), assert(onTimeChange != null),
assert(onModeChange != null), assert(onModeChange != null),
assert(targetPlatform != null),
assert(use24HourDials != null); assert(use24HourDials != null);
final TextTheme headerTextTheme;
final TextDirection textDirection;
final TimeOfDay selectedTime; final TimeOfDay selectedTime;
final _TimePickerMode mode; final _TimePickerMode mode;
final Color activeColor;
final TextStyle activeStyle;
final Color inactiveColor;
final TextStyle inactiveStyle;
final ValueChanged<TimeOfDay> onTimeChange; final ValueChanged<TimeOfDay> onTimeChange;
final ValueChanged<_TimePickerMode> onModeChange; final ValueChanged<_TimePickerMode> onModeChange;
final TargetPlatform targetPlatform;
final bool use24HourDials; final bool use24HourDials;
} }
/// Contains the [widget] and layout properties of an atom of time information, class _TimePickerHeader extends StatelessWidget {
/// such as am/pm indicator, hour, minute and string literals appearing in the const _TimePickerHeader({
/// formatted time string. @required this.selectedTime,
class _TimePickerHeaderFragment { @required this.mode,
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,
@required this.orientation, @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 Orientation orientation;
final ValueChanged<_TimePickerMode> onModeChanged;
final ValueChanged<TimeOfDay> onChanged;
final bool use24HourDials;
final String helpText;
void _togglePeriod() { void _handleChangeMode(_TimePickerMode value) {
final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; if (value != mode)
final TimeOfDay newTime = fragmentContext.selectedTime.replacing(hour: newHour); onModeChanged(value);
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();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); assert(debugCheckHasMediaQuery(context));
final TextTheme headerTextTheme = fragmentContext.headerTextTheme; final ThemeData themeData = Theme.of(context);
final TimeOfDay selectedTime = fragmentContext.selectedTime; final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(
final Color activeColor = fragmentContext.activeColor; alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
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 _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext(
selectedTime: selectedTime,
mode: mode,
onTimeChange: onChanged,
onModeChange: _handleChangeMode,
use24HourDials: use24HourDials,
); );
final bool layoutPortrait = orientation == Orientation.portrait;
final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0); EdgeInsets padding;
double width;
Widget controls;
final Widget amButton = ConstrainedBox( switch (orientation) {
constraints: _kMinTappableRegion, case Orientation.portrait:
child: Material( // Keep width null because in portrait we don't cap the width.
type: MaterialType.transparency, padding = const EdgeInsets.symmetric(horizontal: 24.0);
child: InkWell( controls = Column(
onTap: Feedback.wrapForTap(() => _setAm(context), context), children: <Widget>[
child: Padding( const SizedBox(height: 16.0),
padding: layoutPortrait ? const EdgeInsets.only(bottom: 2.0) : const EdgeInsets.only(right: 4.0), Container(
child: Align( height: kMinInteractiveDimension * 2,
alignment: layoutPortrait ? Alignment.bottomCenter : Alignment.centerRight, child: Row(
widthFactor: 1, children: <Widget>[
heightFactor: 1, if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
child: Semantics( _DayPeriodControl(
selected: amSelected, selectedTime: selectedTime,
child: Text( orientation: orientation,
materialLocalizations.anteMeridiemAbbreviation, onChanged: onChanged,
style: amStyle, ),
textScaleFactor: buttonTextScaleFactor, 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) ...<Widget>[
const SizedBox(width: 12.0),
_DayPeriodControl(
selectedTime: selectedTime,
orientation: orientation,
onChanged: onChanged,
),
]
],
), ),
), ),
],
);
break;
case Orientation.landscape:
width = _kTimePickerHeaderLandscapeWidth;
padding = const EdgeInsets.symmetric(horizontal: 24.0);
controls = Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm)
_DayPeriodControl(
selectedTime: selectedTime,
orientation: orientation,
onChanged: onChanged,
),
Container(
height: kMinInteractiveDimension * 2,
child: Row(
children: <Widget>[
Expanded(child: _HourControl(fragmentContext: fragmentContext)),
_StringFragment(timeOfDayFormat: timeOfDayFormat),
Expanded(child: _MinuteControl(fragmentContext: fragmentContext)),
],
),
),
if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm)
_DayPeriodControl(
selectedTime: selectedTime,
orientation: orientation,
onChanged: onChanged,
),
],
), ),
), );
break;
}
return Container(
width: width,
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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;
final Widget pmButton = ConstrainedBox( @override
constraints: _kMinTappableRegion, 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<MaterialState> states) {
return states.contains(MaterialState.selected)
? themeData.colorScheme.primary
: themeData.colorScheme.onSurface;
});
final Color backgroundColor = timePickerTheme.hourMinuteColor
?? MaterialStateColor.resolveWith((Set<MaterialState> 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<MaterialState> states = isSelected ? <MaterialState>{MaterialState.selected} : <MaterialState>{};
return Container(
height: _kTimePickerHeaderControlHeight,
child: Material( child: Material(
type: MaterialType.transparency, color: MaterialStateProperty.resolveAs(backgroundColor, states),
textStyle: pmStyle, clipBehavior: Clip.antiAlias,
shape: shape,
child: InkWell( child: InkWell(
onTap: Feedback.wrapForTap(() => _setPm(context), context), onTap: onTap,
child: Padding( child: Center(
padding: layoutPortrait ? const EdgeInsets.only(top: 2.0) : const EdgeInsets.only(left: 4.0), child: Text(
child: Align( text,
alignment: orientation == Orientation.portrait ? Alignment.topCenter : Alignment.centerLeft, style: style.copyWith(color: MaterialStateProperty.resolveAs(textColor, states)),
widthFactor: 1, textScaleFactor: 1.0,
heightFactor: 1,
child: Semantics(
selected: !amSelected,
child: Text(
materialLocalizations.postMeridiemAbbreviation,
style: pmStyle,
textScaleFactor: buttonTextScaleFactor,
),
),
), ),
), ),
), ),
), ),
); );
switch (orientation) {
case Orientation.portrait:
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
amButton,
pmButton,
],
);
case Orientation.landscape:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
amButton,
pmButton,
],
);
}
return null;
} }
} }
/// Displays the hour fragment. /// Displays the hour fragment.
/// ///
/// When tapped changes time picker dial mode to [_TimePickerMode.hour]. /// When tapped changes time picker dial mode to [_TimePickerMode.hour].
...@@ -346,9 +304,6 @@ class _HourControl extends StatelessWidget { ...@@ -346,9 +304,6 @@ class _HourControl extends StatelessWidget {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
final MaterialLocalizations localizations = MaterialLocalizations.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
? fragmentContext.activeStyle
: fragmentContext.inactiveStyle;
final String formattedHour = localizations.formatHour( final String formattedHour = localizations.formatHour(
fragmentContext.selectedTime, fragmentContext.selectedTime,
alwaysUse24HourFormat: alwaysUse24HourFormat, alwaysUse24HourFormat: alwaysUse24HourFormat,
...@@ -393,20 +348,10 @@ class _HourControl extends StatelessWidget { ...@@ -393,20 +348,10 @@ class _HourControl extends StatelessWidget {
onDecrease: () { onDecrease: () {
fragmentContext.onTimeChange(previousHour); fragmentContext.onTimeChange(previousHour);
}, },
child: ConstrainedBox( child: _HourMinuteControl(
constraints: _kMinTappableRegion, isSelected: fragmentContext.mode == _TimePickerMode.hour,
child: Material( text: formattedHour,
type: MaterialType.transparency, onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
child: InkWell(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
child: Text(
formattedHour,
style: hourStyle,
textAlign: TextAlign.end,
textScaleFactor: 1.0,
),
),
),
), ),
); );
} }
...@@ -415,17 +360,44 @@ class _HourControl extends StatelessWidget { ...@@ -415,17 +360,44 @@ class _HourControl extends StatelessWidget {
/// A passive fragment showing a string value. /// A passive fragment showing a string value.
class _StringFragment extends StatelessWidget { class _StringFragment extends StatelessWidget {
const _StringFragment({ const _StringFragment({
@required this.fragmentContext, @required this.timeOfDayFormat,
@required this.value,
}); });
final _TimePickerFragmentContext fragmentContext; final TimeOfDayFormat timeOfDayFormat;
final String value;
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 @override
Widget build(BuildContext context) { 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( 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, <MaterialState>{})),
textScaleFactor: 1.0,
),
),
),
); );
} }
} }
...@@ -443,9 +415,6 @@ class _MinuteControl extends StatelessWidget { ...@@ -443,9 +415,6 @@ class _MinuteControl extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(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 String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime);
final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing( final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing(
minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour, minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour,
...@@ -468,414 +437,337 @@ class _MinuteControl extends StatelessWidget { ...@@ -468,414 +437,337 @@ class _MinuteControl extends StatelessWidget {
onDecrease: () { onDecrease: () {
fragmentContext.onTimeChange(previousMinute); fragmentContext.onTimeChange(previousMinute);
}, },
child: ConstrainedBox( child: _HourMinuteControl(
constraints: _kMinTappableRegion, isSelected: fragmentContext.mode == _TimePickerMode.minute,
child: Material( text: formattedMinute,
type: MaterialType.transparency, onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
child: InkWell(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
child: Text(formattedMinute, style: minuteStyle, textAlign: TextAlign.start, textScaleFactor: 1.0),
),
),
), ),
); );
} }
} }
/// 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. /// Displays the am/pm fragment and provides controls for switching between am
_TimePickerHeaderFragment minute() { /// and pm.
return _TimePickerHeaderFragment( class _DayPeriodControl extends StatelessWidget {
layoutId: _TimePickerHeaderId.minute, const _DayPeriodControl({
widget: _MinuteControl(fragmentContext: context), @required this.selectedTime,
); @required this.onChanged,
} @required this.orientation,
});
// Creates a string fragment. final TimeOfDay selectedTime;
_TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) { final Orientation orientation;
return _TimePickerHeaderFragment( final ValueChanged<TimeOfDay> onChanged;
layoutId: layoutId,
widget: _StringFragment(
fragmentContext: context,
value: value,
),
);
}
// Creates an am/pm fragment. void _togglePeriod() {
_TimePickerHeaderFragment dayPeriod() { final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
return _TimePickerHeaderFragment( final TimeOfDay newTime = selectedTime.replacing(hour: newHour);
layoutId: _TimePickerHeaderId.period, onChanged(newTime);
widget: _DayPeriodControl(fragmentContext: context, orientation: orientation),
);
} }
// Convenience function for creating a time header format with up to two pieces. void _setAm(BuildContext context) {
_TimePickerHeaderFormat format( if (selectedTime.period == DayPeriod.am) {
_TimePickerHeaderPiece piece1, [ return;
_TimePickerHeaderPiece piece2, }
]) { switch (Theme.of(context).platform) {
final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[]; case TargetPlatform.android:
switch (context.textDirection) { case TargetPlatform.fuchsia:
case TextDirection.ltr: case TargetPlatform.linux:
pieces.add(piece1); case TargetPlatform.windows:
if (piece2 != null) _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation);
pieces.add(piece2);
break; break;
case TextDirection.rtl: case TargetPlatform.iOS:
if (piece2 != null) case TargetPlatform.macOS:
pieces.add(piece2);
pieces.add(piece1);
break; break;
} }
int centerpieceIndex; _togglePeriod();
for (int i = 0; i < pieces.length; i += 1) { }
if (pieces[i].pivotIndex >= 0) {
centerpieceIndex = i; void _setPm(BuildContext context) {
} if (selectedTime.period == DayPeriod.pm) {
return;
} }
assert(centerpieceIndex != null); switch (Theme.of(context).platform) {
return _TimePickerHeaderFormat(centerpieceIndex, pieces); case TargetPlatform.android:
} case TargetPlatform.fuchsia:
case TargetPlatform.linux:
// Convenience function for creating a time header piece with up to three fragments. case TargetPlatform.windows:
_TimePickerHeaderPiece piece({ _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation);
int pivotIndex = -1, break;
double bottomMargin = 0.0, case TargetPlatform.iOS:
_TimePickerHeaderFragment fragment1, case TargetPlatform.macOS:
_TimePickerHeaderFragment fragment2, break;
_TimePickerHeaderFragment fragment3, }
}) { _togglePeriod();
final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[
fragment1,
if (fragment2 != null) ...<_TimePickerHeaderFragment>[
fragment2,
if (fragment3 != null) fragment3,
],
];
return _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin);
} }
switch (timeOfDayFormat) { @override
case TimeOfDayFormat.h_colon_mm_space_a: Widget build(BuildContext context) {
return format( final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context);
piece( final ColorScheme colorScheme = Theme.of(context).colorScheme;
pivotIndex: 1, final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context);
fragment1: hour(), final bool isDark = colorScheme.brightness == Brightness.dark;
fragment2: string(_TimePickerHeaderId.colon, ':'), final Color textColor = timePickerTheme.dayPeriodTextColor
fragment3: minute(), ?? MaterialStateColor.resolveWith((Set<MaterialState> states) {
), return states.contains(MaterialState.selected)
piece( ? colorScheme.primary
fragment1: dayPeriod(), : colorScheme.onSurface.withOpacity(0.60);
), });
); final Color backgroundColor = timePickerTheme.dayPeriodColor
case TimeOfDayFormat.H_colon_mm: ?? MaterialStateColor.resolveWith((Set<MaterialState> states) {
return format(piece( // The unselected day period should match the overall picker dialog
pivotIndex: 1, // color. Making it transparent enables that without being redundant
fragment1: hour(), // and allows the optional elevation overlay for dark mode to be
fragment2: string(_TimePickerHeaderId.colon, ':'), // visible.
fragment3: minute(), return states.contains(MaterialState.selected)
)); ? colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12)
case TimeOfDayFormat.HH_dot_mm: : Colors.transparent;
return format(piece( });
pivotIndex: 1, final bool amSelected = selectedTime.period == DayPeriod.am;
fragment1: hour(), final Set<MaterialState> amStates = amSelected ? <MaterialState>{MaterialState.selected} : <MaterialState>{};
fragment2: string(_TimePickerHeaderId.dot, '.'), final bool pmSelected = !amSelected;
fragment3: minute(), final Set<MaterialState> pmStates = pmSelected ? <MaterialState>{MaterialState.selected} : <MaterialState>{};
)); final TextStyle textStyle = timePickerTheme.dayPeriodTextStyle ?? Theme.of(context).textTheme.subtitle1;
case TimeOfDayFormat.a_space_h_colon_mm: final TextStyle amStyle = textStyle.copyWith(
return format( color: MaterialStateProperty.resolveAs(textColor, amStates),
piece( );
fragment1: dayPeriod(), final TextStyle pmStyle = textStyle.copyWith(
), color: MaterialStateProperty.resolveAs(textColor, pmStates),
piece( );
pivotIndex: 1, OutlinedBorder shape = timePickerTheme.dayPeriodShape ??
fragment1: hour(), const RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius);
fragment2: string(_TimePickerHeaderId.colon, ':'), final BorderSide borderSide = timePickerTheme.dayPeriodBorderSide ?? BorderSide(
fragment3: minute(), color: Color.alphaBlend(colorScheme.onBackground.withOpacity(0.38), colorScheme.surface),
), );
); // Apply the custom borderSide.
case TimeOfDayFormat.frenchCanadian: shape = shape.copyWith(
return format(piece( side: borderSide,
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 { final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0);
_TimePickerHeaderLayout(this.orientation, this.format)
: assert(orientation != null),
assert(format != null);
final Orientation orientation; final Widget amButton = Material(
final _TimePickerHeaderFormat format; 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,
),
),
),
),
);
@override final Widget pmButton = Material(
void performLayout(Size size) { color: MaterialStateProperty.resolveAs(backgroundColor, pmStates),
final BoxConstraints constraints = BoxConstraints.loose(size); 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) { switch (orientation) {
case Orientation.portrait: case Orientation.portrait:
_layoutHorizontally(size, constraints); 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: <Widget>[
Expanded(child: amButton),
Container(
decoration: BoxDecoration(
border: Border(top: borderSide),
),
height: 1,
),
Expanded(child: pmButton),
],
),
),
),
);
break; break;
case Orientation.landscape: case Orientation.landscape:
_layoutVertically(size, constraints); 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: <Widget>[
Expanded(child: amButton),
Container(
decoration: BoxDecoration(
border: Border(left: borderSide),
),
width: 1,
),
Expanded(child: pmButton),
],
),
),
),
);
break; break;
} }
return result;
} }
}
void _layoutHorizontally(Size size, BoxConstraints constraints) { /// A widget to pad the area around the [_DayPeriodControl]'s inner [Material].
final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[]; class _DayPeriodInputPadding extends SingleChildRenderObjectWidget {
final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; const _DayPeriodInputPadding({
int pivotIndex = 0; Key key,
for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) { Widget child,
final _TimePickerHeaderPiece piece = format.pieces[pieceIndex]; this.minSize,
for (final _TimePickerHeaderFragment fragment in piece.fragments) { this.orientation,
childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints); }) : super(key: key, child: child);
fragmentsFlattened.add(fragment);
}
if (pieceIndex == format.centerpieceIndex) final Size minSize;
pivotIndex += format.pieces[format.centerpieceIndex].pivotIndex; final Orientation orientation;
else if (pieceIndex < format.centerpieceIndex)
pivotIndex += piece.fragments.length;
}
_positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex); @override
RenderObject createRenderObject(BuildContext context) {
return _RenderInputPadding(minSize, orientation);
} }
void _layoutVertically(Size size, BoxConstraints constraints) { @override
final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
final List<double> pieceHeights = <double>[]; renderObject.minSize = minSize;
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]; class _RenderInputPadding extends RenderShiftedBox {
double y = (size.height - height) / 2.0; _RenderInputPadding(this._minSize, this.orientation, [RenderBox child]) : super(child);
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; final Orientation orientation;
}
Size get minSize => _minSize;
Size _minSize;
set minSize(Size value) {
if (_minSize == value)
return;
_minSize = value;
markNeedsLayout();
} }
void _positionPivoted(double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) { @override
double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0; double computeMinIntrinsicWidth(double height) {
for (final _TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) { if (child != null) {
tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin; return math.max(child.getMinIntrinsicWidth(height), minSize.width);
} }
return 0.0;
}
double x = width / 2.0 + tailWidth; @override
x = math.min(x, width); double computeMinIntrinsicHeight(double width) {
for (int i = fragments.length - 1; i >= 0; i -= 1) { if (child != null) {
final _TimePickerHeaderFragment fragment = fragments[i]; return math.max(child.getMinIntrinsicHeight(width), minSize.height);
final Size childSize = childSizes[fragment.layoutId];
x -= childSize.width;
positionChild(fragment.layoutId, Offset(x, y - childSize.height / 2.0));
x -= fragment.startMargin;
} }
return 0.0;
} }
void _positionPiece(double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) { @override
double pieceWidth = 0.0; double computeMaxIntrinsicWidth(double height) {
double nextMargin = 0.0; if (child != null) {
for (final _TimePickerHeaderFragment fragment in fragments) { return math.max(child.getMaxIntrinsicWidth(height), minSize.width);
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;
} }
return 0.0;
} }
@override @override
bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format; double computeMaxIntrinsicHeight(double width) {
} if (child != null) {
return math.max(child.getMaxIntrinsicHeight(width), minSize.height);
class _TimePickerHeader extends StatelessWidget { }
const _TimePickerHeader({ return 0.0;
@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);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final Orientation orientation;
final ValueChanged<_TimePickerMode> onModeChanged;
final ValueChanged<TimeOfDay> onChanged;
final bool use24HourDials;
void _handleChangeMode(_TimePickerMode value) {
if (value != mode)
onModeChanged(value);
} }
TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) { @override
// These font sizes aren't listed in the spec explicitly. I worked them out void performLayout() {
// by measuring the text using a screen ruler and comparing them to the if (child != null) {
// screen shots of the time picker in the spec. child.layout(constraints, parentUsesSize: true);
assert(orientation != null); final double width = math.max(child.size.width, minSize.width);
switch (orientation) { final double height = math.max(child.size.height, minSize.height);
case Orientation.portrait: size = constraints.constrain(Size(width, height));
return headerTextTheme.headline2.copyWith(fontSize: 60.0); final BoxParentData childParentData = child.parentData as BoxParentData;
case Orientation.landscape: childParentData.offset = Alignment.center.alongOffset(size - child.size as Offset);
return headerTextTheme.headline3.copyWith(fontSize: 50.0); } else {
size = Size.zero;
} }
return null;
} }
@override @override
Widget build(BuildContext context) { bool hitTest(BoxHitTestResult result, { Offset position }) {
assert(debugCheckHasMediaQuery(context)); if (super.hitTest(result, position: position)) {
final ThemeData themeData = Theme.of(context); return true;
final MediaQueryData media = MediaQuery.of(context); }
final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context)
.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
EdgeInsets padding; if (position.dx < 0.0 ||
double height; position.dx > math.max(child.size.width, minSize.width) ||
double width; position.dy < 0.0 ||
position.dy > math.max(child.size.height, minSize.height)) {
return false;
}
assert(orientation != null); Offset newPosition = child.size.center(Offset.zero);
switch (orientation) { switch (orientation) {
case Orientation.portrait: case Orientation.portrait:
height = _kTimePickerHeaderPortraitHeight; if (position.dy > newPosition.dy) {
padding = const EdgeInsets.symmetric(horizontal: 24.0); newPosition += const Offset(0.0, 1.0);
} else {
newPosition += const Offset(0.0, -1.0);
}
break; break;
case Orientation.landscape: case Orientation.landscape:
width = _kTimePickerHeaderLandscapeWidth; if (position.dx > newPosition.dx) {
padding = const EdgeInsets.symmetric(horizontal: 16.0); newPosition += const Offset(1.0, 0.0);
} else {
newPosition += const Offset(-1.0, 0.0);
}
break; break;
} }
Color backgroundColor;
switch (themeData.brightness) {
case Brightness.light:
backgroundColor = themeData.primaryColor;
break;
case Brightness.dark:
backgroundColor = themeData.backgroundColor;
break;
}
Color activeColor; return result.addWithRawTransform(
Color inactiveColor; transform: MatrixUtils.forceToPoint(newPosition),
switch (themeData.primaryColorBrightness) { position: newPosition,
case Brightness.light: hitTest: (BoxHitTestResult result, Offset position) {
activeColor = Colors.black87; assert(position == newPosition);
inactiveColor = Colors.black54; return child.hitTest(result, position: newPosition);
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 _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext, orientation);
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<Widget>((_TimePickerHeaderFragment fragment) {
return LayoutId(
id: fragment.layoutId,
child: fragment.widget,
);
})
.toList(),
),
); );
} }
} }
enum _DialRing {
outer,
inner,
}
class _TappableLabel { class _TappableLabel {
_TappableLabel({ _TappableLabel({
@required this.value, @required this.value,
...@@ -895,29 +787,27 @@ class _TappableLabel { ...@@ -895,29 +787,27 @@ class _TappableLabel {
class _DialPainter extends CustomPainter { class _DialPainter extends CustomPainter {
_DialPainter({ _DialPainter({
@required this.primaryOuterLabels, @required this.primaryLabels,
@required this.primaryInnerLabels, @required this.secondaryLabels,
@required this.secondaryOuterLabels,
@required this.secondaryInnerLabels,
@required this.backgroundColor, @required this.backgroundColor,
@required this.accentColor, @required this.accentColor,
@required this.dotColor,
@required this.theta, @required this.theta,
@required this.activeRing,
@required this.textDirection, @required this.textDirection,
@required this.selectedValue, @required this.selectedValue,
}) : super(repaint: PaintingBinding.instance.systemFonts); }) : super(repaint: PaintingBinding.instance.systemFonts);
final List<_TappableLabel> primaryOuterLabels; final List<_TappableLabel> primaryLabels;
final List<_TappableLabel> primaryInnerLabels; final List<_TappableLabel> secondaryLabels;
final List<_TappableLabel> secondaryOuterLabels;
final List<_TappableLabel> secondaryInnerLabels;
final Color backgroundColor; final Color backgroundColor;
final Color accentColor; final Color accentColor;
final Color dotColor;
final double theta; final double theta;
final _DialRing activeRing;
final TextDirection textDirection; final TextDirection textDirection;
final int selectedValue; final int selectedValue;
static const double _labelPadding = 28.0;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final double radius = size.shortestSide / 2.0; final double radius = size.shortestSide / 2.0;
...@@ -925,147 +815,62 @@ class _DialPainter extends CustomPainter { ...@@ -925,147 +815,62 @@ class _DialPainter extends CustomPainter {
final Offset centerPoint = center; final Offset centerPoint = center;
canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor);
const double labelPadding = 24.0; final double labelRadius = radius - _labelPadding;
final double outerLabelRadius = radius - labelPadding; Offset getOffsetForTheta(double theta) {
final double innerLabelRadius = radius - labelPadding * 2.5; return center + Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta));
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));
} }
void paintLabels(List<_TappableLabel> labels, _DialRing ring) { void paintLabels(List<_TappableLabel> labels) {
if (labels == null) if (labels == null)
return; return;
final double labelThetaIncrement = -_kTwoPi / labels.length; final double labelThetaIncrement = -_kTwoPi / labels.length;
double labelTheta = math.pi / 2.0; double labelTheta = math.pi / 2.0;
for (final _TappableLabel label in labels) { for (final _TappableLabel label in labels) {
final TextPainter labelPainter = label.painter; final TextPainter labelPainter = label.painter;
final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0); 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);
final Paint selectorPaint = Paint()
..color = accentColor;
final Offset focusedPoint = getOffsetForTheta(theta, activeRing);
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);
final Rect focusedRect = Rect.fromCircle(
center: focusedPoint, radius: focusedRadius,
);
canvas
..save()
..clipPath(Path()..addOval(focusedRect));
paintLabels(secondaryOuterLabels, _DialRing.outer);
paintLabels(secondaryInnerLabels, _DialRing.inner);
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<CustomPainterSemantics> _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<CustomPainterSemantics> nodes = <CustomPainterSemantics>[];
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 <SemanticsTag>{
// Used by tests to find this node.
SemanticsTag('dial-label'),
},
);
nodes.add(node);
labelTheta += labelThetaIncrement; labelTheta += labelThetaIncrement;
} }
} }
paintLabels(primaryOuterLabels, _DialRing.outer); paintLabels(primaryLabels);
paintLabels(primaryInnerLabels, _DialRing.inner);
final Paint selectorPaint = Paint()
..color = accentColor;
final Offset focusedPoint = getOffsetForTheta(theta);
const double focusedRadius = _labelPadding - 4.0;
canvas.drawCircle(centerPoint, 4.0, selectorPaint);
canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint);
selectorPaint.strokeWidth = 2.0;
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);
}
return nodes; final Rect focusedRect = Rect.fromCircle(
center: focusedPoint, radius: focusedRadius,
);
canvas
..save()
..clipPath(Path()..addOval(focusedRect));
paintLabels(secondaryLabels);
canvas.restore();
} }
@override @override
bool shouldRepaint(_DialPainter oldPainter) { bool shouldRepaint(_DialPainter oldPainter) {
return oldPainter.primaryOuterLabels != primaryOuterLabels return oldPainter.primaryLabels != primaryLabels
|| oldPainter.primaryInnerLabels != primaryInnerLabels || oldPainter.secondaryLabels != secondaryLabels
|| oldPainter.secondaryOuterLabels != secondaryOuterLabels
|| oldPainter.secondaryInnerLabels != secondaryInnerLabels
|| oldPainter.backgroundColor != backgroundColor || oldPainter.backgroundColor != backgroundColor
|| oldPainter.accentColor != accentColor || oldPainter.accentColor != accentColor
|| oldPainter.theta != theta || oldPainter.theta != theta;
|| oldPainter.activeRing != activeRing;
} }
} }
...@@ -1094,14 +899,13 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1094,14 +899,13 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_updateDialRingFromWidget();
_thetaController = AnimationController( _thetaController = AnimationController(
duration: _kDialAnimateDuration, duration: _kDialAnimateDuration,
vsync: this, vsync: this,
); );
_thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime)); _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime));
_theta = _thetaController _theta = _thetaController
.drive(CurveTween(curve: Curves.fastOutSlowIn)) .drive(CurveTween(curve: standardEasing))
.drive(_thetaTween) .drive(_thetaTween)
..addListener(() => setState(() { /* _theta.value has changed */ })); ..addListener(() => setState(() { /* _theta.value has changed */ }));
} }
...@@ -1122,23 +926,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1122,23 +926,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override @override
void didUpdateWidget(_Dial oldWidget) { void didUpdateWidget(_Dial oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
_updateDialRingFromWidget();
if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) { if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) {
if (!_dragging) if (!_dragging)
_animateTo(_getThetaForTime(widget.selectedTime)); _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 @override
void dispose() { void dispose() {
_thetaController.dispose(); _thetaController.dispose();
...@@ -1167,36 +960,36 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1167,36 +960,36 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
} }
double _getThetaForTime(TimeOfDay time) { double _getThetaForTime(TimeOfDay time) {
final int hoursFactor = widget.use24HourDials ? TimeOfDay.hoursPerDay : TimeOfDay.hoursPerPeriod;
final double fraction = widget.mode == _TimePickerMode.hour final double fraction = widget.mode == _TimePickerMode.hour
? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod ? (time.hour / hoursFactor) % hoursFactor
: (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi; 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; final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
if (widget.mode == _TimePickerMode.hour) { if (widget.mode == _TimePickerMode.hour) {
int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; int newHour;
if (widget.use24HourDials) { if (widget.use24HourDials) {
if (_activeRing == _DialRing.outer) { newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay;
if (newHour != 0)
newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
} else if (newHour == 0) {
newHour = TimeOfDay.hoursPerPeriod;
}
} else { } else {
newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod;
newHour = newHour + widget.selectedTime.periodOffset; newHour = newHour + widget.selectedTime.periodOffset;
} }
return widget.selectedTime.replacing(hour: newHour); return widget.selectedTime.replacing(hour: newHour);
} else { } else {
return widget.selectedTime.replacing( int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour;
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() { TimeOfDay _notifyOnChangedIfNeeded({ bool roundMinutes = false }) {
final TimeOfDay current = _getTimeForTheta(_theta.value); final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes);
if (widget.onChanged == null) if (widget.onChanged == null)
return current; return current;
if (current != widget.selectedTime) if (current != widget.selectedTime)
...@@ -1204,27 +997,21 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1204,27 +997,21 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
return current; return current;
} }
void _updateThetaForPan() { void _updateThetaForPan({ bool roundMinutes = false }) {
setState(() { setState(() {
final Offset offset = _position - _center; 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 _thetaTween
..begin = angle ..begin = angle
..end = angle; // The controller doesn't animate during the pan gesture. ..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 _position;
Offset _center; Offset _center;
_DialRing _activeRing = _DialRing.outer;
void _handlePanStart(DragStartDetails details) { void _handlePanStart(DragStartDetails details) {
assert(!_dragging); assert(!_dragging);
...@@ -1259,8 +1046,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1259,8 +1046,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
final RenderBox box = context.findRenderObject() as RenderBox; final RenderBox box = context.findRenderObject() as RenderBox;
_position = box.globalToLocal(details.globalPosition); _position = box.globalToLocal(details.globalPosition);
_center = box.size.center(Offset.zero); _center = box.size.center(Offset.zero);
_updateThetaForPan(); _updateThetaForPan(roundMinutes: true);
final TimeOfDay newTime = _notifyOnChangedIfNeeded(); final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true);
if (widget.mode == _TimePickerMode.hour) { if (widget.mode == _TimePickerMode.hour) {
if (widget.use24HourDials) { if (widget.use24HourDials) {
_announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); _announceToAccessibility(context, localizations.formatDecimal(newTime.hour));
...@@ -1273,7 +1060,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1273,7 +1060,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
} else { } else {
_announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); _announceToAccessibility(context, localizations.formatDecimal(newTime.minute));
} }
_animateTo(_getThetaForTime(_getTimeForTheta(_theta.value))); _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value, roundMinutes: true)));
_dragging = false; _dragging = false;
_position = null; _position = null;
_center = null; _center = null;
...@@ -1283,12 +1070,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1283,12 +1070,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
_announceToAccessibility(context, localizations.formatDecimal(hour)); _announceToAccessibility(context, localizations.formatDecimal(hour));
TimeOfDay time; TimeOfDay time;
if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
_activeRing = hour >= 1 && hour <= 12
? _DialRing.inner
: _DialRing.outer;
time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
} else { } else {
_activeRing = _DialRing.outer;
if (widget.selectedTime.period == DayPeriod.am) { if (widget.selectedTime.period == DayPeriod.am) {
time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
} else { } else {
...@@ -1330,19 +1113,19 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1330,19 +1113,19 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
TimeOfDay(hour: 11, minute: 0), TimeOfDay(hour: 11, minute: 0),
]; ];
static const List<TimeOfDay> _pmHours = <TimeOfDay>[ static const List<TimeOfDay> _twentyFourHours = <TimeOfDay>[
TimeOfDay(hour: 0, minute: 0), 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: 14, minute: 0),
TimeOfDay(hour: 15, minute: 0),
TimeOfDay(hour: 16, minute: 0), TimeOfDay(hour: 16, minute: 0),
TimeOfDay(hour: 17, minute: 0),
TimeOfDay(hour: 18, minute: 0), TimeOfDay(hour: 18, minute: 0),
TimeOfDay(hour: 19, minute: 0),
TimeOfDay(hour: 20, minute: 0), TimeOfDay(hour: 20, minute: 0),
TimeOfDay(hour: 21, minute: 0),
TimeOfDay(hour: 22, minute: 0), TimeOfDay(hour: 22, minute: 0),
TimeOfDay(hour: 23, minute: 0),
]; ];
_TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) { _TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) {
...@@ -1359,20 +1142,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1359,20 +1142,8 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
); );
} }
List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) => <_TappableLabel>[ List<_TappableLabel> _build24HourRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in _amHours) for (final TimeOfDay timeOfDay in _twentyFourHours)
_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
),
];
List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in _pmHours)
_buildTappableLabel( _buildTappableLabel(
textTheme, textTheme,
timeOfDay.hour, timeOfDay.hour,
...@@ -1383,7 +1154,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1383,7 +1154,7 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
), ),
]; ];
List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) => <_TappableLabel>[ List<_TappableLabel> _build12HourRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in _amHours) for (final TimeOfDay timeOfDay in _amHours)
_buildTappableLabel( _buildTappableLabel(
textTheme, textTheme,
...@@ -1426,42 +1197,29 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1426,42 +1197,29 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { 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); final ThemeData theme = Theme.of(context);
List<_TappableLabel> primaryOuterLabels; final TimePickerThemeData pickerTheme = TimePickerTheme.of(context);
List<_TappableLabel> primaryInnerLabels; final Color backgroundColor = pickerTheme.dialBackgroundColor ?? themeData.colorScheme.onBackground.withOpacity(0.12);
List<_TappableLabel> secondaryOuterLabels; final Color accentColor = pickerTheme.dialHandColor ?? themeData.colorScheme.primary;
List<_TappableLabel> secondaryInnerLabels; List<_TappableLabel> primaryLabels;
List<_TappableLabel> secondaryLabels;
int selectedDialValue; int selectedDialValue;
switch (widget.mode) { switch (widget.mode) {
case _TimePickerMode.hour: case _TimePickerMode.hour:
if (widget.use24HourDials) { if (widget.use24HourDials) {
selectedDialValue = widget.selectedTime.hour; selectedDialValue = widget.selectedTime.hour;
primaryOuterLabels = _build24HourOuterRing(theme.textTheme); primaryLabels = _build24HourRing(theme.textTheme);
secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme); secondaryLabels = _build24HourRing(theme.accentTextTheme);
primaryInnerLabels = _build24HourInnerRing(theme.textTheme);
secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme);
} else { } else {
selectedDialValue = widget.selectedTime.hourOfPeriod; selectedDialValue = widget.selectedTime.hourOfPeriod;
primaryOuterLabels = _build12HourOuterRing(theme.textTheme); primaryLabels = _build12HourRing(theme.textTheme);
secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme); secondaryLabels = _build12HourRing(theme.accentTextTheme);
} }
break; break;
case _TimePickerMode.minute: case _TimePickerMode.minute:
selectedDialValue = widget.selectedTime.minute; selectedDialValue = widget.selectedTime.minute;
primaryOuterLabels = _buildMinutes(theme.textTheme); primaryLabels = _buildMinutes(theme.textTheme);
primaryInnerLabels = null; secondaryLabels = _buildMinutes(theme.accentTextTheme);
secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
secondaryInnerLabels = null;
break; break;
} }
...@@ -1475,14 +1233,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -1475,14 +1233,12 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
key: const ValueKey<String>('time-picker-dial'), key: const ValueKey<String>('time-picker-dial'),
painter: _DialPainter( painter: _DialPainter(
selectedValue: selectedDialValue, selectedValue: selectedDialValue,
primaryOuterLabels: primaryOuterLabels, primaryLabels: primaryLabels,
primaryInnerLabels: primaryInnerLabels, secondaryLabels: secondaryLabels,
secondaryOuterLabels: secondaryOuterLabels,
secondaryInnerLabels: secondaryInnerLabels,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
accentColor: themeData.accentColor, accentColor: accentColor,
dotColor: theme.colorScheme.surface,
theta: _theta.value, theta: _theta.value,
activeRing: _activeRing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
), ),
), ),
...@@ -1490,6 +1246,342 @@ class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { ...@@ -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<TimeOfDay> 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: <Widget>[
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: <Widget>[
if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
_DayPeriodControl(
selectedTime: _selectedTime,
orientation: Orientation.portrait,
onChanged: _handleDayPeriodChanged,
),
const SizedBox(width: 12.0),
],
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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) ...<Widget>[
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<String> validator;
final ValueChanged<String> onSavedSubmitted;
final ValueChanged<String> 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: <Widget>[
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. /// A material design time picker designed to appear inside a popup dialog.
/// ///
/// Pass this widget to [showDialog]. The value returned by [showDialog] is the /// Pass this widget to [showDialog]. The value returned by [showDialog] is the
...@@ -1503,21 +1595,45 @@ class _TimePickerDialog extends StatefulWidget { ...@@ -1503,21 +1595,45 @@ class _TimePickerDialog extends StatefulWidget {
const _TimePickerDialog({ const _TimePickerDialog({
Key key, Key key,
@required this.initialTime, @required this.initialTime,
@required this.cancelText,
@required this.confirmText,
@required this.helpText,
this.initialEntryMode = TimePickerEntryMode.dial,
}) : assert(initialTime != null), }) : assert(initialTime != null),
super(key: key); super(key: key);
/// The time initially selected when the dialog is shown. /// The time initially selected when the dialog is shown.
final TimeOfDay initialTime; 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 @override
_TimePickerDialogState createState() => _TimePickerDialogState(); _TimePickerDialogState createState() => _TimePickerDialogState();
} }
class _TimePickerDialogState extends State<_TimePickerDialog> { class _TimePickerDialogState extends State<_TimePickerDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedTime = widget.initialTime; _selectedTime = widget.initialTime;
_entryMode = widget.initialEntryMode;
_autoValidate = false;
} }
@override @override
...@@ -1528,8 +1644,10 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ...@@ -1528,8 +1644,10 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
_announceModeOnce(); _announceModeOnce();
} }
TimePickerEntryMode _entryMode;
_TimePickerMode _mode = _TimePickerMode.hour; _TimePickerMode _mode = _TimePickerMode.hour;
_TimePickerMode _lastModeAnnounced; _TimePickerMode _lastModeAnnounced;
bool _autoValidate;
TimeOfDay get selectedTime => _selectedTime; TimeOfDay get selectedTime => _selectedTime;
TimeOfDay _selectedTime; TimeOfDay _selectedTime;
...@@ -1563,6 +1681,21 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ...@@ -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() { void _announceModeOnce() {
if (_lastModeAnnounced == _mode) { if (_lastModeAnnounced == _mode) {
// Already announced it. // Already announced it.
...@@ -1613,9 +1746,52 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ...@@ -1613,9 +1746,52 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
} }
void _handleOk() { void _handleOk() {
if (_entryMode == TimePickerEntryMode.input) {
final FormState form = _formKey.currentState;
if (!form.validate()) {
setState(() { _autoValidate = true; });
return;
}
form.save();
}
Navigator.pop(context, _selectedTime); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
...@@ -1623,113 +1799,144 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { ...@@ -1623,113 +1799,144 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
final ShapeBorder shape = TimePickerTheme.of(context).shape ?? _kDefaultShape;
final Orientation orientation = media.orientation;
final Widget picker = Padding( final Widget actions = Row(
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(
children: <Widget>[ children: <Widget>[
FlatButton( const SizedBox(width: 10.0),
child: Text(localizations.cancelButtonLabel), IconButton(
onPressed: _handleCancel, 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( Expanded(
child: Text(localizations.okButtonLabel), // TODO(rami-a): Move away from ButtonBar to avoid https://github.com/flutter/flutter/issues/53378.
onPressed: _handleOk, child: ButtonBar(
layoutBehavior: ButtonBarLayoutBehavior.constrained,
children: <Widget>[
FlatButton(
onPressed: _handleCancel,
child: Text(widget.cancelText ?? localizations.cancelButtonLabel),
),
FlatButton(
onPressed: _handleOk,
child: Text(widget.confirmText ?? localizations.okButtonLabel),
),
],
),
), ),
], ],
); );
final Dialog dialog = Dialog( Widget picker;
child: OrientationBuilder( switch (_entryMode) {
builder: (BuildContext context, Orientation orientation) { case TimePickerEntryMode.dial:
final Widget header = _TimePickerHeader( final Widget dial = Padding(
selectedTime: _selectedTime, padding: orientation == Orientation.portrait ? const EdgeInsets.symmetric(horizontal: 36, vertical: 24) : const EdgeInsets.all(24),
mode: _mode, child: ExcludeSemantics(
orientation: orientation, child: AspectRatio(
onModeChanged: _handleModeChanged, aspectRatio: 1.0,
onChanged: _handleTimeChanged, child: _Dial(
use24HourDials: use24HourDials, mode: _mode,
); use24HourDials: use24HourDials,
selectedTime: _selectedTime,
final Widget pickerAndActions = Container( onChanged: _handleTimeChanged,
color: theme.dialogBackgroundColor, onHourSelected: _handleHourSelected,
),
),
),
);
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: <Widget>[
header,
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Dial grows and shrinks with the available space.
Expanded(child: dial),
actions,
],
),
),
],
);
break;
case Orientation.landscape:
picker = Column(
children: <Widget>[
Expanded(
child: Row(
children: <Widget>[
header,
Expanded(child: dial),
],
),
),
actions,
],
);
break;
}
break;
case TimePickerEntryMode.input:
picker = Form(
key: _formKey,
autovalidate: _autoValidate,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Expanded(child: picker), // picker grows and shrinks with the available space _TimePickerInput(
initialSelectedTime: _selectedTime,
helpText: widget.helpText,
onChanged: _handleTimeChanged,
),
actions, actions,
], ],
), ),
); ),
);
double timePickerHeightPortrait; break;
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: <Widget>[
header,
Expanded(
child: pickerAndActions,
),
],
),
);
case Orientation.landscape:
return SizedBox(
width: _kTimePickerWidthLandscape,
height: timePickerHeightLandscape,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
header,
Flexible(
child: pickerAndActions,
),
],
),
);
}
return null;
}
),
);
return Theme( final Size dialogSize = _dialogSize(context);
data: theme.copyWith( return Dialog(
dialogBackgroundColor: Colors.transparent, shape: shape,
backgroundColor: TimePickerTheme.of(context).backgroundColor ?? theme.colorScheme.surface,
insetPadding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: _entryMode == TimePickerEntryMode.input ? 0.0 : 24.0,
),
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> { ...@@ -1764,6 +1971,13 @@ class _TimePickerDialogState extends State<_TimePickerDialog> {
/// to add inherited widgets like [Localizations.override], /// to add inherited widgets like [Localizations.override],
/// [Directionality], or [MediaQuery]. /// [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} /// {@tool snippet}
/// Show a dialog with the text direction overridden to be [TextDirection.rtl]. /// Show a dialog with the text direction overridden to be [TextDirection.rtl].
/// ///
...@@ -1807,14 +2021,25 @@ Future<TimeOfDay> showTimePicker({ ...@@ -1807,14 +2021,25 @@ Future<TimeOfDay> showTimePicker({
@required TimeOfDay initialTime, @required TimeOfDay initialTime,
TransitionBuilder builder, TransitionBuilder builder,
bool useRootNavigator = true, bool useRootNavigator = true,
TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial,
String cancelText,
String confirmText,
String helpText,
RouteSettings routeSettings, RouteSettings routeSettings,
}) async { }) async {
assert(context != null); assert(context != null);
assert(initialTime != null); assert(initialTime != null);
assert(useRootNavigator != null); assert(useRootNavigator != null);
assert(initialEntryMode != null);
assert(debugCheckHasMaterialLocalizations(context)); 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<TimeOfDay>( return await showDialog<TimeOfDay>(
context: context, context: context,
useRootNavigator: useRootNavigator, useRootNavigator: useRootNavigator,
......
// 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<TextStyle>('hourMinuteTextStyle', hourMinuteTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('dayPeriodTextStyle', dayPeriodTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('helpTextStyle', helpTextStyle, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('shape', shape, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('hourMinuteShape', hourMinuteShape, defaultValue: null));
properties.add(DiagnosticsProperty<ShapeBorder>('dayPeriodShape', dayPeriodShape, defaultValue: null));
properties.add(DiagnosticsProperty<BorderSide>('dayPeriodBorderSide', dayPeriodBorderSide, defaultValue: null));
properties.add(DiagnosticsProperty<InputDecorationTheme>('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<TimePickerTheme>();
return timePickerTheme?.data ?? Theme.of(context).timePickerTheme;
}
@override
Widget wrap(BuildContext context, Widget child) {
final TimePickerTheme ancestorTheme = context.findAncestorWidgetOfExactType<TimePickerTheme>();
return identical(this, ancestorTheme) ? child : TimePickerTheme(data: data, child: child);
}
@override
bool updateShouldNotify(TimePickerTheme oldWidget) => data != oldWidget.data;
}
...@@ -282,6 +282,7 @@ void main() { ...@@ -282,6 +282,7 @@ void main() {
dividerTheme: const DividerThemeData(color: Colors.black), dividerTheme: const DividerThemeData(color: Colors.black),
buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.start), buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.start),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.fixed),
timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.black),
fixTextFieldOutlineLabel: false, fixTextFieldOutlineLabel: false,
); );
...@@ -363,6 +364,7 @@ void main() { ...@@ -363,6 +364,7 @@ void main() {
dividerTheme: const DividerThemeData(color: Colors.white), dividerTheme: const DividerThemeData(color: Colors.white),
buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.end), buttonBarTheme: const ButtonBarThemeData(alignment: MainAxisAlignment.end),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.shifting), bottomNavigationBarTheme: const BottomNavigationBarThemeData(type: BottomNavigationBarType.shifting),
timePickerTheme: const TimePickerThemeData(backgroundColor: Colors.white),
fixTextFieldOutlineLabel: true, fixTextFieldOutlineLabel: true,
); );
...@@ -430,6 +432,7 @@ void main() { ...@@ -430,6 +432,7 @@ void main() {
dividerTheme: otherTheme.dividerTheme, dividerTheme: otherTheme.dividerTheme,
buttonBarTheme: otherTheme.buttonBarTheme, buttonBarTheme: otherTheme.buttonBarTheme,
bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme, bottomNavigationBarTheme: otherTheme.bottomNavigationBarTheme,
timePickerTheme: otherTheme.timePickerTheme,
fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel, fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel,
); );
...@@ -499,6 +502,7 @@ void main() { ...@@ -499,6 +502,7 @@ void main() {
expect(themeDataCopy.dividerTheme, equals(otherTheme.dividerTheme)); expect(themeDataCopy.dividerTheme, equals(otherTheme.dividerTheme));
expect(themeDataCopy.buttonBarTheme, equals(otherTheme.buttonBarTheme)); expect(themeDataCopy.buttonBarTheme, equals(otherTheme.buttonBarTheme));
expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme)); expect(themeDataCopy.bottomNavigationBarTheme, equals(otherTheme.bottomNavigationBarTheme));
expect(themeDataCopy.timePickerTheme, equals(otherTheme.timePickerTheme));
expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel)); expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel));
}); });
......
...@@ -6,15 +6,12 @@ ...@@ -6,15 +6,12 @@
@TestOn('!chrome') // entire file needs triage. @TestOn('!chrome') // entire file needs triage.
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
import '../rendering/recording_canvas.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart'; import 'feedback_tester.dart';
...@@ -23,10 +20,16 @@ final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widge ...@@ -23,10 +20,16 @@ final Finder _minuteControl = find.byWidgetPredicate((Widget widget) => '${widge
final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePickerDialog'); final Finder _timePickerDialog = find.byWidgetPredicate((Widget widget) => '${widget.runtimeType}' == '_TimePickerDialog');
class _TimePickerLauncher extends StatelessWidget { 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<TimeOfDay> onChanged; final ValueChanged<TimeOfDay> onChanged;
final Locale locale; final Locale locale;
final TimePickerEntryMode entryMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -35,17 +38,18 @@ class _TimePickerLauncher extends StatelessWidget { ...@@ -35,17 +38,18 @@ class _TimePickerLauncher extends StatelessWidget {
home: Material( home: Material(
child: Center( child: Center(
child: Builder( child: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return RaisedButton( return RaisedButton(
child: const Text('X'), child: const Text('X'),
onPressed: () async { onPressed: () async {
onChanged(await showTimePicker( onChanged(await showTimePicker(
context: context, context: context,
initialTime: const TimeOfDay(hour: 7, minute: 0), initialTime: const TimeOfDay(hour: 7, minute: 0),
)); initialEntryMode: entryMode,
}, ));
); },
} );
}
), ),
), ),
), ),
...@@ -53,11 +57,15 @@ class _TimePickerLauncher extends StatelessWidget { ...@@ -53,11 +57,15 @@ class _TimePickerLauncher extends StatelessWidget {
} }
} }
Future<Offset> startPicker(WidgetTester tester, ValueChanged<TimeOfDay> onChanged) async { Future<Offset> startPicker(
await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: const Locale('en', 'US'))); WidgetTester tester,
ValueChanged<TimeOfDay> 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.tap(find.text('X'));
await tester.pumpAndSettle(const Duration(seconds: 1)); await tester.pumpAndSettle(const Duration(seconds: 1));
return tester.getCenter(find.byKey(const ValueKey<String>('time-picker-dial'))); return entryMode == TimePickerEntryMode.dial ? tester.getCenter(find.byKey(const ValueKey<String>('time-picker-dial'))) : null;
} }
Future<void> finishPicker(WidgetTester tester) async { Future<void> finishPicker(WidgetTester tester) async {
...@@ -67,9 +75,13 @@ Future<void> finishPicker(WidgetTester tester) async { ...@@ -67,9 +75,13 @@ Future<void> finishPicker(WidgetTester tester) async {
} }
void main() { void main() {
group('Time picker', () { group('Time picker - Dial', () {
_tests(); _tests();
}); });
group('Time picker - Input', () {
_testsInput();
});
} }
void _tests() { void _tests() {
...@@ -170,6 +182,34 @@ void _tests() { ...@@ -170,6 +182,34 @@ void _tests() {
expect(result, equals(const TimeOfDay(hour: 9, minute: 15))); 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', () { group('haptic feedback', () {
const Duration kFastFeedbackInterval = Duration(milliseconds: 10); const Duration kFastFeedbackInterval = Duration(milliseconds: 10);
const Duration kSlowFeedbackInterval = Duration(milliseconds: 200); const Duration kSlowFeedbackInterval = Duration(milliseconds: 200);
...@@ -256,64 +296,18 @@ void _tests() { ...@@ -256,64 +296,18 @@ void _tests() {
}); });
const List<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; const List<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
const List<String> labels12To11TwoDigit = <String>['12', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11']; const List<String> labels00To22 = <String>['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22'];
const List<String> labels00To23 = <String>['00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'];
Future<void> 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 <LocalizationsDelegate<dynamic>>[
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<void>(builder: (BuildContext context) {
return FlatButton(
onPressed: () {
showTimePicker(context: context, initialTime: initialTime);
},
child: const Text('X'),
);
});
},
),
),
),
),
),
);
await tester.tap(find.text('X'));
await tester.pumpAndSettle();
}
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async { testWidgets('respects MediaQueryData.alwaysUse24HourFormat == false', (WidgetTester tester) async {
await mediaQueryBoilerplate(tester, false); await mediaQueryBoilerplate(tester, false);
final CustomPaint dialPaint = tester.widget(findDialPaint); final CustomPaint dialPaint = tester.widget(findDialPaint);
final dynamic dialPainter = dialPaint.painter; final dynamic dialPainter = dialPaint.painter;
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels as List<dynamic>; final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11); expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
expect(dialPainter.primaryInnerLabels, null);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels as List<dynamic>; final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11); expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11);
expect(dialPainter.secondaryInnerLabels, null);
}); });
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async { testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
...@@ -321,15 +315,11 @@ void _tests() { ...@@ -321,15 +315,11 @@ void _tests() {
final CustomPaint dialPaint = tester.widget(findDialPaint); final CustomPaint dialPaint = tester.widget(findDialPaint);
final dynamic dialPainter = dialPaint.painter; final dynamic dialPainter = dialPaint.painter;
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels as List<dynamic>; final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
expect(primaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23); expect(primaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
final List<dynamic> primaryInnerLabels = dialPainter.primaryInnerLabels as List<dynamic>;
expect(primaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11TwoDigit); final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
expect(secondaryLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To22);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels as List<dynamic>;
expect(secondaryOuterLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels00To23);
final List<dynamic> secondaryInnerLabels = dialPainter.secondaryInnerLabels as List<dynamic>;
expect(secondaryInnerLabels.map<String>((dynamic tp) => tp.painter.text.text as String), labels12To11TwoDigit);
}); });
testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async { testWidgets('provides semantics information for AM/PM indicator', (WidgetTester tester) async {
...@@ -347,10 +337,10 @@ void _tests() { ...@@ -347,10 +337,10 @@ void _tests() {
await mediaQueryBoilerplate(tester, true); await mediaQueryBoilerplate(tester, true);
expect(semantics, isNot(includesNodeWith(label: ':'))); expect(semantics, isNot(includesNodeWith(label: ':')));
expect(semantics.nodesWith(value: '00'), hasLength(2), expect(semantics.nodesWith(value: '00'), hasLength(1),
reason: '00 appears once in the header, then again in the dial'); reason: '00 appears once in the header');
expect(semantics.nodesWith(value: '07'), hasLength(2), expect(semantics.nodesWith(value: '07'), hasLength(1),
reason: '07 appears once in the header, then again in the dial'); reason: '07 appears once in the header');
expect(semantics, includesNodeWith(label: 'CANCEL')); expect(semantics, includesNodeWith(label: 'CANCEL'));
expect(semantics, includesNodeWith(label: 'OK')); expect(semantics, includesNodeWith(label: 'OK'));
...@@ -361,82 +351,6 @@ void _tests() { ...@@ -361,82 +351,6 @@ void _tests() {
semantics.dispose(); 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<String>('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<String>('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 { testWidgets('can increment and decrement hours', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
...@@ -550,21 +464,15 @@ void _tests() { ...@@ -550,21 +464,15 @@ void _tests() {
}); });
testWidgets('header touch regions are large enough', (WidgetTester tester) async { 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); await mediaQueryBoilerplate(tester, false);
final Size amSize = tester.getSize(find.ancestor( final Size dayPeriodControlSize = tester.getSize(find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'));
of: find.text('AM'), expect(dayPeriodControlSize.width, greaterThanOrEqualTo(48.0));
matching: find.byType(InkWell), // Height should be double the minimum size to account for both AM/PM stacked.
)); expect(dayPeriodControlSize.height, greaterThanOrEqualTo(48.0 * 2));
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 hourSize = tester.getSize(find.ancestor( final Size hourSize = tester.getSize(find.ancestor(
of: find.text('7'), of: find.text('7'),
...@@ -579,6 +487,9 @@ void _tests() { ...@@ -579,6 +487,9 @@ void _tests() {
)); ));
expect(minuteSize.width, greaterThanOrEqualTo(48.0)); expect(minuteSize.width, greaterThanOrEqualTo(48.0));
expect(minuteSize.height, 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 { testWidgets('builder parameter', (WidgetTester tester) async {
...@@ -696,126 +607,166 @@ void _tests() { ...@@ -696,126 +607,166 @@ void _tests() {
expect(nestedObserver.pickerCount, 1); expect(nestedObserver.pickerCount, 1);
}); });
testWidgets('text scale affects certain elements and not others', testWidgets('optional text parameters are utilized', (WidgetTester tester) async {
(WidgetTester tester) async { const String cancelText = 'Custom Cancel';
await mediaQueryBoilerplate( const String confirmText = 'Custom OK';
tester, const String helperText = 'Custom Help';
false, await tester.pumpWidget(MaterialApp(
textScaleFactor: 1.0, home: Material(
initialTime: const TimeOfDay(hour: 7, minute: 41), 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.tap(find.text('X'));
await tester.pumpAndSettle(); await tester.pumpAndSettle(const Duration(seconds: 1));
final double minutesDisplayHeight = tester.getSize(find.text('41')).height; expect(find.text(cancelText), findsOneWidget);
final double amHeight = tester.getSize(find.text('AM')).height; expect(find.text(confirmText), findsOneWidget);
expect(find.text(helperText), findsOneWidget);
});
await tester.tap(find.text('OK')); // dismiss the dialog // TODO(rami-a): Re-enable and fix test.
await tester.pumpAndSettle(); 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'));
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));
});
}
// Verify that the time display is not affected by text scale. void _testsInput() {
await mediaQueryBoilerplate( testWidgets('Initial entry mode is used', (WidgetTester tester) async {
tester, await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
false, expect(find.byType(TextField), findsNWidgets(2));
textScaleFactor: 2.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)); testWidgets('Initial time is the default', (WidgetTester tester) async {
expect(tester.getSize(find.text('AM')).height, equals(amHeight * 2)); TimeOfDay result;
await startPicker(tester, (TimeOfDay time) { result = time; }, entryMode: TimePickerEntryMode.input);
await finishPicker(tester);
expect(result, equals(const TimeOfDay(hour: 7, minute: 0)));
});
await tester.tap(find.text('OK')); // dismiss the dialog testWidgets('Help text is used - Input', (WidgetTester tester) async {
await tester.pumpAndSettle(); const String helpText = 'help';
await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input, helpText: helpText);
expect(find.text(helpText), findsOneWidget);
});
// Verify that text scale for AM/PM is at most 2x. testWidgets('Can toggle to dial entry mode', (WidgetTester tester) async {
await mediaQueryBoilerplate( await mediaQueryBoilerplate(tester, true, entryMode: TimePickerEntryMode.input);
tester, await tester.tap(find.byIcon(Icons.access_time));
false,
textScaleFactor: 3.0,
initialTime: const TimeOfDay(hour: 7, minute: 41),
);
await tester.tap(find.text('X'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
expect(tester.getSize(find.text('41')).height, equals(minutesDisplayHeight));
expect(tester.getSize(find.text('AM')).height, equals(amHeight * 2));
}); });
}
final Finder findDialPaint = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byWidgetPredicate((Widget w) => w is CustomPaint),
);
class _SemanticsNodeExpectation {
_SemanticsNodeExpectation(this.label, this.left, this.top, this.right, this.bottom);
final String label; testWidgets('Entered text returns time', (WidgetTester tester) async {
final double left; TimeOfDay result;
final double top; await startPicker(tester, (TimeOfDay time) { result = time; }, entryMode: TimePickerEntryMode.input);
final double right; await tester.enterText(find.byType(TextField).first, '9');
final double bottom; await tester.enterText(find.byType(TextField).last, '12');
} await finishPicker(tester);
expect(result, equals(const TimeOfDay(hour: 9, minute: 12)));
});
class _CustomPainterSemanticsTester { testWidgets('Toggle to dial mode keeps selected time', (WidgetTester tester) async {
_CustomPainterSemanticsTester(this.tester, this.painter, this.semantics); 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)));
});
final WidgetTester tester; testWidgets('Invalid text prevents dismissing', (WidgetTester tester) async {
final CustomPainter painter; TimeOfDay result;
final SemanticsTester semantics; await startPicker(tester, (TimeOfDay time) { result = time; }, entryMode: TimePickerEntryMode.input);
final PaintPattern expectedLabels = paints;
final List<_SemanticsNodeExpectation> expectedNodes = <_SemanticsNodeExpectation>[];
void addLabel(String label, double left, double top, double right, double bottom) { // Invalid hour.
expectedNodes.add(_SemanticsNodeExpectation(label, left, top, right, bottom)); await tester.enterText(find.byType(TextField).first, '88');
} await tester.enterText(find.byType(TextField).last, '15');
await finishPicker(tester);
expect(result, null);
void assertExpectations() { // Invalid minute.
final TestRecordingCanvas canvasRecording = TestRecordingCanvas(); await tester.enterText(find.byType(TextField).first, '8');
painter.paint(canvasRecording, const Size(220.0, 220.0)); await tester.enterText(find.byType(TextField).last, '150');
final List<ui.Paragraph> paragraphs = canvasRecording.invocations await finishPicker(tester);
.where((RecordedInvocation recordedInvocation) { expect(result, null);
return recordedInvocation.invocation.memberName == #drawParagraph;
})
.map<ui.Paragraph>((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<SemanticsNode> 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( await tester.enterText(find.byType(TextField).first, '8');
paragraph: paragraph, await tester.enterText(find.byType(TextField).last, '15');
offset: within<Offset>(distance: 1.0, from: topLeft), await finishPicker(tester);
); expect(result, equals(const TimeOfDay(hour: 8, minute: 15)));
} });
expect(tester.renderObject(findDialPaint), expectedLabels);
}
} }
final Finder findDialPaint = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_Dial'),
matching: find.byWidgetPredicate((Widget w) => w is CustomPaint),
);
class PickerObserver extends NavigatorObserver { class PickerObserver extends NavigatorObserver {
int pickerCount = 0; int pickerCount = 0;
...@@ -827,3 +778,53 @@ class PickerObserver extends NavigatorObserver { ...@@ -827,3 +778,53 @@ class PickerObserver extends NavigatorObserver {
super.didPush(route, previousRoute); super.didPush(route, previousRoute);
} }
} }
Future<void> mediaQueryBoilerplate(
WidgetTester tester,
bool alwaysUse24HourFormat, {
TimeOfDay initialTime = const TimeOfDay(hour: 7, minute: 0),
double textScaleFactor = 1.0,
TimePickerEntryMode entryMode = TimePickerEntryMode.dial,
String helpText,
}) async {
await tester.pumpWidget(
Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
child: MediaQuery(
data: MediaQueryData(
alwaysUse24HourFormat: alwaysUse24HourFormat,
textScaleFactor: textScaleFactor,
),
child: Material(
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
onGenerateRoute: (RouteSettings settings) {
return MaterialPageRoute<void>(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();
}
// 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<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, <String>[]);
});
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<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString())
.toList();
expect(description, <String>[
'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(<all styles inherited>)',
'dayPeriodTextStyle: TextStyle(<all styles inherited>)',
'helpTextStyle: TextStyle(<all styles inherited>)',
'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<RenderBox>(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<RenderBox>(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<MaterialState> 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<Material>(find.descendant(of: find.byType(Dialog), matching: find.byType(Material)).first);
}
Material _textMaterial(WidgetTester tester, String text) {
return tester.widget<Material>(find.ancestor(of: find.text(text), matching: find.byType(Material)).first);
}
TextField _textField(WidgetTester tester, String text) {
return tester.widget<TextField>(find.ancestor(of: find.text(text), matching: find.byType(TextField)).first);
}
Material _dayPeriodMaterial(WidgetTester tester) {
return tester.widget<Material>(find.descendant(of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), matching: find.byType(Material)).first);
}
Container _dayPeriodDivider(WidgetTester tester) {
return tester.widget<Container>(find.descendant(of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_DayPeriodControl'), matching: find.byType(Container)).at(1));
}
IconButton _entryModeIconButton(WidgetTester tester) {
return tester.widget<IconButton>(find.descendant(of: find.byType(Dialog), matching: find.byType(IconButton)).first);
}
RenderParagraph _textRenderParagraph(WidgetTester tester, String text) {
return tester.element<StatelessElement>(find.text(text).first).renderObject as RenderParagraph;
}
\ No newline at end of file
...@@ -43,7 +43,7 @@ class _TimePickerLauncher extends StatelessWidget { ...@@ -43,7 +43,7 @@ class _TimePickerLauncher extends StatelessWidget {
Future<Offset> startPicker( Future<Offset> startPicker(
WidgetTester tester, WidgetTester tester,
ValueChanged<TimeOfDay> onChanged, { ValueChanged<TimeOfDay> onChanged, {
Locale locale = const Locale('en', 'US'), Locale locale = const Locale('en', 'US'),
}) async { }) async {
await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: locale,)); await tester.pumpWidget(_TimePickerLauncher(onChanged: onChanged, locale: locale,));
await tester.tap(find.text('X')); await tester.tap(find.text('X'));
...@@ -58,66 +58,151 @@ Future<void> finishPicker(WidgetTester tester) async { ...@@ -58,66 +58,151 @@ Future<void> finishPicker(WidgetTester tester) async {
} }
void main() { void main() {
testWidgets('can localize the header in all known formats', (WidgetTester tester) async { 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;
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 // 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<Locale, List<String>> locales = <Locale, List<String>>{ final List<Locale> locales = <Locale>[
const Locale('en', 'US'): const <String>['hour', 'string :', 'minute', 'period'], //'h:mm a' const Locale('en', 'US'), //'h:mm a'
const Locale('en', 'GB'): const <String>['hour', 'string :', 'minute'], //'HH:mm' const Locale('en', 'GB'), //'HH:mm'
const Locale('es', 'ES'): const <String>['hour', 'string :', 'minute'], //'H:mm' const Locale('es', 'ES'), //'H:mm'
const Locale('fr', 'CA'): const <String>['hour', 'string h', 'minute'], //'HH \'h\' mm' const Locale('fr', 'CA'), //'HH \'h\' mm'
const Locale('zh', 'ZH'): const <String>['period', 'hour', 'string :', 'minute'], //'ah:mm' const Locale('zh', 'ZH'), //'ah:mm'
}; ];
for (final Locale locale in locales.keys) { for (final Locale locale in locales) {
final Offset center = await startPicker(tester, (TimeOfDay time) { }, locale: locale); final Offset center = await startPicker(tester, (TimeOfDay time) { }, locale: locale);
final List<String> actual = <String>[]; final Text stringFragmentText = tester.widget(stringFragmentTextFinder);
tester.element(find.byType(CustomMultiChildLayout)).visitChildren((Element child) { final double hourLeftOffset = tester.getTopLeft(hourControlFinder).dx;
final LayoutId layout = child.widget as LayoutId; final double minuteLeftOffset = tester.getTopLeft(minuteControlFinder).dx;
final String fragmentType = '${layout.child.runtimeType}'; final double stringFragmentLeftOffset = tester.getTopLeft(stringFragmentTextFinder).dx;
final dynamic widget = layout.child;
if (fragmentType == '_MinuteControl') { if (locale == const Locale('en', 'US')) {
actual.add('minute'); final double dayPeriodLeftOffset = tester.getTopLeft(dayPeriodControlFinder).dx;
} else if (fragmentType == '_DayPeriodControl') { expect(stringFragmentText.data, ':');
actual.add('period'); expect(hourLeftOffset, lessThan(stringFragmentLeftOffset));
} else if (fragmentType == '_HourControl') { expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset));
actual.add('hour'); expect(minuteLeftOffset, lessThan(dayPeriodLeftOffset));
} else if (fragmentType == '_StringFragment') { } else if (locale == const Locale('en', 'GB')) {
actual.add('string ${widget.value}'); expect(stringFragmentText.data, ':');
} else { expect(hourLeftOffset, lessThan(stringFragmentLeftOffset));
fail('Unsupported fragment type: $fragmentType'); expect(stringFragmentLeftOffset, lessThan(minuteLeftOffset));
} expect(dayPeriodControlFinder, findsNothing);
}); } else if (locale == const Locale('es', 'ES')) {
expect(actual, locales[locale]); 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 tester.tapAt(Offset(center.dx, center.dy - 50.0));
await finishPicker(tester); 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 { testWidgets('can localize the header in all known formats - landscape', (WidgetTester tester) async {
// Tap along the segment stretching from the center to the edge at // Ensure picker is displayed in landscape mode.
// 12:00 AM position. Because there's only one ring, no matter where you tester.binding.window.physicalSizeTestValue = const Size(800, 400);
// tap the time will be the same. See the 24-hour dial test that behaves tester.binding.window.devicePixelRatioTestValue = 1;
// differently.
for (int i = 1; i < 10; i++) { final Finder stringFragmentTextFinder = find.descendant(
TimeOfDay result; of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_StringFragment'),
final Offset center = await startPicker(tester, (TimeOfDay time) { result = time; }); matching: find.byType(Text),
final Size size = tester.getSize(find.byKey(const Key('time-picker-dial'))); ).first;
final double dy = (size.height / 2.0 / 10) * i; final Finder hourControlFinder = find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_HourControl');
await tester.tapAt(Offset(center.dx, center.dy - dy)); 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<Locale> locales = <Locale>[
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); 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<Locale> locales = <Locale>[ const List<Locale> locales = <Locale>[
Locale('en', 'US'), // h
Locale('en', 'GB'), // HH Locale('en', 'GB'), // HH
Locale('es', 'ES'), // H Locale('es', 'ES'), // H
]; ];
for (final Locale locale in locales) { for (final Locale locale in locales) {
// Tap along the segment stretching from the center to the edge at // 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 // 12:00 AM position. Because there's only one ring, no matter where you
// switches between inner ring and outer ring. // tap the time will be the same.
for (int i = 1; i < 10; i++) { for (int i = 1; i < 10; i++) {
TimeOfDay result; TimeOfDay result;
final Offset center = await startPicker(tester, (TimeOfDay time) { result = time; }, locale: locale); final Offset center = await startPicker(tester, (TimeOfDay time) { result = time; }, locale: locale);
...@@ -125,14 +210,13 @@ void main() { ...@@ -125,14 +210,13 @@ void main() {
final double dy = (size.height / 2.0 / 10) * i; final double dy = (size.height / 2.0 / 10) * i;
await tester.tapAt(Offset(center.dx, center.dy - dy)); await tester.tapAt(Offset(center.dx, center.dy - dy));
await finishPicker(tester); 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<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; const List<String> labels12To11 = <String>['12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'];
const List<String> labels12To11TwoDigit = <String>['12', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11']; const List<String> labels00To22TwoDigit = <String>['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22'];
const List<String> labels00To23 = <String>['00', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'];
Future<void> mediaQueryBoilerplate(WidgetTester tester, bool alwaysUse24HourFormat) async { Future<void> mediaQueryBoilerplate(WidgetTester tester, bool alwaysUse24HourFormat) async {
await tester.pumpWidget( await tester.pumpWidget(
...@@ -174,19 +258,17 @@ void main() { ...@@ -174,19 +258,17 @@ void main() {
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial'))); final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final dynamic dialPainter = dialPaint.painter; final dynamic dialPainter = dialPaint.painter;
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels as List<dynamic>; final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
expect( expect(
primaryOuterLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), primaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels12To11, labels12To11,
); );
expect(dialPainter.primaryInnerLabels, null);
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels as List<dynamic>; final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
expect( expect(
secondaryOuterLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), secondaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels12To11, labels12To11,
); );
expect(dialPainter.secondaryInnerLabels, null);
}); });
testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async { testWidgets('respects MediaQueryData.alwaysUse24HourFormat == true', (WidgetTester tester) async {
...@@ -194,26 +276,16 @@ void main() { ...@@ -194,26 +276,16 @@ void main() {
final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial'))); final CustomPaint dialPaint = tester.widget(find.byKey(const ValueKey<String>('time-picker-dial')));
final dynamic dialPainter = dialPaint.painter; final dynamic dialPainter = dialPaint.painter;
final List<dynamic> primaryOuterLabels = dialPainter.primaryOuterLabels as List<dynamic>; final List<dynamic> primaryLabels = dialPainter.primaryLabels as List<dynamic>;
expect(
primaryOuterLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels00To23,
);
final List<dynamic> primaryInnerLabels = dialPainter.primaryInnerLabels as List<dynamic>;
expect( expect(
primaryInnerLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), primaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels12To11TwoDigit, labels00To22TwoDigit,
); );
final List<dynamic> secondaryOuterLabels = dialPainter.secondaryOuterLabels as List<dynamic>; final List<dynamic> secondaryLabels = dialPainter.secondaryLabels as List<dynamic>;
expect(
secondaryOuterLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels00To23,
);
final List<dynamic> secondaryInnerLabels = dialPainter.secondaryInnerLabels as List<dynamic>;
expect( expect(
secondaryInnerLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text), secondaryLabels.map<String>((dynamic tp) => ((tp.painter as TextPainter).text as TextSpan).text),
labels12To11TwoDigit, labels00To22TwoDigit,
); );
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment