DEV Community

Cover image for Build Reliable Local Notifications in Flutter (Step-by-Step)
Mxolisi Masuku
Mxolisi Masuku

Posted on • Originally published at mxomasuku.com

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

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
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode
// 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, ...);
}
Enter fullscreen mode Exit fullscreen mode

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((_) {});
}
Enter fullscreen mode Exit fullscreen mode

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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"}');
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  ),
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Complete Reference

Which mechanism to use

Scenario Method Repeats?
Daily notification (8:00 AM every day) zonedSchedule + DateTimeComponents.time Every day
Weekly reminder (Mon at 6:00 PM) zonedSchedule + DateTimeComponents.dayOfWeekAndTime Every week
One-shot alarm (25 min from now) zonedSchedule, no matchDateTimeComponents Once
Persistent indicator (timer running) _plugin.show() with ongoing: true Until cancelled
Instant alert (event just happened) _plugin.show() Once

Which notification details to use

Scenario Importance fullScreenIntent Category Why
Daily nudge / reminder high false Standard heads-up, doesn't need to wake the screen
Pomodoro phase transition max true alarm Must wake the screen and break through Samsung battery killing
Ongoing timer low false stopwatch Persistent but non-intrusive, lives in the notification shade
Session reminder high false Standard importance, user is likely awake

What not to use

Approach What goes wrong
WorkManager periodic task OS throttles it. 15-minute minimum becomes hours in Doze.
Dart Timer.periodic for alerts Stops when phone locks. App suspended = timer dead.
cancel() then zonedSchedule() on app open Perpetually pushes alarm to "tomorrow." Never fires.
iOS Background Fetch 0–2 executions per day. Apple decides when, not you.
Importance.high for time-critical alarms Samsung silently suppresses it. Use max + alarm.
Ignoring battery optimization Works on Pixel, dies on Samsung. 70%+ of Android users are on OEM skins.

Android permissions checklist

Permission When needed
POST_NOTIFICATIONS Always (Android 13+)
RECEIVE_BOOT_COMPLETED If alarms should survive reboots
SCHEDULE_EXACT_ALARM Exact alarms (API ≤ 33, runtime request on API 34+)
USE_EXACT_ALARM Guaranteed exact alarms (API 33+), no user prompt
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS Battery exemption dialog
WAKE_LOCK Keep 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
Enter fullscreen mode Exit fullscreen mode

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

Component Cost 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.

Top comments (0)