Skip to content

Instantly share code, notes, and snippets.

@CoderNamedHendrick
Created November 23, 2023 18:36
Show Gist options
  • Save CoderNamedHendrick/30e559128c14d4654bbf7dfc0a4dea3e to your computer and use it in GitHub Desktop.
Save CoderNamedHendrick/30e559128c14d4654bbf7dfc0a4dea3e to your computer and use it in GitHub Desktop.
Customisable circular countdown
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;
}
}
/// 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