Mxo.

Back to Blog
Flutter
android
Mobile Development

Build Reliable Local Notifications in Flutter (Step-by-Step)

A tried and tested step by step way of building a notification system for your flutter app with flutter-local-notifications and other packages.

Mxo MasukuApril 16, 202621 min read
Build Reliable Local Notifications in Flutter (Step-by-Step)
Image by Gerd Altmann from Pixabay

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:

PermissionWhy
POST_NOTIFICATIONSRequired on Android 13+ to show any notification at all
RECEIVE_BOOT_COMPLETEDAlarms survive device reboots
SCHEDULE_EXACT_ALARMFires at the exact second, even in Doze mode (API ≤ 33)
USE_EXACT_ALARMGuaranteed exact alarms on API 33+ — no user prompt needed, unlike SCHEDULE_EXACT_ALARM on API 34+
REQUEST_IGNORE_BATTERY_OPTIMIZATIONSLets you show the system "Allow unrestricted battery?" dialog — critical for Samsung, Xiaomi, Huawei
WAKE_LOCKKeeps 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:

  1. exactAllowWhileIdle — fires even in Doze mode. The OS wakes up just enough to deliver your notification.
  2. 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.
  3. _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:

  1. User opens the app at 9:00 AM
  2. cancel(1) — destroys the existing repeating 8:00 AM alarm
  3. _nextInstanceOfTime(8, 0) — 8:00 AM today already passed, returns tomorrow 8:00 AM
  4. zonedSchedule(1, ...) — schedules a new alarm starting tomorrow
  5. Tomorrow, user opens the app at 9:00 AM again → same thing
  6. 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:

  1. 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.

  2. 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:

FlagEffect
importance: Importance.maxHeads-up notification — appears at the top of the screen
fullScreenIntent: trueWakes the screen and shows the notification even when locked. This is what alarm clock apps use.
category: AndroidNotificationCategory.alarmTells the OS this is time-critical. Samsung's battery manager respects this category.
visibility: NotificationVisibility.publicContent visible on the lock screen without unlocking
vibrationPatternCustom vibration so the user physically feels it
interruptionLevel: InterruptionLevel.timeSensitiveiOS 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 scheduled with exactAllowWhileIdle, the OEM's battery manager can:

  • Kill your app process after ~5 minutes of screen-off time
  • Block AlarmManager exact alarms from waking the app
  • Silently suppress notifications from "sleeping" apps

This is why WhatsApp, Duolingo, and Telegram work but your app doesn't — Google Play Services (which delivers FCM push notifications) is whitelisted at the system level. Your app is not. You need the user to manually exempt you.

The solution

Android provides a system dialog that asks the user to whitelist your app from battery optimization. You can trigger it using the permission_handler package (which you already have for notification permissions):

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
 
class BatteryOptimizationService {
  static const String _keyAsked = 'battery_optimization_asked';
 
  /// Check if the app is already exempted from battery optimizations.
  static Future<bool> isExempted() async {
    if (!Platform.isAndroid) return true;
    final status = await Permission.ignoreBatteryOptimizations.status;
    return status.isGranted;
  }
 
  /// Request battery optimization exemption.
  /// Shows the system "Allow unrestricted battery?" dialog.
  static Future<bool> requestExemption() async {
    if (!Platform.isAndroid) return true;
    final status = await Permission.ignoreBatteryOptimizations.request();
    return status.isGranted;
  }
 
  /// Check and prompt once — call on first pomodoro start.
  static Future<void> ensureExemptedForPomodoro() async {
    if (!Platform.isAndroid) return;
    if (await isExempted()) return;
 
    final prefs = await SharedPreferences.getInstance();
    if (prefs.getBool(_keyAsked) ?? false) return;
 
    final granted = await requestExemption();
    await prefs.setBool(_keyAsked, true);
    debugPrint('[Battery] Exemption ${granted ? "granted" : "denied"}');
  }
}

When to prompt

Don't ask on app first launch — the user has no context for why you need it. Ask at the moment it matters:

Future<void> startPomodoro(String projectId, {required String projectName}) async {
  // Ask BEFORE the timer starts — user understands why
  await BatteryOptimizationService.ensureExemptedForPomodoro();
 
  // ... start the timer, schedule the alarm
}

The system dialog is native Android UI — it looks official, not spammy. And because we gate it with _keyAsked, the user only sees it once.

What the user sees

The dialog says something like:

Let LongGame run in the background?
This app will be able to run in the background, which may increase battery usage.

[DENY] [ALLOW]

If they tap "Allow", your app is exempted from the OEM's battery killing. Your exactAllowWhileIdle alarms now fire reliably even with the screen off.

For extra reliability

If your users are on Samsung specifically, you may also want to link them to the device-specific battery settings. Samsung has an additional "Sleeping Apps" list that operates independently of Android's standard battery optimization. The don't kill my app project maintains device-specific instructions you can reference in your settings screen.


Step 11: Custom Notification Sounds

If you want distinct sounds for different notification types — like a bright bell for "focus" and a warm chime for "break" — you need three things.

File placement

android/app/src/main/res/raw/bell_focus.wav     ← Android reads from res/raw
ios/Runner/bell_focus.wav                        ← iOS reads from app bundle
assets/sounds/bell_focus.wav                     ← Optional: Flutter assets

Notification details with sound

NotificationDetails(
  android: AndroidNotificationDetails(
    'channel_focus_bell',                                    // Unique channel ID
    'Focus Bell',
    sound: RawResourceAndroidNotificationSound('bell_focus'), // No file extension
    playSound: true,
  ),
  iOS: DarwinNotificationDetails(
    presentSound: true,
    sound: 'bell_focus.wav',                                  // With file extension
  ),
);

The channel rule

Android requires a separate notification channel for each distinct sound. Once a channel is created, its sound cannot be changed programmatically — the user would need to clear app data or reinstall. Name your channels carefully the first time.

// One channel per sound
'longgame_focus_bell'   →  bell_focus.wav
'longgame_break_bell'   →  bell_break.wav
'longgame_reminders'default system sound
 
// Don't try to reuse a channel with different sounds
// The first sound "wins" and the channel ignores subsequent changes

The Complete Reference

Which mechanism to use

ScenarioMethodRepeats?
Daily notification (8:00 AM every day)zonedSchedule + DateTimeComponents.timeEvery day
Weekly reminder (Mon at 6:00 PM)zonedSchedule + DateTimeComponents.dayOfWeekAndTimeEvery week
One-shot alarm (25 min from now)zonedSchedule, no matchDateTimeComponentsOnce
Persistent indicator (timer running)_plugin.show() with ongoing: trueUntil cancelled
Instant alert (event just happened)_plugin.show()Once

Which notification details to use

ScenarioImportancefullScreenIntentCategoryWhy
Daily nudge / reminderhighfalseStandard heads-up, doesn't need to wake the screen
Pomodoro phase transitionmaxtruealarmMust wake the screen and break through Samsung battery killing
Ongoing timerlowfalsestopwatchPersistent but non-intrusive, lives in the notification shade
Session reminderhighfalseStandard importance, user is likely awake

What not to use

ApproachWhat goes wrong
WorkManager periodic taskOS throttles it. 15-minute minimum becomes hours in Doze.
Dart Timer.periodic for alertsStops when phone locks. App suspended = timer dead.
cancel() then zonedSchedule() on app openPerpetually pushes alarm to "tomorrow." Never fires.
iOS Background Fetch0–2 executions per day. Apple decides when, not you.
Importance.high for time-critical alarmsSamsung silently suppresses it. Use max + alarm.
Ignoring battery optimizationWorks on Pixel, dies on Samsung. 70%+ of Android users are on OEM skins.

Android permissions checklist

PermissionWhen needed
POST_NOTIFICATIONSAlways (Android 13+)
RECEIVE_BOOT_COMPLETEDIf alarms should survive reboots
SCHEDULE_EXACT_ALARMExact alarms (API ≤ 33, runtime request on API 34+)
USE_EXACT_ALARMGuaranteed exact alarms (API 33+), no user prompt
REQUEST_IGNORE_BATTERY_OPTIMIZATIONSBattery exemption dialog
WAKE_LOCKKeep CPU alive during alarm processing

Notification IDs

Use fixed IDs per notification type. zonedSchedule with the same ID replaces the existing alarm. This is your friend — it's how you update content without breaking repeats.

static const int idMorningIntent = 1;
static const int idDriftAlert    = 2;
static const int idMirror        = 3;
static const int idStreak        = 4;
static const int idTimerRunning  = 5;
static const int idOngoingTimer  = 6;
static const int idPomodoroAlarm = 98;
static const int idPomodoro      = 99;
static const int idSessionBase   = 100;  // + project hash + weekday

The Bugs To Watch For

Bug 1: The startup hang

Your rescheduleAll() queries Firestore for personalized content. If no user is signed in, the Firestore SDK with offline persistence doesn't throw — it hangs. Your main() never reaches runApp(). The app is stuck on the splash screen.

Fix: Check FirebaseAuth.instance.currentUser != null before rescheduling. Also make the call fire-and-forget with .catchError() so a Firestore timeout doesn't block startup.

Bug 2: The cancel-reschedule cycle

Covered in Step 5, but worth repeating because it's the most insidious bug. It works perfectly in development (you're always testing right after scheduling), and fails silently in production (the user opens the app after the notification time, so it's always pushed to tomorrow).

Fix: Never cancel before zonedSchedule for enabled notifications. Only cancel when disabling.

Bug 3: The locked-phone silence

Your Dart timer works nicely in the foreground. You test it, the notification fires after 25 minutes, you ship. Then a user runs a pomodoro session, puts the phone down, and hears nothing. The OS suspended your app. Your timer stopped.

Fix: Schedule a one-shot OS alarm for the exact phase end time. The Dart timer is for foreground UX; the OS alarm is for reliability. Let them race.

Bug 4: The Samsung battery killer

Everything works on your Pixel. Every alarm fires on time. You ship. Then 70% of your users (Samsung, Xiaomi, Huawei) report that pomodoro notifications never arrive when the screen is off.

Samsung adds "Sleeping Apps" and "Deep Sleeping Apps" lists on top of Android's standard Doze. Even exactAllowWhileIdle alarms are suppressed for apps on these lists. Your perfectly scheduled alarm never fires because Samsung killed your process and blocked the AlarmManager wakeup.

Fix (three layers, use all of them):

  1. Request battery optimization exemption — show the system "Allow unrestricted?" dialog on first pomodoro start. This removes you from the standard Android optimization. (Step 10)

  2. Use alarm-grade notification detailsfullScreenIntent: true + category: alarm + Importance.max. The OS treats these like alarm clock notifications and is far less likely to suppress them. (Step 8)

  3. Declare USE_EXACT_ALARM — this permission is always granted on API 33+ without user interaction, giving you a stronger guarantee than SCHEDULE_EXACT_ALARM alone. (Step 2)

None of these alone is sufficient. Together, they give you the same delivery reliability as WhatsApp on Samsung.


The Cost

ComponentCost per user
FCM push messages$0 — not used
Cloud Functions$0 — computed on-device
Firestore reads for content$0 — bundled with existing app queries
Local alarm scheduling$0 — OS-level, no backend
Battery optimization prompt$0 — native system dialog

The entire notification system runs at zero marginal cost. The trade-off is that notification content is only as fresh as the user's last app open. For daily reflections and nudges, that's perfectly fine — you're computing tomorrow's content with today's data, and it's accurate enough to be useful.


This is the system behind Long Game, a life auditing app for people who want to be intentional with their time. The original post explaining the design decisions is here.

Share this article

Thoughts on this?

I'd love to hear from you — whether it's a question, a counterpoint, or something this sparked for you.

Follow Systems for Humans

I run a newsletter called Systems for Humans, where I publish essays, experiments, and projects on building software and systems that help humans act with clarity, discipline, and autonomy. Get updates when something new drops.