Skip to content

Instantly share code, notes, and snippets.

@ltvu93
Last active June 7, 2024 04:29
Show Gist options
  • Save ltvu93/1999c0d21791146750fd0de3a3482885 to your computer and use it in GitHub Desktop.
Save ltvu93/1999c0d21791146750fd0de3a3482885 to your computer and use it in GitHub Desktop.
Customize base on progressive_time_picker package.
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