Skip to content

Instantly share code, notes, and snippets.

@sclausen
Created May 28, 2020 15:07
Show Gist options
  • Save sclausen/b97e96f33373b8cdc33fcd7bb6f6dfe7 to your computer and use it in GitHub Desktop.
Save sclausen/b97e96f33373b8cdc33fcd7bb6f6dfe7 to your computer and use it in GitHub Desktop.
Material Time Picker with passing Times to show
// @formatter:off
// 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 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
List<TimeOfDay> getAmHours(List<TimeOfDay> times) =>
times.where((t) => t.hour <= TimeOfDay.hoursPerPeriod).map((e) => e.replacing(minute: 0)).toSet().toList();
List<TimeOfDay> getPmHours(List<TimeOfDay> times) =>
times.where((t) => t.hour > TimeOfDay.hoursPerPeriod).map((e) => e.replacing(minute: 0)).toSet().toList();
List<TimeOfDay> getMinutesForHour(TimeOfDay time, List<TimeOfDay> times) => times.where((element) => element.hour == time.hour).toList();
// Examples can assume:
// BuildContext context;
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 _kTimePickerWidthPortrait = 328.0;
const double _kTimePickerWidthLandscape = 512.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);
enum _TimePickerHeaderId {
hour,
colon,
minute,
period, // AM/PM picker
dot,
hString, // French Canadian "h" literal
}
extension FancyIterable on Iterable<TimeOfDay> {
TimeOfDay get max => this.length > 0 ? reduce((curr, next) => curr.hour > next.hour ? curr : next) : TimeOfDay(hour: null, minute: null);
TimeOfDay get min => this.length > 0 ? reduce((curr, next) => curr.hour < next.hour ? curr : next) : TimeOfDay(hour: null, minute: null);
int get distance => this.max.hour != null && this.min.hour != null ? this.max.hour - this.min.hour : 0;
}
/// 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(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,
@required this.orientation,
});
final _TimePickerFragmentContext fragmentContext;
final Orientation orientation;
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();
}
@override
Widget build(BuildContext context) {
final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context);
final TextTheme headerTextTheme = fragmentContext.headerTextTheme;
final TimeOfDay selectedTime = fragmentContext.selectedTime;
final Color activeColor = fragmentContext.activeColor;
final Color inactiveColor = fragmentContext.inactiveColor;
final bool amSelected = selectedTime.period == DayPeriod.am;
final TextStyle amStyle = headerTextTheme.subtitle1.copyWith(color: amSelected ? activeColor : inactiveColor);
final TextStyle pmStyle = headerTextTheme.subtitle1.copyWith(color: !amSelected ? activeColor : inactiveColor);
final bool layoutPortrait = orientation == Orientation.portrait;
final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0);
final Widget amButton = ConstrainedBox(
constraints: _kMinTappableRegion,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: Feedback.wrapForTap(() => _setAm(context), context),
child: Padding(
padding: layoutPortrait ? const EdgeInsets.only(bottom: 2.0) : const EdgeInsets.only(right: 4.0),
child: Align(
alignment: layoutPortrait ? Alignment.bottomCenter : Alignment.centerRight,
widthFactor: 1,
heightFactor: 1,
child: Semantics(
selected: amSelected,
child: Text(
materialLocalizations.anteMeridiemAbbreviation,
style: amStyle,
textScaleFactor: buttonTextScaleFactor,
),
),
),
),
),
),
);
final Widget pmButton = ConstrainedBox(
constraints: _kMinTappableRegion,
child: Material(
type: MaterialType.transparency,
textStyle: pmStyle,
child: InkWell(
onTap: Feedback.wrapForTap(() => _setPm(context), context),
child: Padding(
padding: layoutPortrait ? const EdgeInsets.only(top: 2.0) : const EdgeInsets.only(left: 4.0),
child: Align(
alignment: orientation == Orientation.portrait ? Alignment.topCenter : Alignment.centerLeft,
widthFactor: 1,
heightFactor: 1,
child: Semantics(
selected: !amSelected,
child: Text(
materialLocalizations.postMeridiemAbbreviation,
style: pmStyle,
textScaleFactor: buttonTextScaleFactor,
),
),
),
),
),
),
);
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].
class _HourControl extends StatelessWidget {
const _HourControl({
@required this.fragmentContext,
});
final _TimePickerFragmentContext fragmentContext;
@override
Widget build(BuildContext context) {
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,
);
TimeOfDay hoursFromSelected(int hoursToAdd) {
if (fragmentContext.use24HourDials) {
final int selectedHour = fragmentContext.selectedTime.hour;
return fragmentContext.selectedTime.replacing(
hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay,
);
} else {
// Cycle 1 through 12 without changing day period.
final int periodOffset = fragmentContext.selectedTime.periodOffset;
final int hours = fragmentContext.selectedTime.hourOfPeriod;
return fragmentContext.selectedTime.replacing(
hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod,
);
}
}
final TimeOfDay nextHour = hoursFromSelected(1);
final String formattedNextHour = localizations.formatHour(
nextHour,
alwaysUse24HourFormat: alwaysUse24HourFormat,
);
final TimeOfDay previousHour = hoursFromSelected(-1);
final String formattedPreviousHour = localizations.formatHour(
previousHour,
alwaysUse24HourFormat: alwaysUse24HourFormat,
);
return Semantics(
hint: localizations.timePickerHourModeAnnouncement,
value: formattedHour,
excludeSemantics: true,
increasedValue: formattedNextHour,
onIncrease: () {
fragmentContext.onTimeChange(nextHour);
},
decreasedValue: formattedPreviousHour,
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,
),
),
),
),
);
}
}
/// A passive fragment showing a string value.
class _StringFragment extends StatelessWidget {
const _StringFragment({
@required this.fragmentContext,
@required this.value,
});
final _TimePickerFragmentContext fragmentContext;
final String value;
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
child: Text(value, style: fragmentContext.inactiveStyle, textScaleFactor: 1.0),
);
}
}
/// Displays the minute fragment.
///
/// When tapped changes time picker dial mode to [_TimePickerMode.minute].
class _MinuteControl extends StatelessWidget {
const _MinuteControl({
@required this.fragmentContext,
});
final _TimePickerFragmentContext fragmentContext;
@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,
);
final String formattedNextMinute = localizations.formatMinute(nextMinute);
final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing(
minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour,
);
final String formattedPreviousMinute = localizations.formatMinute(previousMinute);
return Semantics(
excludeSemantics: true,
hint: localizations.timePickerMinuteModeAnnouncement,
value: formattedMinute,
increasedValue: formattedNextMinute,
onIncrease: () {
fragmentContext.onTimeChange(nextMinute);
},
decreasedValue: formattedPreviousMinute,
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),
),
),
),
);
}
}
/// Provides time picker header layout configuration for the given
/// [timeOfDayFormat] passing [context] to each widget in the
/// configuration.
///
/// The [timeOfDayFormat] and [context] arguments must not be null.
_TimePickerHeaderFormat _buildHeaderFormat(
TimeOfDayFormat timeOfDayFormat,
_TimePickerFragmentContext context,
Orientation orientation,
) {
// Creates an hour fragment.
_TimePickerHeaderFragment hour() {
return _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.hour,
widget: _HourControl(fragmentContext: context),
);
}
// Creates a minute fragment.
_TimePickerHeaderFragment minute() {
return _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.minute,
widget: _MinuteControl(fragmentContext: context),
);
}
// Creates a string fragment.
_TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) {
return _TimePickerHeaderFragment(
layoutId: layoutId,
widget: _StringFragment(
fragmentContext: context,
value: value,
),
);
}
// Creates an am/pm fragment.
_TimePickerHeaderFragment dayPeriod() {
return _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.period,
widget: _DayPeriodControl(fragmentContext: context, orientation: orientation),
);
}
// Convenience function for creating a time header format with up to two pieces.
_TimePickerHeaderFormat format(
_TimePickerHeaderPiece piece1, [
_TimePickerHeaderPiece piece2,
]) {
final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[];
switch (context.textDirection) {
case TextDirection.ltr:
pieces.add(piece1);
if (piece2 != null) pieces.add(piece2);
break;
case TextDirection.rtl:
if (piece2 != null) pieces.add(piece2);
pieces.add(piece1);
break;
}
int centerpieceIndex;
for (int i = 0; i < pieces.length; i += 1) {
if (pieces[i].pivotIndex >= 0) {
centerpieceIndex = i;
}
}
assert(centerpieceIndex != null);
return _TimePickerHeaderFormat(centerpieceIndex, pieces);
}
// Convenience function for creating a time header piece with up to three fragments.
_TimePickerHeaderPiece piece({
int pivotIndex = -1,
double bottomMargin = 0.0,
_TimePickerHeaderFragment fragment1,
_TimePickerHeaderFragment fragment2,
_TimePickerHeaderFragment fragment3,
}) {
final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[
fragment1,
if (fragment2 != null) ...<_TimePickerHeaderFragment>[
fragment2,
if (fragment3 != null) fragment3,
],
];
return _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin);
}
switch (timeOfDayFormat) {
case TimeOfDayFormat.h_colon_mm_space_a:
return format(
piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.colon, ':'),
fragment3: minute(),
),
piece(
fragment1: dayPeriod(),
),
);
case TimeOfDayFormat.H_colon_mm:
return format(piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.colon, ':'),
fragment3: minute(),
));
case TimeOfDayFormat.HH_dot_mm:
return format(piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.dot, '.'),
fragment3: minute(),
));
case TimeOfDayFormat.a_space_h_colon_mm:
return format(
piece(
fragment1: dayPeriod(),
),
piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.colon, ':'),
fragment3: minute(),
),
);
case TimeOfDayFormat.frenchCanadian:
return format(piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.hString, 'h'),
fragment3: minute(),
));
case TimeOfDayFormat.HH_colon_mm:
return format(piece(
pivotIndex: 1,
fragment1: hour(),
fragment2: string(_TimePickerHeaderId.colon, ':'),
fragment3: minute(),
));
}
return null;
}
class _TimePickerHeaderLayout extends MultiChildLayoutDelegate {
_TimePickerHeaderLayout(this.orientation, this.format)
: assert(orientation != null),
assert(format != null);
final Orientation orientation;
final _TimePickerHeaderFormat format;
@override
void performLayout(Size size) {
final BoxConstraints constraints = BoxConstraints.loose(size);
switch (orientation) {
case Orientation.portrait:
_layoutHorizontally(size, constraints);
break;
case Orientation.landscape:
_layoutVertically(size, constraints);
break;
}
}
void _layoutHorizontally(Size size, BoxConstraints constraints) {
final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[];
final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
int pivotIndex = 0;
for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
final _TimePickerHeaderPiece piece = format.pieces[pieceIndex];
for (final _TimePickerHeaderFragment fragment in piece.fragments) {
childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints);
fragmentsFlattened.add(fragment);
}
if (pieceIndex == format.centerpieceIndex)
pivotIndex += format.pieces[format.centerpieceIndex].pivotIndex;
else if (pieceIndex < format.centerpieceIndex) pivotIndex += piece.fragments.length;
}
_positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex);
}
void _layoutVertically(Size size, BoxConstraints constraints) {
final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{};
final List<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;
}
final _TimePickerHeaderPiece centerpiece = format.pieces[format.centerpieceIndex];
double y = (size.height - height) / 2.0;
for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
final double pieceVerticalCenter = y + pieceHeights[pieceIndex] / 2.0;
if (pieceIndex != format.centerpieceIndex)
_positionPiece(size.width, pieceVerticalCenter, childSizes, format.pieces[pieceIndex].fragments);
else
_positionPivoted(size.width, pieceVerticalCenter, childSizes, centerpiece.fragments, centerpiece.pivotIndex);
y += pieceHeights[pieceIndex] + format.pieces[pieceIndex].bottomMargin;
}
}
void _positionPivoted(
double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) {
double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0;
for (final _TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) {
tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin;
}
double x = width / 2.0 + tailWidth;
x = math.min(x, width);
for (int i = fragments.length - 1; i >= 0; i -= 1) {
final _TimePickerHeaderFragment fragment = fragments[i];
final Size childSize = childSizes[fragment.layoutId];
x -= childSize.width;
positionChild(fragment.layoutId, Offset(x, y - childSize.height / 2.0));
x -= fragment.startMargin;
}
}
void _positionPiece(
double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) {
double pieceWidth = 0.0;
double nextMargin = 0.0;
for (final _TimePickerHeaderFragment fragment in fragments) {
final Size childSize = childSizes[fragment.layoutId];
pieceWidth += childSize.width + nextMargin;
// Delay application of margin until next element because margin of the
// left-most fragment should not contribute to the size.
nextMargin = fragment.startMargin;
}
double x = (width + pieceWidth) / 2.0;
for (int i = fragments.length - 1; i >= 0; i -= 1) {
final _TimePickerHeaderFragment fragment = fragments[i];
final Size childSize = childSizes[fragment.layoutId];
x -= childSize.width;
positionChild(fragment.layoutId, Offset(x, centeredAroundY - childSize.height / 2.0));
x -= fragment.startMargin;
}
}
@override
bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format;
}
class _TimePickerHeader extends StatelessWidget {
const _TimePickerHeader({
@required this.selectedTime,
@required this.mode,
@required this.orientation,
@required this.onModeChanged,
@required this.onChanged,
@required this.use24HourDials,
}) : assert(selectedTime != null),
assert(mode != null),
assert(orientation != null),
assert(use24HourDials != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final Orientation orientation;
final ValueChanged<_TimePickerMode> onModeChanged;
final ValueChanged<TimeOfDay> onChanged;
final bool use24HourDials;
void _handleChangeMode(_TimePickerMode value) {
if (value != mode) onModeChanged(value);
}
TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) {
// 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);
}
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);
EdgeInsets padding;
double height;
double width;
assert(orientation != null);
switch (orientation) {
case Orientation.portrait:
height = _kTimePickerHeaderPortraitHeight;
padding = const EdgeInsets.symmetric(horizontal: 24.0);
break;
case Orientation.landscape:
width = _kTimePickerHeaderLandscapeWidth;
padding = const EdgeInsets.symmetric(horizontal: 16.0);
break;
}
Color backgroundColor;
switch (themeData.brightness) {
case Brightness.light:
backgroundColor = themeData.primaryColor;
break;
case Brightness.dark:
backgroundColor = themeData.backgroundColor;
break;
}
Color activeColor;
Color inactiveColor;
switch (themeData.primaryColorBrightness) {
case Brightness.light:
activeColor = Colors.black87;
inactiveColor = Colors.black54;
break;
case Brightness.dark:
activeColor = Colors.white;
inactiveColor = Colors.white70;
break;
}
final TextTheme headerTextTheme = themeData.primaryTextTheme;
final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme);
final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext(
headerTextTheme: headerTextTheme,
textDirection: Directionality.of(context),
selectedTime: selectedTime,
mode: mode,
activeColor: activeColor,
activeStyle: baseHeaderStyle.copyWith(color: activeColor),
inactiveColor: inactiveColor,
inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor),
onTimeChange: onChanged,
onModeChange: _handleChangeMode,
targetPlatform: themeData.platform,
use24HourDials: use24HourDials,
);
final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext, orientation);
return Container(
width: width,
height: height,
padding: padding,
color: backgroundColor,
child: CustomMultiChildLayout(
delegate: _TimePickerHeaderLayout(orientation, format),
children: format.pieces
.expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments)
.map<Widget>((_TimePickerHeaderFragment fragment) {
return LayoutId(
id: fragment.layoutId,
child: fragment.widget,
);
}).toList(),
),
);
}
}
enum _DialRing {
outer,
inner,
}
class _TappableLabel {
_TappableLabel({
@required this.value,
@required this.painter,
@required this.onTap,
});
/// The value this label is displaying.
final int value;
/// Paints the text of the label.
final TextPainter painter;
/// Called when a tap gesture is detected on the label.
final VoidCallback onTap;
}
class _DialPainter extends CustomPainter {
_DialPainter({
@required this.primaryOuterLabels,
@required this.primaryInnerLabels,
@required this.secondaryOuterLabels,
@required this.secondaryInnerLabels,
@required this.backgroundColor,
@required this.accentColor,
@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 Color backgroundColor;
final Color accentColor;
final double theta;
final _DialRing activeRing;
final TextDirection textDirection;
final int selectedValue;
@override
void paint(Canvas canvas, Size size) {
final double radius = size.shortestSide / 2.0;
final Offset center = Offset(size.width / 2.0, size.height / 2.0);
final 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));
}
void paintLabels(List<_TappableLabel> labels, _DialRing ring) {
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);
labelTheta += labelThetaIncrement;
}
}
paintLabels(primaryOuterLabels, _DialRing.outer);
paintLabels(primaryInnerLabels, _DialRing.inner);
return nodes;
}
@override
bool shouldRepaint(_DialPainter oldPainter) {
return oldPainter.primaryOuterLabels != primaryOuterLabels ||
oldPainter.primaryInnerLabels != primaryInnerLabels ||
oldPainter.secondaryOuterLabels != secondaryOuterLabels ||
oldPainter.secondaryInnerLabels != secondaryInnerLabels ||
oldPainter.backgroundColor != backgroundColor ||
oldPainter.accentColor != accentColor ||
oldPainter.theta != theta ||
oldPainter.activeRing != activeRing;
}
}
class _Dial extends StatefulWidget {
const _Dial({
@required this.selectedTime,
@required this.mode,
@required this.use24HourDials,
@required this.onChanged,
@required this.onHourSelected,
@required this.times,
}) : assert(selectedTime != null),
assert(mode != null),
assert(use24HourDials != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final bool use24HourDials;
final ValueChanged<TimeOfDay> onChanged;
final VoidCallback onHourSelected;
final List<TimeOfDay> times;
@override
_DialState createState() => _DialState(times);
}
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
_DialState(this.times);
final List<TimeOfDay> times;
@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(_thetaTween)
..addListener(() => setState(() {/* _theta.value has changed */}));
}
ThemeData themeData;
MaterialLocalizations localizations;
MediaQueryData media;
@override
void didChangeDependencies() {
super.didChangeDependencies();
assert(debugCheckHasMediaQuery(context));
themeData = Theme.of(context);
localizations = MaterialLocalizations.of(context);
media = MediaQuery.of(context);
}
@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();
super.dispose();
}
Tween<double> _thetaTween;
Animation<double> _theta;
AnimationController _thetaController;
bool _dragging = false;
static double _nearest(double target, double a, double b) {
return ((target - a).abs() < (target - b).abs()) ? a : b;
}
void _animateTo(double targetTheta) {
final double currentTheta = _theta.value;
double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi);
beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi);
_thetaTween
..begin = beginTheta
..end = targetTheta;
_thetaController
..value = 0.0
..forward();
}
int pmMinHour(List<TimeOfDay> pmHours, List<TimeOfDay> amHours) => pmHours.min.hour != null ? pmHours.min.hour : amHours.min.hour;
int amMinHour(List<TimeOfDay> pmHours, List<TimeOfDay> amHours) => amHours.min.hour != null ? amHours.min.hour : pmHours.min.hour;
double _getThetaForTime(TimeOfDay time) {
List<TimeOfDay> amHours = getAmHours(times);
List<TimeOfDay> pmHours = getPmHours(times);
int hoursPerPeriod = _activeRing == _DialRing.outer && pmHours.distance != null ? (pmHours.distance + 1) : (amHours.distance + 1);
int minTimeForPeriod = _activeRing == _DialRing.outer ? pmMinHour(pmHours, amHours) : amMinHour(pmHours, amHours);
final double fraction = widget.mode == _TimePickerMode.hour
? ((time.hour - minTimeForPeriod) / hoursPerPeriod) % hoursPerPeriod
: (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi;
}
TimeOfDay _getTimeForTheta(double theta) {
List<TimeOfDay> amHours = getAmHours(times);
List<TimeOfDay> pmHours = getPmHours(times);
final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
if (widget.mode == _TimePickerMode.hour) {
int newHour = amHours.length > 0
? ((fraction * (amHours.distance + 1)).round() % (amHours.distance + 1)) + amHours.min.hour
: (((fraction * (pmHours.distance + 1)).round() % (pmHours.distance + 1)) + pmHours.min.hour);
if (widget.use24HourDials) {
if (_activeRing == _DialRing.outer) {
newHour =
pmHours.length > 0 ? ((fraction * (pmHours.distance + 1)).round() % (pmHours.distance + 1)) + pmHours.min.hour : newHour;
} else if (newHour == 0) {
newHour = pmHours.max.hour;
}
} else {
newHour = newHour + widget.selectedTime.periodOffset;
}
return widget.selectedTime.replacing(hour: newHour, minute: 0);
} else {
List<TimeOfDay> minutes = getMinutesForHour(widget.selectedTime, times);
var minute = minutes.max.minute == 0 ? 0 : (fraction * (minutes.max.minute + 15)).round() % (minutes.max.minute * 15);
minute = minute > 0 ? (minute / 15).round() * 15 : 0;
minute = minute == (minutes.max.minute + 15) ? 0 : minute;
return widget.selectedTime.replacing(minute: minute);
}
}
TimeOfDay _notifyOnChangedIfNeeded() {
final TimeOfDay current = _getTimeForTheta(_theta.value);
if (widget.onChanged == null) return current;
if (current != widget.selectedTime) widget.onChanged(current);
return current;
}
void _updateThetaForPan() {
setState(() {
final Offset offset = _position - _center;
final double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi;
_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);
_dragging = true;
final RenderBox box = context.findRenderObject() as RenderBox;
_position = box.globalToLocal(details.globalPosition);
_center = box.size.center(Offset.zero);
_updateThetaForPan();
_notifyOnChangedIfNeeded();
}
void _handlePanUpdate(DragUpdateDetails details) {
_position += details.delta;
_updateThetaForPan();
_notifyOnChangedIfNeeded();
}
void _handlePanEnd(DragEndDetails details) {
assert(_dragging);
_dragging = false;
_position = null;
_center = null;
_animateTo(_getThetaForTime(widget.selectedTime));
if (widget.mode == _TimePickerMode.hour) {
if (widget.onHourSelected != null) {
widget.onHourSelected();
}
}
}
void _handleTapUp(TapUpDetails details) {
final RenderBox box = context.findRenderObject() as RenderBox;
_position = box.globalToLocal(details.globalPosition);
_center = box.size.center(Offset.zero);
_updateThetaForPan();
final TimeOfDay newTime = _notifyOnChangedIfNeeded();
if (widget.mode == _TimePickerMode.hour) {
if (widget.use24HourDials) {
_announceToAccessibility(context, localizations.formatDecimal(newTime.hour));
} else {
_announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod));
}
if (widget.onHourSelected != null) {
widget.onHourSelected();
}
} else {
_announceToAccessibility(context, localizations.formatDecimal(newTime.minute));
}
_animateTo(_getThetaForTime(_getTimeForTheta(_theta.value)));
_dragging = false;
_position = null;
_center = null;
}
void _selectHour(int hour) {
_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 {
time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute);
}
}
final double angle = _getThetaForTime(time);
_thetaTween
..begin = angle
..end = angle;
_notifyOnChangedIfNeeded();
}
void _selectMinute(int minute) {
_announceToAccessibility(context, localizations.formatDecimal(minute));
final TimeOfDay time = TimeOfDay(
hour: widget.selectedTime.hour,
minute: minute,
);
final double angle = _getThetaForTime(time);
_thetaTween
..begin = angle
..end = angle;
_notifyOnChangedIfNeeded();
}
_TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) {
final TextStyle style = textTheme.subtitle1;
final double labelScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0);
return _TappableLabel(
value: value,
painter: TextPainter(
text: TextSpan(style: style, text: label),
textDirection: TextDirection.ltr,
textScaleFactor: labelScaleFactor,
)..layout(),
onTap: onTap,
);
}
List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in getAmHours(times))
_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
),
];
List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in getPmHours(times))
_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
),
];
List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) => <_TappableLabel>[
for (final TimeOfDay timeOfDay in getAmHours(times))
_buildTappableLabel(
textTheme,
timeOfDay.hour,
localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
() {
_selectHour(timeOfDay.hour);
},
),
];
List<_TappableLabel> _buildMinutes(TextTheme textTheme) {
return <_TappableLabel>[
for (final TimeOfDay timeOfDay in getMinutesForHour(widget.selectedTime, times))
_buildTappableLabel(
textTheme,
timeOfDay.minute,
localizations.formatMinute(timeOfDay),
() {
_selectMinute(timeOfDay.minute);
},
),
];
}
@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;
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);
} else {
selectedDialValue = widget.selectedTime.hourOfPeriod;
primaryOuterLabels = _build12HourOuterRing(theme.textTheme);
secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme);
}
break;
case _TimePickerMode.minute:
selectedDialValue = widget.selectedTime.minute;
primaryOuterLabels = _buildMinutes(theme.textTheme);
primaryInnerLabels = null;
secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
secondaryInnerLabels = null;
break;
}
return GestureDetector(
excludeFromSemantics: true,
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
onTapUp: _handleTapUp,
child: CustomPaint(
key: const ValueKey<String>('time-picker-dial'),
painter: _DialPainter(
selectedValue: selectedDialValue,
primaryOuterLabels: primaryOuterLabels,
primaryInnerLabels: primaryInnerLabels,
secondaryOuterLabels: secondaryOuterLabels,
secondaryInnerLabels: secondaryInnerLabels,
backgroundColor: backgroundColor,
accentColor: themeData.accentColor,
theta: _theta.value,
activeRing: _activeRing,
textDirection: Directionality.of(context),
),
),
);
}
}
/// A material design time picker designed to appear inside a popup dialog.
///
/// Pass this widget to [showDialog]. The value returned by [showDialog] is the
/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user
/// taps the "CANCEL" button. The selected time is reported by calling
/// [Navigator.pop].
class _TimePickerDialog extends StatefulWidget {
/// Creates a material time picker.
///
/// [initialTime] must not be null.
const _TimePickerDialog({
Key key,
@required this.initialTime,
@required this.times,
}) : assert(initialTime != null),
super(key: key);
/// The time initially selected when the dialog is shown.
final TimeOfDay initialTime;
final List<TimeOfDay> times;
@override
_TimePickerDialogState createState() => _TimePickerDialogState(times);
}
class _TimePickerDialogState extends State<_TimePickerDialog> {
_TimePickerDialogState(this.times);
final List<TimeOfDay> times;
@override
void initState() {
super.initState();
_selectedTime = widget.initialTime;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
localizations = MaterialLocalizations.of(context);
_announceInitialTimeOnce();
_announceModeOnce();
}
_TimePickerMode _mode = _TimePickerMode.hour;
_TimePickerMode _lastModeAnnounced;
TimeOfDay get selectedTime => _selectedTime;
TimeOfDay _selectedTime;
Timer _vibrateTimer;
MaterialLocalizations localizations;
void _vibrate() {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_vibrateTimer?.cancel();
_vibrateTimer = Timer(_kVibrateCommitDelay, () {
HapticFeedback.vibrate();
_vibrateTimer = null;
});
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
break;
}
}
void _handleModeChanged(_TimePickerMode mode) {
_vibrate();
setState(() {
_mode = mode;
_announceModeOnce();
});
}
void _announceModeOnce() {
if (_lastModeAnnounced == _mode) {
// Already announced it.
return;
}
switch (_mode) {
case _TimePickerMode.hour:
_announceToAccessibility(context, localizations.timePickerHourModeAnnouncement);
break;
case _TimePickerMode.minute:
_announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement);
break;
}
_lastModeAnnounced = _mode;
}
bool _announcedInitialTime = false;
void _announceInitialTimeOnce() {
if (_announcedInitialTime) return;
final MediaQueryData media = MediaQuery.of(context);
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
_announceToAccessibility(
context,
localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
);
_announcedInitialTime = true;
}
void _handleTimeChanged(TimeOfDay value) {
_vibrate();
setState(() {
_selectedTime = value;
});
}
void _handleHourSelected() {
setState(() {
_mode = _TimePickerMode.minute;
});
}
void _handleCancel() {
Navigator.pop(context);
}
void _handleOk() {
Navigator.pop(context, _selectedTime);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData media = MediaQuery.of(context);
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
final ThemeData theme = Theme.of(context);
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,
times: times,
),
),
);
final Widget actions = ButtonBar(
children: <Widget>[
FlatButton(
child: Text(localizations.cancelButtonLabel),
onPressed: _handleCancel,
),
FlatButton(
child: Text(localizations.okButtonLabel),
onPressed: _handleOk,
),
],
);
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,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Expanded(child: picker), // picker grows and shrinks with the available space
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;
}),
);
return Theme(
data: theme.copyWith(
dialogBackgroundColor: Colors.transparent,
),
child: dialog,
);
}
@override
void dispose() {
_vibrateTimer?.cancel();
_vibrateTimer = null;
super.dispose();
}
}
/// Shows a dialog containing a material design time picker.
///
/// The returned Future resolves to the time selected by the user when the user
/// closes the dialog. If the user cancels the dialog, null is returned.
///
/// {@tool snippet}
/// Show a dialog with [initialTime] equal to the current time.
///
/// ```dart
/// Future<TimeOfDay> selectedTime = showTimePicker(
/// initialTime: TimeOfDay.now(),
/// context: context,
/// );
/// ```
/// {@end-tool}
///
/// The [context], [useRootNavigator] and [routeSettings] arguments are passed to
/// [showDialog], the documentation for which discusses how it is used.
///
/// The [builder] parameter can be used to wrap the dialog widget
/// to add inherited widgets like [Localizations.override],
/// [Directionality], or [MediaQuery].
///
/// {@tool snippet}
/// Show a dialog with the text direction overridden to be [TextDirection.rtl].
///
/// ```dart
/// Future<TimeOfDay> selectedTimeRTL = showTimePicker(
/// context: context,
/// initialTime: TimeOfDay.now(),
/// builder: (BuildContext context, Widget child) {
/// return Directionality(
/// textDirection: TextDirection.rtl,
/// child: child,
/// );
/// },
/// );
/// ```
/// {@end-tool}
///
/// {@tool snippet}
/// Show a dialog with time unconditionally displayed in 24 hour format.
///
/// ```dart
/// Future<TimeOfDay> selectedTime24Hour = showTimePicker(
/// context: context,
/// initialTime: TimeOfDay(hour: 10, minute: 47),
/// builder: (BuildContext context, Widget child) {
/// return MediaQuery(
/// data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
/// child: child,
/// );
/// },
/// );
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [showDatePicker], which shows a dialog that contains a material design
/// date picker.
Future<TimeOfDay> showCustomTimePicker({
@required BuildContext context,
@required TimeOfDay initialTime,
TransitionBuilder builder,
bool useRootNavigator = true,
List<TimeOfDay> times,
RouteSettings routeSettings,
}) async {
assert(context != null);
assert(initialTime != null);
assert(useRootNavigator != null);
assert(debugCheckHasMaterialLocalizations(context));
final Widget dialog = _TimePickerDialog(
initialTime: initialTime,
times: times,
);
return await showDialog<TimeOfDay>(
context: context,
useRootNavigator: useRootNavigator,
builder: (BuildContext context) {
return builder == null ? dialog : builder(context, dialog);
},
routeSettings: routeSettings,
);
}
void _announceToAccessibility(BuildContext context, String message) {
SemanticsService.announce(message, Directionality.of(context));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment