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