image

29 Jan 2026

9K

35K

Building an Event Countdown Widget with Progress Ring in Flutter

Introduction

Creating engaging user interfaces often involves dynamic elements that provide real-time information. An event countdown widget is a prime example, commonly used for product launches, special events, or holiday anticipation. By integrating a progress ring, we can add a visual dimension that intuitively shows the elapsed time relative to the total duration, making the countdown more informative and visually appealing. This article will guide you through building such a widget in Flutter, combining state management, custom painting, and timer logic.

Prerequisites

To follow along with this tutorial, you should have:

  • Flutter SDK installed and configured.
  • A basic understanding of Flutter widgets (StatefulWidget, StatelessWidget).
  • Familiarity with Dart programming language.

Project Setup

First, let's create a new Flutter project. Open your terminal or command prompt and run:


flutter create event_countdown_app
cd event_countdown_app

Next, open the project in your preferred IDE (like VS Code or Android Studio).

Designing the `CountdownProgressRing` Widget

Our countdown widget will be a `StatefulWidget` because it needs to manage its internal state (the remaining time) and update the UI periodically. We'll also define a `CustomPainter` to draw the progress ring.

`ProgressRingPainter` Implementation

The `ProgressRingPainter` will be responsible for drawing the background circle and the progress arc. It needs to know the current progress percentage and various styling parameters.


import 'dart:math';
import 'package:flutter/material.dart';

class ProgressRingPainter extends CustomPainter {
  final double progress; // Value from 0.0 to 1.0
  final Color backgroundColor;
  final Color progressColor;
  final double strokeWidth;

  ProgressRingPainter({
    required this.progress,
    this.backgroundColor = Colors.grey,
    this.progressColor = Colors.blue,
    this.strokeWidth = 10.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint backgroundPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    Paint progressPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = min(size.width / 2, size.height / 2) - strokeWidth / 2;

    // Draw background circle
    canvas.drawCircle(center, radius, backgroundPaint);

    // Draw progress arc
    double sweepAngle = 2 * pi * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -pi / 2, // Start from the top
      sweepAngle,
      false,
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return oldDelegate.progress != progress ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.progressColor != progressColor ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

`CountdownProgressRing` Widget Implementation

This widget will manage the timer, calculate the remaining time, determine the progress, and display everything.


import 'dart:async';
import 'package:flutter/material.dart';
import 'package:event_countdown_app/progress_ring_painter.dart'; // Assuming you save the painter in a separate file

class CountdownProgressRing extends StatefulWidget {
  final DateTime targetDate;
  final Color ringBackgroundColor;
  final Color ringProgressColor;
  final double ringStrokeWidth;
  final TextStyle textStyle;
  final VoidCallback? onFinished;

  const CountdownProgressRing({
    Key? key,
    required this.targetDate,
    this.ringBackgroundColor = Colors.grey,
    this.ringProgressColor = Colors.blue,
    this.ringStrokeWidth = 10.0,
    this.textStyle = const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
    this.onFinished,
  }) : super(key: key);

  @override
  _CountdownProgressRingState createState() => _CountdownProgressRingState();
}

class _CountdownProgressRingState extends State {
  late Duration _timeRemaining;
  late Timer _timer;
  late Duration _totalDuration; // Total duration from now to target date

  @override
  void initState() {
    super.initState();
    _totalDuration = widget.targetDate.isAfter(DateTime.now())
        ? widget.targetDate.difference(DateTime.now())
        : Duration.zero;
    _timeRemaining = widget.targetDate.difference(DateTime.now());
    _startTimer();
  }

  void _startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (!mounted) {
        timer.cancel();
        return;
      }
      setState(() {
        _timeRemaining = widget.targetDate.difference(DateTime.now());
        if (_timeRemaining.isNegative) {
          _timeRemaining = Duration.zero;
          _timer.cancel();
          widget.onFinished?.call();
        }
      });
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  String _formatDuration(Duration duration) {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String days = duration.inDays.toString();
    String hours = twoDigits(duration.inHours.remainder(24));
    String minutes = twoDigits(duration.inMinutes.remainder(60));
    String seconds = twoDigits(duration.inSeconds.remainder(60));

    if (duration.inDays > 0) {
      return "${days}d ${hours}h ${minutes}m ${seconds}s";
    } else if (duration.inHours > 0) {
      return "${hours}h ${minutes}m ${seconds}s";
    } else if (duration.inMinutes > 0) {
      return "${minutes}m ${seconds}s";
    } else {
      return "${seconds}s";
    }
  }

  @override
  Widget build(BuildContext context) {
    double progress = _totalDuration.inSeconds > 0
        ? 1.0 - (_timeRemaining.inSeconds / _totalDuration.inSeconds)
        : 1.0;

    if (_timeRemaining == Duration.zero) {
      progress = 1.0; // Ensure progress is full when time is up
    } else if (_timeRemaining.isNegative && _totalDuration.inSeconds <= 0) {
      // Handle cases where targetDate is already in the past from the start
      progress = 1.0;
    } else if (_totalDuration.inSeconds <= 0) {
       // If the target date was in the past when the widget was initialized,
       // or it's a very short duration that becomes 0 quickly.
       progress = 1.0;
    }


    return SizedBox(
      width: 200,
      height: 200,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: ProgressRingPainter(
              progress: progress,
              backgroundColor: widget.ringBackgroundColor,
              progressColor: widget.ringProgressColor,
              strokeWidth: widget.ringStrokeWidth,
            ),
            child: Container(),
          ),
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                _timeRemaining == Duration.zero ? "Event Started!" : "Remaining:",
                style: widget.textStyle.copyWith(fontSize: widget.textStyle.fontSize! * 0.7),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 5),
              Text(
                _timeRemaining == Duration.zero ? "" : _formatDuration(_timeRemaining),
                style: widget.textStyle,
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ],
      ),
    );
  }
}

Integrating into `main.dart`

Now, let's use our `CountdownProgressRing` widget in the main application file.


import 'package:flutter/material.dart';
import 'package:event_countdown_app/countdown_progress_ring.dart'; // Assuming your widget is in this file

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Event Countdown',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  State createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  // Example target date: 7 days from now
  // For testing, you can set a date in the near future, e.g., 30 seconds from now
  final DateTime _eventTargetDate = DateTime.now().add(const Duration(days: 7, hours: 10, minutes: 30, seconds: 0));
  // final DateTime _eventTargetDate = DateTime.now().add(const Duration(seconds: 30));


  void _onCountdownFinished() {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Countdown Finished! Event Has Started!')),
    );
    print("Countdown finished!");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Event Countdown'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Upcoming Product Launch:',
              style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 30),
            CountdownProgressRing(
              targetDate: _eventTargetDate,
              ringBackgroundColor: Colors.grey[300]!,
              ringProgressColor: Colors.deepPurple,
              ringStrokeWidth: 12.0,
              textStyle: const TextStyle(
                fontSize: 28,
                fontWeight: FontWeight.bold,
                color: Colors.deepPurple,
              ),
              onFinished: _onCountdownFinished,
            ),
            const SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                // You can add functionality here, e.g., navigate to event details
                ScaffoldMessenger.of(context).showSnackBar(
                  const SnackBar(content: Text('View Event Details')),
                );
              },
              child: const Text('View Event Details'),
            ),
          ],
        ),
      ),
    );
  }
}

Full Code Example (`progress_ring_painter.dart`)


import 'dart:math';
import 'package:flutter/material.dart';

class ProgressRingPainter extends CustomPainter {
  final double progress; // Value from 0.0 to 1.0
  final Color backgroundColor;
  final Color progressColor;
  final double strokeWidth;

  ProgressRingPainter({
    required this.progress,
    this.backgroundColor = Colors.grey,
    this.progressColor = Colors.blue,
    this.strokeWidth = 10.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    Paint backgroundPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    Paint progressPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = min(size.width / 2, size.height / 2) - strokeWidth / 2;

    // Draw background circle
    canvas.drawCircle(center, radius, backgroundPaint);

    // Draw progress arc
    double sweepAngle = 2 * pi * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -pi / 2, // Start from the top
      sweepAngle,
      false,
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return oldDelegate.progress != progress ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.progressColor != progressColor ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

Full Code Example (`countdown_progress_ring.dart`)


import 'dart:async';
import 'package:flutter/material.dart';
import 'package:event_countdown_app/progress_ring_painter.dart'; // Ensure correct path

class CountdownProgressRing extends StatefulWidget {
  final DateTime targetDate;
  final Color ringBackgroundColor;
  final Color ringProgressColor;
  final double ringStrokeWidth;
  final TextStyle textStyle;
  final VoidCallback? onFinished;

  const CountdownProgressRing({
    Key? key,
    required this.targetDate,
    this.ringBackgroundColor = Colors.grey,
    this.ringProgressColor = Colors.blue,
    this.ringStrokeWidth = 10.0,
    this.textStyle = const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
    this.onFinished,
  }) : super(key: key);

  @override
  _CountdownProgressRingState createState() => _CountdownProgressRingState();
}

class _CountdownProgressRingState extends State {
  late Duration _timeRemaining;
  late Timer _timer;
  late Duration _initialDuration; // Duration from widget creation to target date

  @override
  void initState() {
    super.initState();
    _initialDuration = widget.targetDate.isAfter(DateTime.now())
        ? widget.targetDate.difference(DateTime.now())
        : Duration.zero;
    _timeRemaining = widget.targetDate.difference(DateTime.now());
    _startTimer();
  }

  void _startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (!mounted) {
        timer.cancel();
        return;
      }
      setState(() {
        _timeRemaining = widget.targetDate.difference(DateTime.now());
        if (_timeRemaining.isNegative) {
          _timeRemaining = Duration.zero;
          _timer.cancel();
          widget.onFinished?.call();
        }
      });
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  String _formatDuration(Duration duration) {
    String twoDigits(int n) => n.toString().padLeft(2, "0");
    String days = duration.inDays.toString();
    String hours = twoDigits(duration.inHours.remainder(24));
    String minutes = twoDigits(duration.inMinutes.remainder(60));
    String seconds = twoDigits(duration.inSeconds.remainder(60));

    if (duration.inDays > 0) {
      return "${days}d ${hours}h ${minutes}m ${seconds}s";
    } else if (duration.inHours > 0) {
      return "${hours}h ${minutes}m ${seconds}s";
    } else if (duration.inMinutes > 0) {
      return "${minutes}m ${seconds}s";
    } else {
      return "${seconds}s";
    }
  }

  @override
  Widget build(BuildContext context) {
    double progress = _initialDuration.inSeconds > 0
        ? 1.0 - (_timeRemaining.inSeconds / _initialDuration.inSeconds)
        : 1.0;

    // Clamp progress to ensure it's between 0.0 and 1.0
    progress = progress.clamp(0.0, 1.0);

    return SizedBox(
      width: 200,
      height: 200,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: ProgressRingPainter(
              progress: progress,
              backgroundColor: widget.ringBackgroundColor,
              progressColor: widget.ringProgressColor,
              strokeWidth: widget.ringStrokeWidth,
            ),
            child: Container(),
          ),
          Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                _timeRemaining == Duration.zero ? "Event Started!" : "Remaining:",
                style: widget.textStyle.copyWith(fontSize: widget.textStyle.fontSize! * 0.7),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 5),
              Text(
                _timeRemaining == Duration.zero ? "" : _formatDuration(_timeRemaining),
                style: widget.textStyle,
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ],
      ),
    );
  }
}

Customization and Enhancements

The widget provided is a solid foundation, but you can enhance it further:

  • Animation: Animate the progress ring smoothly when the progress updates using `TweenAnimationBuilder` or `AnimationController`.
  • Custom Text: Allow more granular control over the displayed text format (e.g., show only days and hours, or a custom message for finished events).
  • Multiple Event Support: Create a list of countdown widgets for different events.
  • Responsive Sizing: Make the widget more flexible to different screen sizes.
  • Theming: Integrate with a global theme for consistent styling.

Conclusion

You have successfully built a dynamic event countdown widget with a visual progress ring in Flutter. This combines `StatefulWidget` for managing time updates, `CustomPainter` for drawing the unique UI, and `Timer` for real-time changes. This widget is not only functional but also highly customizable, allowing you to create visually appealing countdowns for various applications.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is