Last active
June 7, 2024 04:29
-
-
Save ltvu93/1999c0d21791146750fd0de3a3482885 to your computer and use it in GitHub Desktop.
Customize base on progressive_time_picker package.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/gestures.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:intl/intl.dart' as intl; | |
void main() { | |
runApp(MaterialApp(home: SleepTestScreen())); | |
} | |
class SleepTestScreen extends StatefulWidget { | |
@override | |
State<SleepTestScreen> createState() => _SleepTestScreenState(); | |
} | |
class _SleepTestScreenState extends State<SleepTestScreen> { | |
DateTime _startTime = DateTime.now().startOfDay.copyWith(hour: 23); | |
DateTime _endTime = DateTime.now().startOfDay.copyWith(hour: 7); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
body: SafeArea( | |
child: SingleChildScrollView( | |
padding: EdgeInsets.all(48), | |
child: Column( | |
children: [ | |
SleepTimeCirclePicker( | |
startTime: _startTime, | |
endTime: _endTime, | |
onTimeChanged: (startTime, endTime) { | |
setState(() { | |
_startTime = startTime; | |
_endTime = endTime; | |
}); | |
}, | |
), | |
], | |
), | |
), | |
), | |
); | |
} | |
} | |
class SleepTimeCirclePicker extends StatefulWidget { | |
final DateTime startTime; | |
final DateTime endTime; | |
final Duration minDuration; | |
final Duration maxDuration; | |
final Function(DateTime, DateTime)? onTimeChanged; | |
SleepTimeCirclePicker({ | |
super.key, | |
required this.startTime, | |
required this.endTime, | |
this.minDuration = const Duration(hours: 1), | |
this.maxDuration = const Duration(hours: 20), | |
this.onTimeChanged, | |
}); | |
@override | |
State<SleepTimeCirclePicker> createState() => _SleepTimeCirclePickerState(); | |
} | |
class _SleepTimeCirclePickerState extends State<SleepTimeCirclePicker> { | |
static int timeStepInMinutes = 5; | |
static int minutesDivisions = 60 ~/ timeStepInMinutes; | |
static int hoursDivisions = minutesDivisions * 24; | |
int _start = 0; | |
int _end = 0; | |
Duration get sleepDuration => | |
_getDurationDiff(widget.startTime, widget.endTime); | |
@override | |
void initState() { | |
super.initState(); | |
_calculateData(); | |
} | |
void _calculateData() { | |
_start = _timeToDivision(widget.startTime); | |
_end = _timeToDivision(widget.endTime); | |
} | |
@override | |
void didUpdateWidget(covariant SleepTimeCirclePicker oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.startTime == widget.startTime && | |
oldWidget.endTime == widget.endTime) { | |
return; | |
} | |
_calculateData(); | |
setState(() {}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
children: [ | |
Row( | |
children: [ | |
_buildTimeSection(title: 'Start time', time: widget.startTime), | |
_buildTimeSection(title: 'End time', time: widget.endTime), | |
], | |
), | |
SizedBox(height: 16), | |
_CircleClockSlider( | |
divisions: hoursDivisions, | |
start: _start, | |
end: _end, | |
min: _durationToDivision(widget.minDuration), | |
max: _durationToDivision(widget.maxDuration), | |
onValuesChanged: (start, end) { | |
if (_start != start || _end != end) { | |
HapticFeedback.lightImpact(); | |
widget.onTimeChanged | |
?.call(_divisionToTime(start), _divisionToTime(end)); | |
} | |
}, | |
progressBackgroundColor: Colors.grey, | |
progressColor: Colors.orange, | |
startHandlerIcon: Icons.bedtime_outlined, | |
endHandlerIcon: Icons.alarm, | |
handlerIconColor: Colors.white, | |
child: Center( | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
Text('Duration'), | |
Text(_getSleepTotalString(), textAlign: TextAlign.center), | |
], | |
), | |
), | |
), | |
], | |
); | |
} | |
Widget _buildTimeSection({required String title, required DateTime time}) { | |
return Expanded( | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text(title), | |
Text(intl.DateFormat(intl.DateFormat.HOUR24_MINUTE).format(time)), | |
], | |
), | |
); | |
} | |
String _getSleepTotalString() { | |
final hours = sleepDuration.inHours; | |
final minutes = sleepDuration.inMinutes - (sleepDuration.inHours * 60); | |
if (minutes == 0) { | |
return '${hours}h'; | |
} | |
return '${hours}h ${minutes}m'; | |
} | |
int _timeToDivision(DateTime time) { | |
final hours = (time.hour * hoursDivisions / 24).round() % hoursDivisions; | |
final minutes = | |
(time.minute * minutesDivisions / 60).round() % minutesDivisions; | |
return (hours + minutes); | |
} | |
DateTime _divisionToTime(int value) { | |
if (value == 0 || value == hoursDivisions) { | |
return widget.startTime.startOfDay.copyWith(hour: 0, minute: 0); | |
} | |
final hours = value ~/ minutesDivisions; | |
final minutes = (value % minutesDivisions) * timeStepInMinutes; | |
return widget.startTime.startOfDay.copyWith(hour: hours, minute: minutes); | |
} | |
int _durationToDivision(Duration duration) { | |
return duration.inMinutes ~/ timeStepInMinutes; | |
} | |
Duration _getDurationDiff(DateTime startTime, DateTime endTime) { | |
final diff = endTime.difference(startTime); | |
int diffInMinutes = diff.inMinutes; | |
if (diffInMinutes < 0) { | |
diffInMinutes = 24 * 60 - diff.inMinutes.abs(); | |
} | |
return Duration(minutes: diffInMinutes); | |
} | |
} | |
const _adjustAngleInRadians = -pi / 2; | |
class _CircleClockSlider extends StatefulWidget { | |
final int divisions; | |
final double handlerWidth; | |
final int start; | |
final int end; | |
final int min; | |
final int max; | |
final Function(int, int)? onValuesChanged; | |
final Color progressBackgroundColor; | |
final Color progressColor; | |
final IconData startHandlerIcon; | |
final IconData endHandlerIcon; | |
final Color handlerIconColor; | |
final Widget? child; | |
const _CircleClockSlider({ | |
required this.divisions, | |
this.handlerWidth = 40, | |
required this.start, | |
required this.end, | |
required this.min, | |
required this.max, | |
this.onValuesChanged, | |
required this.progressBackgroundColor, | |
required this.progressColor, | |
required this.startHandlerIcon, | |
required this.endHandlerIcon, | |
required this.handlerIconColor, | |
this.child, | |
}); | |
@override | |
State<_CircleClockSlider> createState() => _CircleClockSliderState(); | |
} | |
class _CircleClockSliderState extends State<_CircleClockSlider> { | |
double _startAngleInRadian = 0; | |
double _endAngleInRadian = 0; | |
double _sweepAngleInRadian = 0; | |
Offset center = Offset.zero; | |
double radius = 0; | |
bool _isStartHandlerSelected = false; | |
bool _isEndHandlerSelected = false; | |
int _diffFromStart = 0; | |
Offset get startPos => _radiansToCoordinates( | |
center, | |
radius - widget.handlerWidth / 2, | |
_adjustAngleInRadians + _startAngleInRadian); | |
Offset get endPos => _radiansToCoordinates( | |
center, | |
radius - widget.handlerWidth / 2, | |
_adjustAngleInRadians + _endAngleInRadian); | |
@override | |
void initState() { | |
super.initState(); | |
_calculateData(); | |
} | |
@override | |
void didUpdateWidget(covariant _CircleClockSlider oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.divisions != widget.divisions || | |
oldWidget.start != widget.start || | |
oldWidget.end != widget.end) { | |
_calculateData(); | |
setState(() {}); | |
return; | |
} | |
if (oldWidget.child != widget.child || | |
oldWidget.handlerWidth != widget.handlerWidth) { | |
setState(() {}); | |
} | |
} | |
void _calculateData() { | |
final startPercent = _valueToPercentage(widget.start, widget.divisions); | |
final endPercent = _valueToPercentage(widget.end, widget.divisions); | |
final sweep = _getSweepAngle(startPercent, endPercent); | |
_startAngleInRadian = _percentageToRadians(startPercent); | |
_endAngleInRadian = _percentageToRadians(endPercent); | |
_sweepAngleInRadian = _percentageToRadians(sweep.abs()); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AspectRatio( | |
aspectRatio: 1, | |
child: LayoutBuilder(builder: (context, constraints) { | |
final width = constraints.maxWidth; | |
final height = constraints.maxHeight; | |
final size = min(width, height); | |
center = Offset(width / 2, height / 2); | |
radius = size / 2; | |
return RawGestureDetector( | |
gestures: <Type, GestureRecognizerFactory>{ | |
_CustomPanGestureRecognizer: GestureRecognizerFactoryWithHandlers< | |
_CustomPanGestureRecognizer>( | |
() => _CustomPanGestureRecognizer( | |
onPanDown: _onPanDown, | |
onPanUpdate: _onPanUpdate, | |
onPanEnd: _onPanEnd, | |
), | |
(_CustomPanGestureRecognizer instance) {}, | |
), | |
}, | |
child: SizedBox( | |
width: size, | |
height: size, | |
child: Stack( | |
children: [ | |
Positioned.fill( | |
child: CustomPaint( | |
painter: _ClockSliderPainter( | |
startAngle: _startAngleInRadian, | |
sweepAngle: _sweepAngleInRadian, | |
handlerWidth: widget.handlerWidth, | |
backgroundColor: widget.progressBackgroundColor, | |
progressColor: widget.progressColor, | |
clockTextStyle: TextStyle(color: Colors.black), | |
clockTickColor: Colors.black, | |
), | |
child: Center( | |
child: ConstrainedBox( | |
constraints: BoxConstraints.expand( | |
width: size * 0.6, height: size * 0.6), | |
child: widget.child, | |
), | |
), | |
), | |
), | |
_buildHandler(pos: startPos, icon: widget.startHandlerIcon), | |
_buildHandler(pos: endPos, icon: widget.endHandlerIcon), | |
], | |
), | |
), | |
); | |
}), | |
); | |
} | |
Widget _buildHandler({required Offset pos, required IconData icon}) { | |
return Positioned( | |
left: max(pos.dx - widget.handlerWidth / 2, 0), | |
top: max(pos.dy - widget.handlerWidth / 2, 0), | |
child: SizedBox( | |
width: widget.handlerWidth, | |
height: widget.handlerWidth, | |
child: Container( | |
margin: EdgeInsets.all(4), | |
decoration: BoxDecoration( | |
shape: BoxShape.circle, | |
color: Colors.black, | |
), | |
child: Center( | |
child: SizedBox( | |
width: 0.6 * widget.handlerWidth, | |
height: 0.6 * widget.handlerWidth, | |
child: Icon( | |
icon, | |
color: widget.handlerIconColor, | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
bool _onPanDown(Offset globalPosition) { | |
final renderBox = context.findRenderObject() as RenderBox?; | |
final position = renderBox?.globalToLocal(globalPosition); | |
if (position == null) return false; | |
_isStartHandlerSelected = | |
_isPointInsideCircle(position, startPos, widget.handlerWidth / 2); | |
if (_isStartHandlerSelected) return true; | |
_isEndHandlerSelected = | |
_isPointInsideCircle(position, endPos, widget.handlerWidth / 2); | |
if (_isEndHandlerSelected) return true; | |
if (_isPointAlongCircle(position, center, radius, widget.handlerWidth)) { | |
final angle = _coordinatesToRadians(center, position); | |
if (_isAngleInsideRadiansSelection( | |
angle, _startAngleInRadian, _sweepAngleInRadian)) { | |
_isEndHandlerSelected = true; | |
_isStartHandlerSelected = true; | |
final positionPercentage = _radiansToPercentage(angle); | |
_diffFromStart = | |
_percentageToValue(positionPercentage, widget.divisions) - | |
widget.start; | |
return true; | |
} | |
} | |
return false; | |
} | |
void _onPanUpdate(Offset globalPosition) { | |
if (!_isStartHandlerSelected && !_isEndHandlerSelected) return; | |
final renderBox = context.findRenderObject() as RenderBox?; | |
final position = renderBox?.globalToLocal(globalPosition); | |
if (position == null) return; | |
final angleInRadian = _coordinatesToRadians(center, position); | |
final percentage = _radiansToPercentage(angleInRadian); | |
final newValue = _percentageToValue(percentage, widget.divisions); | |
if (_isStartHandlerSelected && _isEndHandlerSelected) { | |
final newStart = (newValue - _diffFromStart) % widget.divisions; | |
final newEnd = | |
(widget.end + (newStart - widget.start)) % widget.divisions; | |
widget.onValuesChanged?.call(newStart, newEnd); | |
return; | |
} | |
if (_isStartHandlerSelected) { | |
int diff = _getDivisionDiff(newValue, widget.end, widget.divisions); | |
if (diff >= widget.max || diff <= widget.min) { | |
diff = max(diff, widget.min); | |
diff = min(diff, widget.max); | |
final newStart = newValue % widget.divisions; | |
final newEnd = (newStart + diff) % widget.divisions; | |
widget.onValuesChanged?.call(newStart, newEnd); | |
return; | |
} | |
widget.onValuesChanged?.call(newValue, widget.end); | |
return; | |
} | |
if (_isEndHandlerSelected) { | |
int diff = _getDivisionDiff(widget.start, newValue, widget.divisions); | |
if (diff >= widget.max || diff <= widget.min) { | |
diff = max(diff, widget.min); | |
diff = min(diff, widget.max); | |
final newEnd = newValue % widget.divisions; | |
int newStart = newEnd - diff; | |
if (newStart <= 0) newStart = widget.divisions + newStart; | |
widget.onValuesChanged?.call(newStart, newEnd); | |
return; | |
} | |
widget.onValuesChanged?.call(widget.start, newValue); | |
} | |
} | |
void _onPanEnd(Offset globalPosition) { | |
_isStartHandlerSelected = false; | |
_isEndHandlerSelected = false; | |
_diffFromStart = 0; | |
} | |
} | |
class _ClockSliderPainter extends CustomPainter { | |
final double startAngle; | |
final double sweepAngle; | |
final double handlerWidth; | |
final Color backgroundColor; | |
final Color progressColor; | |
final TextStyle clockTextStyle; | |
final Color clockTickColor; | |
final double clockTickHeight; | |
final double clockTickWidth; | |
final double clockPadding; | |
_ClockSliderPainter({ | |
required this.startAngle, | |
required this.sweepAngle, | |
required this.handlerWidth, | |
required this.backgroundColor, | |
required this.progressColor, | |
required this.clockTextStyle, | |
required this.clockTickColor, | |
this.clockTickHeight = 4, | |
this.clockTickWidth = 0.5, | |
this.clockPadding = 12, | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
final center = Offset(size.width / 2, size.height / 2); | |
final radius = min(size.width / 2, size.height / 2); | |
final backgroundPaint = Paint() | |
..color = backgroundColor | |
..strokeCap = StrokeCap.round | |
..style = PaintingStyle.stroke | |
..strokeWidth = handlerWidth; | |
final progressPaint = Paint() | |
..color = progressColor | |
..strokeCap = StrokeCap.round | |
..style = PaintingStyle.stroke | |
..strokeWidth = handlerWidth; | |
canvas.drawCircle(center, radius - handlerWidth / 2, backgroundPaint); | |
canvas.drawArc( | |
Rect.fromCircle(center: center, radius: radius - handlerWidth / 2), | |
startAngle + _adjustAngleInRadians, | |
sweepAngle, | |
false, | |
progressPaint, | |
); | |
_drawClock(canvas, radius); | |
} | |
void _drawClock(Canvas canvas, double radius) { | |
canvas.save(); | |
canvas.translate(radius, radius); | |
final stepAngleInRadians = pi * 2 / 24; | |
for (int time = 0; time < 24; time++) { | |
if (time % 3 == 0) { | |
canvas.save(); | |
canvas.translate( | |
0.0, -radius + handlerWidth + clockPadding + clockTickHeight * 0.5); | |
canvas.rotate(-stepAngleInRadians * time); | |
final textPainter = TextPainter( | |
text: TextSpan(style: clockTextStyle, text: time.toString()), | |
textAlign: TextAlign.center, | |
textDirection: TextDirection.ltr, | |
); | |
textPainter.layout(); | |
textPainter.paint(canvas, | |
Offset(-(textPainter.width / 2), -(textPainter.height / 2))); | |
canvas.restore(); | |
} else { | |
final clockTickPaint = Paint() | |
..color = clockTickColor | |
..strokeWidth = clockTickWidth; | |
canvas.drawLine( | |
Offset(0, -radius + handlerWidth + clockPadding), | |
Offset(0, -radius + handlerWidth + clockPadding + clockTickHeight), | |
clockTickPaint, | |
); | |
} | |
canvas.rotate(stepAngleInRadians); | |
} | |
canvas.restore(); | |
} | |
@override | |
bool shouldRepaint(CustomPainter oldDelegate) { | |
return true; | |
} | |
} | |
double _percentageToRadians(double percentage) => ((2 * pi * percentage) / 100); | |
double _radiansToPercentage(double radians) { | |
final normalized = radians < 0 ? -radians : 2 * pi - radians; | |
final percentage = ((100 * normalized) / (2 * pi)); | |
// we have an inconsistency of pi/2 in terms of percentage and radians | |
return (percentage + 25) % 100; | |
} | |
double _coordinatesToRadians(Offset center, Offset coords) { | |
final a = coords.dx - center.dx; | |
final b = center.dy - coords.dy; | |
return atan2(b, a); | |
} | |
Offset _radiansToCoordinates(Offset center, double radius, double radians) { | |
final dx = center.dx + radius * cos(radians); | |
final dy = center.dy + radius * sin(radians); | |
return Offset(dx, dy); | |
} | |
double _valueToPercentage(int time, int intervals) => (time / intervals) * 100; | |
int _percentageToValue(double percentage, int intervals) => | |
((percentage * intervals) / 100).round(); | |
bool _isPointInsideCircle(Offset point, Offset center, double radius) { | |
return point.dx < (center.dx + radius) && | |
point.dx > (center.dx - radius) && | |
point.dy < (center.dy + radius) && | |
point.dy > (center.dy - radius); | |
} | |
bool _isPointAlongCircle( | |
Offset point, Offset center, double radius, double width) { | |
// distance is root(sqr(x2 - x1) + sqr(y2 - y1)) | |
// i.e., (7,8) and (3,2) -> 7.21 | |
final d1 = pow(point.dx - center.dx, 2); | |
final d2 = pow(point.dy - center.dy, 2); | |
final distance = sqrt(d1 + d2); | |
return (distance - radius).abs() < width; | |
} | |
bool _isAngleInsideRadiansSelection(double angle, double start, double sweep) { | |
final normalized = angle > pi / 2 ? 5 * pi / 2 - angle : pi / 2 - angle; | |
final end = (start + sweep) % (2 * pi); | |
return end > start | |
? normalized > start && normalized < end | |
: normalized > start || normalized < end; | |
} | |
double _getSweepAngle(double start, double end) { | |
if (end > start) { | |
return end - start; | |
} | |
return (100 - start + end).abs(); | |
} | |
int _getDivisionDiff(int start, int end, int divisions) { | |
if (start <= end) { | |
return end - start; | |
} else { | |
return divisions - (end - start).abs(); | |
} | |
} | |
// CustomPanGestureRecognizer to prevent parent scroll view swallow pan event. | |
class _CustomPanGestureRecognizer extends OneSequenceGestureRecognizer { | |
final bool Function(Offset) onPanDown; | |
final Function(Offset) onPanUpdate; | |
final Function(Offset) onPanEnd; | |
_CustomPanGestureRecognizer({ | |
required this.onPanDown, | |
required this.onPanUpdate, | |
required this.onPanEnd, | |
}); | |
@override | |
void addPointer(PointerEvent event) { | |
if (onPanDown(event.position)) { | |
startTrackingPointer(event.pointer); | |
resolve(GestureDisposition.accepted); | |
} else { | |
stopTrackingPointer(event.pointer); | |
} | |
} | |
@override | |
void handleEvent(PointerEvent event) { | |
if (event is PointerMoveEvent) { | |
onPanUpdate(event.position); | |
} | |
if (event is PointerUpEvent) { | |
onPanEnd(event.position); | |
stopTrackingPointer(event.pointer); | |
} | |
} | |
@override | |
String get debugDescription => 'customPan'; | |
@override | |
void didStopTrackingLastPointer(int pointer) {} | |
} | |
extension DateTimeExtension on DateTime { | |
DateTime get startOfDay => this | |
.copyWith(hour: 0, minute: 0, second: 0, microsecond: 0, millisecond: 0); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment