Local notifications #
We will be using the awesome_notifications plugin. It only supports iOS and Android (no web).
Project setup #
flutter create awesome_notifications_demo --platforms=ios,android
cd awesome_notifications_demo
flutter pub add awesome_notifications_core:^0.9.3 awesome_notifications:any
Android #
Following changes are shown with diff syntax.
Red lines starting with -
should be removed.
Green lines starting with +
should be added.
Lines starting with @@
are just indication of lines numbers and should not be
included.
Make the following changes to android/app/build.gradle
.
@@ -24,7 +24,7 @@ if (flutterVersionName == null) {
android {
namespace "com.example.awesome_notifications_demo"
- compileSdk flutter.compileSdkVersion
+ compileSdkVersion 34
ndkVersion flutter.ndkVersion
compileOptions {
@@ -45,8 +45,8 @@ android {
applicationId "com.example.awesome_notifications_demo"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
- minSdkVersion flutter.minSdkVersion
- targetSdkVersion flutter.targetSdkVersion
+ minSdkVersion 21
+ targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
Add permissions to android/app/src/main/AndroidManifest.xml
.
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-permission android:name="android.permission.VIBRATE"/>
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:label="awesome_notifications_demo"
android:name="${applicationName}"
You also need an icon for notifications on Android.
We can just copy android/app/src/main/res/mipmap-hdpi/ic_launcher.png
to
android/app/src/main/res/drawable/app_icon.png
.
iOS #
🍎 Configuring iOS for Awesome Notifications
Starting point #
Theming #
We are going to use colors a couple of different places. To keep it consistent we will start by defining some constants for it.
lib/theme.dart
import 'package:flutter/material.dart';
const brandColor = Colors.deepPurple;
final theme = ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: brandColor, brightness: Brightness.light));
final darkTheme = ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: brandColor, brightness: Brightness.dark));
Scaffold #
Replace the counter demo app with our own scaffold using color scheme defined above.
import 'package:flutter/material.dart';
import 'theme.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
final title = 'Local Notifications Demo';
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
title: title,
color: brandColor,
theme: theme,
darkTheme: theme,
home: Scaffold(
appBar: AppBar(title: Text(title), centerTitle: true),
body: Text("TODO"),
),
);
}
}
Nothing fancy. Moving on.
Notification #
The plugin #
Here is a quick overview of the parts on the plugin API we will be using.
Just for illustration. Don’t add it to your app (yet).
Initialization #
The plugin needs to be initialized before runApp
is called.
AwesomeNotifications().initialize(
// set the icon to null if you want to use the default app icon
'resource://drawable/app_icon',
[
NotificationChannel(
channelGroupKey: 'basic_channel_group',
channelKey: 'basic_channel',
channelName: 'Basic notifications',
channelDescription: 'Notification channel for basic tests',
defaultColor: brandColor,
)
],
// Channel groups are only visual and are not required
channelGroups: [
NotificationChannelGroup(
channelGroupKey: 'basic_channel_group',
channelGroupName: 'Basic group',
)
],
debug: true,
);
The first parameter is an icon to use for notifications on Android. It is the file you copied in the setup step for Android.
Next we have some channel stuff. Apps can show notifications for different reasons. Channels allows the user to customize which notifications they receive from the app.
The channelKey
is important.
When creating a notification we use the same value to indicate what channel the
notification belongs to.
It is a good idea to make it a constant.
A small side-note.
AwesomeNotifications
is just a simple wrapper around a platform specific
singleton.
Plugins like this, are some of the rare cases where the singleton
pattern is a good idea.
Listeners / callbacks #
We can register listeners/callbacks for different notification events. The callbacks either need to be top-level functions or static methods.
AwesomeNotifications().setListeners(
onActionReceivedMethod: NotificationController.onActionReceivedMethod,
onNotificationCreatedMethod:
NotificationController.onNotificationCreatedMethod,
onNotificationDisplayedMethod:
NotificationController.onNotificationDisplayedMethod,
onDismissActionReceivedMethod:
NotificationController.onDismissActionReceivedMethod,
);
All callbacks are set to static methods on a NotificationController class. We will look at the controller a bit later.
Permissions #
Before the app can create notifications it needs permission to do so.
bool isAllowed = await AwesomeNotifications().isNotificationAllowed();
if (!isAllowed) {
isAllowed =
await AwesomeNotifications().requestPermissionToSendNotifications();
}
It is good practice to inform the user why the app wants to create notifications before requesting permission.
The user might deny. In which case you can’t get the permission dialog to show again. The best you can to is to instruct the user on why it is important and how to enable it.
Create notification #
When the plugin is initialized, callbacks are configured and the app got permission granted, we can create notifications.
int lastId = 0;
AwesomeNotifications().createNotification(
content: NotificationContent(
id: lastId++,
channelKey: 'basic_channel',
actionType: ActionType.Default,
title: title,
body: body,
payload: payload,
color: brandColor,
),
);
Remember the channelKey
from the initialization step?
We need to use a channel key that is an exact match when sending notifications.
The id
should be different for each notification.
It can be used to update an existing notification.
Payload is some data that can be attached to the notification. Our app can access the data from one of the callbacks.
Build abstraction #
It is often a good idea to wrap your external dependencies in an abstraction. There are multiple reasons for this.
- It can encapsulate some of the complexity of the package behind an interface that expose just what you need in your application.
- It simplifies migration should a new version of the package come out with API changes.
- It makes it easier to switch the dependency out for something else (like flutter_local_notifications).
Awesome Notifications plugin got a fairly simple interface already. But we are still going to hide it in an abstraction for the above mentioned reasons.
Add this to your code
class NotificationService {
/// Invoke before `runApp`
Future<bool> initialize() {
return AwesomeNotifications().initialize(
// set the icon to null if you want to use the default app icon
'resource://drawable/app_icon',
[
NotificationChannel(
channelGroupKey: 'basic_channel_group',
channelKey: 'basic_channel',
channelName: 'Basic notifications',
channelDescription: 'Notification channel for basic tests',
defaultColor: brandColor,
)
],
// Channel groups are only visual and are not required
channelGroups: [
NotificationChannelGroup(
channelGroupKey: 'basic_channel_group',
channelGroupName: 'Basic group',
)
],
debug: true,
);
}
/// Register callbacks for the notification.
Future<bool> setupCallbacks() async {
// Only after at least the action method is set, the notification events are delivered
final success = await AwesomeNotifications().setListeners(
onActionReceivedMethod: NotificationController.onActionReceivedMethod,
onNotificationCreatedMethod:
NotificationController.onNotificationCreatedMethod,
onNotificationDisplayedMethod:
NotificationController.onNotificationDisplayedMethod,
onDismissActionReceivedMethod:
NotificationController.onDismissActionReceivedMethod,
);
return success;
}
/// Request permission from OS to show notifications, if it isn't allowed already
Future<bool> requestPermission() async {
bool isAllowed = await AwesomeNotifications().isNotificationAllowed();
if (!isAllowed) {
isAllowed =
await AwesomeNotifications().requestPermissionToSendNotifications();
}
return isAllowed;
}
int _lastId = 0;
/// Create a notification
show({String? title, String? body, Map<String, String?>? payload}) {
AwesomeNotifications().createNotification(
content: NotificationContent(
id: _lastId++,
channelKey: 'basic_channel',
actionType: ActionType.Default,
title: title,
body: body,
payload: payload,
),
);
}
}
Callbacks #
Lets define the callback handlers.
class NotificationController {
/// Use this method to detect when a new notification or a schedule is created
@pragma("vm:entry-point")
static Future<void> onNotificationCreatedMethod(
ReceivedNotification receivedNotification) async {
// Your code goes here
}
/// Use this method to detect every time that a new notification is displayed
@pragma("vm:entry-point")
static Future<void> onNotificationDisplayedMethod(
ReceivedNotification receivedNotification) async {
// Your code goes here
}
/// Use this method to detect if the user dismissed a notification
@pragma("vm:entry-point")
static Future<void> onDismissActionReceivedMethod(
ReceivedAction receivedAction) async {
// Your code goes here
}
/// Use this method to detect when the user taps on a notification or action button
@pragma("vm:entry-point")
static Future<void> onActionReceivedMethod(
ReceivedAction receivedAction) async {
// Navigate into pages, avoiding to open the notification details page over another details page already opened
MyApp.navigatorKey.currentState?.pushAndRemoveUntil(
MaterialPageRoute(
builder: (context) => NotificationScreen(receivedAction),
),
(route) => route.isFirst,
);
}
}
The @pragma("vm:entry-point")
is because the callbacks will be invoked from
native code via the plugin.
Did you notice that we had a navigatorKey
in the MyApp
?
We use it in the last callback so we can access the Navigator
so we can change
routes.
A Key can be used to refer to a specific widget. Think of it like the HTML id attribute.
UI #
Let’s write the presentation layer. It is going to be really basic.
Main #
Change the main method to initialize the plugin through our
NotificationService
.
Then use Provider
to allow other part of the app to access it.
void main() async {
final notification = NotificationService();
await notification.initialize();
runApp(Provider<NotificationService>.value(
value: notification,
child: const MyApp(),
));
}
Providing dependencies makes it easy to swap out the implementation for something else when testing.
MyApp #
Convert MyApp
to a StatefulWidget
.
Then add an override for initState
.
@override
void initState() {
super.initState();
context.read<NotificationService>().setupCallbacks();
}
And replace body of Scaffold
in the build method.
home: Scaffold(
appBar: AppBar(title: Text(title), centerTitle: true),
body: const NotificationForm(),
),
Notification form #
We got a couple of input fields for values in the notification. A button to request permission. And a button to show a notification.
The familiar provider pattern is used to
access NotificationService
.
class NotificationForm extends StatefulWidget {
const NotificationForm({super.key});
@override
State<NotificationForm> createState() => _NotificationFormState();
}
class _NotificationFormState extends State<NotificationForm> {
final _titleController = TextEditingController();
final _bodyController = TextEditingController();
final _payloadController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
OutlinedButton(
onPressed: () {
context.read<NotificationService>().requestPermission();
},
child: Text("Request permission"),
),
TextFormField(
controller: _titleController,
decoration: const InputDecoration(label: Text("Title")),
),
spacer,
TextFormField(
controller: _bodyController,
decoration: const InputDecoration(label: Text("Body")),
),
spacer,
TextFormField(
controller: _payloadController,
decoration: const InputDecoration(label: Text("Payload")),
),
spacer,
ElevatedButton(
onPressed: () {
context.read<NotificationService>().show(
title: _titleController.text,
body: _bodyController.text,
payload: {"text": _payloadController.text},
);
},
child: const Text("Show notification"),
)
],
),
);
}
}
const spacer = SizedBox(height: 8);
Here is what it looks like.
NotificationScreen #
A simple screen that will show the data that notification callback receives.
class NotificationScreen extends StatelessWidget {
const NotificationScreen(this.receivedAction, {super.key});
final ReceivedAction receivedAction;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Received notification")),
body: Column(
children: [
const Text("Received action:"),
Text(receivedAction.toMap().toString()),
],
),
);
}
}
Received action #
Here is an exempt of what data we can find in ReceivedAction
.
It is formatted as JSON for easy reading.
The result of jsonEncode(receivedAction.toMap())
.
{
"id": 1,
"channelKey": "basic_channel",
"title": "test",
"body": "test",
"payload": { "text": "test" },
"actionType": "Default",
"createdSource": "Local",
"createdLifeCycle": "Foreground",
"displayedLifeCycle": "Foreground",
"createdDate": "2024-03-27 20:39:29",
"displayedDate": "2024-03-27 20:39:29",
"actionDate": "2024-03-27 20:39:32",
"actionLifeCycle": "Background"
}
Can you guess what all the fields are?
Done #
That’s it. Try it out.
The Awesome Notifications plugin can do a lot more. Familiarize yourself with the documentation.