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.periodicfor 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.1That'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 AMzonedSchedule(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 scheduled with exactAllowWhileIdle, the OEM's battery manager can:
- Kill your app process after ~5 minutes of screen-off time
- Block
AlarmManagerexact 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 assetsNotification 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 changesThe 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 + weekdayThe 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):
-
Request battery optimization exemption — show the system "Allow unrestricted?" dialog on first pomodoro start. This removes you from the standard Android optimization. (Step 10)
-
Use alarm-grade notification details —
fullScreenIntent: true+category: alarm+Importance.max. The OS treats these like alarm clock notifications and is far less likely to suppress them. (Step 8) -
Declare
USE_EXACT_ALARM— this permission is always granted on API 33+ without user interaction, giving you a stronger guarantee thanSCHEDULE_EXACT_ALARMalone. (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.
