From 717b921194b3362aabbee4dbbfd8e82b9db3e4aa Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Tue, 24 Nov 2015 01:36:37 -0800 Subject: [PATCH] Teach the TimerPicker how to pick a time The TimePicker can now actually pick a time. However, it doesn't understand AM or PM and there's lots of visual polish missing. --- .../flutter/lib/src/material/date_picker.dart | 6 +- .../flutter/lib/src/material/time_picker.dart | 232 +++++++++++++++++- .../lib/src/material/time_picker_dialog.dart | 9 +- 3 files changed, 234 insertions(+), 13 deletions(-) diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index 38c0e54984..92e9eb191c 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -107,9 +107,9 @@ class _DatePickerHeader extends StatelessComponent { assert(mode != null); } - DateTime selectedDate; - _DatePickerMode mode; - ValueChanged<_DatePickerMode> onModeChanged; + final DateTime selectedDate; + final _DatePickerMode mode; + final ValueChanged<_DatePickerMode> onModeChanged; void _handleChangeMode(_DatePickerMode value) { if (value != mode) diff --git a/packages/flutter/lib/src/material/time_picker.dart b/packages/flutter/lib/src/material/time_picker.dart index 71e1b0dd7a..7b012621d9 100644 --- a/packages/flutter/lib/src/material/time_picker.dart +++ b/packages/flutter/lib/src/material/time_picker.dart @@ -2,6 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -12,11 +16,32 @@ import 'typography.dart'; class TimeOfDay { const TimeOfDay({ this.hour, this.minute }); + TimeOfDay replacing({ int hour, int minute }) { + 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; + + bool operator ==(dynamic other) { + if (other is! TimeOfDay) + return false; + final TimeOfDay typedOther = other; + return typedOther.hour == hour + && typedOther.minute == minute; + } + + int get hashCode { + int value = 373; + value = 37 * value + hour.hashCode; + value = 37 * value + minute.hashCode; + return value; + } + + String toString() => 'TimeOfDay(hour: $hour, minute: $minute)'; } enum _TimePickerMode { hour, minute } @@ -57,15 +82,15 @@ class _TimePickerState extends State { aspectRatio: 1.0, child: new Container( margin: const EdgeDims.all(12.0), - decoration: new BoxDecoration( - backgroundColor: Colors.grey[300], - shape: Shape.circle + child: new _Dial( + mode: _mode, + selectedTime: config.selectedTime, + onChanged: config.onChanged ) ) ) ], alignItems: FlexAlignItems.stretch); } - } // Shows the selected date in large font and toggles between year and day mode @@ -75,9 +100,9 @@ class _TimePickerHeader extends StatelessComponent { assert(mode != null); } - TimeOfDay selectedTime; - _TimePickerMode mode; - ValueChanged<_TimePickerMode> onModeChanged; + final TimeOfDay selectedTime; + final _TimePickerMode mode; + final ValueChanged<_TimePickerMode> onModeChanged; void _handleChangeMode(_TimePickerMode value) { if (value != mode) @@ -100,8 +125,8 @@ class _TimePickerHeader extends StatelessComponent { inactiveColor = Colors.white70; break; } - TextStyle activeStyle = headerTheme.display3.copyWith(color: activeColor, height: 1.0); - TextStyle inactiveStyle = headerTheme.display3.copyWith(color: inactiveColor, height: 1.0); + TextStyle activeStyle = headerTheme.display3.copyWith(color: activeColor); + TextStyle inactiveStyle = headerTheme.display3.copyWith(color: inactiveColor); TextStyle hourStyle = mode == _TimePickerMode.hour ? activeStyle : inactiveStyle; TextStyle minuteStyle = mode == _TimePickerMode.minute ? activeStyle : inactiveStyle; @@ -123,3 +148,192 @@ class _TimePickerHeader extends StatelessComponent { ); } } + +final List _kHours = _initHours(); +final List _kMinutes = _initMinutes(); + +List _initPainters(List labels) { + TextStyle style = Typography.black.subhead.copyWith(height: 1.0); + List painters = new List(labels.length); + for (int i = 0; i < painters.length; ++i) { + String label = labels[i]; + TextPainter painter = new TextPainter( + new StyledTextSpan(style, [ + new PlainTextSpan(label) + ]) + ); + painter + ..maxWidth = double.INFINITY + ..maxHeight = double.INFINITY + ..layout() + ..maxWidth = painter.maxIntrinsicWidth + ..layout(); + painters[i] = painter; + } + return painters; +} + +List _initHours() { + return _initPainters(['12', '1', '2', '3', '4', '5', + '6', '7', '8', '9', '10', '11']); +} + +List _initMinutes() { + return _initPainters(['00', '05', '10', '15', '20', '25', + '30', '35', '40', '45', '50', '55']); +} + +class _DialPainter extends CustomPainter { + const _DialPainter({ + this.labels, + this.primaryColor, + this.theta + }); + + final List labels; + final Color primaryColor; + final double theta; + + void paint(Canvas canvas, Size size) { + double radius = size.shortestSide / 2.0; + Offset center = new Offset(size.width / 2.0, size.height / 2.0); + Point centerPoint = center.toPoint(); + canvas.drawCircle(centerPoint, radius, new Paint()..color = Colors.grey[200]); + + const double labelPadding = 24.0; + double labelRadius = radius - labelPadding; + Offset getOffsetForTheta(double theta) { + return center + new Offset(labelRadius * math.cos(theta), + -labelRadius * math.sin(theta)); + } + + Paint primaryPaint = new Paint() + ..color = primaryColor; + Point currentPoint = getOffsetForTheta(theta).toPoint(); + canvas.drawCircle(centerPoint, 4.0, primaryPaint); + canvas.drawCircle(currentPoint, labelPadding - 4.0, primaryPaint); + primaryPaint.strokeWidth = 2.0; + canvas.drawLine(centerPoint, currentPoint, primaryPaint); + + double labelThetaIncrement = -2 * math.PI / _kHours.length; + double labelTheta = math.PI / 2.0; + + for (TextPainter label in labels) { + Offset labelOffset = new Offset(-label.width / 2.0, -label.height / 2.0); + label.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset); + labelTheta += labelThetaIncrement; + } + } + + bool shouldRepaint(_DialPainter oldPainter) { + return oldPainter.labels != labels + || oldPainter.primaryColor != primaryColor + || oldPainter.theta != theta; + } +} + +class _Dial extends StatefulComponent { + _Dial({ + this.selectedTime, + this.mode, + this.onChanged + }) { + assert(selectedTime != null); + } + + final TimeOfDay selectedTime; + final _TimePickerMode mode; + final ValueChanged onChanged; + + _DialState createState() => new _DialState(); +} + +class _DialState extends State<_Dial> { + double _theta; + + void initState() { + super.initState(); + _theta = _getThetaForTime(config.selectedTime); + } + + void didUpdateConfig(_Dial oldConfig) { + if (config.mode != oldConfig.mode) + _theta = _getThetaForTime(config.selectedTime); + } + + double _getThetaForTime(TimeOfDay time) { + double fraction = (config.mode == _TimePickerMode.hour) ? + (time.hour / 12) % 12 : (time.minute / 60) % 60; + return math.PI / 2.0 - fraction * 2 * math.PI; + } + + TimeOfDay _getTimeForTheta(double theta) { + double fraction = (0.25 - (theta % (2 * math.PI)) / (2 * math.PI)) % 1.0; + if (config.mode == _TimePickerMode.hour) { + return config.selectedTime.replacing( + hour: (fraction * 12).round() + ); + } else { + return config.selectedTime.replacing( + minute: (fraction * 60).round() + ); + } + } + + void _notifyOnChangedIfNeeded() { + if (config.onChanged == null) + return; + TimeOfDay current = _getTimeForTheta(_theta); + if (current != config.selectedTime) + config.onChanged(current); + } + + void _updateThetaForPan() { + setState(() { + Offset offset = _position - _center; + _theta = (math.atan2(offset.dx, offset.dy) - math.PI / 2.0) % (2 * math.PI); + }); + } + + Point _position; + Point _center; + + void _handlePanStart(Point globalPosition) { + RenderBox box = context.findRenderObject(); + _position = box.globalToLocal(globalPosition); + double radius = box.size.shortestSide / 2.0; + _center = new Point(radius, radius); + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanUpdate(Offset delta) { + _position += delta; + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanEnd(Offset velocity) { + _position = null; + _center = null; + setState(() { + // TODO(abarth): Animate to the final value. + _theta = _getThetaForTime(config.selectedTime); + }); + } + + Widget build(BuildContext context) { + return new GestureDetector( + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + child: new CustomPaint( + painter: new _DialPainter( + labels: config.mode == _TimePickerMode.hour ? _kHours : _kMinutes, + primaryColor: Theme.of(context).primaryColor, + theta: _theta + ) + ) + ); + } +} diff --git a/packages/flutter/lib/src/material/time_picker_dialog.dart b/packages/flutter/lib/src/material/time_picker_dialog.dart index 54dabb8e8e..b471d3e8dd 100644 --- a/packages/flutter/lib/src/material/time_picker_dialog.dart +++ b/packages/flutter/lib/src/material/time_picker_dialog.dart @@ -29,6 +29,12 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { TimeOfDay _selectedTime; + void _handleTimeChanged(TimeOfDay value) { + setState(() { + _selectedTime = value; + }); + } + void _handleCancel() { Navigator.of(context).pop(); } @@ -40,7 +46,8 @@ class _TimePickerDialogState extends State<_TimePickerDialog> { Widget build(BuildContext context) { return new Dialog( content: new TimePicker( - selectedTime: _selectedTime + selectedTime: _selectedTime, + onChanged: _handleTimeChanged ), contentPadding: EdgeDims.zero, actions: [