Creating a Multi-Event Countdown Timer Widget with Reminder, Notification, and Repeat Features in Flutter
Countdown timers are a ubiquitous feature in modern applications, ranging from productivity tools and event organizers to e-commerce platforms and gaming. Building a robust countdown timer in Flutter that can handle multiple events, provide timely reminders, send notifications, and even repeat on a schedule, presents an interesting challenge. This article delves into the architecture and implementation details for creating such a sophisticated widget.
Key Features of Our Advanced Countdown Timer
- Multi-Event Management: Ability to display and manage multiple distinct countdown events simultaneously.
- Countdown Timer Logic: Accurate real-time countdown display for each event, showing remaining days, hours, minutes, and seconds.
- Reminder Mechanism: Option to set reminders a certain period before the event starts.
- Notification System: Leveraging local notifications to alert users when an event is approaching or has started.
- Repeat Feature: Functionality to automatically reschedule events daily, weekly, monthly, or yearly.
Prerequisites
Before diving into the implementation, ensure you have a basic understanding of Flutter development and have the following packages added to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
flutter_local_notifications: ^17.0.0 # For local notifications
intl: ^0.19.0 # For date and time formatting (optional but recommended)
After adding, run flutter pub get to fetch the packages.
Step-by-Step Implementation
1. Defining the Event Model
First, we need a data model to represent each countdown event. This model will hold the event's name, target date/time, repeat type, and reminder settings.
import 'package:flutter/foundation.dart';
enum RepeatType {
none,
daily,
weekly,
monthly,
yearly,
}
class CountdownEvent {
final String id;
String title;
DateTime eventDateTime;
RepeatType repeatType;
Duration? reminderDuration; // e.g., 15 minutes before
CountdownEvent({
required this.id,
required this.title,
required this.eventDateTime,
this.repeatType = RepeatType.none,
this.reminderDuration,
});
// Helper to update event for repeat
void rescheduleEvent() {
DateTime now = DateTime.now();
DateTime newDateTime = eventDateTime;
while (newDateTime.isBefore(now)) {
switch (repeatType) {
case RepeatType.daily:
newDateTime = newDateTime.add(const Duration(days: 1));
break;
case RepeatType.weekly:
newDateTime = newDateTime.add(const Duration(days: 7));
break;
case RepeatType.monthly:
// A bit more complex for monthly to handle different day counts
newDateTime = DateTime(newDateTime.year, newDateTime.month + 1, newDateTime.day,
newDateTime.hour, newDateTime.minute, newDateTime.second);
break;
case RepeatType.yearly:
newDateTime = DateTime(newDateTime.year + 1, newDateTime.month, newDateTime.day,
newDateTime.hour, newDateTime.minute, newDateTime.second);
break;
case RepeatType.none:
break;
}
if (repeatType == RepeatType.none) break; // Exit if not repeating
}
eventDateTime = newDateTime;
}
}
2. Setting Up Local Notifications
Initialize flutter_local_notifications early in your application lifecycle, typically in your main.dart or a dedicated notification service.
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Future initializeNotifications() async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: (NotificationResponse response) async {
// Handle notification tap
if (response.payload != null) {
debugPrint('notification payload: ${response.payload}');
}
},
);
}
// Call this in your main function before runApp:
// void main() async {
// WidgetsFlutterBinding.ensureInitialized();
// await initializeNotifications();
// runApp(const MyApp());
// }
3. Designing the Multi-Event Countdown Screen
We'll create a stateful widget to manage a list of CountdownEvent objects. This widget will display each event using a dedicated sub-widget.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; // For formatting duration nicely
// Assuming CountdownEvent class is defined in event_model.dart
// And initializeNotifications is defined in notification_service.dart
class MultiEventCountdownScreen extends StatefulWidget {
const MultiEventCountdownScreen({super.key});
@override
_MultiEventCountdownScreenState createState() => _MultiEventCountdownScreenState();
}
class _MultiEventCountdownScreenState extends State {
final List _events = [];
@override
void initState() {
super.initState();
_loadEvents(); // Simulate loading events
}
void _loadEvents() {
setState(() {
_events.add(CountdownEvent(
id: '1',
title: 'Project Deadline',
eventDateTime: DateTime.now().add(const Duration(days: 5, hours: 10, minutes: 30)),
reminderDuration: const Duration(hours: 1),
repeatType: RepeatType.none,
));
_events.add(CountdownEvent(
id: '2',
title: 'Daily Standup',
eventDateTime: DateTime.now().add(const Duration(minutes: 2)), // Soon for testing repeat
reminderDuration: const Duration(minutes: 5),
repeatType: RepeatType.daily,
));
_events.add(CountdownEvent(
id: '3',
title: 'Yearly Anniversary',
eventDateTime: DateTime(DateTime.now().year + 1, 1, 1),
reminderDuration: const Duration(days: 7),
repeatType: RepeatType.yearly,
));
// Schedule notifications for initial load
for (var event in _events) {
_scheduleEventNotifications(event);
}
});
}
void _addEvent(CountdownEvent newEvent) {
setState(() {
_events.add(newEvent);
_scheduleEventNotifications(newEvent);
});
}
void _updateEvent(CountdownEvent updatedEvent) {
setState(() {
final index = _events.indexWhere((event) => event.id == updatedEvent.id);
if (index != -1) {
_events[index] = updatedEvent;
// Re-schedule notifications if event details changed
_cancelEventNotifications(updatedEvent.id);
_scheduleEventNotifications(updatedEvent);
}
});
}
void _deleteEvent(String eventId) {
setState(() {
_events.removeWhere((event) => event.id == eventId);
_cancelEventNotifications(eventId);
});
}
// --- Notification Scheduling Helper ---
Future _scheduleEventNotifications(CountdownEvent event) async {
// Unique ID for the event's primary notification
int eventNotificationId = int.parse(event.id);
// Unique ID for the event's reminder notification (e.g., event ID + 1000)
int reminderNotificationId = int.parse(event.id) + 1000;
// Cancel any existing notifications for this event before rescheduling
await flutterLocalNotificationsPlugin.cancel(eventNotificationId);
await flutterLocalNotificationsPlugin.cancel(reminderNotificationId);
// Schedule primary event notification
if (event.eventDateTime.isAfter(DateTime.now())) {
await _scheduleNotification(
id: eventNotificationId,
title: '${event.title} is starting!',
body: 'The event "${event.title}" is happening now.',
scheduledDate: event.eventDateTime,
payload: event.id,
);
}
// Schedule reminder notification if set
if (event.reminderDuration != null) {
final reminderTime = event.eventDateTime.subtract(event.reminderDuration!);
if (reminderTime.isAfter(DateTime.now())) {
await _scheduleNotification(
id: reminderNotificationId,
title: 'Reminder: ${event.title} is coming soon!',
body: 'Your event "${event.title}" is in '
'${event.reminderDuration!.inMinutes} minutes.',
scheduledDate: reminderTime,
payload: '${event.id}_reminder',
);
}
}
}
Future _cancelEventNotifications(String eventId) async {
await flutterLocalNotificationsPlugin.cancel(int.parse(eventId));
await flutterLocalNotificationsPlugin.cancel(int.parse(eventId) + 1000); // Also cancel reminder
}
Future _scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
String? payload,
}) async {
if (scheduledDate.isBefore(DateTime.now())) return; // Don't schedule past events
const AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails(
'countdown_timer_channel',
'Countdown Timer Notifications',
channelDescription: 'Notifications for your countdown events',
importance: Importance.high,
priority: Priority.high,
ticker: 'ticker',
);
const DarwinNotificationDetails iOSPlatformChannelSpecifics =
DarwinNotificationDetails();
const NotificationDetails platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics,
);
await flutterLocalNotificationsPlugin.schedule(
id,
title,
body,
scheduledDate,
platformChannelSpecifics,
payload: payload,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Multi-Event Countdown'),
),
body: _events.isEmpty
? const Center(child: Text('No events yet! Add one below.'))
: ListView.builder(
itemCount: _events.length,
itemBuilder: (context, index) {
final event = _events[index];
return CountdownTile(
event: event,
onEventComplete: (completedEvent) {
// Handle event completion, e.g., reschedule or remove
if (completedEvent.repeatType != RepeatType.none) {
completedEvent.rescheduleEvent();
_updateEvent(completedEvent); // This will also reschedule notifications
} else {
_deleteEvent(completedEvent.id);
}
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implement an Add Event form/dialog
// For now, adding a dummy event
_addEvent(CountdownEvent(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Unique ID
title: 'New Event ${_events.length + 1}',
eventDateTime: DateTime.now().add(const Duration(minutes: 10)),
reminderDuration: const Duration(minutes: 2),
repeatType: RepeatType.none,
));
},
child: const Icon(Icons.add),
),
);
}
}
4. Implementing the Individual Countdown Tile
Each event needs its own widget to display the countdown. This widget will be responsible for starting its own timer and updating its display.
class CountdownTile extends StatefulWidget {
final CountdownEvent event;
final Function(CountdownEvent) onEventComplete;
const CountdownTile({
super.key,
required this.event,
required this.onEventComplete,
});
@override
_CountdownTileState createState() => _CountdownTileState();
}
class _CountdownTileState extends State {
Timer? _timer;
Duration _timeRemaining = Duration.zero;
@override
void initState() {
super.initState();
_startTimer();
}
@override
void didUpdateWidget(covariant CountdownTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.event.eventDateTime != oldWidget.event.eventDateTime) {
_stopTimer();
_startTimer();
}
}
void _startTimer() {
_timeRemaining = widget.event.eventDateTime.difference(DateTime.now());
if (_timeRemaining.isNegative) {
_timeRemaining = Duration.zero;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onEventComplete(widget.event);
});
return;
}
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_timeRemaining = widget.event.eventDateTime.difference(DateTime.now());
if (_timeRemaining.isNegative) {
_timeRemaining = Duration.zero;
_stopTimer();
widget.onEventComplete(widget.event); // Notify parent event is complete
}
});
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, "0");
String days = duration.inDays > 0 ? "${duration.inDays}d " : "";
String hours = twoDigits(duration.inHours.remainder(24));
String minutes = twoDigits(duration.inMinutes.remainder(60));
String seconds = twoDigits(duration.inSeconds.remainder(60));
return "$days$hours:$minutes:$seconds";
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.event.title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Target: ${DateFormat('EEE, MMM d, y HH:mm').format(widget.event.eventDateTime)}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (widget.event.repeatType != RepeatType.none)
Text(
'Repeats: ${widget.event.repeatType.name.toUpperCase()}',
style: const TextStyle(fontSize: 14, color: Colors.blueAccent),
),
const SizedBox(height: 8),
_timeRemaining.inSeconds <= 0
? const Text(
'Event has started!',
style: TextStyle(fontSize: 24, color: Colors.green, fontWeight: FontWeight.bold),
)
: Text(
_formatDuration(_timeRemaining),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}
5. Integrating Reminders and Notifications (Already Handled)
The logic for scheduling notifications, including reminders, is integrated directly into the _MultiEventCountdownScreenState. Whenever an event is added or updated (especially its date or repeat type), the _scheduleEventNotifications method is called. This method ensures that appropriate notifications are set up for both the event's start time and its reminder time.
6. Adding the Repeat Feature (Already Handled)
The rescheduleEvent() method within the CountdownEvent class handles the core logic for repeating events. When an event completes and has a repeatType other than none, the onEventComplete callback in MultiEventCountdownScreen triggers this method. The parent widget then updates the event in its list, which in turn causes the CountdownTile to rebuild and restart its timer with the new event date, and the notifications are also automatically re-scheduled.
7. Managing Event State
In this example, we use a simple setState in the parent _MultiEventCountdownScreenState to manage the list of events. For larger applications, you might consider more advanced state management solutions like Provider, BLoC, Riverpod, or GetX to handle adding, updating, and deleting events more robustly, especially if events need to be persisted across app launches (e.g., using shared_preferences or a database like Hive/SQLite).
Conclusion
Building a multi-event countdown timer with reminders, notifications, and repeat features in Flutter requires careful orchestration of UI updates, background task scheduling (via notifications), and robust data modeling. By breaking down the problem into smaller, manageable components—an event model, a notification service, a main event list manager, and individual countdown tiles—we can construct a powerful and user-friendly solution. This foundation can be further extended with persistence, event editing forms, and more sophisticated repeat patterns to create a truly comprehensive event management system.