flutter_local_notifications久しぶりに使ってみた
ローカル通知を実装するには?
Flutterでローカル通知を最近個人アプリで使っていたのですが書き方が変わっているのか詰まったところがあるので記事にしようと思いました。
iOS/Androidに対応した設定を追加していく。
このパッケージも必要だったので追加。
主な役割:- 正確な通知スケジューリング:
- 異なるタイムゾーンでも正確な時間に通知を表示
- 日の出時刻が早まる時期(DST)の自動処理
- ローカル時間の一貫性:
- ユーザーの現在のタイムゾーンに基づいて通知時間を調整
- 端末の設定が変更された場合でも正しい時間に通知
- グローバル対応:
- 世界中のどのタイムゾーンでも正確に動作
- タイムゾーン間の変換を自動処理
🤖Android Setup
READMEを見ながら、Androidの設定からやっていきます。公式の設定見ますがここ詰まったんですよね。よくわからないエラーが出た💦
公式:
私のはこれ
build.gradle
AndroidManifest.xml
dependencies {
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
}
Githubのサンプルを確認しながらやってましたね。Flutterのコードはサンプルが多すぎてわかりづらかったので、自分で実装しました。
🍎iOS setup
公式のサンプルをそのまま使ったと思います。
私の設定:
パッケージは2個追加
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
flutter_local_notifications: ^18.0.1
timezone: ^0.10.0
今回はボタンを押すとボトムシートが表示されてそこから通知の時間を設定するデモアプリを作ってみました。ファイルは3用意します。
通知機能を提供するクラス
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:flutter/material.dart';
/// ローカル通知を管理するサービスクラス
///
/// シングルトンパターンを採用し、アプリケーション全体で単一のインスタンスを共有します。
/// これにより、通知の重複や競合を防ぎ、一貫した通知管理を実現します。
///
/// 使用例:
/// ```dart
/// final notificationService = NotificationService();
/// await notificationService.initialize();
/// await notificationService.scheduleNotification(DateTime.now());
/// ```
class NotificationService {
static final NotificationService _instance = NotificationService._();
factory NotificationService() => _instance;
NotificationService._();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
Future<void> initialize() async {
try {
tzdata.initializeTimeZones();
const androidInitializationSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initializationSettingsIOS = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
);
const initializationSettings = InitializationSettings(
iOS: initializationSettingsIOS,
android: androidInitializationSettings,
);
await _notifications.initialize(initializationSettings);
// Android 13以降の場合、通知権限を要求
final androidPlugin =
_notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
await androidPlugin.requestNotificationsPermission();
}
// iOS用の権限リクエスト
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
critical: true,
);
}
} catch (e) {
debugPrint('通知の初期化に失敗: $e');
}
}
Future<void> scheduleNotification(DateTime scheduledTime) async {
try {
var scheduledDate = tz.TZDateTime.from(scheduledTime, tz.local);
// 過去の時間が指定された場合は翌日の同時刻に設定
if (scheduledDate.isBefore(tz.TZDateTime.now(tz.local))) {
scheduledDate = scheduledDate.add(const Duration(days: 1));
}
await _notifications.zonedSchedule(
0,
'🔔',
'ローカル通知です。',
scheduledDate,
const NotificationDetails(
android: AndroidNotificationDetails(
'notification_channel_id',
'Notification Channel',
channelDescription:
'This channel is used for important notifications.',
importance: Importance.max,
priority: Priority.high,
icon: null,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time,
);
debugPrint('通知をスケジュール: $scheduledTime');
} catch (e) {
debugPrint('通知のスケジュールに失敗: $e');
rethrow;
}
}
Future<void> cancelAllNotifications() async {
try {
await _notifications.cancelAll();
debugPrint('全ての通知をキャンセルしました');
} catch (e) {
debugPrint('通知のキャンセルに失敗: $e');
rethrow;
}
}
}
cupertino
を今回使ってしまったが両方のOSで対応するならドラムロールのようなUIを使った方が良いかもしれません。AndroidのアプリだとiOSのホイールのようなデザインにならないですね。
通知設定のコンポーネント
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import '../notification/notification_service.dart';
class NotificationSheet extends StatefulWidget {
const NotificationSheet({super.key});
State<NotificationSheet> createState() => _NotificationSheetState();
}
class _NotificationSheetState extends State<NotificationSheet> {
DateTime _selectedTime = DateTime.now();
void _showTimePicker() {
showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
color: CupertinoColors.systemBackground.resolveFrom(context),
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _selectedTime,
mode: CupertinoDatePickerMode.time,
use24hFormat: true,
onDateTimeChanged: (DateTime newTime) {
setState(() {
_selectedTime = newTime;
});
},
),
),
),
);
}
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'お買い物通知設定',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('通知時間'),
trailing: TextButton(
onPressed: _showTimePicker,
child: Text(
'${_selectedTime.hour.toString().padLeft(2, '0')}:${_selectedTime.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 18),
),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
try {
await NotificationService().scheduleNotification(_selectedTime);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('通知を設定しました'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.pop(context);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('通知の設定に失敗しました: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
},
child: const Text('通知を設定'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () async {
await NotificationService().cancelAllNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('通知をキャンセルしました'),
behavior: SnackBarBehavior.floating,
),
);
if (context.mounted) {
Navigator.pop(context);
}
}
},
child: const Text(
'通知をキャンセル',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}
main.dart
でここまで作成したロジックとコンポーネントをインポートすればローカル通知のデモアプリの完成です。
main
import 'package:flutter/material.dart';
import 'package:local_notification_demo/notification/notification_service.dart';
import 'package:local_notification_demo/widgets/notification_sheet.dart';
void main() async {
/// 通史機能を使用するには、WidgetsFlutterBindingを初期化する必要があります
/// main関数の中でNotificationServiceクラスのinitializeメソッドを呼び出し、
/// 通知サービスを初期化します
WidgetsFlutterBinding.ensureInitialized();
await NotificationService().initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Local Notification Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
void _showNotificationSheet(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => const NotificationSheet(),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('通知デモ'),
actions: [
TextButton(
onPressed: () => _showNotificationSheet(context),
child: const Text(
'通知設定',
style: TextStyle(color: Colors.blue),
),
),
],
),
body: const Center(
child: Text(
'右上の「通知設定」ボタンから\n通知時間を設定できます',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
);
}
}
最後に
今回はiOS/Androidに対応したローカル通知の機能をFlutterで実装してみました。
参考した情報
今回設定で詰まったのはここの設定が変わっていたことですね。昔のコードと少し違った💦
毎回公式を見ながら設定しないとダメですね🔧
await _notifications.zonedSchedule(
0,
'🔔',
'ローカル通知です。',
scheduledDate,
const NotificationDetails(
android: AndroidNotificationDetails(
'notification_channel_id',
'Notification Channel',
channelDescription:
'This channel is used for important notifications.',
importance: Importance.max,
priority: Priority.high,
icon: null,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
),
),
ここで詰まった👇
Scheduling a notification
Starting in version 2.0 of the plugin, scheduling notifications now requires developers to specify a date and time relative to a specific time zone. This is to solve issues with daylight saving time that existed in the schedule method that is now deprecated. A new zonedSchedule method is provided that expects an instance TZDateTime class provided by the timezone package. Even though the timezone package is be a transitive dependency via this plugin, it is recommended based on this lint rule that you also add the timezone package as a direct dependency.
Once the depdendency as been added, usage of the timezone package requires initialisation that is covered in the package's readme. For convenience the following are code snippets used by the example app.
通知のスケジューリング
プラグインのバージョン2.0から、通知のスケジューリングは特定のタイムゾーンに相対する日付と時刻を指定する必要があります。これはscheduleメソッドに存在した夏時間の問題を解決するためです。新しいzonedScheduleメソッドは、timezoneパッケージによって提供されるインスタンスTZDateTimeクラスを必要とします。timezoneパッケージがこのプラグインを経由して推移的依存関係にあるとしても、このlintルールに基づき、timezoneパッケージを直接依存関係として追加することが推奨されます。
いったん依存関係が追加されると、timezone パッケージの使用には、パッケージの readme で説明されている初期化が必要になります。便宜上、サンプルアプリで使用されているコード・スニペットを以下に示します。
Import the timezone package
timezone
が必要なので追加してくれと書いてあった。
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
今のバージョンのコード
await flutterLocalNotificationsPlugin.zonedSchedule(
0,
'scheduled title',
'scheduled body',
tz.TZDateTime.now(tz.local).add(const Duration(seconds: 5)),
const NotificationDetails(
android: AndroidNotificationDetails(
'your channel id', 'your channel name',
channelDescription: 'your channel description')),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime);
FCMとローカル通知の比較
1. 通知の発生源と用途
ローカル通知
- アプリ内部からスケジュール設定
- オフライン動作可能
- 定期的なリマインダーや時間ベースの通知に最適
- 例:毎日の運動リマインダー、予定された会議の通知
FCM
- サーバーからプッシュ配信
- インターネット接続が必要
- リアルタイムの更新やイベント通知に最適
- 例:新着メッセージ、ニュース速報、キャンペーン通知
2. 実装の違い
ローカル通知
// 現在の実装例
await _notifications.zonedSchedule(
0,
'🔔',
'ローカル通知です。',
scheduledDate,
notificationDetails,
// ...
);
FCM
// FCMの実装例
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
// サーバーからのメッセージを受信
final notification = message.notification;
final android = message.notification?.android;
final ios = message.notification?.apple;
// 通知を表示
FlutterLocalNotificationsPlugin().show(
message.hashCode,
notification?.title,
notification?.body,
NotificationDetails(/*...*/),
);
});
3. 主な特徴の比較
機能 | ローカル通知 | FCM |
---|---|---|
インターネット要否 | 不要 | 必要 |
バックグラウンド動作 | ✓ | ✓ |
サーバー要否 | 不要 | 必要 |
ユーザーターゲティング | × | ✓ |
トピックベース配信 | × | ✓ |
オフライン動作 | ✓ | × |
スケジュール設定 | ✓ | △(サーバー側) |
4. 使い分けの指針
ローカル通知を使う場合
- ユーザーが設定した時間ベースの通知
- オフラインでも確実に通知が必要な場合
- アプリ内のローカルイベント通知
FCMを使う場合
- リアルタイムの更新が必要な場合
- 特定ユーザーやグループへのターゲット通知
- サーバーサイドからの一斉配信
- プッシュ通知によるユーザーエンゲージメント施策
5. セキュリティ面
- ローカル通知: アプリ内で完結するため、比較的安全
- FCM: Firebase認証が必要で、サーバーとの通信にセキュリティ考慮が必要
組み合わせ使用の例
チャットアプリ
- FCM:新着メッセージの通知
- ローカル通知:予約メッセージの送信リマインド
タスク管理アプリ
- FCM:タスクの共有や更新通知
- ローカル通知:締切日のリマインダー
Discussion