Creating a Multi-Event Countdown Timer Widget with Notification and Repeat Feature in Flutter
Building a robust multi-event countdown timer in Flutter involves managing multiple distinct timers, handling their completion, scheduling local notifications, and implementing flexible repeat functionalities. This article guides you through creating such a widget, leveraging Flutter's reactive UI capabilities and integrating with a local notification package.
Core Concepts and Dependencies
At the heart of our solution will be a data model for each event, a state management approach to update the UI efficiently, a timer mechanism to track time, and a notification service. We'll use the following key dependencies:
flutter_local_notifications: For scheduling and displaying local notifications.provider: A widely used package for state management, making it easier to share event data across widgets.intl: For convenient date and time formatting.
First, add these dependencies to your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
flutter_local_notifications: ^17.1.2
provider: ^6.1.2
intl: ^0.19.0
After adding, run flutter pub get.
1. The Event Data Model
We need a class to represent each countdown event, including its target time, a unique ID, notification status, and repetition details.
// lib/models/countdown_event.dart
import 'package:flutter/foundation.dart';
enum RepeatInterval {
none,
daily,
weekly,
monthly,
yearly,
}
class CountdownEvent {
final String id;
String title;
DateTime targetDateTime;
bool enableNotifications;
RepeatInterval repeat;
CountdownEvent({
required this.id,
required this.title,
required this.targetDateTime,
this.enableNotifications = true,
this.repeat = RepeatInterval.none,
});
Duration get remainingDuration => targetDateTime.difference(DateTime.now());
void advanceTargetDateTime() {
DateTime now = DateTime.now();
switch (repeat) {
case RepeatInterval.daily:
while (targetDateTime.isBefore(now)) {
targetDateTime = targetDateTime.add(const Duration(days: 1));
}
break;
case RepeatInterval.weekly:
while (targetDateTime.isBefore(now)) {
targetDateTime = targetDateTime.add(const Duration(days: 7));
}
break;
case RepeatInterval.monthly:
// Note: Handling months precisely is complex due to varying days.
// A simple approach is to add a month, then adjust day if target day
// is greater than days in new month. For simplicity, we add 30 days.
while (targetDateTime.isBefore(now)) {
targetDateTime = DateTime(
targetDateTime.year,
targetDateTime.month + 1,
targetDateTime.day,
targetDateTime.hour,
targetDateTime.minute,
targetDateTime.second,
);
}
break;
case RepeatInterval.yearly:
while (targetDateTime.isBefore(now)) {
targetDateTime = DateTime(
targetDateTime.year + 1,
targetDateTime.month,
targetDateTime.day,
targetDateTime.hour,
targetDateTime.minute,
targetDateTime.second,
);
}
break;
case RepeatInterval.none:
// Do nothing for non-repeating events
break;
}
}
}
2. Notification Service
This service handles initializing flutter_local_notifications and scheduling notifications for our events.
// lib/services/notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
class NotificationService {
static final NotificationService _notificationService = NotificationService._internal();
factory NotificationService() {
return _notificationService;
}
NotificationService._internal();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Future init() async {
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,
macOS: initializationSettingsIOS); // Also use iOS settings for macOS
tz.initializeTimeZones();
// Set the local location to your device's timezone
tz.setLocalLocation(tz.getLocation('Asia/Jakarta')); // Example: set your desired timezone
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
Future scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
if (scheduledDate.isBefore(DateTime.now())) {
return; // Do not schedule past notifications
}
await flutterLocalNotificationsPlugin.zonedSchedule(
id,
title,
body,
tz.TZDateTime.from(scheduledDate, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'countdown_channel',
'Countdown Notifications',
channelDescription: 'Notifications for your countdown events',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker',
),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: null, // Schedule exact time
);
}
Future cancelNotification(int id) async {
await flutterLocalNotificationsPlugin.cancel(id);
}
}
3. Countdown Manager
This ChangeNotifier will manage our list of CountdownEvent objects. It will update the events' remaining durations, handle completion, trigger repetitions, and integrate with the notification service.
// lib/providers/countdown_manager.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:multi_event_countdown/models/countdown_event.dart';
import 'package:multi_event_countdown/services/notification_service.dart';
class CountdownManager with ChangeNotifier {
final List _events = [];
Timer? _timer;
final NotificationService _notificationService = NotificationService();
List get events => _events;
CountdownManager() {
_startTimer();
}
void addEvent(CountdownEvent event) {
_events.add(event);
_scheduleEventNotification(event);
notifyListeners();
}
void updateEvent(CountdownEvent updatedEvent) {
int index = _events.indexWhere((event) => event.id == updatedEvent.id);
if (index != -1) {
_notificationService.cancelNotification(int.parse(updatedEvent.id)); // Cancel old notification
_events[index] = updatedEvent;
_scheduleEventNotification(updatedEvent); // Schedule new notification
notifyListeners();
}
}
void removeEvent(String id) {
_events.removeWhere((event) {
if (event.id == id) {
_notificationService.cancelNotification(int.parse(event.id));
return true;
}
return false;
});
notifyListeners();
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_checkEvents();
notifyListeners(); // Notify listeners every second to update UI
});
}
void _checkEvents() {
List completedEvents = [];
for (var event in _events) {
if (event.remainingDuration.isNegative) {
completedEvents.add(event);
}
}
for (var event in completedEvents) {
if (event.repeat != RepeatInterval.none) {
_handleRepeatingEvent(event);
} else {
_handleCompletedEvent(event);
}
}
}
void _handleCompletedEvent(CountdownEvent event) {
_notificationService.cancelNotification(int.parse(event.id));
// Optionally remove the event if it's not repeating.
// For this example, we'll keep it as a "past event" until manually removed.
// _events.remove(event);
}
void _handleRepeatingEvent(CountdownEvent event) {
_notificationService.cancelNotification(int.parse(event.id)); // Cancel existing
event.advanceTargetDateTime();
_scheduleEventNotification(event); // Schedule new
}
void _scheduleEventNotification(CountdownEvent event) {
if (event.enableNotifications && event.remainingDuration.isPositive) {
_notificationService.scheduleNotification(
id: int.parse(event.id),
title: '${event.title} is happening now!',
body: 'Your event "${event.title}" has started or is about to start.',
scheduledDate: event.targetDateTime,
);
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
4. The Countdown Widget
This widget will display a list of our countdown events. We'll create a single card widget for each event and a parent widget to manage the list.
// lib/widgets/countdown_card.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:multi_event_countdown/models/countdown_event.dart';
class CountdownCard extends StatelessWidget {
final CountdownEvent event;
final VoidCallback onEdit;
final VoidCallback onDelete;
const CountdownCard({
Key? key,
required this.event,
required this.onEdit,
required this.onDelete,
}) : super(key: key);
String _formatDuration(Duration duration) {
if (duration.isNegative) {
return "Event finished!";
}
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.symmetric(vertical: 8.0, horizontal: 16.0),
elevation: 4.0,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
event.title,
style: Theme.of(context).textTheme.headlineSmall,
overflow: TextOverflow.ellipsis,
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: onEdit,
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: onDelete,
),
],
),
],
),
const SizedBox(height: 8.0),
Text(
'Target: ${DateFormat('yyyy-MM-dd HH:mm:ss').format(event.targetDateTime)}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4.0),
Text(
'Repeat: ${event.repeat.name.toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12.0),
Center(
child: Text(
_formatDuration(event.remainingDuration),
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: event.remainingDuration.isNegative ? Colors.red : Colors.green,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
}
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; // Add uuid: ^4.3.3 to pubspec.yaml for unique IDs
import 'package:multi_event_countdown/models/countdown_event.dart';
import 'package:multi_event_countdown/providers/countdown_manager.dart';
import 'package:multi_event_countdown/widgets/countdown_card.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
void _showAddEditEventDialog(BuildContext context, {CountdownEvent? event}) {
final TextEditingController titleController = TextEditingController(text: event?.title);
DateTime selectedDateTime = event?.targetDateTime ?? DateTime.now();
bool enableNotifications = event?.enableNotifications ?? true;
RepeatInterval selectedRepeat = event?.repeat ?? RepeatInterval.none;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event == null ? 'Add New Event' : 'Edit Event',
style: Theme.of(context).textTheme.headlineSmall,
),
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Event Title'),
),
ListTile(
title: Text(
'Target Date & Time: ${DateFormat('yyyy-MM-dd HH:mm').format(selectedDateTime)}',
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: selectedDateTime,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365 * 10)),
);
if (pickedDate != null) {
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(selectedDateTime),
);
if (pickedTime != null) {
setState(() {
selectedDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
}
},
),
Row(
children: [
const Text('Enable Notifications:'),
Switch(
value: enableNotifications,
onChanged: (value) {
setState(() {
enableNotifications = value;
});
},
),
],
),
DropdownButton(
value: selectedRepeat,
onChanged: (RepeatInterval? newValue) {
if (newValue != null) {
setState(() {
selectedRepeat = newValue;
});
}
},
items: RepeatInterval.values.map>(
(RepeatInterval value) {
return DropdownMenuItem(
value: value,
child: Text(value.name.toUpperCase()),
);
},
).toList(),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
if (titleController.text.isEmpty) return;
final manager = Provider.of(context, listen: false);
if (event == null) {
manager.addEvent(CountdownEvent(
id: const Uuid().v4(),
title: titleController.text,
targetDateTime: selectedDateTime,
enableNotifications: enableNotifications,
repeat: selectedRepeat,
));
} else {
manager.updateEvent(CountdownEvent(
id: event.id,
title: titleController.text,
targetDateTime: selectedDateTime,
enableNotifications: enableNotifications,
repeat: selectedRepeat,
));
}
Navigator.pop(context);
},
child: Text(event == null ? 'Add Event' : 'Save Changes'),
),
const SizedBox(height: 10),
],
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Multi-Event Countdown Timer'),
),
body: Consumer(
builder: (context, manager, child) {
if (manager.events.isEmpty) {
return const Center(
child: Text('No countdown events yet! Tap + to add one.'),
);
}
return ListView.builder(
itemCount: manager.events.length,
itemBuilder: (context, index) {
final event = manager.events[index];
return CountdownCard(
event: event,
onEdit: () => _showAddEditEventDialog(context, event: event),
onDelete: () => manager.removeEvent(event.id),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddEditEventDialog(context),
child: const Icon(Icons.add),
),
);
}
}
5. Putting It All Together (main.dart)
Finally, we initialize our services and wrap our app with the ChangeNotifierProvider to make our CountdownManager accessible throughout the widget tree.
// main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:multi_event_countdown/providers/countdown_manager.dart';
import 'package:multi_event_countdown/screens/home_screen.dart';
import 'package:multi_event_countdown/services/notification_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await NotificationService().init(); // Initialize notification service
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => CountdownManager(),
child: MaterialApp(
title: 'Multi-Event Countdown',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const HomeScreen(),
),
);
}
}
Advanced Considerations
-
Persistence: Currently, events are lost when the app restarts. You can implement persistence using packages like
shared_preferencesor a database like SQLite (withsqflite) to save and load events. -
Background Execution: For highly accurate background notifications and timer updates, consider using Flutter's background services (e.g.,
workmanager) or native platform-specific background tasks. This is crucial if you need timers to function even when the app is terminated. - User Interface: Enhance the UI with more visual feedback, custom animations, and better input forms for adding/editing events.
- Error Handling: Add more robust error handling for notification scheduling and date parsing.
Conclusion
You now have a foundational multi-event countdown timer widget in Flutter, complete with local notifications and a repeat feature. This setup provides a solid base that can be expanded with persistence, more complex repeat logic, and a refined user experience to meet the specific needs of your application.