Flutter Local Notifications: Permissions, Scheduling and Taps

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter local notifications.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for Flutter local notifications.

You will build a small Flutter screen that can request notification access, display an alert immediately, schedule another for a chosen local time, and reveal a payload when the user taps it. Those four actions expose the platform details that commonly make notification demos work on one device but fail in a release build.

Follow me on Instagram@sagnikteaches

This tutorial uses flutter_local_notifications with Android and Darwin initialisation settings, an Android notification channel, and the timezone package. It also separates ordinary notification permission from Android's special exact-alarm access.

Connect on LinkedInSagnik Bhattacharya

Start with a current stable Flutter project and test on physical Android and iOS devices as well as emulators. Local notifications do not need a remote messaging service, but platform permission decisions, battery restrictions, device time zones, and app launch state still affect their behaviour.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Add the notification and time-zone packages

Add both packages from the project root so that Flutter selects compatible current versions:

flutter pub add flutter_local_notifications
flutter pub add timezone

The notification plug-in owns the Android and Apple platform integrations. The time-zone package supplies the IANA database used by TZDateTime; a plain DateTime cannot reliably describe “09:00 in Europe/London” across daylight-saving transitions.

The example below deliberately requests permissions from a button rather than during application start-up. Users are more likely to understand the system prompt after choosing a feature that clearly needs notifications.

Initialise Android and Darwin before runApp

Create one plug-in instance and initialise it after WidgetsFlutterBinding.ensureInitialized(). Darwin settings cover iOS and macOS; this guide uses the iOS-specific implementation when requesting permission.

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz;
import 'package:timezone/timezone.dart' as tz;

final FlutterLocalNotificationsPlugin notifications =
    FlutterLocalNotificationsPlugin();

final ValueNotifier<String?> openedPayload = ValueNotifier<String?>(null);

const AndroidNotificationChannel remindersChannel =
    AndroidNotificationChannel(
  'important_reminders',
  'Important reminders',
  description: 'Time-sensitive reminders chosen by the user.',
  importance: Importance.high,
);

void handleNotificationResponse(NotificationResponse response) {
  openedPayload.value = response.payload;
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  tz.initializeTimeZones();
  tz.setLocalLocation(tz.getLocation('Europe/London'));

  const settings = InitializationSettings(
    android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    iOS: DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    ),
  );

  await notifications.initialize(
    settings: settings,
    onDidReceiveNotificationResponse: handleNotificationResponse,
  );

  await notifications
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(remindersChannel);

  final launchDetails =
      await notifications.getNotificationAppLaunchDetails();
  if (launchDetails?.didNotificationLaunchApp ?? false) {
    openedPayload.value =
        launchDetails?.notificationResponse?.payload;
  }

  runApp(const NotificationExampleApp());
}

Setting the Darwin request flags to false prevents an unexplained prompt at launch. The Android icon must name a real drawable or mipmap resource. A dedicated white notification drawable is preferable in production because Android renders small status-bar icons as monochrome masks.

getNotificationAppLaunchDetails() covers a notification tap that launched a terminated application. The response callback handles taps while the process already exists. Read launch details before runApp so the initial payload is not lost.

Declare Android permissions and scheduling receivers

Add the required declarations to android/app/src/main/AndroidManifest.xml. Place permissions directly inside manifest and receivers inside the existing application element:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <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" />

    <application
        android:label="Notification example"
        android:icon="@mipmap/ic_launcher">

        <receiver
            android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
            android:exported="false" />

        <receiver
            android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
                <action android:name="android.intent.action.QUICKBOOT_POWERON" />
                <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
            </intent-filter>
        </receiver>
    </application>
</manifest>

POST_NOTIFICATIONS supports the Android 13 runtime prompt. The boot permission and receivers allow pending schedules to be restored after a restart or application update. Scheduled notifications also require the Android desugaring configuration documented for the installed plug-in version, so retain that setting when adjusting Gradle files.

Local notifications do not require an iOS privacy string in Info.plist. Apple notification authorisation is requested through the notification framework, although signing, bundle identifiers, and deployment targets must still be valid for the app.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Request access and respect a refusal

Permission results are nullable because a platform implementation may not be present. Treat anything other than true as unavailable, and keep the rest of the app usable when access is denied.

Future<bool> requestNotificationPermission() async {
  final android = notifications
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>();

  final ios = notifications
      .resolvePlatformSpecificImplementation<
          IOSFlutterLocalNotificationsPlugin>();

  if (android != null) {
    return await android.requestNotificationsPermission() ?? false;
  }

  if (ios != null) {
    return await ios.requestPermissions(
          alert: true,
          badge: true,
          sound: true,
        ) ??
        false;
  }

  return false;
}

Android versions before 13 normally return an allowed state without showing the new runtime dialog, although users can still disable notifications in system settings. On iOS, a denied system prompt cannot simply be presented again; explain why alerts help and provide a route to the app's settings rather than repeatedly calling the method.

Permission to display notifications is distinct from exact-alarm access. Do not request the latter merely because the application contains a scheduling feature.

Give Android notifications a stable channel

Android 8 and later assigns sound, vibration, interruption level, and other behaviour through a channel. The channel created during initialisation uses the same identifier as every notification shown below:

const NotificationDetails reminderDetails = NotificationDetails(
  android: AndroidNotificationDetails(
    'important_reminders',
    'Important reminders',
    channelDescription: 'Time-sensitive reminders chosen by the user.',
    importance: Importance.high,
    priority: Priority.high,
  ),
  iOS: DarwinNotificationDetails(
    presentAlert: true,
    presentBadge: true,
    presentSound: true,
  ),
);

Channel identifiers are persistent. After a channel has been created, changing importance in Dart does not overwrite the user's system choice or reliably migrate the existing channel. Introduce a new channel identifier only when the product meaning genuinely changes, and avoid classifying routine promotional messages as high importance.

Show an immediate notification with a payload

An immediate notification is the fastest end-to-end check of permission, channel, icon, foreground presentation, and tap handling. Use an identifier that your application can reproduce if it later needs to replace or cancel this alert.

Future<void> showImmediateNotification() async {
  await notifications.show(
    id: 1001,
    title: 'Water break',
    body: 'Take a short break and refill your glass.',
    notificationDetails: reminderDetails,
    payload: 'reminder:water',
  );
}

The payload is application data, not visible notification copy. Keep it compact and non-sensitive because platform notification storage is not an appropriate place for secrets. A small route key or record identifier is usually enough; fetch current record data after the app opens.

Calling show can succeed even when the operating system suppresses presentation, so a completed future does not prove that the user granted access. Keep the permission result visible in your interface and verify system settings during diagnosis.

Schedule against an IANA time zone

The earlier Europe/London value makes the example deterministic. A production app should obtain the device's current IANA identifier through a maintained platform time-zone plug-in, or use a zone explicitly selected by the user. Do not pass abbreviations such as BST or IST to getLocation, because they are ambiguous.

tz.TZDateTime nextLocalTime(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,
  );

  if (!scheduled.isAfter(now)) {
    scheduled = tz.TZDateTime(
      tz.local,
      now.year,
      now.month,
      now.day + 1,
      hour,
      minute,
    );
  }

  return scheduled;
}

Future<void> scheduleMorningReminder() async {
  await notifications.zonedSchedule(
    id: 2001,
    title: 'Plan the day',
    body: 'Review the three tasks that matter most.',
    scheduledDate: nextLocalTime(9, 0),
    notificationDetails: reminderDetails,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    payload: 'reminder:morning-plan',
  );
}

Constructing the next calendar day with TZDateTime preserves the intended wall-clock meaning through a daylight-saving boundary. Adding a fixed 24-hour duration can instead move a reminder by an hour. For a daily recurrence, calculate and store the next occurrence deliberately, or use matchDateTimeComponents only after confirming that its recurrence semantics match the product requirement.

Use exact alarms only for genuinely exact events

Android 12 and later protects exact alarms with special access. Before selecting exactAllowWhileIdle, request access through the Android implementation:

Future<bool> requestExactAlarmAccess() async {
  final android = notifications
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>();

  if (android == null) {
    return true;
  }

  return await android.requestExactAlarmsPermission() ?? false;
}

The user may decline or later revoke this access. For approximate reminders, switch the schedule call to AndroidScheduleMode.inexactAllowWhileIdle; the operating system may batch it, which saves power and avoids promising precision the feature does not need.

SCHEDULE_EXACT_ALARM is user-controlled special access. Android also offers USE_EXACT_ALARM for narrowly eligible applications, but store policy restricts its use. Alarm clocks and calendars may justify exact timing; hydration tips and content reminders generally do not.

Connect the controls and display tapped payloads

This complete widget calls the functions above and makes both permission denial and a tapped payload observable:

class NotificationExampleApp extends StatelessWidget {
  const NotificationExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Local notification example',
      home: Scaffold(
        appBar: AppBar(title: const Text('Local notifications')),
        body: Padding(
          padding: const EdgeInsets.all(24),
          child: ListView(
            children: [
              ElevatedButton(
                onPressed: () async {
                  final granted =
                      await requestNotificationPermission();
                  if (!context.mounted) return;
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text(
                        granted
                            ? 'Notifications are allowed.'
                            : 'Notification permission was not granted.',
                      ),
                    ),
                  );
                },
                child: const Text('Allow notifications'),
              ),
              ElevatedButton(
                onPressed: showImmediateNotification,
                child: const Text('Show now'),
              ),
              ElevatedButton(
                onPressed: () async {
                  final exact = await requestExactAlarmAccess();
                  if (exact) {
                    await scheduleMorningReminder();
                  }
                  if (!context.mounted) return;
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text(
                        exact
                            ? 'Reminder scheduled for 09:00.'
                            : 'Exact-alarm access was not granted.',
                      ),
                    ),
                  );
                },
                child: const Text('Schedule 09:00 reminder'),
              ),
              const SizedBox(height: 24),
              ValueListenableBuilder<String?>(
                valueListenable: openedPayload,
                builder: (context, payload, child) {
                  return Text(
                    payload == null
                        ? 'No notification has been opened.'
                        : 'Opened payload: $payload',
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

In a real application, validate the payload and map it to a known route rather than treating arbitrary text as a route name. Notification action buttons or background responses need a separate top-level callback with the required entry-point annotation; the foreground tap callback shown here should not perform long-running work.

Avoid the failures that hide behind a successful build

  • Requesting at start-up: the prompt appears without context and users decline it. Ask when they enable a reminder.
  • Mismatched channel identifiers: creating one channel and posting to another produces unexpected importance or a fallback configuration.
  • Leaving tz.local as UTC: scheduled wall-clock times then shift for most users. Initialise the database and set an IANA location first.
  • Ignoring terminated launches: relying only on onDidReceiveNotificationResponse can lose the payload that started the app.
  • Assuming exact access: exactAllowWhileIdle is not a substitute for checking or requesting Android's special access.
  • Reusing every notification ID: a new notification can replace an older one. Allocate IDs according to whether replacement or coexistence is intended.
  • Forgetting cancellation: when a reminder is deleted, call notifications.cancel(id); use cancelAll() only for an explicit clear-all action.

Verify immediate, scheduled, and launch behaviour

First grant permission and press Show now while the app is foregrounded. Tap the notification and confirm that reminder:water appears. Then close the app fully, show another notification during development, tap it, and confirm that the launch-details path restores the payload.

Temporarily schedule a notification two minutes ahead rather than waiting until 09:00. Test once with exact-alarm access and once with an inexact mode, then restart the device to verify that the manifest receiver restores the schedule. Also deny notification permission, deny exact-alarm access, change the device time zone, and check that each branch remains understandable instead of failing silently.

final pending = await notifications.pendingNotificationRequests();

for (final request in pending) {
  debugPrint(
    'Pending ${request.id}: ${request.title} '
    'payload=${request.payload}',
  );
}

Pending requests confirm that the plug-in recorded a schedule; they do not guarantee an exact delivery instant because operating-system policy still applies. The final release check should use a physical device, a release build, the production application icon, and the same permission journey that a new user will see.

Further reads

Keep going with these related tutorials from this site.