Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter
Countdown timers are essential in many applications, from productivity tools to event management. A simple countdown might suffice for a single event, but a robust multi-event countdown timer, enriched with features like reminders, notifications, repeat functionality, and custom labels, elevates the user experience significantly. This article explores the architecture and implementation of such a sophisticated widget in Flutter, empowering developers to create highly interactive and feature-rich applications.
The Core Concept: Managing Multiple Timers
At its heart, a multi-event countdown timer needs to manage several independent countdowns concurrently. Each event will have a specific target date and time, a unique identifier, and various associated properties. Flutter's reactive nature and powerful state management options make this task manageable and efficient.
Key Features Breakdown
1. Custom Labels
Allowing users to assign custom names or descriptions to each event is fundamental. This makes the timer highly personal and easy to understand at a glance, transforming a generic countdown into "Project Deadline," "Birthday Party," or "Meeting Start."
2. Countdown Logic
The core functionality involves calculating the remaining duration (days, hours, minutes, seconds) until each event's target time. This calculation needs to be updated in real-time, typically every second, to provide an accurate countdown display.
3. Reminders and Notifications
Beyond a visual countdown, proactive alerts are crucial. This feature involves scheduling local notifications that trigger at a specified time before an event occurs (e.g., 15 minutes before, 1 hour before). For repeating events, these notifications must be intelligently rescheduled.
4. Repeat Functionality
Many events are recurring (daily meetings, weekly tasks, annual anniversaries). Implementing a repeat option (daily, weekly, monthly, annually, or custom intervals) simplifies event management by automatically advancing the target date/time after an event concludes.
Architectural Considerations
To build a robust multi-event countdown timer, a well-thought-out architecture is vital. We will focus on:
- Data Model: A structured class to represent each event.
- State Management: A mechanism to manage the list of events and their real-time countdowns. Provider is an excellent choice for this.
- Local Notifications: Utilizing a plugin to handle scheduling and displaying notifications.
Implementation Steps
1. Project Setup and Dependencies
First, create a new Flutter project and add the necessary dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
provider: ^6.0.5 # For state management
flutter_local_notifications: ^17.0.0 # For local notifications
timezone: ^0.9.2 # For local notifications time zone handling
Run flutter pub get after adding dependencies.
2. The Event Data Model
Define an Event class to hold all the necessary properties for each countdown item. This class should be immutable or have methods for updating properties.
import 'package:flutter/foundation.dart';
enum RepeatInterval { none, daily, weekly, monthly, yearly }
class Event {
final String id;
String name;
DateTime targetDateTime;
Duration? reminderOffset; // e.g., 15 minutes before
bool enableNotifications;
RepeatInterval repeatInterval;
int notificationId; // Unique ID for flutter_local_notifications
Event({
required this.id,
required this.name,
required this.targetDateTime,
this.reminderOffset,
this.enableNotifications = true,
this.repeatInterval = RepeatInterval.none,
required this.notificationId,
});
Event copyWith({
String? id,
String? name,
DateTime? targetDateTime,
Duration? reminderOffset,
bool? enableNotifications,
RepeatInterval? repeatInterval,
int? notificationId,
}) {
return Event(
id: id ?? this.id,
name: name ?? this.name,
targetDateTime: targetDateTime ?? this.targetDateTime,
reminderOffset: reminderOffset ?? this.reminderOffset,
enableNotifications: enableNotifications ?? this.enableNotifications,
repeatInterval: repeatInterval ?? this.repeatInterval,
notificationId: notificationId ?? this.notificationId,
);
}
// Helper to advance repeating events
void advanceRepeat() {
switch (repeatInterval) {
case RepeatInterval.daily:
targetDateTime = targetDateTime.add(const Duration(days: 1));
break;
case RepeatInterval.weekly:
targetDateTime = targetDateTime.add(const Duration(days: 7));
break;
case RepeatInterval.monthly:
targetDateTime = DateTime(targetDateTime.year, targetDateTime.month + 1, targetDateTime.day,
targetDateTime.hour, targetDateTime.minute, targetDateTime.second);
break;
case RepeatInterval.yearly:
targetDateTime = DateTime(targetDateTime.year + 1, targetDateTime.month, targetDateTime.day,
targetDateTime.hour, targetDateTime.minute, targetDateTime.second);
break;
case RepeatInterval.none:
// Do nothing
break;
}
}
}
3. State Management with Provider (CountdownProvider)
Create a ChangeNotifier to manage the list of events. This provider will handle adding, updating, deleting events, and also the core countdown logic.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; // Add uuid: ^4.3.3 to pubspec.yaml for unique IDs
import 'package:your_app_name/models/event.dart'; // Adjust import path
import 'package:your_app_name/services/notification_service.dart'; // We'll create this next
class CountdownProvider with ChangeNotifier {
List<Event> _events = [];
Timer? _timer;
final Uuid _uuid = const Uuid();
final NotificationService _notificationService = NotificationService();
List<Event> get events => _events;
CountdownProvider() {
_initNotifications();
_startTimer();
}
void _initNotifications() async {
await _notificationService.init();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_updateEventStatuses();
notifyListeners();
});
}
void _updateEventStatuses() {
for (var i = 0; i < _events.length; i++) {
final event = _events[i];
if (event.targetDateTime.isBefore(DateTime.now())) {
// Event has passed
if (event.repeatInterval != RepeatInterval.none) {
event.advanceRepeat(); // Advance to the next occurrence
_scheduleEventNotification(event); // Reschedule notification
} else {
// Remove non-repeating, past events or mark as completed
// For simplicity, we'll keep them visible but could remove them
}
}
}
}
void addEvent(Event event) {
_events.add(event);
_scheduleEventNotification(event);
notifyListeners();
}
void updateEvent(Event updatedEvent) {
final index = _events.indexWhere((event) => event.id == updatedEvent.id);
if (index != -1) {
_notificationService.cancelNotification(updatedEvent.notificationId); // Cancel old notification
_events[index] = updatedEvent;
_scheduleEventNotification(updatedEvent); // Schedule new notification
notifyListeners();
}
}
void deleteEvent(String id) {
final event = _events.firstWhere((e) => e.id == id);
_notificationService.cancelNotification(event.notificationId);
_events.removeWhere((event) => event.id == id);
notifyListeners();
}
void _scheduleEventNotification(Event event) {
if (event.enableNotifications && event.reminderOffset != null) {
final notificationTime = event.targetDateTime.subtract(event.reminderOffset!);
if (notificationTime.isAfter(DateTime.now())) {
_notificationService.scheduleNotification(
id: event.notificationId,
title: "Reminder: ${event.name}",
body: "Your event '${event.name}' is coming up in ${event.reminderOffset!.inMinutes} minutes!",
scheduledDateTime: notificationTime,
);
}
}
}
// Dispose of the timer when the provider is no longer needed
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
4. Notification Service
Set up flutter_local_notifications to handle scheduling and displaying notifications. You'll need to configure it for Android and iOS in their respective native project files.
// services/notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tz;
class NotificationService {
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
Future<void> init() async {
tz.initializeAll();
final AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid, iOS: initializationSettingsIOS);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDateTime,
}) async {
await flutterLocalNotificationsPlugin.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDateTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'countdown_channel',
'Countdown Reminders',
channelDescription: 'Reminders for your countdown events',
importance: Importance.high,
priority: Priority.high,
ticker: 'ticker',
),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.dayOfMonthAndTime,
);
}
Future<void> cancelNotification(int id) async {
await flutterLocalNotificationsPlugin.cancel(id);
}
}
5. UI Implementation (Countdown Widget)
Create a widget to display the list of countdown events. Use Consumer from the provider package to react to changes in the CountdownProvider.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:your_app_name/models/event.dart';
import 'package:your_app_name/providers/countdown_provider.dart'; // Adjust import path
import 'package:uuid/uuid.dart'; // For generating unique IDs
class CountdownScreen extends StatelessWidget {
const CountdownScreen({super.key});
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 Scaffold(
appBar: AppBar(
title: const Text('Multi-Event Countdown'),
),
body: Consumer<CountdownProvider>(
builder: (context, countdownProvider, child) {
if (countdownProvider.events.isEmpty) {
return const Center(
child: Text('No events yet. Add one!'),
);
}
return ListView.builder(
itemCount: countdownProvider.events.length,
itemBuilder: (context, index) {
final event = countdownProvider.events[index];
final duration = event.targetDateTime.difference(DateTime.now());
return Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Target: ${event.targetDateTime.toLocal().toString().split('.')[0]}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
duration.isNegative
? Text(
event.repeatInterval != RepeatInterval.none
? 'Event passed (will repeat)'
: 'Event passed',
style: const TextStyle(
fontSize: 18,
color: Colors.red,
fontWeight: FontWeight.w600,
),
)
: Text(
_formatDuration(duration),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blueAccent,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// Navigate to an edit screen or show a dialog
_showAddEditEventDialog(context, event: event);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
countdownProvider.deleteEvent(event.id);
},
),
],
),
],
),
),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_showAddEditEventDialog(context);
},
child: const Icon(Icons.add),
),
);
}
void _showAddEditEventDialog(BuildContext context, {Event? event}) {
final TextEditingController nameController = TextEditingController(text: event?.name ?? '');
DateTime selectedDateTime = event?.targetDateTime ?? DateTime.now().add(const Duration(days: 1));
Duration? reminderOffset = event?.reminderOffset;
RepeatInterval selectedRepeatInterval = event?.repeatInterval ?? RepeatInterval.none;
bool enableNotifications = event?.enableNotifications ?? true;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return AlertDialog(
title: Text(event == null ? 'Add New Event' : 'Edit Event'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Event Name'),
),
ListTile(
title: Text('Date: ${selectedDateTime.toLocal().toString().split(' ')[0]}'),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDateTime,
firstDate: DateTime.now(),
lastDate: DateTime(2101),
);
if (picked != null) {
setState(() {
selectedDateTime = DateTime(
picked.year, picked.month, picked.day,
selectedDateTime.hour, selectedDateTime.minute, selectedDateTime.second,
);
});
}
},
),
ListTile(
title: Text('Time: ${selectedDateTime.toLocal().toString().split(' ')[1].substring(0, 5)}'),
trailing: const Icon(Icons.access_time),
onTap: () async {
TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(selectedDateTime),
);
if (picked != null) {
setState(() {
selectedDateTime = DateTime(
selectedDateTime.year, selectedDateTime.month, selectedDateTime.day,
picked.hour, picked.minute,
);
});
}
},
),
DropdownButton<RepeatInterval>(
value: selectedRepeatInterval,
onChanged: (RepeatInterval? newValue) {
if (newValue != null) {
setState(() {
selectedRepeatInterval = newValue;
});
}
},
items: RepeatInterval.values
.map<DropdownMenuItem<RepeatInterval>>((RepeatInterval value) {
return DropdownMenuItem<RepeatInterval>(
value: value,
child: Text(value.toString().split('.').last),
);
}).toList(),
),
CheckboxListTile(
title: const Text('Enable Notifications'),
value: enableNotifications,
onChanged: (bool? value) {
setState(() {
enableNotifications = value ?? false;
});
},
),
if (enableNotifications)
DropdownButton<Duration>(
value: reminderOffset,
hint: const Text('Reminder before event'),
onChanged: (Duration? newValue) {
setState(() {
reminderOffset = newValue;
});
},
items: const [
DropdownMenuItem(value: Duration(minutes: 5), child: Text('5 minutes before')),
DropdownMenuItem(value: Duration(minutes: 15), child: Text('15 minutes before')),
DropdownMenuItem(value: Duration(hours: 1), child: Text('1 hour before')),
DropdownMenuItem(value: Duration(days: 1), child: Text('1 day before')),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (nameController.text.isNotEmpty) {
final newEvent = Event(
id: event?.id ?? const Uuid().v4(),
name: nameController.text,
targetDateTime: selectedDateTime,
reminderOffset: reminderOffset,
enableNotifications: enableNotifications,
repeatInterval: selectedRepeatInterval,
notificationId: event?.notificationId ?? DateTime.now().millisecondsSinceEpoch % 100000, // Simple unique ID
);
if (event == null) {
Provider.of<CountdownProvider>(context, listen: false).addEvent(newEvent);
} else {
Provider.of<CountdownProvider>(context, listen: false).updateEvent(newEvent);
}
Navigator.pop(dialogContext);
}
},
child: Text(event == null ? 'Add' : 'Save'),
),
],
);
},
);
},
);
}
}
6. Main Application Integration
Wrap your MaterialApp with a ChangeNotifierProvider to make the CountdownProvider available throughout your app.
// main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:your_app_name/providers/countdown_provider.dart';
import 'package:your_app_name/screens/countdown_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => CountdownProvider(),
child: MaterialApp(
title: 'Multi-Event Countdown',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const CountdownScreen(),
),
);
}
}
Challenges and Solutions
- Background Execution: Local notifications handle background reminders effectively. For more complex background logic (e.g., updating UI when the app is killed), consider using
workmanageror platform-specific background services, which is beyond the scope of this basic setup. - Time Zone Handling: Always store
DateTimeobjects in UTC and convert to local time for display or notification scheduling. Thetimezonepackage is crucial for accurate local time-based notifications. - Persistent Storage: For a real-world application, events should be saved to persistent storage (e.g., SQLite with
sqflite, Hive, or shared preferences) so they are not lost when the app is closed. Load events from storage in theCountdownProviderconstructor. - Unique Notification IDs: Ensure each scheduled notification has a unique ID to prevent overwriting. Using
DateTime.now().millisecondsSinceEpochor a UUID generator for event IDs and derived notification IDs is a good strategy.
Conclusion
Building a multi-event countdown timer with advanced features like reminders, notifications, repeat functionality, and custom labels in Flutter is a powerful way to enhance user engagement. By leveraging Flutter's state management (Provider), local notification plugins, and a well-defined data model, developers can create a highly functional and intuitive application that caters to diverse event management needs. This foundation provides a strong starting point for further enhancements, such as integration with calendars, cloud synchronization, or more elaborate UI designs.