//Forked from https://github.com/cdharris/flutter_duration_picker //Copyright https://github.com/cdharris //License MIT https://github.com/cdharris/flutter_duration_picker/blob/master/LICENSE 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'; const Duration _kDialAnimateDuration = Duration(milliseconds: 200); const double _kDurationPickerWidthPortrait = 328.0; const double _kDurationPickerWidthLandscape = 512.0; const double _kDurationPickerHeightPortrait = 380.0; const double _kDurationPickerHeightLandscape = 304.0; const double _kTwoPi = 2 * math.pi; const double _kPiByTwo = math.pi / 2; const double _kCircleTop = _kPiByTwo; class _DialPainter extends CustomPainter { const _DialPainter({ @required this.context, @required this.labels, @required this.backgroundColor, @required this.accentColor, @required this.theta, @required this.textDirection, @required this.selectedValue, @required this.pct, @required this.multiplier, @required this.secondHand, }); final List labels; final Color backgroundColor; final Color accentColor; final double theta; final TextDirection textDirection; final int selectedValue; final BuildContext context; final double pct; final int multiplier; final int secondHand; @override void paint(Canvas canvas, Size size) { const _epsilon = .001; const _sweep = _kTwoPi - _epsilon; const _startAngle = -math.pi / 2.0; final radius = size.shortestSide / 2.0; final center = Offset(size.width / 2.0, size.height / 2.0); final centerPoint = center; var pctTheta = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; // Draw the background outer ring canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); // Draw a translucent circle for every hour for (var i = 0; i < multiplier; i = i + 1) { canvas.drawCircle(centerPoint, radius, Paint()..color = accentColor.withOpacity((i == 0) ? 0.3 : 0.1)); } // Draw the inner background circle canvas.drawCircle(centerPoint, radius * 0.88, Paint()..color = Theme.of(context).canvasColor); // Get the offset point for an angle value of theta, and a distance of _radius Offset getOffsetForTheta(double theta, double _radius) { return center + Offset(_radius * math.cos(theta), -_radius * math.sin(theta)); } // Draw the handle that is used to drag and to indicate the position around the circle final handlePaint = Paint()..color = accentColor; final handlePoint = getOffsetForTheta(theta, radius - 10.0); canvas.drawCircle(handlePoint, 20.0, handlePaint); // Draw the Text in the center of the circle which displays hours and mins var minutes = (multiplier == 0) ? '' : "${multiplier}min "; // int minutes = (pctTheta * 60).round(); // minutes = minutes == 60 ? 0 : minutes; var seconds = "$secondHand"; var textDurationValuePainter = TextPainter( textAlign: TextAlign.center, text: TextSpan( text: '$minutes$seconds', style: Theme.of(context) .textTheme .headline4 .copyWith(fontSize: size.shortestSide * 0.15)), textDirection: TextDirection.ltr) ..layout(); var middleForValueText = Offset( centerPoint.dx - (textDurationValuePainter.width / 2), centerPoint.dy - textDurationValuePainter.height / 2); textDurationValuePainter.paint(canvas, middleForValueText); var textMinPainter = TextPainter( textAlign: TextAlign.center, text: TextSpan( text: 'sec', //th: ${theta}', style: Theme.of(context).textTheme.bodyText1), textDirection: TextDirection.ltr) ..layout(); textMinPainter.paint( canvas, Offset( centerPoint.dx - (textMinPainter.width / 2), centerPoint.dy + (textDurationValuePainter.height / 2) - textMinPainter.height / 2)); // Draw an arc around the circle for the amount of the circle that has elapsed. var elapsedPainter = Paint() ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..color = accentColor.withOpacity(0.3) ..isAntiAlias = true ..strokeWidth = radius * 0.12; canvas.drawArc( Rect.fromCircle( center: centerPoint, radius: radius - radius * 0.12 / 2, ), _startAngle, _sweep * pctTheta, false, elapsedPainter, ); // Paint the labels (the minute strings) void paintLabels(List labels) { if (labels == null) return; final labelThetaIncrement = -_kTwoPi / labels.length; var labelTheta = _kPiByTwo; for (var label in labels) { final labelOffset = Offset(-label.width / 2.0, -label.height / 2.0); label.paint( canvas, getOffsetForTheta(labelTheta, radius - 40.0) + labelOffset); labelTheta += labelThetaIncrement; } } paintLabels(labels); } @override bool shouldRepaint(_DialPainter oldPainter) { return oldPainter.labels != labels || oldPainter.backgroundColor != backgroundColor || oldPainter.accentColor != accentColor || oldPainter.theta != theta; } } class _Dial extends StatefulWidget { const _Dial( {@required this.duration, @required this.onChanged, this.snapToMins = 1.0}) : assert(duration != null); final Duration duration; final ValueChanged onChanged; /// The resolution of mins of the dial, i.e. if snapToMins = 5.0, only durations of 5min intervals will be selectable. final double snapToMins; @override _DialState createState() => _DialState(); } class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { @override void initState() { super.initState(); _thetaController = AnimationController( duration: _kDialAnimateDuration, vsync: this, ); _thetaTween = Tween(begin: _getThetaForDuration(widget.duration)); _theta = _thetaTween.animate( CurvedAnimation(parent: _thetaController, curve: Curves.fastOutSlowIn)) ..addListener(() => setState(() {})); _thetaController.addStatusListener((status) { // if (status == AnimationStatus.completed && _hours != _snappedHours) { // _hours = _snappedHours; if (status == AnimationStatus.completed) { _minutes = _minuteHand(_turningAngle); _seconds = _secondHand(_turningAngle); setState(() {}); } }); // _hours = widget.duration.inHours; _turningAngle = _kPiByTwo - widget.duration.inSeconds / 60.0 * _kTwoPi; _minutes = _minuteHand(_turningAngle); _seconds = _secondHand(_turningAngle); } 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 dispose() { _thetaController.dispose(); super.dispose(); } Tween _thetaTween; Animation _theta; AnimationController _thetaController; final double _pct = 0.0; int _seconds = 0; bool _dragging = false; int _minutes = 0; double _turningAngle = 0.0; static double _nearest(double target, double a, double b) { return ((target - a).abs() < (target - b).abs()) ? a : b; } void _animateTo(double targetTheta) { final currentTheta = _theta.value; var beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); _thetaTween ..begin = beginTheta ..end = targetTheta; _thetaController ..value = 0.0 ..forward(); } double _getThetaForDuration(Duration duration) { return (_kPiByTwo - (duration.inSeconds % 60) / 60.0 * _kTwoPi) % _kTwoPi; } Duration _getTimeForTheta(double theta) { return _angleToDuration(_turningAngle); } Duration _notifyOnChangedIfNeeded() { // final Duration current = _getTimeForTheta(_theta.value); // var d = Duration(hours: _hours, minutes: current.inMinutes % 60); _minutes = _minuteHand(_turningAngle); _seconds = _secondHand(_turningAngle); var d = _angleToDuration(_turningAngle); widget.onChanged(d); return d; } void _updateThetaForPan() { setState(() { final offset = _position - _center; final angle = (math.atan2(offset.dx, offset.dy) - _kPiByTwo) % _kTwoPi; // Stop accidental abrupt pans from making the dial seem like it starts from 1h. // (happens when wanting to pan from 0 clockwise, but when doing so quickly, one actually pans from before 0 (e.g. setting the duration to 59mins, and then crossing 0, which would then mean 1h 1min). if (angle >= _kCircleTop && _theta.value <= _kCircleTop && _theta.value >= 0.1 && // to allow the radians sign change at 15mins. _minutes == 0) return; _thetaTween ..begin = angle ..end = angle; }); } 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) { var oldTheta = _theta.value; _position += details.delta; _updateThetaForPan(); var newTheta = _theta.value; // _updateRotations(oldTheta, newTheta); _updateTurningAngle(oldTheta, newTheta); _notifyOnChangedIfNeeded(); } int _minuteHand(double angle) { return _angleToDuration(angle).inMinutes.toInt(); } int _secondHand(double angle) { // Result is in [0; 59], even if overall time is >= 1 hour return (_angleToSeconds(angle) % 60.0).toInt(); } Duration _angleToDuration(double angle) { return _secondToDuration(_angleToSeconds(angle)); } Duration _secondToDuration(seconds) { return Duration( minutes: (seconds ~/ 60).toInt(), seconds: (seconds % 60.0).toInt()); } double _angleToSeconds(double angle) { // Coordinate transformation from mathematical COS to dial COS var dialAngle = _kPiByTwo - angle; // Turn dial angle into minutes, may go beyond 60 minutes (multiple turns) return dialAngle / _kTwoPi * 60.0; } void _updateTurningAngle(double oldTheta, double newTheta) { // Register any angle by which the user has turned the dial. // // The resulting turning angle fully captures the state of the dial, // including multiple turns (= full hours). The [_turningAngle] is in // mathematical coordinate system, i.e. 3-o-clock position being zero, and // increasing counter clock wise. // From positive to negative (in mathematical COS) if (newTheta > 1.5 * math.pi && oldTheta < 0.5 * math.pi) { _turningAngle = _turningAngle - ((_kTwoPi - newTheta) + oldTheta); } // From negative to positive (in mathematical COS) else if (newTheta < 0.5 * math.pi && oldTheta > 1.5 * math.pi) { _turningAngle = _turningAngle + ((_kTwoPi - oldTheta) + newTheta); } else { _turningAngle = _turningAngle + (newTheta - oldTheta); } } void _handlePanEnd(DragEndDetails details) { assert(_dragging); _dragging = false; _position = null; _center = null; //_notifyOnChangedIfNeeded(); //_animateTo(_getThetaForDuration(widget.duration)); } void _handleTapUp(TapUpDetails details) { final RenderBox box = context.findRenderObject(); _position = box.globalToLocal(details.globalPosition); _center = box.size.center(Offset.zero); _updateThetaForPan(); _notifyOnChangedIfNeeded(); _animateTo(_getThetaForDuration(_getTimeForTheta(_theta.value))); _dragging = false; _position = null; _center = null; } List _buildSeconds(TextTheme textTheme) { final style = textTheme.subtitle1; const _secondsMarkerValues = [ Duration(seconds: 0), Duration(seconds: 5), Duration(seconds: 10), Duration(seconds: 15), Duration(seconds: 20), Duration(seconds: 25), Duration(seconds: 30), Duration(seconds: 35), Duration(seconds: 40), Duration(seconds: 45), Duration(seconds: 50), Duration(seconds: 55), ]; final labels = []; for (var duration in _secondsMarkerValues) { var painter = TextPainter( text: TextSpan(style: style, text: duration.inSeconds.toString()), textDirection: TextDirection.ltr, )..layout(); labels.add(painter); } return labels; } @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 theme = Theme.of(context); int selectedDialValue; _minutes = _minuteHand(_turningAngle); _seconds = _secondHand(_turningAngle); return GestureDetector( excludeFromSemantics: true, onPanStart: _handlePanStart, onPanUpdate: _handlePanUpdate, onPanEnd: _handlePanEnd, onTapUp: _handleTapUp, child: CustomPaint( painter: _DialPainter( pct: _pct, multiplier: _minutes, secondHand: _seconds, context: context, selectedValue: selectedDialValue, labels: _buildSeconds(theme.textTheme), backgroundColor: backgroundColor, accentColor: themeData.accentColor, theta: _theta.value, textDirection: Directionality.of(context), ), )); } } /// A duration picker designed to appear inside a popup dialog. /// /// Pass this widget to [showDialog]. The value returned by [showDialog] is the /// selected [Duration] 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 _DurationPickerDialog extends StatefulWidget { /// Creates a duration picker. /// /// [initialTime] must not be null. const _DurationPickerDialog( {Key key, @required this.initialTime, this.snapToMins}) : assert(initialTime != null), super(key: key); /// The duration initially selected when the dialog is shown. final Duration initialTime; final double snapToMins; @override _DurationPickerDialogState createState() => _DurationPickerDialogState(); } class _DurationPickerDialogState extends State<_DurationPickerDialog> { @override void initState() { super.initState(); _selectedDuration = widget.initialTime; } @override void didChangeDependencies() { super.didChangeDependencies(); localizations = MaterialLocalizations.of(context); } Duration get selectedDuration => _selectedDuration; Duration _selectedDuration; MaterialLocalizations localizations; void _handleTimeChanged(Duration value) { setState(() { _selectedDuration = value; }); } void _handleCancel() { Navigator.pop(context); } void _handleOk() { Navigator.pop(context, _selectedDuration); } @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); final theme = Theme.of(context); final Widget picker = Padding( padding: const EdgeInsets.all(16.0), child: AspectRatio( aspectRatio: 1.0, child: _Dial( duration: _selectedDuration, onChanged: _handleTimeChanged, snapToMins: widget.snapToMins, ))); final Widget actions = ButtonBar(children: [ FlatButton( child: Text(localizations.cancelButtonLabel), onPressed: _handleCancel), FlatButton( child: Text(localizations.okButtonLabel), onPressed: _handleOk), ]); final dialog = Dialog(child: OrientationBuilder(builder: (context, orientation) { final Widget pickerAndActions = Container( color: theme.dialogBackgroundColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: picker), // picker grows and shrinks with the available space actions, ], ), ); assert(orientation != null); switch (orientation) { case Orientation.portrait: return SizedBox( width: _kDurationPickerWidthPortrait, height: _kDurationPickerHeightPortrait, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: pickerAndActions, ), ])); case Orientation.landscape: return SizedBox( width: _kDurationPickerWidthLandscape, height: _kDurationPickerHeightLandscape, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Flexible( child: pickerAndActions, ), ])); } return null; })); return Theme( data: theme.copyWith( dialogBackgroundColor: Colors.transparent, ), child: dialog, ); } @override void dispose() { super.dispose(); } } /// Shows a dialog containing the duration picker. /// /// The returned Future resolves to the duration 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 /// showDurationPicker( /// initialTime: new Duration.now(), /// context: context, /// ); /// ``` Future showDurationPicker( {@required BuildContext context, @required Duration initialTime, double snapToMins}) async { assert(context != null); assert(initialTime != null); return await showDialog( context: context, builder: (context) => _DurationPickerDialog(initialTime: initialTime, snapToMins: snapToMins), ); } class DurationPicker extends StatelessWidget { final Duration duration; final ValueChanged onChange; final double snapToMins; final double width; final double height; DurationPicker( {this.duration = const Duration(minutes: 0), @required this.onChange, this.snapToMins, this.width, this.height, Key key}) : super(key: key); @override Widget build(BuildContext context) { return SizedBox( width: width ?? _kDurationPickerWidthPortrait / 1.5, height: height ?? _kDurationPickerHeightPortrait / 1.5, child: _Dial( duration: duration, onChanged: onChange, snapToMins: snapToMins, ), ); } }