Building a Multi-Event Countdown Timer Widget with Reminder, Notification, Repeat, and Custom Label in Flutter
Countdown timers are a staple in many applications, from event management to productivity tools. A robust countdown timer often requires more than just displaying time; it needs features like reminders, notifications, repeating events, and custom labels to be truly versatile. This article will guide you through building a sophisticated multi-event countdown timer widget in Flutter, incorporating these advanced functionalities.
1. Project Setup and Dependencies
First, create a new Flutter project and add the necessary dependencies to your pubspec.yaml file. We'll primarily use flutter_local_notifications for scheduling local reminders.
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
flutter_local_notifications: ^17.1.2 # Use the latest version
intl: ^0.19.0 # For date and time formatting
uuid: ^4.4.0 # For unique event IDs
shared_preferences: ^2.2.3 # Optional: for persistent storage
After adding, run flutter pub get to fetch the packages.
2. Defining the Event Model
We need a data model to represent each countdown event. This model will hold all the necessary information, including the target time, reminder offset, repeat interval, and custom label.
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
enum RepeatType { none, daily, weekly, monthly, yearly }
@immutable
class CountdownEvent {
final String id;
final String title;
final String? customLabel;
DateTime targetDateTime;
final Duration? reminderOffset; // How long before the event to remind
final RepeatType repeatType;
final int? notificationId; // Unique ID for local notifications
CountdownEvent({
String? id,
required this.title,
this.customLabel,
required this.targetDateTime,
this.reminderOffset,
this.repeatType = RepeatType.none,
this.notificationId,
}) : id = id ?? const Uuid().v4();
CountdownEvent copyWith({
String? id,
String? title,
String? customLabel,
DateTime? targetDateTime,
Duration? reminderOffset,
RepeatType? repeatType,
int? notificationId,
}) {
return CountdownEvent(
id: id ?? this.id,
title: title ?? this.title,
customLabel: customLabel ?? this.customLabel,
targetDateTime: targetDateTime ?? this.targetDateTime,
reminderOffset: reminderOffset ?? this.reminderOffset,
repeatType: repeatType ?? this.repeatType,
notificationId: notificationId ?? this.notificationId,
);
}
// Convert CountdownEvent to JSON for persistence (optional)
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'customLabel': customLabel,
'targetDateTime': targetDateTime.toIso8601String(),
'reminderOffsetSeconds': reminderOffset?.inSeconds,
'repeatType': repeatType.index,
'notificationId': notificationId,
};
// Create CountdownEvent from JSON (optional)
factory CountdownEvent.fromJson(Map<String, dynamic> json) {
return CountdownEvent(
id: json['id'],
title: json['title'],
customLabel: json['customLabel'],
targetDateTime: DateTime.parse(json['targetDateTime']),
reminderOffset: json['reminderOffsetSeconds'] != null
? Duration(seconds: json['reminderOffsetSeconds'])
: null,
repeatType: RepeatType.values[json['repeatType'] ?? 0],
notificationId: json['notificationId'],
);
}
}
3. Notification Service Setup
Initialize flutter_local_notifications and create a service to handle scheduling and canceling notifications.
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz;
class NotificationService {
static final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
static Future<void> init() async {
tz.initializeTimeZones(); // Initialize timezone data
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
print('Notification tapped: ${response.payload}');
},
);
}
static Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDateTime,
String? payload,
}) async {
await _flutterLocalNotificationsPlugin.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDateTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'countdown_channel_id',
'Countdown Reminders',
channelDescription: 'Reminders for countdown events',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
),
iOS: DarwinNotificationDetails(
sound: 'default.wav',
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
payload: payload,
);
}
static Future<void> cancelNotification(int id) async {
await _flutterLocalNotificationsPlugin.cancel(id);
}
}
4. The Countdown Timer Widget Logic
This widget will manage a list of CountdownEvent objects, calculate remaining time, update the UI, and interact with the NotificationService. We'll use a StatefulWidget and a Timer.periodic for updates.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:math'; // For random notification ID
import 'package:countdown_app/models/countdown_event.dart'; // Your event model
import 'package:countdown_app/services/notification_service.dart'; // Your notification service
class MultiEventCountdownScreen extends StatefulWidget {
const MultiEventCountdownScreen({super.key});
@override
State<MultiEventCountdownScreen> createState() => _MultiEventCountdownScreenState();
}
class _MultiEventCountdownScreenState extends State<MultiEventCountdownScreen> {
List<CountdownEvent> _events = [];
Timer? _timer;
@override
void initState() {
super.initState();
NotificationService.init();
_loadEvents(); // Load events from persistent storage if implemented
_startTimer();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _loadEvents() {
// For simplicity, hardcode some events. In a real app, load from SharedPreferences or database.
setState(() {
_events = [
CountdownEvent(
title: 'New Year 2025',
customLabel: 'Party Time!',
targetDateTime: DateTime(2025, 1, 1, 0, 0, 0),
reminderOffset: const Duration(hours: 24), // 24 hours before
repeatType: RepeatType.yearly,
),
CountdownEvent(
title: 'Project Deadline',
customLabel: 'Final Submission',
targetDateTime: DateTime.now().add(const Duration(days: 7, hours: 10)),
reminderOffset: const Duration(hours: 1), // 1 hour before
),
CountdownEvent(
title: 'Daily Standup',
customLabel: 'Work starts!',
targetDateTime: DateTime.now().copyWith(
hour: 9, minute: 0, second: 0, millisecond: 0, microsecond: 0
).add(
DateTime.now().hour >= 9 ? const Duration(days: 1) : Duration.zero
), // Next 9 AM
reminderOffset: const Duration(minutes: 10), // 10 minutes before
repeatType: RepeatType.daily,
),
];
});
_scheduleAllNotifications();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_events = _events.map((event) {
if (event.targetDateTime.isBefore(DateTime.now())) {
// Event has passed, handle repeat logic
return _handleEventCompletion(event);
}
return event; // Event is still counting down
}).toList();
});
});
}
CountdownEvent _handleEventCompletion(CountdownEvent event) {
if (event.repeatType != RepeatType.none) {
DateTime newTarget = event.targetDateTime;
while (newTarget.isBefore(DateTime.now())) {
switch (event.repeatType) {
case RepeatType.daily:
newTarget = newTarget.add(const Duration(days: 1));
break;
case RepeatType.weekly:
newTarget = newTarget.add(const Duration(days: 7));
break;
case RepeatType.monthly:
newTarget = DateTime(newTarget.year, newTarget.month + 1, newTarget.day, newTarget.hour, newTarget.minute, newTarget.second);
break;
case RepeatType.yearly:
newTarget = DateTime(newTarget.year + 1, newTarget.month, newTarget.day, newTarget.hour, newTarget.minute, newTarget.second);
break;
case RepeatType.none:
break; // Should not happen
}
}
final updatedEvent = event.copyWith(targetDateTime: newTarget);
_scheduleNotificationForEvent(updatedEvent); // Reschedule notification for next occurrence
return updatedEvent;
} else {
// For non-repeating events, you might want to remove them or mark as complete
// For this example, we'll keep it as is, just showing 00:00:00
return event;
}
}
void _addEvent(CountdownEvent newEvent) {
setState(() {
_events.add(newEvent);
});
_scheduleNotificationForEvent(newEvent);
// Optionally save events to persistent storage here
}
void _scheduleAllNotifications() {
for (var event in _events) {
_scheduleNotificationForEvent(event);
}
}
void _scheduleNotificationForEvent(CountdownEvent event) {
if (event.reminderOffset != null) {
final notificationTime = event.targetDateTime.subtract(event.reminderOffset!);
if (notificationTime.isAfter(DateTime.now())) {
final int notificationId = event.notificationId ?? Random().nextInt(1000000); // Generate unique ID
NotificationService.scheduleNotification(
id: notificationId,
title: 'Reminder: ${event.title}',
body: '${event.customLabel ?? 'Your event'} is starting soon!',
scheduledDateTime: notificationTime,
payload: event.id,
);
// Update event with notification ID if it was newly generated
if (event.notificationId == null) {
setState(() {
final index = _events.indexOf(event);
if (index != -1) {
_events[index] = event.copyWith(notificationId: notificationId);
}
});
}
} else if (event.notificationId != null) {
// Cancel notification if it's in the past and not yet triggered
NotificationService.cancelNotification(event.notificationId!);
}
}
}
@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!'))
: ListView.builder(
itemCount: _events.length,
itemBuilder: (context, index) {
final event = _events[index];
final Duration remaining = event.targetDateTime.difference(DateTime.now());
String formattedTime;
if (remaining.isNegative) {
formattedTime = "Event Passed"; // Or "00:00:00" if you prefer
} else {
final int days = remaining.inDays;
final int hours = remaining.inHours % 24;
final int minutes = remaining.inMinutes % 60;
final int seconds = remaining.inSeconds % 60;
formattedTime = '${days > 0 ? '$days d ' : ''}'
'${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: const TextStyle(
fontSize: 20, fontWeight: FontWeight.bold),
),
if (event.customLabel != null && event.customLabel!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
event.customLabel!,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
),
const SizedBox(height: 8),
Text(
'Target: ${DateFormat.yMd().add_Hms().format(event.targetDateTime)}',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
const SizedBox(height: 8),
Text(
formattedTime,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue),
),
if (event.repeatType != RepeatType.none)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'Repeats: ${event.repeatType.name.capitalizeFirst()}',
style: TextStyle(fontSize: 12, color: Colors.green[700]),
),
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Example: Add a new event dynamically (e.g., via a dialog)
_addEvent(
CountdownEvent(
title: 'New Custom Event',
customLabel: 'Created on ${DateFormat.yMd().format(DateTime.now())}',
targetDateTime: DateTime.now().add(const Duration(minutes: 5, seconds: 30)),
reminderOffset: const Duration(minutes: 1),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
// Extension to capitalize first letter for display
extension StringExtension on String {
String capitalizeFirst() {
if (isEmpty) return this;
return "${this[0].toUpperCase()}${substring(1)}";
}
}
// Your main.dart to run the app
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Countdown App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MultiEventCountdownScreen(),
);
}
}
5. Detailed Feature Breakdown
Multi-Event Management
The _events list holds all active countdowns. The ListView.builder dynamically renders a card for each event, making it easy to manage a variable number of timers.
Countdown Logic
The Timer.periodic updates the UI every second. For each event, it calculates the difference between the targetDateTime and DateTime.now(). The remaining duration is then formatted into a user-friendly string (e.g., "1d 05:30:15").
Custom Labels
The customLabel field in CountdownEvent allows you to attach additional descriptive text to each timer, which is displayed directly below the title in the UI.
Reminder and Notification
When an event is added or loaded, _scheduleNotificationForEvent is called. It calculates the reminder time (targetDateTime - reminderOffset) and uses NotificationService.scheduleNotification to set up a local notification. Each notification is assigned a unique ID (notificationId) allowing it to be canceled or updated later.
Repeat Functionality
The repeatType in the CountdownEvent model determines if and how an event should repeat. When an event's targetDateTime is in the past, _handleEventCompletion is triggered. It calculates the next occurrence based on the repeatType (daily, weekly, monthly, yearly) and updates the event's targetDateTime accordingly. Crucially, it then calls _scheduleNotificationForEvent again to set a reminder for the new target time.
Conclusion
Building a multi-event countdown timer in Flutter, complete with reminders, notifications, repeating events, and custom labels, significantly enhances its utility. By structuring your application with a clear event model, a dedicated notification service, and robust timer logic, you can create a powerful and flexible tool. This example provides a solid foundation, which can be further extended with persistent storage (e.g., using shared_preferences or a local database), event editing capabilities, and more advanced UI/UX.