Local notifications in Flutter: solved exercise with flutter_local_notifications

Local notifications in Flutter: solved exercise with flutter_local_notifications

Local notifications are triggered by the app itself without a server — they are ideal for reminders, alarms, download progress, and any alert that doesn’t depend on a backend. flutter_local_notifications is the standard package for this in Flutter.

Problem statement

Implement a notification system that:

  • Shows an immediate notification with title, body, and icon.
  • Schedules a notification for 5 seconds from now.
  • When the notification is tapped, navigates to a detail screen passing a payload.
  • Creates differentiated notification channels on Android (high and low importance).
  • Cancels notifications by ID.

Dependencies

1
2
3
4
5
6
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_local_notifications: ^18.0.0
  timezone: ^0.9.4

Android setup

In android/app/src/main/AndroidManifest.xml, inside <manifest>:

1
2
3
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

Inside <application>:

1
2
3
4
5
6
7
8
<receiver android:exported="false"
    android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"/>
<receiver android:exported="false"
    android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
  <intent-filter>
    <action android:name="android.intent.action.BOOT_COMPLETED"/>
  </intent-filter>
</receiver>

iOS setup

In ios/Runner/Info.plist:

1
2
3
4
5
<key>UIBackgroundModes</key>
<array>
  <string>fetch</string>
  <string>remote-notification</string>
</array>

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
import 'dart:async';
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;

// ── Notification service ───────────────────────────────────────────────────────
class NotificationService {
  static final NotificationService _instance = NotificationService._();
  factory NotificationService() => _instance;
  NotificationService._();

  final _plugin = FlutterLocalNotificationsPlugin();

  // Callback to handle notification tap when app is closed
  static String? pendingPayload;

  // Android channel IDs
  static const _channelHighId = 'high_importance';
  static const _channelLowId = 'low_importance';

  Future<void> initialize() async {
    tz.initializeTimeZones();

    const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );

    await _plugin.initialize(
      const InitializationSettings(android: androidSettings, iOS: iosSettings),
      onDidReceiveNotificationResponse: (response) {
        // App is in foreground or background — handle navigation here
        pendingPayload = response.payload;
        _navigationKey.currentState
            ?.pushNamed('/detail', arguments: response.payload);
      },
    );

    // Request permission on Android 13+
    await _plugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.requestNotificationsPermission();

    // Create Android channels
    await _createAndroidChannels();
  }

  Future<void> _createAndroidChannels() async {
    final android = _plugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>();

    await android?.createNotificationChannel(const AndroidNotificationChannel(
      _channelHighId,
      'Important notifications',
      description: 'Urgent alerts and reminders',
      importance: Importance.high,
      playSound: true,
    ));

    await android?.createNotificationChannel(const AndroidNotificationChannel(
      _channelLowId,
      'Informational notifications',
      description: 'Updates and news',
      importance: Importance.low,
      playSound: false,
    ));
  }

  // Immediate notification
  Future<void> showNow({
    required int id,
    required String title,
    required String body,
    String? payload,
    bool highImportance = true,
  }) async {
    final channelId = highImportance ? _channelHighId : _channelLowId;
    final importance = highImportance ? Importance.high : Importance.low;
    final priority = highImportance ? Priority.high : Priority.defaultPriority;

    await _plugin.show(
      id,
      title,
      body,
      NotificationDetails(
        android: AndroidNotificationDetails(
          channelId,
          highImportance ? 'Important notifications' : 'Informational notifications',
          importance: importance,
          priority: priority,
          icon: '@mipmap/ic_launcher',
        ),
        iOS: const DarwinNotificationDetails(
          presentAlert: true,
          presentBadge: true,
          presentSound: true,
        ),
      ),
      payload: payload,
    );
  }

  // Scheduled notification (in seconds from now)
  Future<void> scheduleAfterSeconds({
    required int id,
    required String title,
    required String body,
    required int seconds,
    String? payload,
  }) async {
    final scheduledDate = tz.TZDateTime.now(tz.local).add(Duration(seconds: seconds));

    await _plugin.zonedSchedule(
      id,
      title,
      body,
      scheduledDate,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          _channelHighId,
          'Important notifications',
          importance: Importance.high,
          priority: Priority.high,
        ),
        iOS: DarwinNotificationDetails(),
      ),
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      payload: payload,
    );
  }

  // Cancel by ID
  Future<void> cancel(int id) => _plugin.cancel(id);

  // Cancel all
  Future<void> cancelAll() => _plugin.cancelAll();
}

// ── Global key for navigation from notification ────────────────────────────────
final _navigationKey = GlobalKey<NavigatorState>();

// ── Main ───────────────────────────────────────────────────────────────────────
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await NotificationService().initialize();

  // Check if app was opened from notification (was closed)
  final plugin = FlutterLocalNotificationsPlugin();
  final launchDetails = await plugin.getNotificationAppLaunchDetails();
  if (launchDetails?.didNotificationLaunchApp == true) {
    NotificationService.pendingPayload =
        launchDetails!.notificationResponse?.payload;
  }

  runApp(MaterialApp(
    navigatorKey: _navigationKey,
    initialRoute: '/',
    routes: {
      '/': (_) => const NotificationDemo(),
      '/detail': (ctx) => DetailPage(
            payload: ModalRoute.of(ctx)!.settings.arguments as String?,
          ),
    },
  ));
}

// ── Main screen ────────────────────────────────────────────────────────────────
class NotificationDemo extends StatefulWidget {
  const NotificationDemo({super.key});
  @override
  State<NotificationDemo> createState() => _NotificationDemoState();
}

class _NotificationDemoState extends State<NotificationDemo> {
  final _service = NotificationService();
  final _logs = <String>[];
  int _notifId = 1;

  void _log(String msg) =>
      setState(() => _logs.insert(0, '${TimeOfDay.now().format(context)}$msg'));

  Future<void> _showImmediate() async {
    await _service.showNow(
      id: _notifId++,
      title: '🔔 Reminder',
      body: "Don't forget to check your Flutter exercises!",
      payload: 'payload_immediate_${_notifId - 1}',
    );
    _log('Immediate notification sent (ID ${_notifId - 1})');
  }

  Future<void> _showScheduled() async {
    await _service.scheduleAfterSeconds(
      id: _notifId++,
      title: '⏰ Scheduled reminder',
      body: '5 seconds have passed since you scheduled this',
      seconds: 5,
      payload: 'payload_scheduled_${_notifId - 1}',
    );
    _log('Notification scheduled in 5 s (ID ${_notifId - 1})');
  }

  Future<void> _showLow() async {
    await _service.showNow(
      id: _notifId++,
      title: '📰 News',
      body: 'New version of Flutter available',
      highImportance: false,
      payload: 'payload_low_${_notifId - 1}',
    );
    _log('Low-importance notification (ID ${_notifId - 1})');
  }

  Future<void> _cancelAll() async {
    await _service.cancelAll();
    _log('All notifications cancelled');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Local notifications')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            FilledButton.icon(
              onPressed: _showImmediate,
              icon: const Icon(Icons.notifications_active),
              label: const Text('Immediate notification (high importance)'),
            ),
            const SizedBox(height: 8),
            FilledButton.icon(
              onPressed: _showScheduled,
              icon: const Icon(Icons.schedule),
              label: const Text('Schedule in 5 seconds'),
              style: FilledButton.styleFrom(backgroundColor: Colors.orange),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: _showLow,
              icon: const Icon(Icons.info_outline),
              label: const Text('Low-importance notification'),
            ),
            const SizedBox(height: 8),
            OutlinedButton.icon(
              onPressed: _cancelAll,
              icon: const Icon(Icons.cancel),
              label: const Text('Cancel all'),
              style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
            ),
            const Divider(height: 32),
            const Text('Action log:',
                style: TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 8),
            Expanded(
              child: _logs.isEmpty
                  ? const Center(
                      child: Text('No actions logged yet',
                          style: TextStyle(color: Colors.grey)))
                  : ListView.builder(
                      itemCount: _logs.length,
                      itemBuilder: (_, i) => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 2),
                        child: Text(_logs[i],
                            style: const TextStyle(fontSize: 13)),
                      ),
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Detail screen (notification tap destination) ───────────────────────────────
class DetailPage extends StatelessWidget {
  final String? payload;
  const DetailPage({super.key, this.payload});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Notification detail')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.notifications_active,
                  size: 64, color: Colors.blue),
              const SizedBox(height: 16),
              const Text('You opened the app from a notification',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  textAlign: TextAlign.center),
              const SizedBox(height: 12),
              Text('Received payload: ${payload ?? '(none)'}',
                  style: const TextStyle(color: Colors.grey)),
            ],
          ),
        ),
      ),
    );
  }
}

Android channels quick guide

ChannelImportanceSoundVibrationUse case
Importance.maxUrgentYesYesCalls, critical alarms
Importance.highHighYesYesMessages, reminders
Importance.defaultImportanceMediumYesYesGeneral updates
Importance.lowLowNoNoNews, informational
Importance.minMinimalNoNoSilent status

Common mistakes

  • MissingPluginException on initialize: make sure to call WidgetsFlutterBinding.ensureInitialized() before initialize(). Without this the plugin cannot register.
  • Scheduled notifications not arriving on Android: from Android 12, exact alarms require the SCHEDULE_EXACT_ALARM permission and the user must grant it in Settings. Use AndroidScheduleMode.exactAllowWhileIdle and check permission state.
  • Not handling the closed-app case: when the user taps the notification with the app closed, onDidReceiveNotificationResponse does not run. Use getNotificationAppLaunchDetails() in main() to capture that case.

Practical use

Local notifications are standard in: fitness apps (workout reminders), learning apps (daily streak), task managers (task due dates), health apps (medication reminders).

Guided practice and next step

FAQ

Does flutter_local_notifications work on Flutter Web?

It has no native Web support. For Web you can use the browser’s native Notifications API (via the js package) or limit yourself to push notifications with Firebase.

How do I schedule recurring notifications?

Use periodicallyShow for fixed intervals (daily, weekly) or call zonedSchedule with matchDateTimeComponents to repeat on a specific schedule (e.g. every day at 8:00 AM).

Can I show a notification with an image or action buttons?

Yes. On Android use BigPictureStyleInformation for images and actions in AndroidNotificationDetails for buttons. On iOS use DarwinNotificationAction. Check the package documentation for each platform.