flutter_local_smart_notifications
A typed, local-only notification service for Flutter. Show notifications immediately, schedule them for later, repeat them on a recurring basis, and create persistent alarm-style reminders, all through a single Dart API.
The package owns the difficult platform details so your app does not have to: permissions, exact alarms, time zones, reboot recovery, pending-notification limits, and notification-tap routing.
Local only. This package does not include Firebase Cloud Messaging, OneSignal, or any other push SDK. If your app also needs server-triggered push notifications, keep that integration in a separate package or adapter.
Table of contents
- Features
- When to use this package
- Supported platforms
- Requirements
- Installation
- Getting started
- Usage
- Error handling with
NotificationResult - Permissions
- Example application
- Limitations
- Release checklist
- Documentation
Features
| Capability | Typical use cases | API |
|---|---|---|
| Instant notification | Immediate local events and status updates | showNotification |
| Scheduled notification | One-time future alerts | scheduleNotification |
| Recurring schedule | Daily, weekly, monthly, and yearly routines | scheduleNotification |
| Persistent reminder | Alarm-like, high-priority user tasks | showReminder, scheduleReminder |
| Android reminder actions | Done, Snooze, Review, Dismiss, Open | ReminderNotification.actions |
| Exact timing | Precise, user-visible deadlines | exactTiming: true |
| Wall-clock timing | Same local time after the user travels | localWallClock |
| Absolute timing | The same global instant everywhere | absoluteInstant |
| Tap routing | Navigate after a tap or action | onRoutingEvent |
| Pending inspection | Display or debug scheduled IDs | getPendingScheduled |
| Cancellation | Remove one or all notifications | cancel* methods |
Highlights
- Typed results, not exceptions. Every public operation returns
NotificationResult<T>, so expected runtime conditions (missing permission, past time, platform limits) are values your app can display or recover from. - Explicit time-zone semantics. Choose between
localWallClock("every day at 09:00 where the user is now") andabsoluteInstant("at this exact moment globally"). - Honest exact alarms. An exact request is never silently downgraded to an inexact schedule. If Android exact-alarm access is unavailable, you receive
ExactAlarmPermissionFailure. - Routing that survives the background. Tap and action events are persisted first, then delivered through
onRoutingEventafter the next successful initialization. - Lifecycle recovery. Persistent reminders are stored and reconciled on the next initialization. Android boot restoration works when your app declares the documented boot receiver.
- Platform-neutral permissions. Typed statuses, a configurable permission dialog, a denial cooldown, a bounded settings-return wait, and a re-check when the app resumes. No upstream plugin enums leak into your code.
- Pending-limit awareness. Capacity is checked before scheduling. On iOS, the budget respects the platform's 64-pending-notification cap and the slots reserved in
ReminderConfig.
When to use this package
Use it when the device can decide when to notify:
- Instant: an expense was recorded, a download finished, a local message was saved, a form needs attention, or a background task completed.
- Scheduled: a bill due in two hours, a meeting reminder, a trial-expiry warning, a medication reminder, or a delivery window.
- Recurring: daily habits and check-ins, weekly budget reviews, monthly rent or statement reminders, yearly birthdays, renewals, or tax dates.
- Persistent reminders: a critical bill due date, a medication alarm, an attendance or check-in deadline, a safety or monitoring alert, or any task that should remain visible until handled.
It does not fetch remote messages. Pair it with a separate push adapter if a server must trigger delivery.
Supported platforms
| Platform | Supported |
|---|---|
| Android | ✅ |
| iOS | ✅ |
Requirements
- Flutter
3.38.1or newer - Dart
3.11.3or newer - Android compile SDK 35 or newer (when building for Android)
- Java 17 and core-library desugaring (Android)
- A real device or emulator for delivery tests. Unit and widget tests can run without a notification-capable device.
Installation
Add the package to your app's pubspec.yaml:
dependencies:
flutter_local_smart_notifications: ^0.0.1
Then fetch dependencies:
flutter pub get
Native setup
Native setup is required before testing on a device. This package intentionally does not modify your Android manifest, Gradle files, iOS Podfile, or iOS project settings, because every app has different product requirements.
See doc/SETUP.md for:
- Android permissions and receivers
- Exact-alarm access and Google Play policy considerations
- Java 17, compile SDK 35, and desugaring
- Android launcher and notification resources, and custom sounds
- iOS permission-handler configuration
- iOS's pending-notification limit
- The platform verification matrix
Getting started
- Add the package and complete the native setup.
- Declare your notification channels.
- Initialize the service once.
- Request permission from a user-visible screen.
- Call
showNotification,scheduleNotification, orshowReminder. - Handle the returned
NotificationResultinstead of assuming delivery succeeded.
1. Configure
Create a file such as lib/notification_config.dart. A configuration has three key parts:
enabledProvidersturns on the capabilities your app needs.channelsdeclares every channel a payload may use.androidDefaultIconnames a native Android resource used for notifications.
import 'dart:ui';
import 'package:flutter_local_smart_notifications/flutter_local_smart_notifications.dart';
const notificationConfig = NotificationConfig(
enabledProviders: {
NotificationFeature.localInstant,
NotificationFeature.localScheduled,
NotificationFeature.localReminder,
},
channels: [
NotificationChannelConfig(
id: 'general',
name: 'General',
description: 'General application notifications',
importance: ChannelImportance.high,
),
NotificationChannelConfig(
id: 'reminders',
name: 'Reminders',
description: 'Time-sensitive reminders',
importance: ChannelImportance.max,
),
],
androidDefaultIcon: '@mipmap/ic_launcher',
androidDefaultColor: Color(0xFF4CAF50),
permissionConfig: PermissionConfig(
autoRequestOnInit: false,
dialogTitle: 'Enable notifications',
dialogMessage: 'Allow notifications so important reminders arrive on time.',
openSettingsLabel: 'Open settings',
notNowLabel: 'Not now',
),
);
NotificationProvideris a backwards-compatible alias forNotificationFeature; either name is valid.
Every channelId used in a NotificationPayload must be present in channels. Otherwise the operation returns ChannelNotRegisteredFailure.
2. Initialize once
Initialization validates the configuration, prepares the platform plugin, restores persisted routing and reminder state, and checks the current permission status.
import 'package:flutter_local_smart_notifications/flutter_local_smart_notifications.dart';
Future<void> initializeNotifications() async {
final result = await NotificationService.initialize(notificationConfig);
result.fold(
onSuccess: (service) {
service.onRoutingEvent.listen((event) {
// Navigate using event.target and read extra values from event.data.
print('Notification route: ${event.target}');
});
},
onFailure: (failure) {
// Show a useful message or log the typed failure.
print('Notification setup failed: ${failure.message}');
},
);
}
Call this once at app startup, for example after WidgetsFlutterBinding.ensureInitialized() and before runApp.
- Only one active runtime is permitted at a time. Do not create multiple runtimes with different configurations. Call
await service.dispose()before replacing the configuration (for example, in a test). NotificationService.instanceis always safe to read. Before initialization completes, operations returnNotInitializedFailurerather than throwing.
3. Request permission
Connect permission prompts to a user-visible action, such as an "Enable notifications" button. Avoid surprising users with a dialog on a splash screen unless that is an intentional product decision.
Future<void> enableNotifications(BuildContext context) async {
final result = await NotificationService.instance.requestPermission(
context: context,
);
if (!context.mounted) return;
result.fold(
onSuccess: (status) {
if (status.isGranted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notifications are enabled.')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Current status: ${status.runtimeType}')),
);
}
},
onFailure: (failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(failure.message)),
);
},
);
}
For features that need precise timing, request notification permission and exact-alarm access together, and only when the user chooses such a feature:
final result = await NotificationService.instance.requestPermission(
context: context,
includeExactAlarm: true,
);
includeExactAlarm never silently makes a schedule inexact. If Android exact-alarm access is unavailable, the operation returns ExactAlarmPermissionFailure.
Usage
Show an instant notification
final result = await NotificationService.instance.showNotification(
const NotificationPayload(
id: 1,
title: 'Expense recorded',
body: 'Your coffee expense was added.',
channelId: 'general',
data: {'route': '/expenses'},
),
);
if (result.isFailure) {
print(result.failureOrNull);
}
The id is the platform notification ID. Reusing an ID updates or replaces the notification on platforms that support it. Use unique IDs when you want multiple alerts to remain visible.
NotificationPayload fields
| Field | Description |
|---|---|
id |
Numeric platform notification ID. |
title |
Notification title. |
body |
Notification body. |
channelId |
One of the channels declared in NotificationConfig. |
data |
JSON-like routing and application data. |
soundName |
Optional key registered in soundAssets. |
silent |
Requests silent presentation. |
Add notification actions
Android reminder actions let a user respond without opening the app. Action taps are delivered through the same routing stream as notification taps.
const reminder = ReminderNotification(
payload: NotificationPayload(
id: 2,
title: 'Review purchase',
body: 'A large purchase needs your attention.',
channelId: 'general',
data: {'route': '/expenses/2'},
),
actions: [
NotificationAction(id: 'review', title: 'Review'),
NotificationAction(
id: 'dismiss',
title: 'Dismiss',
cancelNotification: true,
openApp: false,
),
],
);
await NotificationService.instance.showReminder(reminder);
cancelNotificationcontrols whether the notification is removed after the action.openAppcontrols whether the action opens the app, where the platform supports that choice.- Android action taps require the action receiver described in doc/SETUP.md.
Schedule a notification
Use ScheduledNotification when delivery should happen in the future or repeat:
final result = await NotificationService.instance.scheduleNotification(
ScheduledNotification(
payload: const NotificationPayload(
id: 42,
title: 'Daily check-in',
body: 'Record today’s expense.',
channelId: 'reminders',
data: {'route': '/dashboard'},
),
scheduledTime: DateTime.now().add(const Duration(minutes: 1)),
repeatInterval: RepeatInterval.none,
allowWhileIdle: true,
exactTiming: false,
semantics: NotificationScheduleSemantics.absoluteInstant,
),
);
The scheduled time must be in the future. A past time returns PastTimeFailure.
Repeat intervals
| Value | Behavior | Example |
|---|---|---|
RepeatInterval.none |
Deliver once | Meeting reminder |
RepeatInterval.daily |
Repeat every day | Medication, check-ins |
RepeatInterval.weekly |
Repeat every week | Weekly planning, budget review |
RepeatInterval.monthly |
Repeat every month | Rent, subscriptions, statements |
RepeatInterval.yearly |
Repeat every year | Birthdays, renewals, taxes |
Choose the time-zone meaning
This choice matters when a user travels or changes the device time zone.
// "Every day at the user's local 09:00."
semantics: NotificationScheduleSemantics.localWallClock,
// "The same absolute moment everywhere."
semantics: NotificationScheduleSemantics.absoluteInstant,
| Semantics | Use for |
|---|---|
localWallClock |
Habits, medication, morning check-ins, and other time-of-day reminders |
absoluteInstant |
Deadlines, expiry moments, and events tied to one global instant |
Persistent wall-clock reminders are re-synchronized after the package detects a device time-zone change during initialization.
Exact versus inexact timing
Set exactTiming: true only when the product requirement justifies precise timing. Android may require the user to grant the exact-alarm special access. The package checks that access and returns ExactAlarmPermissionFailure when it is unavailable. It does not silently downgrade the request.
Use exact alarms only when the requirement justifies the extra permission and the associated platform policy review.
Create a persistent reminder
Reminders are intended for high-priority, alarm-like experiences. They can be instant or scheduled.
final result = await NotificationService.instance.scheduleReminder(
ReminderNotification(
payload: const NotificationPayload(
id: 300,
title: 'Bill due soon',
body: 'Your electricity bill is due today.',
channelId: 'reminders',
data: {'route': '/bills/electricity'},
),
scheduledTime: DateTime.now().add(const Duration(minutes: 2)),
timeout: const Duration(minutes: 10),
persistent: true,
fullScreenIntent: false,
exactTiming: true,
actions: const [
NotificationAction(id: 'done', title: 'Done'),
NotificationAction(id: 'snooze', title: 'Snooze'),
],
semantics: NotificationScheduleSemantics.absoluteInstant,
),
);
Use showReminder instead when scheduledTime is null and the reminder should appear immediately. ReminderNotification.isInstant reports which form an object represents.
| Field | Description |
|---|---|
timeout |
How long the reminder stays active before timing out. |
persistent |
Requests an ongoing, harder-to-dismiss presentation. |
fullScreenIntent |
Requests an alarm-like full-screen presentation. Use only when genuinely needed, and verify platform and store policy separately. |
exactTiming |
Requests exact scheduling for a scheduled reminder. |
actions |
Reminder-specific action buttons. |
semantics |
Absolute-instant or local-wall-clock meaning. |
ReminderConfig limits active reminders and pending notifications, controls exact alarm delivery by default, and can enable reboot recovery.
Handle taps and actions
Subscribe after successful initialization:
service.onRoutingEvent.listen((event) {
switch (event.target) {
case '/expenses':
// Navigate to the expenses page.
break;
case '/bills/electricity':
// Navigate to the bill details page.
break;
}
print('source=${event.source} interaction=${event.interaction}');
print('actionId=${event.actionId} data=${event.data}');
});
Put a route and any small JSON-like values in NotificationPayload.data. This works for foreground taps, app-launch taps, and background action or tap events.
RoutingEvent contains:
| Property | Description |
|---|---|
target |
The route or target name, from data['route'] or data['target']. |
actionId |
The tapped action, when an action caused the event. |
data |
The original payload data. |
source |
local, scheduled, or reminder. |
interaction |
tap, action, dismiss, or timeout. |
timestamp |
When the routing event was created. |
Background callbacks cannot access the singleton or in-memory UI state. The package persists the event first and delivers it through onRoutingEvent after the next initialization.
Cancel and inspect notifications
await NotificationService.instance.cancelNotification(42);
await NotificationService.instance.cancelScheduled(42);
await NotificationService.instance.cancelReminder(300);
final pending = await NotificationService.instance.getPendingScheduled();
pending.fold(
onSuccess: (ids) => print('Pending scheduled IDs: $ids'),
onFailure: (failure) => print(failure.message),
);
await NotificationService.instance.cancelAllScheduled();
await NotificationService.instance.cancelAllReminders();
await NotificationService.instance.cancelAll();
cancelNotification cancels the matching notification through the enabled local and scheduled providers. Use the provider-specific methods when you want the intent to be explicit.
Error handling with NotificationResult
Every public operation returns NotificationResult<T>:
final result = await NotificationService.instance.showNotification(payload);
if (result.isSuccess) {
print('Notification request accepted by the platform.');
} else {
final failure = result.failureOrNull!;
print('${failure.runtimeType}: ${failure.message}');
}
For a single expression, use fold:
final message = result.fold(
onSuccess: (_) => 'Done',
onFailure: (failure) => failure.message,
);
Common failures
| Failure | Resolution |
|---|---|
NotInitializedFailure |
Initialize first, or wait for initialization to finish. |
ProviderNotEnabledFailure |
Enable the feature in NotificationConfig. |
PermissionNotGrantedFailure |
Request notification access. |
ExactAlarmPermissionFailure |
Ask for exact-alarm access, or use inexact timing. |
ChannelNotRegisteredFailure |
Add the payload's channel to the config. |
PastTimeFailure |
Choose a future schedule time. |
PlatformLimitExceededFailure |
Reduce pending notifications or adjust the reminder budget. |
ReminderLimitExceededFailure |
Cancel reminders or increase maxActiveReminders. |
UnsupportedPlatformFailure |
Run the operation on a supported platform. |
See the complete list in doc/API.md.
Permissions
The package exposes its own platform-neutral status types:
| Status | Meaning |
|---|---|
PermissionNotDetermined |
The user has not answered yet. |
PermissionGranted |
Normal notification access is granted. |
PermissionDenied |
Access is denied but may be requested again. |
PermissionPermanentlyDenied |
The user must use system settings. |
PermissionRestricted |
The platform restricts access. |
PermissionProvisional |
iOS provisional access. Counts as granted. |
Use status.isGranted, status.isDenied, and status.canRequest rather than inspecting platform plugin enums.
Permission is checked at initialization and whenever the app resumes.
Example application
The example/ app is a capability lab that demonstrates every supported notification and scheduling mode:
- App-level expense notification configuration
- Instant notification cases
- One-shot and recurring schedules
- Both time-zone semantics
- Persistent reminders and full-screen policy
- Cancellation and pending-ID cases
- Deterministic widget and unit tests
cd example
flutter pub get
dart run flutter_launcher_icons
flutter run
flutter test
Limitations
- Remote push notifications are not supported.
- Android manifest entries and iOS project settings are not added to your app automatically.
- Exact schedules are never silently downgraded to inexact schedules.
- Dart code cannot run immediately after an Android reboot. Native boot restoration plus next-launch reconciliation are used instead.
- A permission revocation cannot be observed while the app is fully closed. Permission is checked at initialization and on app resume.
Release checklist
Before shipping a notification feature:
- Test a fresh install with permission granted and denied.
- Test permanent denial and opening system settings.
- Test Android API 31, 33, 34, and 35+.
- Test exact-alarm grant and refusal.
- Test both an exact and an inexact schedule.
- Test a device reboot with the boot receiver declared.
- Change the device time zone and verify both schedule semantics.
- Tap the notification, tap each action, and test a background tap.
- Test iOS pending-notification capacity.
- Verify that every operation displays or logs its
NotificationFailure.
Documentation
Libraries
- flutter_local_smart_notifications
- Public API for the Flutter Local Smart Notifications package.
- notifications/core/config/notification_channel_config
- notifications/core/config/notification_config
- notifications/core/config/permission_config
- notifications/core/config/reminder_config
- notifications/core/constants/defaults
- notifications/core/logger/notification_logger
- notifications/d_print
- notifications/data/repositories/local/local_notification_repository
- notifications/data/repositories/local/local_notification_repository_impl
- notifications/data/repositories/local/local_notification_repository_stub
- notifications/data/repositories/permission/permission_repository
- notifications/data/repositories/permission/permission_repository_impl
- notifications/data/repositories/permission/permission_repository_stub
- notifications/data/repositories/reminder/reminder_notification_repository
- notifications/data/repositories/reminder/reminder_notification_repository_impl
- notifications/data/repositories/reminder/reminder_notification_repository_stub
- notifications/data/repositories/scheduled/scheduled_notification_repository
- notifications/data/repositories/scheduled/scheduled_notification_repository_impl
- notifications/data/repositories/scheduled/scheduled_notification_repository_stub
- notifications/data/routing/notification_response_codec
- notifications/data/routing/routing_event_bus
- notifications/data/storage/notification_storage_impl
- notifications/domain/entities/notification_action
- notifications/domain/entities/notification_payload
- notifications/domain/entities/notification_result
- notifications/domain/entities/permission_status
- notifications/domain/entities/reminder_notification
- notifications/domain/entities/routing_event
- notifications/domain/entities/scheduled_notification
- notifications/domain/failures/notification_failure
- notifications/domain/repositories/i_local_notification_repository
- notifications/domain/repositories/i_notification_logger
- notifications/domain/repositories/i_notification_storage
- notifications/domain/repositories/i_permission_repository
- notifications/domain/repositories/i_reminder_notification_repository
- notifications/domain/repositories/i_scheduled_notification_repository
- notifications/domain/usecases/cancel_notification_usecase
- notifications/domain/usecases/cancel_reminder_usecase
- notifications/domain/usecases/schedule_notification_usecase
- notifications/domain/usecases/schedule_reminder_usecase
- notifications/domain/usecases/show_instant_notification_usecase
- notifications/domain/usecases/show_reminder_usecase
- notifications/infrastructure/di/notification_service_factory
- notifications/presentation/dialogs/default_permission_dialog
- notifications/presentation/dialogs/i_permission_dialog
- notifications/presentation/notification_service