blob: cca45f07eda81a31f6636822f958d2c5b85c18c2 [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 'theme.dart';
import 'typography.dart';
const Duration _kDialAnimateDuration = const Duration(milliseconds: 200);
const double _kTwoPi = 2 * math.PI;
const int _kHoursPerDay = 24;
const int _kHoursPerPeriod = 12;
const int _kMinutesPerHour = 60;
const Duration _kVibrateCommitDelay = const Duration(milliseconds: 100);
/// Whether the [TimeOfDay] is before or after noon.
enum DayPeriod {
/// Ante meridiem (before noon).
am,
/// Post meridiem (after noon).
pm,
}
/// A value representing a time during the day
@immutable
class TimeOfDay {
/// Creates a time of day.
///
/// The [hour] argument must be between 0 and 23, inclusive. The [minute]
/// argument must be between 0 and 59, inclusive.
const TimeOfDay({ @required this.hour, @required this.minute });
/// Creates a time of day based on the given time.
///
/// The [hour] is set to the time's hour and the [minute] is set to the time's
/// minute in the timezone of the given [DateTime].
TimeOfDay.fromDateTime(DateTime time) : hour = time.hour, minute = time.minute;
/// Creates a time of day based on the current time.
///
/// The [hour] is set to the current hour and the [minute] is set to the
/// current minute in the local time zone.
factory TimeOfDay.now() { return new TimeOfDay.fromDateTime(new DateTime.now()); }
/// Returns a new TimeOfDay with the hour and/or minute replaced.
TimeOfDay replacing({ int hour, int minute }) {
assert(hour == null || (hour >= 0 && hour < _kHoursPerDay));
assert(minute == null || (minute >= 0 && minute < _kMinutesPerHour));
return new TimeOfDay(hour: hour ?? this.hour, minute: minute ?? this.minute);
}
/// The selected hour, in 24 hour time from 0..23.
final int hour;
/// The selected minute.
final int minute;
/// Whether this time of day is before or after noon.
DayPeriod get period => hour < _kHoursPerPeriod ? DayPeriod.am : DayPeriod.pm;
/// Which hour of the current period (e.g., am or pm) this time is.
int get hourOfPeriod => hour - periodOffset;
String _addLeadingZeroIfNeeded(int value) {
if (value < 10)
return '0$value';
return value.toString();
}
/// A string representing the hour, in 24 hour time (e.g., '04' or '18').
String get hourLabel => _addLeadingZeroIfNeeded(hour);
/// A string representing the minute (e.g., '07').
String get minuteLabel => _addLeadingZeroIfNeeded(minute);
/// A string representing the hour of the current period (e.g., '4' or '6').
String get hourOfPeriodLabel {
// TODO(ianh): Localize.
final int hourOfPeriod = this.hourOfPeriod;
if (hourOfPeriod == 0)
return '12';
return hourOfPeriod.toString();
}
/// A string representing the current period (e.g., 'a.m.').
String get periodLabel => period == DayPeriod.am ? 'a.m.' : 'p.m.'; // TODO(ianh): Localize.
/// The hour at which the current period starts.
int get periodOffset => period == DayPeriod.am ? 0 : _kHoursPerPeriod;
@override
bool operator ==(dynamic other) {
if (other is! TimeOfDay)
return false;
final TimeOfDay typedOther = other;
return typedOther.hour == hour
&& typedOther.minute == minute;
}
@override
int get hashCode => hashValues(hour, minute);
// TODO(ianh): Localize.
@override
String toString() => '$hourOfPeriodLabel:$minuteLabel $periodLabel';
}
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;
const double _kPeriodGap = 8.0;
enum _TimePickerHeaderId {
hour,
colon,
minute,
period, // AM/PM picker
}
class _TimePickerHeaderLayout extends MultiChildLayoutDelegate {
_TimePickerHeaderLayout(this.orientation);
final Orientation orientation;
@override
void performLayout(Size size) {
final BoxConstraints constraints = new BoxConstraints.loose(size);
final Size hourSize = layoutChild(_TimePickerHeaderId.hour, constraints);
final Size colonSize = layoutChild(_TimePickerHeaderId.colon, constraints);
final Size minuteSize = layoutChild(_TimePickerHeaderId.minute, constraints);
final Size periodSize = layoutChild(_TimePickerHeaderId.period, constraints);
switch (orientation) {
// 11:57--period
//
// The colon is centered horizontally, the entire layout is centered vertically.
// The "--" is a _kPeriodGap horizontal gap.
case Orientation.portrait:
final double width = colonSize.width / 2.0 + minuteSize.width + _kPeriodGap + periodSize.width;
final double right = math.max(0.0, size.width / 2.0 - width);
double x = size.width - right - periodSize.width;
positionChild(_TimePickerHeaderId.period, new Offset(x, (size.height - periodSize.height) / 2.0));
x -= minuteSize.width + _kPeriodGap;
positionChild(_TimePickerHeaderId.minute, new Offset(x, (size.height - minuteSize.height) / 2.0));
x -= colonSize.width;
positionChild(_TimePickerHeaderId.colon, new Offset(x, (size.height - colonSize.height) / 2.0));
x -= hourSize.width;
positionChild(_TimePickerHeaderId.hour, new Offset(x, (size.height - hourSize.height) / 2.0));
break;
// 11:57
// --
// period
//
// The colon is centered horizontally, the entire layout is centered vertically.
// The "--" is a _kPeriodGap vertical gap.
case Orientation.landscape:
final double width = colonSize.width / 2.0 + minuteSize.width;
final double offset = math.max(0.0, size.width / 2.0 - width);
final double timeHeight = math.max(hourSize.height, colonSize.height);
final double height = timeHeight + _kPeriodGap + periodSize.height;
final double timeCenter = (size.height - height) / 2.0 + timeHeight / 2.0;
double x = size.width - offset - minuteSize.width;
positionChild(_TimePickerHeaderId.minute, new Offset(x, timeCenter - minuteSize.height / 2.0));
x -= colonSize.width;
positionChild(_TimePickerHeaderId.colon, new Offset(x, timeCenter - colonSize.height / 2.0));
x -= hourSize.width;
positionChild(_TimePickerHeaderId.hour, new Offset(x, timeCenter - hourSize.height / 2.0));
x = (size.width - periodSize.width) / 2.0;
positionChild(_TimePickerHeaderId.period, new Offset(x, timeCenter + timeHeight / 2.0 + _kPeriodGap));
break;
}
}
@override
bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation;
}
// TODO(ianh): Localize!
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);
}
void _handleChangeDayPeriod() {
final int newHour = (selectedTime.hour + _kHoursPerPeriod) % _kHoursPerDay;
onChanged(selectedTime.replacing(hour: newHour));
}
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) {
final ThemeData themeData = Theme.of(context);
final TextTheme headerTextTheme = themeData.primaryTextTheme;
final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme);
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;
}
Color backgroundColor;
switch (themeData.brightness) {
case Brightness.light:
backgroundColor = themeData.primaryColor;
break;
case Brightness.dark:
backgroundColor = themeData.backgroundColor;
break;
}
final TextStyle activeStyle = baseHeaderStyle.copyWith(color: activeColor);
final TextStyle inactiveStyle = baseHeaderStyle.copyWith(color: inactiveColor);
final TextStyle hourStyle = mode == _TimePickerMode.hour ? activeStyle : inactiveStyle;
final TextStyle minuteStyle = mode == _TimePickerMode.minute ? activeStyle : inactiveStyle;
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
);
final Widget dayPeriodPicker = new GestureDetector(
onTap: Feedback.wrapForTap(_handleChangeDayPeriod, context),
behavior: HitTestBehavior.opaque,
child: new Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
new Text('AM', style: amStyle),
const SizedBox(width: 0.0, height: 4.0), // Vertical spacer
new Text('PM', style: pmStyle),
]
)
);
final Widget hour = new GestureDetector(
onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.hour), context),
child: new Text(selectedTime.hourOfPeriodLabel, style: hourStyle),
);
final Widget minute = new GestureDetector(
onTap: Feedback.wrapForTap(() => _handleChangeMode(_TimePickerMode.minute), context),
child: new Text(selectedTime.minuteLabel, style: minuteStyle),
);
final Widget colon = new Text(':', style: inactiveStyle);
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;
}
return new Container(
width: width,
height: height,
padding: padding,
color: backgroundColor,
child: new CustomMultiChildLayout(
delegate: new _TimePickerHeaderLayout(orientation),
children: <Widget>[
new LayoutId(id: _TimePickerHeaderId.hour, child: hour),
new LayoutId(id: _TimePickerHeaderId.colon, child: colon),
new LayoutId(id: _TimePickerHeaderId.minute, child: minute),
new LayoutId(id: _TimePickerHeaderId.period, child: dayPeriodPicker),
],
)
);
}
}
List<TextPainter> _initPainters(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)
)..layout();
}
return painters;
}
List<TextPainter> _initHours(TextTheme textTheme) {
return _initPainters(textTheme, <String>[
'12', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'
]);
}
List<TextPainter> _initMinutes(TextTheme textTheme) {
return _initPainters(textTheme, <String>[
'00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'
]);
}
class _DialPainter extends CustomPainter {
const _DialPainter({
this.primaryLabels,
this.secondaryLabels,
this.backgroundColor,
this.accentColor,
this.theta
});
final List<TextPainter> primaryLabels;
final List<TextPainter> secondaryLabels;
final Color backgroundColor;
final Color accentColor;
final double theta;
@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 labelRadius = radius - labelPadding;
Offset getOffsetForTheta(double theta) {
return center + new Offset(labelRadius * math.cos(theta),
-labelRadius * math.sin(theta));
}
void paintLabels(List<TextPainter> labels) {
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) + labelOffset);
labelTheta += labelThetaIncrement;
}
}
paintLabels(primaryLabels);
final Paint selectorPaint = new Paint()
..color = accentColor;
final Offset focusedPoint = getOffsetForTheta(theta);
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(secondaryLabels);
canvas.restore();
}
@override
bool shouldRepaint(_DialPainter oldPainter) {
return oldPainter.primaryLabels != primaryLabels
|| oldPainter.secondaryLabels != secondaryLabels
|| oldPainter.backgroundColor != backgroundColor
|| oldPainter.accentColor != accentColor
|| oldPainter.theta != theta;
}
}
class _Dial extends StatefulWidget {
const _Dial({
@required this.selectedTime,
@required this.mode,
@required this.onChanged
}) : assert(selectedTime != null);
final TimeOfDay selectedTime;
final _TimePickerMode mode;
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(() { }));
}
@override
void didUpdateWidget(_Dial oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.mode != oldWidget.mode && !_dragging)
_animateTo(_getThetaForTime(widget.selectedTime));
}
@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 / _kHoursPerPeriod) % _kHoursPerPeriod :
(time.minute / _kMinutesPerHour) % _kMinutesPerHour;
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) {
final int hourOfPeriod = (fraction * _kHoursPerPeriod).round() % _kHoursPerPeriod;
return widget.selectedTime.replacing(
hour: hourOfPeriod + widget.selectedTime.periodOffset
);
} else {
return widget.selectedTime.replacing(
minute: (fraction * _kMinutesPerHour).round() % _kMinutesPerHour
);
}
}
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.
});
}
Offset _position;
Offset _center;
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));
}
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(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> primaryLabels;
List<TextPainter> secondaryLabels;
switch (widget.mode) {
case _TimePickerMode.hour:
primaryLabels = _initHours(theme.textTheme);
secondaryLabels = _initHours(theme.accentTextTheme);
break;
case _TimePickerMode.minute:
primaryLabels = _initMinutes(theme.textTheme);
secondaryLabels = _initMinutes(theme.accentTextTheme);
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(
primaryLabels: primaryLabels,
secondaryLabels: secondaryLabels,
backgroundColor: backgroundColor,
accentColor: themeData.accentColor,
theta: _theta.value
)
)
);
}
}
class _TimePickerDialog extends StatefulWidget {
const _TimePickerDialog({
Key key,
@required this.initialTime
}) : assert(initialTime != null),
super(key: key);
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) {
final Widget picker = new Padding(
padding: const EdgeInsets.all(16.0),
child: new AspectRatio(
aspectRatio: 1.0,
child: new _Dial(
mode: _mode,
selectedTime: _selectedTime,
onChanged: _handleTimeChanged,
)
)
);
final Widget actions = new ButtonTheme.bar(
child: new ButtonBar(
children: <Widget>[
new FlatButton(
child: const Text('CANCEL'),
onPressed: _handleCancel
),
new FlatButton(
child: const Text('OK'),
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
/// );
/// ```
///
/// 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(
context: context,
child: new _TimePickerDialog(initialTime: initialTime)
);
}