blob: ef1f80cfedbf62ed0277f6edaa7b31f599469119 [file] [log] [blame]
// Copyright 2015 The Chromium 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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
import 'button_bar.dart';
import 'colors.dart';
import 'dialog.dart';
import 'feedback.dart';
import 'flat_button.dart';
import 'material_localizations.dart';
import 'theme.dart';
import 'time.dart';
import 'typography.dart';
const Duration _kDialAnimateDuration = const Duration(milliseconds: 200);
const double _kTwoPi = 2 * math.PI;
const Duration _kVibrateCommitDelay = const 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 = 484.0;
const double _kTimePickerHeightLandscape = 304.0;
/// The horizontal gap between the day period fragment and the fragment
/// positioned next to it horizontally.
///
/// Normally there's only one horizontal sibling, and it may appear on the left
/// or right depending on the current [TextDirection].
const double _kPeriodGap = 8.0;
/// The vertical gap between pieces when laid out vertically (in portrait mode).
const double _kVerticalGap = 8.0;
enum _TimePickerHeaderId {
hour,
colon,
minute,
period, // AM/PM picker
dot,
hString, // French Canadian "h" literal
}
/// 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,
}) : 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);
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;
}
/// 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.centrepieceIndex, this.pieces)
: assert(centrepieceIndex != null),
assert(pieces != null);
/// Index into the [pieces] list pointing at the piece that contains the
/// pivot fragment.
final int centrepieceIndex;
/// 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,
});
final _TimePickerFragmentContext fragmentContext;
void _handleChangeDayPeriod() {
final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
fragmentContext.onTimeChange(fragmentContext.selectedTime.replacing(hour: newHour));
}
@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 TextStyle amStyle = headerTextTheme.subhead.copyWith(
color: selectedTime.period == DayPeriod.am ? activeColor: inactiveColor
);
final TextStyle pmStyle = headerTextTheme.subhead.copyWith(
color: selectedTime.period == DayPeriod.pm ? activeColor: inactiveColor
);
return new GestureDetector(
onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context),
behavior: HitTestBehavior.opaque,
child: new Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle),
const SizedBox(width: 0.0, height: 4.0), // Vertical spacer
new Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle),
],
),
);
}
}
/// 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 MaterialLocalizations localizations = MaterialLocalizations.of(context);
final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour
? fragmentContext.activeStyle
: fragmentContext.inactiveStyle;
return new GestureDetector(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context),
child: new Text(localizations.formatHour(
fragmentContext.selectedTime,
alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
), style: hourStyle),
);
}
}
/// 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 new Text(value, style: fragmentContext.inactiveStyle);
}
}
/// 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;
return new GestureDetector(
onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context),
child: new Text(localizations.formatMinute(fragmentContext.selectedTime), style: minuteStyle),
);
}
}
/// 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) {
// Creates an hour fragment.
_TimePickerHeaderFragment hour() {
return new _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.hour,
widget: new _HourControl(fragmentContext: context),
startMargin: _kPeriodGap,
);
}
// Creates a minute fragment.
_TimePickerHeaderFragment minute() {
return new _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.minute,
widget: new _MinuteControl(fragmentContext: context),
);
}
// Creates a string fragment.
_TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) {
return new _TimePickerHeaderFragment(
layoutId: layoutId,
widget: new _StringFragment(
fragmentContext: context,
value: value,
),
);
}
// Creates an am/pm fragment.
_TimePickerHeaderFragment dayPeriod() {
return new _TimePickerHeaderFragment(
layoutId: _TimePickerHeaderId.period,
widget: new _DayPeriodControl(fragmentContext: context),
startMargin: _kPeriodGap,
);
}
// 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 centrepieceIndex;
for (int i = 0; i < pieces.length; i += 1) {
if (pieces[i].pivotIndex >= 0) {
centrepieceIndex = i;
}
}
assert(centrepieceIndex != null);
return new _TimePickerHeaderFormat(centrepieceIndex, 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) {
fragments.add(fragment2);
if (fragment3 != null)
fragments.add(fragment3);
}
return new _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(
bottomMargin: _kVerticalGap,
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(
bottomMargin: _kVerticalGap,
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 = new 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.centrepieceIndex)
pivotIndex += format.pieces[format.centrepieceIndex].pivotIndex;
else if (pieceIndex < format.centrepieceIndex)
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 centrepiece = format.pieces[format.centrepieceIndex];
double y = (size.height - height) / 2.0;
for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) {
if (pieceIndex != format.centrepieceIndex)
_positionPiece(size.width, y, childSizes, format.pieces[pieceIndex].fragments);
else
_positionPivoted(size.width, y, childSizes, centrepiece.fragments, centrepiece.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 (_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, new 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 (_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, new 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,
}) : assert(selectedTime != null),
assert(mode != null),
assert(orientation != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final Orientation orientation;
final ValueChanged<_TimePickerMode> onModeChanged;
final ValueChanged<TimeOfDay> onChanged;
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.display3.copyWith(fontSize: 60.0);
case Orientation.landscape:
return headerTextTheme.display2.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 = new _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,
);
final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext);
return new Container(
width: width,
height: height,
padding: padding,
color: backgroundColor,
child: new CustomMultiChildLayout(
delegate: new _TimePickerHeaderLayout(orientation, format),
children: format.pieces
.expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments)
.map<Widget>((_TimePickerHeaderFragment fragment) {
return new LayoutId(
id: fragment.layoutId,
child: fragment.widget,
);
})
.toList(),
)
);
}
}
List<TextPainter> _buildPainters(TextTheme textTheme, List<String> labels) {
final TextStyle style = textTheme.subhead;
final List<TextPainter> painters = new List<TextPainter>(labels.length);
for (int i = 0; i < painters.length; ++i) {
final String label = labels[i];
// TODO(abarth): Handle textScaleFactor.
// https://github.com/flutter/flutter/issues/5939
painters[i] = new TextPainter(
text: new TextSpan(style: style, text: label),
textDirection: TextDirection.ltr,
)..layout();
}
return painters;
}
enum _DialRing {
outer,
inner,
}
class _DialPainter extends CustomPainter {
const _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,
});
final List<TextPainter> primaryOuterLabels;
final List<TextPainter> primaryInnerLabels;
final List<TextPainter> secondaryOuterLabels;
final List<TextPainter> secondaryInnerLabels;
final Color backgroundColor;
final Color accentColor;
final double theta;
final _DialRing activeRing;
@override
void paint(Canvas canvas, Size size) {
final double radius = size.shortestSide / 2.0;
final Offset center = new Offset(size.width / 2.0, size.height / 2.0);
final Offset centerPoint = center;
canvas.drawCircle(centerPoint, radius, new 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 + new Offset(labelRadius * math.cos(theta),
-labelRadius * math.sin(theta));
}
void paintLabels(List<TextPainter> labels, _DialRing ring) {
if (labels == null)
return;
final double labelThetaIncrement = -_kTwoPi / labels.length;
double labelTheta = math.PI / 2.0;
for (TextPainter label in labels) {
final Offset labelOffset = new Offset(-label.width / 2.0, -label.height / 2.0);
label.paint(canvas, getOffsetForTheta(labelTheta, ring) + labelOffset);
labelTheta += labelThetaIncrement;
}
}
paintLabels(primaryOuterLabels, _DialRing.outer);
paintLabels(primaryInnerLabels, _DialRing.inner);
final Paint selectorPaint = new Paint()
..color = accentColor;
final Offset focusedPoint = getOffsetForTheta(theta, activeRing);
final 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 = new Rect.fromCircle(
center: focusedPoint, radius: focusedRadius
);
canvas
..save()
..clipPath(new Path()..addOval(focusedRect));
paintLabels(secondaryOuterLabels, _DialRing.outer);
paintLabels(secondaryInnerLabels, _DialRing.inner);
canvas.restore();
}
@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
}) : assert(selectedTime != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final bool use24HourDials;
final ValueChanged<TimeOfDay> onChanged;
@override
_DialState createState() => new _DialState();
}
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
_thetaController = new AnimationController(
duration: _kDialAnimateDuration,
vsync: this,
);
_thetaTween = new Tween<double>(begin: _getThetaForTime(widget.selectedTime));
_theta = _thetaTween.animate(new CurvedAnimation(
parent: _thetaController,
curve: Curves.fastOutSlowIn
))..addListener(() => setState(() { }));
}
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);
if (widget.mode != oldWidget.mode) {
if (!_dragging)
_animateTo(_getThetaForTime(widget.selectedTime));
}
if (widget.mode == _TimePickerMode.hour && widget.use24HourDials && widget.selectedTime.period == DayPeriod.am) {
_activeRing = _DialRing.inner;
} 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();
}
double _getThetaForTime(TimeOfDay time) {
final double fraction = (widget.mode == _TimePickerMode.hour) ?
(time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod :
(time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
return (math.PI / 2.0 - fraction * _kTwoPi) % _kTwoPi;
}
TimeOfDay _getTimeForTheta(double theta) {
final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
if (widget.mode == _TimePickerMode.hour) {
int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod;
if (widget.use24HourDials) {
if (_activeRing == _DialRing.outer) {
if (newHour != 0)
newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
} else if (newHour == 0) {
newHour = TimeOfDay.hoursPerPeriod;
}
} else {
newHour = newHour + widget.selectedTime.periodOffset;
}
return widget.selectedTime.replacing(hour: newHour);
} else {
return widget.selectedTime.replacing(
minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour
);
}
}
void _notifyOnChangedIfNeeded() {
if (widget.onChanged == null)
return;
final TimeOfDay current = _getTimeForTheta(_theta.value);
if (current != widget.selectedTime)
widget.onChanged(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();
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();
_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));
}
static const List<TimeOfDay> _amHours = const <TimeOfDay>[
const TimeOfDay(hour: 12, minute: 0),
const TimeOfDay(hour: 1, minute: 0),
const TimeOfDay(hour: 2, minute: 0),
const TimeOfDay(hour: 3, minute: 0),
const TimeOfDay(hour: 4, minute: 0),
const TimeOfDay(hour: 5, minute: 0),
const TimeOfDay(hour: 6, minute: 0),
const TimeOfDay(hour: 7, minute: 0),
const TimeOfDay(hour: 8, minute: 0),
const TimeOfDay(hour: 9, minute: 0),
const TimeOfDay(hour: 10, minute: 0),
const TimeOfDay(hour: 11, minute: 0),
];
static const List<TimeOfDay> _pmHours = const <TimeOfDay>[
const TimeOfDay(hour: 0, minute: 0),
const TimeOfDay(hour: 13, minute: 0),
const TimeOfDay(hour: 14, minute: 0),
const TimeOfDay(hour: 15, minute: 0),
const TimeOfDay(hour: 16, minute: 0),
const TimeOfDay(hour: 17, minute: 0),
const TimeOfDay(hour: 18, minute: 0),
const TimeOfDay(hour: 19, minute: 0),
const TimeOfDay(hour: 20, minute: 0),
const TimeOfDay(hour: 21, minute: 0),
const TimeOfDay(hour: 22, minute: 0),
const TimeOfDay(hour: 23, minute: 0),
];
List<TextPainter> _build24HourInnerRing(TextTheme textTheme) {
return _buildPainters(textTheme, _amHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
}
List<TextPainter> _build24HourOuterRing(TextTheme textTheme) {
return _buildPainters(textTheme, _pmHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
}
List<TextPainter> _build12HourOuterRing(TextTheme textTheme) {
return _buildPainters(textTheme, _amHours
.map((TimeOfDay timeOfDay) {
return localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat);
})
.toList());
}
List<TextPainter> _buildMinutes(TextTheme textTheme) {
const List<TimeOfDay> _minuteMarkerValues = const <TimeOfDay>[
const TimeOfDay(hour: 0, minute: 0),
const TimeOfDay(hour: 0, minute: 5),
const TimeOfDay(hour: 0, minute: 10),
const TimeOfDay(hour: 0, minute: 15),
const TimeOfDay(hour: 0, minute: 20),
const TimeOfDay(hour: 0, minute: 25),
const TimeOfDay(hour: 0, minute: 30),
const TimeOfDay(hour: 0, minute: 35),
const TimeOfDay(hour: 0, minute: 40),
const TimeOfDay(hour: 0, minute: 45),
const TimeOfDay(hour: 0, minute: 50),
const TimeOfDay(hour: 0, minute: 55),
];
return _buildPainters(textTheme, _minuteMarkerValues.map(localizations.formatMinute).toList());
}
@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<TextPainter> primaryOuterLabels;
List<TextPainter> primaryInnerLabels;
List<TextPainter> secondaryOuterLabels;
List<TextPainter> secondaryInnerLabels;
switch (widget.mode) {
case _TimePickerMode.hour:
if (widget.use24HourDials) {
primaryOuterLabels = _build24HourOuterRing(theme.textTheme);
secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme);
primaryInnerLabels = _build24HourInnerRing(theme.textTheme);
secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme);
} else {
primaryOuterLabels = _build12HourOuterRing(theme.textTheme);
secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme);
}
break;
case _TimePickerMode.minute:
primaryOuterLabels = _buildMinutes(theme.textTheme);
primaryInnerLabels = null;
secondaryOuterLabels = _buildMinutes(theme.accentTextTheme);
secondaryInnerLabels = null;
break;
}
return new GestureDetector(
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
child: new CustomPaint(
key: const ValueKey<String>('time-picker-dial'), // used for testing.
painter: new _DialPainter(
primaryOuterLabels: primaryOuterLabels,
primaryInnerLabels: primaryInnerLabels,
secondaryOuterLabels: secondaryOuterLabels,
secondaryInnerLabels: secondaryInnerLabels,
backgroundColor: backgroundColor,
accentColor: themeData.accentColor,
theta: _theta.value,
activeRing: _activeRing,
)
)
);
}
}
/// 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
}) : assert(initialTime != null),
super(key: key);
/// The time initially selected when the dialog is shown.
final TimeOfDay initialTime;
@override
_TimePickerDialogState createState() => new _TimePickerDialogState();
}
class _TimePickerDialogState extends State<_TimePickerDialog> {
@override
void initState() {
super.initState();
_selectedTime = widget.initialTime;
}
_TimePickerMode _mode = _TimePickerMode.hour;
TimeOfDay _selectedTime;
Timer _vibrateTimer;
void _vibrate() {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
_vibrateTimer?.cancel();
_vibrateTimer = new Timer(_kVibrateCommitDelay, () {
HapticFeedback.vibrate();
_vibrateTimer = null;
});
break;
case TargetPlatform.iOS:
break;
}
}
void _handleModeChanged(_TimePickerMode mode) {
_vibrate();
setState(() {
_mode = mode;
});
}
void _handleTimeChanged(TimeOfDay value) {
_vibrate();
setState(() {
_selectedTime = value;
});
}
void _handleCancel() {
Navigator.pop(context);
}
void _handleOk() {
Navigator.pop(context, _selectedTime);
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final MediaQueryData media = MediaQuery.of(context);
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
final Widget picker = new Padding(
padding: const EdgeInsets.all(16.0),
child: new AspectRatio(
aspectRatio: 1.0,
child: new _Dial(
mode: _mode,
use24HourDials: hourFormat(of: timeOfDayFormat) != HourFormat.h,
selectedTime: _selectedTime,
onChanged: _handleTimeChanged,
)
)
);
final Widget actions = new ButtonTheme.bar(
child: new ButtonBar(
children: <Widget>[
new FlatButton(
child: new Text(localizations.cancelButtonLabel),
onPressed: _handleCancel
),
new FlatButton(
child: new Text(localizations.okButtonLabel),
onPressed: _handleOk
),
]
)
);
return new Dialog(
child: new OrientationBuilder(
builder: (BuildContext context, Orientation orientation) {
final Widget header = new _TimePickerHeader(
selectedTime: _selectedTime,
mode: _mode,
orientation: orientation,
onModeChanged: _handleModeChanged,
onChanged: _handleTimeChanged,
);
assert(orientation != null);
switch (orientation) {
case Orientation.portrait:
return new SizedBox(
width: _kTimePickerWidthPortrait,
height: _kTimePickerHeightPortrait,
child: new Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
header,
new Expanded(child: picker),
actions,
]
)
);
case Orientation.landscape:
return new SizedBox(
width: _kTimePickerWidthLandscape,
height: _kTimePickerHeightLandscape,
child: new Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
header,
new Flexible(
child: new Column(
children: <Widget>[
new Expanded(child: picker),
actions,
]
)
),
]
)
);
}
return null;
}
)
);
}
@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.
///
/// To show a dialog with [initialTime] equal to the current time:
///
/// ```dart
/// showTimePicker(
/// initialTime: new TimeOfDay.now(),
/// context: context,
/// );
/// ```
///
/// The `context` argument is passed to [showDialog], the documentation for
/// which discusses how it is used.
///
/// See also:
///
/// * [showDatePicker]
/// * <https://material.google.com/components/pickers.html#pickers-time-pickers>
Future<TimeOfDay> showTimePicker({
@required BuildContext context,
@required TimeOfDay initialTime
}) async {
assert(context != null);
assert(initialTime != null);
return await showDialog<TimeOfDay>(
context: context,
child: new _TimePickerDialog(initialTime: initialTime),
);
}