Created
November 23, 2023 18:36
-
-
Save CoderNamedHendrick/30e559128c14d4654bbf7dfc0a4dea3e to your computer and use it in GitHub Desktop.
Customisable circular countdown
This file contains 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 'package:flutter/material.dart'; | |
import 'dart:math'; | |
class CountdownOTPWidget extends StatefulWidget { | |
const CountdownOTPWidget({ | |
super.key, | |
required this.countDownDurationInMinutes, | |
this.children = const [], | |
this.progressColor, | |
this.progressBackgroundColor, | |
this.countdownTextStyle, | |
this.progressIndicatorWidth = 12, | |
this.isIndicatorProgressingClockwise = false, | |
}) : assert( | |
countDownDurationInMinutes >= 1, | |
'Countdown duration must be 1 or more minutes', | |
); | |
final int countDownDurationInMinutes; | |
final List<Widget> children; | |
final double progressIndicatorWidth; | |
final bool isIndicatorProgressingClockwise; | |
final Color? progressColor; | |
final Color? progressBackgroundColor; | |
final TextStyle? countdownTextStyle; | |
@override | |
State<CountdownOTPWidget> createState() => _CountdownOTPWidgetState(); | |
} | |
class _CountdownOTPWidgetState extends State<CountdownOTPWidget> { | |
late final indicatorProgress = ValueNotifier<double>(0); | |
@override | |
void dispose() { | |
indicatorProgress.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return ValueListenableBuilder( | |
valueListenable: indicatorProgress, | |
builder: (context, value, child) { | |
return CircularPercentageProgress( | |
progress: value, | |
clockwise: widget.isIndicatorProgressingClockwise, | |
width: widget.progressIndicatorWidth, | |
color: widget.progressColor, | |
backgroundColor: widget.progressBackgroundColor, | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: [ | |
CountdownAnimatedWidget( | |
timeLimitInSeconds: widget.countDownDurationInMinutes * | |
CountdownAnimatedWidget.secondsInAMinute, | |
countDownProgress: _updateIndicatorProgress, | |
countdownTextStyle: widget.countdownTextStyle, | |
), | |
...widget.children, | |
], | |
), | |
); | |
}, | |
); | |
} | |
void _updateIndicatorProgress(double progress) { | |
indicatorProgress.value = progress; | |
} | |
} | |
class CountdownAnimatedWidget extends StatefulWidget { | |
const CountdownAnimatedWidget({ | |
super.key, | |
required this.timeLimitInSeconds, | |
this.countDownProgress, | |
this.countdownTextStyle, | |
}); | |
final int timeLimitInSeconds; | |
final void Function(double progressPercentage)? countDownProgress; | |
final TextStyle? countdownTextStyle; | |
static const secondsInAMinute = 60; | |
@override | |
State<CountdownAnimatedWidget> createState() => | |
_CountdownAnimatedWidgetState(); | |
} | |
class _CountdownAnimatedWidgetState extends State<CountdownAnimatedWidget> | |
with SingleTickerProviderStateMixin { | |
late final AnimationController animationController; | |
late final Animation<int> countdownAnim; | |
@override | |
void initState() { | |
super.initState(); | |
animationController = AnimationController( | |
vsync: this, | |
duration: Duration(seconds: widget.timeLimitInSeconds), | |
); | |
countdownAnim = IntTween(begin: widget.timeLimitInSeconds, end: 0) | |
.animate(animationController); | |
animationController.addListener(() { | |
final progress = (countdownAnim.value / widget.timeLimitInSeconds) * 100; | |
widget.countDownProgress?.call(progress.ceilToDouble()); | |
}); | |
animationController.forward(); | |
} | |
@override | |
void dispose() { | |
animationController.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: animationController, | |
builder: (context, _) => RepaintBoundary( | |
child: SizedBox( | |
width: double.infinity, | |
child: AnimatedDefaultTextStyle( | |
duration: kThemeChangeDuration, | |
style: Theme.of(context) | |
.textTheme | |
.bodyMedium! | |
.copyWith(color: Colors.grey), | |
child: Text( | |
formatCountdown(countdownAnim.value), | |
textAlign: TextAlign.center, | |
style: widget.countdownTextStyle, | |
), | |
), | |
), | |
), | |
); | |
} | |
String formatCountdown(int value) { | |
final seconds = value % CountdownAnimatedWidget.secondsInAMinute; | |
final minutes = value ~/ CountdownAnimatedWidget.secondsInAMinute; | |
return '${_formatNumber(minutes)}:${_formatNumber(seconds)}'; | |
} | |
String _formatNumber(int number) { | |
if (number < 10) { | |
return '0$number'; | |
} | |
return number.toString(); | |
} | |
} | |
class CircularPercentageProgress extends StatelessWidget { | |
const CircularPercentageProgress({ | |
super.key, | |
double progress = 0, | |
this.size = 250.0, | |
this.clockwise = true, | |
this.width = 8.0, | |
this.backgroundColor, | |
this.color, | |
this.child, | |
}) : _percentage = progress, | |
assert(progress >= 0 && progress <= 100, | |
'percentage must be less than 100'); | |
final double _percentage; | |
final double size; | |
final double width; | |
final bool clockwise; | |
final Color? backgroundColor; | |
final Color? color; | |
final Widget? child; | |
@override | |
Widget build(BuildContext context) { | |
return RepaintBoundary( | |
child: SizedBox( | |
width: size, | |
height: size, | |
child: TweenAnimationBuilder( | |
curve: Curves.easeInOut, | |
tween: Tween<double>(begin: 0, end: _percentage), | |
duration: const Duration(milliseconds: 1300), | |
builder: (_, percentageProgress, __) { | |
return CustomPaint( | |
painter: _CircularPercentagePainter( | |
percentage: percentageProgress, | |
width: width, | |
clockwise: clockwise, | |
color: color, | |
backgroundColor: backgroundColor, | |
), | |
size: Size(size, size), | |
child: child, | |
); | |
}, | |
), | |
), | |
); | |
} | |
} | |
class _CircularPercentagePainter extends CustomPainter { | |
final double percentage; | |
final bool clockwise; | |
final double width; | |
final Color? backgroundColor; | |
final Color? color; | |
const _CircularPercentagePainter({ | |
this.percentage = 0, | |
this.clockwise = true, | |
this.width = 5, | |
this.backgroundColor, | |
this.color, | |
}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
final backgroundPaint = Paint() | |
..style = PaintingStyle.stroke | |
..strokeCap = StrokeCap.round | |
..strokeWidth = width | |
..color = backgroundColor ?? Colors.grey; | |
final foregroundPaint = Paint() | |
..style = PaintingStyle.stroke | |
..strokeCap = StrokeCap.round | |
..strokeWidth = width | |
..color = color ?? Colors.green; | |
Offset center = Offset(size.height / 2, size.height / 2); | |
canvas.drawArc( | |
Rect.fromCircle(center: center, radius: size.height / 2), | |
-pi / 2, | |
2 * pi, | |
false, | |
backgroundPaint, | |
); | |
canvas.drawArc( | |
Rect.fromCircle(center: center, radius: size.height / 2), | |
-pi / 2, | |
_radiansFromAngle, | |
false, | |
foregroundPaint, | |
); | |
} | |
double get _radiansFromAngle { | |
final progress = 3.6 * percentage; | |
final angle = (progress * pi) / 180; | |
return clockwise ? angle : -angle; | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) { | |
if (oldDelegate is! _CircularPercentagePainter) return false; | |
if (oldDelegate.percentage == percentage) return false; | |
return true; | |
} | |
} |
This file contains 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
/// Example Usage | |
class MyWidget extends StatelessWidget { | |
const MyWidget({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: [ | |
CountdownOTPWidget( | |
countDownDurationInMinutes: 2, | |
children: [ | |
Text( | |
'112 334', | |
style: Theme.of(context).textTheme.headlineLarge, | |
), | |
const SizedBox(height: 10), | |
Text( | |
'One time password'.toUpperCase(), | |
), | |
const SizedBox(height: 10), | |
IconButton( | |
onPressed: () {}, | |
icon: const Icon( | |
Icons.copy_rounded, | |
size: 28, | |
), | |
), | |
], | |
), | |
], | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment