Companion post to How I Built the Long Game Notification System. This is the walkthrough. If you want the thinking behind the decisions, read that first. If you just want the recipe, you're in the right place.
What You're Building
A notification system where:
- Notifications fire at the exact time you set, every day
- They work when the phone is locked, the app is killed, or the device is in Doze mode
- Notification content is personalized with fresh data on every app open
- The entire system runs locally — no push server, no Cloud Functions, zero delivery cost
- Pomodoro-style one-shot alarms fire on time even when the app is suspended
- Notifications survive Samsung, Xiaomi, and Huawei battery killers without FCM
What You're NOT Using
workmanager — the OS throttles background tasks. Your 15-minute poll becomes hours.
Dart Timer.periodic for delivery — dies the moment the phone locks.
- Firebase Cloud Messaging — overkill for local, user-specific scheduling.
- Background Fetch (iOS) — Apple gives you 0–2 executions per day if you're lucky.
Step 1: Dependencies
# pubspec.yaml
dependencies:
flutter_local_notifications: ^18.0.1
timezone: ^0.10.0
flutter_timezone: ^3.0.1
permission_handler: ^11.3.1
That's it. Four packages.
- The notification plugin handles scheduling.
- The timezone packages ensure your 8:00 AM means 8:00 AM in Johannesburg, not UTC.
- The permission handler lets you request notification access on Android 13+ and battery optimization exemption on Samsung/OEM devices.
Step 2: Android Permissions
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
What each one does:
| Permission | Why |
POST_NOTIFICATIONS | Required on Android 13+ to show any notification at all |
RECEIVE_BOOT_COMPLETED | Alarms survive device reboots |
SCHEDULE_EXACT_ALARM | Fires at the exact second, even in Doze mode (API ≤ 33) |
USE_EXACT_ALARM | Guaranteed exact alarms on API 33+ — no user prompt needed, unlike SCHEDULE_EXACT_ALARM on API 34+ |
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | Lets you show the system "Allow unrestricted battery?" dialog — critical for Samsung, Xiaomi, Huawei |
WAKE_LOCK | Keeps the CPU alive long enough to process the alarm and fire the notification |
Watch out: On Android 14+ (API 34), SCHEDULE_EXACT_ALARM isn't granted by default. USE_EXACT_ALARM is a stronger alternative that's always granted for apps that declare it — but it may trigger Google Play review. Having both ensures maximum compatibility. Your code still needs the inexact fallback we'll cover in Step 7.
Step 3: Initialize on App Start
Before you can schedule anything, you need to initialize the timezone database and the notification plugin. This runs once in main(), before runApp().
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// ... your other init code (Firebase, etc.)
await NotificationService.init();
runApp(const MyApp());
}
// 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 tzdata;
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationService {
static final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
static Future<void> init() async {
// 1. Initialize the timezone database and detect the device's zone.
// Without this, all scheduled times are wrong.
tzdata.initializeTimeZones();
try {
final tzName = await FlutterTimezone.getLocalTimezone();
tz.setLocalLocation(tz.getLocation(tzName));
} catch (_) {
// Fallback: stays UTC if detection fails
}
// 2. Initialize the plugin.
// We don't request permissions here — that happens later,
// at a moment that makes sense in your UX flow.
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
await _plugin.initialize(
const InitializationSettings(
android: androidSettings,
iOS: iosSettings,
),
onDidReceiveNotificationResponse: _onNotificationTap,
onDidReceiveBackgroundNotificationResponse: _onBackgroundTap,
);
}
// Handle taps when the app is alive
static void _onNotificationTap(NotificationResponse response) {
// Navigate to the relevant screen, stop a timer, etc.
}
// Handle taps when the app was killed — must be top-level or static
@pragma('vm:entry-point')
static void _onBackgroundTap(NotificationResponse response) {
// App relaunches — handle in your normal init flow
}
}
Why timezone matters: If you schedule for "8:00 AM" without initializing timezones, the plugin may interpret that as UTC. In Johannesburg (UTC+2), that's 10:00 AM. In New York (UTC-5), that's 3:00 AM. Always initialize before any zonedSchedule call.
Step 4: Schedule a Daily Repeating Notification
This is the core of the entire system. Three lines do the heavy lifting.
static Future<void> scheduleDailyNotification({
required int id,
required String title,
required String body,
required int hour,
required int minute,
}) async {
await _plugin.zonedSchedule(
id, // Fixed ID per notification type
title,
body,
_nextInstanceOfTime(hour, minute), // Next occurrence of this time
const NotificationDetails(
android: AndroidNotificationDetails(
'your_channel_id',
'Your Channel Name',
channelDescription: 'What this channel is for',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
),
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time, // ← THIS IS THE KEY
);
}
The three things that make it work:
exactAllowWhileIdle — fires even in Doze mode. The OS wakes up just enough to deliver your notification.
matchDateTimeComponents: DateTimeComponents.time — tells the OS: "repeat this every day at this hour:minute." You schedule it once. It fires every day. No background task. No polling.
_nextInstanceOfTime — computes the next future occurrence of the target time.
static tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
final now = tz.TZDateTime.now(tz.local);
var scheduled = tz.TZDateTime(
tz.local,
now.year,
now.month,
now.day,
hour,
minute,
);
// zonedSchedule requires a future datetime
if (scheduled.isBefore(now)) {
scheduled = scheduled.add(const Duration(days: 1));
}
return scheduled;
}
Why this helper exists: zonedSchedule requires the initial fire time to be in the future. If it's 9:00 AM and you schedule for 8:00 AM, the helper pushes it to 8:00 AM tomorrow. The matchDateTimeComponents flag handles every day after that.
Step 5: CRITICAL: The Cancel Trap
==(This bit is annoying if not taken care of)==
This is the bug that will silently break your notifications and you won't notice for days. I shipped it. It cost me a full day of missed notifications before I caught it.
The broken pattern
// DO NOT DO THIS
static Future<void> reschedule() async {
await _plugin.cancel(id); // Destroys the repeating alarm
await _plugin.zonedSchedule(id, ...); // Creates a new one
}
It looks correct. It's not. Here's what happens:
- User opens the app at 9:00 AM
cancel(1) — destroys the existing repeating 8:00 AM alarm
_nextInstanceOfTime(8, 0) — 8:00 AM today already passed, returns tomorrow 8:00 AM
zonedSchedule(1, ...) — schedules a new alarm starting tomorrow
- Tomorrow, user opens the app at 9:00 AM again → same thing
- The notification is perpetually pushed to "tomorrow" and never fires
The correct pattern
// Only cancel when the user disables the notification
static Future<void> schedule(bool enabled, int hour, int minute) async {
if (!enabled) {
await _plugin.cancel(id); // User turned it off — remove the alarm
return;
}
// Calling zonedSchedule with the same ID REPLACES the existing alarm
// without resetting the repeat cycle. No cancel needed.
await _plugin.zonedSchedule(id, ...);
}
The rule: zonedSchedule with the same ID overwrites the previous alarm — it updates the title, body, and schedule time without destroying the repeat. You only need cancel when you want the notification to stop entirely.
This means you can safely call rescheduleAll() on every app open to refresh notification content (e.g., "You have 3 projects today" becomes "You have 4 projects today") without breaking delivery.
Step 6: Refresh Content on App Open
Notification bodies are frozen at schedule time. If the user adds a project at 11 PM, you want tomorrow's morning notification to include it.
// main.dart — after init, before runApp
if (FirebaseAuth.instance.currentUser != null) {
NotificationService.rescheduleAllNotifications().catchError((_) {});
}
rescheduleAllNotifications() fetches fresh data and calls zonedSchedule for each enabled notification, overwriting the stale body. No cancel — just overwrite.
Two things to guard against:
Auth check — if your notification content depends on user-specific data (Firestore queries), and no user is signed in, the query will hang. This blocks main() and your app never starts. Gate it.
Fire-and-forget — use .catchError((_) {}). If the reschedule fails (offline, Firestore timeout), the previously scheduled alarm still fires with yesterday's content. That's better than crashing on startup.
Step 7: Handle Android 14+ Exact Alarm Restriction
Android 14 changed the rules. SCHEDULE_EXACT_ALARM is no longer auto-granted. If your app calls exactAllowWhileIdle without the permission, it throws.
The graceful fallback:
static Future<void> scheduleDailyNotification({
required int id,
required String title,
required String body,
required int hour,
required int minute,
}) async {
final scheduledTime = _nextInstanceOfTime(hour, minute);
final details = /* your NotificationDetails */;
try {
// Try exact first — best experience
await _plugin.zonedSchedule(
id, title, body, scheduledTime, details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
);
} catch (e) {
// Exact alarm not permitted — fall back to inexact
// Delivery may drift by ~10 minutes, but the notification still fires
await _plugin.zonedSchedule(
id, title, body, scheduledTime, details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
);
}
}
For most notifications, a 10-minute window is fine. "Your day is 63% gone" at 2:10 PM instead of 2:00 PM doesn't break the experience.
Apply this same pattern to one-shot alarms too. Your pomodoro alarm scheduler should have the same try/catch:
static Future<void> schedulePhaseAlarm({
required int durationMinutes,
required NotificationDetails details,
}) async {
final fireAt = tz.TZDateTime.now(tz.local)
.add(Duration(minutes: durationMinutes));
try {
await _plugin.zonedSchedule(
alarmId, title, message, fireAt, details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (_) {
// Fall back to inexact — still fires, may drift ~5-10 min
try {
await _plugin.zonedSchedule(
alarmId, title, message, fireAt, details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
);
} catch (e) {
print('[Notifications] Failed to schedule alarm: $e');
}
}
}
Step 8: One-Shot Alarms (Pomodoro, Timers)
Daily repeating alarms cover most cases. But sometimes you need a notification that fires once at a specific future time — like when a 25-minute pomodoro focus session ends.
static Future<void> scheduleOneShot({
required int id,
required String title,
required String body,
required int minutesFromNow,
required NotificationDetails details,
}) async {
final fireAt = tz.TZDateTime.now(tz.local)
.add(Duration(minutes: minutesFromNow));
await _plugin.zonedSchedule(
id,
title,
body,
fireAt,
details,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
// NO matchDateTimeComponents — this fires once, doesn't repeat
);
}
Why you need this for pomodoro: A Dart Timer.periodic runs in your app's process. When the user locks their phone, the OS suspends the app within ~30 seconds. Your timer stops ticking. The phase ends, and — silence. The bell only rings when they unlock the phone and the app resumes.
An OS alarm doesn't care about your app's lifecycle. It fires regardless.
Making one-shot alarms alarm-grade
A standard Importance.high notification won't cut it on Samsung, Xiaomi, or Huawei devices. These OEMs aggressively kill background processes and suppress notifications they deem non-essential. You need to make your notification look like an alarm clock to the OS:
import 'dart:typed_data';
NotificationDetails _alarmGradeDetails({required bool isWorkPhase}) {
final soundFile = isWorkPhase ? 'bell_focus' : 'bell_break';
final channelId = isWorkPhase ? 'focus_bell' : 'break_bell';
final channelName = isWorkPhase ? 'Focus Bell' : 'Break Bell';
return NotificationDetails(
android: AndroidNotificationDetails(
channelId,
channelName,
channelDescription: 'Pomodoro phase transition',
importance: Importance.max, // Maximum priority
priority: Priority.max,
sound: RawResourceAndroidNotificationSound(soundFile),
playSound: true,
fullScreenIntent: true, // Wakes the screen
category: AndroidNotificationCategory.alarm, // Treated like an alarm
visibility: NotificationVisibility.public, // Shows on lock screen
enableVibration: true,
vibrationPattern: Int64List.fromList([0, 400, 200, 400]),
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
sound: '$soundFile.wav',
interruptionLevel: InterruptionLevel.timeSensitive, // Breaks through Focus
),
);
}
What each flag does:
| Flag | Effect |
importance: Importance.max | Heads-up notification — appears at the top of the screen |
fullScreenIntent: true | Wakes the screen and shows the notification even when locked. This is what alarm clock apps use. |
category: AndroidNotificationCategory.alarm | Tells the OS this is time-critical. Samsung's battery manager respects this category. |
visibility: NotificationVisibility.public | Content visible on the lock screen without unlocking |
vibrationPattern | Custom vibration so the user physically feels it |
interruptionLevel: InterruptionLevel.timeSensitive | iOS 15+: breaks through Focus mode |
This is the difference between a notification that works on your desk and one that works in your pocket. Standard Importance.high gets silently suppressed by Samsung's battery manager. Importance.max + fullScreenIntent + category: alarm does not.
The dual-delivery pattern
When the app is in the foreground, the Dart timer catches the transition first (instant feedback). When the phone is locked, the OS alarm delivers it. To avoid the user hearing two bells:
// In your timer tick (foreground path)
void _onPomodoroPhaseEnd() {
_plugin.cancel(alarmId); // Cancel the OS alarm (Dart beat it)
_plugin.show(displayId, ...); // Show the notification immediately
_scheduleNextPhaseAlarm(); // Schedule for the next phase
}
Use separate IDs for the scheduled alarm and the instant notification if you want, or the same ID if you want one to replace the other. Either way, the user sees exactly one notification.
Step 9: Weekly Repeating Notifications
For notifications that fire on specific weekdays — like a project reminder every Monday, Wednesday, and Friday at 6:00 PM.
static Future<void> scheduleWeekly({
required int id,
required String title,
required String body,
required int weekday, // 1 = Monday, 7 = Sunday
required int hour,
required int minute,
}) async {
await _plugin.zonedSchedule(
id,
title,
body,
_nextInstanceOfWeekdayTime(weekday, hour, minute),
/* notificationDetails */,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime, // ← Weekly
);
}
static tz.TZDateTime _nextInstanceOfWeekdayTime(
int weekday, int hour, int minute,
) {
final now = tz.TZDateTime.now(tz.local);
var scheduled = tz.TZDateTime(
tz.local, now.year, now.month, now.day, hour, minute,
);
// Advance to the target weekday
while (scheduled.weekday != weekday) {
scheduled = scheduled.add(const Duration(days: 1));
}
// If it's already passed this week, push to next week
if (scheduled.isBefore(now)) {
scheduled = scheduled.add(const Duration(days: 7));
}
return scheduled;
}
Important: Each weekday needs its own notification ID. If you want reminders on Monday, Wednesday, and Friday, that's three separate zonedSchedule calls with three different IDs. When removing the reminder, cancel all of them.
// Generating unique IDs per project + weekday
final notifId = baseId + projectId.hashCode.abs() % 900 + weekday;
Step 10: Surviving Samsung Battery Optimization
This is the step most Flutter notification tutorials skip, and it's why your notifications work perfectly during development but fail silently in production.
The problem
Samsung, Xiaomi, Huawei, OnePlus, and most Chinese OEMs add an aggressive battery optimization layer on top of Android's standard Doze mode. Even if your alarm is correctly s