🔔

【Flutter】flutter_local_notifications で通知を実装する②

2024/12/18に公開

初めに

今回は flutter_local_notifications パッケージを使ってローカル通知の実装を行いたいと思います。
この記事では前の記事の続きになっています。前回の記事ではflutter_local_notifications パッケージを使って任意のタイミングで通知を表示するまで実装しました。

今回はローカル通知をカスタマイズして表示を変更する実装を行いたいと思います。

今回実装する内容は以下のブランチで公開しているので、適宜ご参照いただければと思います。

https://github.com/Koichi5/sample-flutter/tree/feature/flutter_local_notifications

記事の対象者

  • Flutter 学習者
  • Flutter でローカル通知を実装したい方

目的

今回の目的は、前回に引き続き flutter_local_notifications でローカル通知を実装することです。前回の内容を踏まえて実装していく形になるので、そちらも読んでいただければと思います。

実装内容

今回は以下の四つのケースを実装していきます。

  1. 通知をタップした際の処理
  2. 通知のカスタムに必要な設定
  3. アセットに含まれる画像を通知に表示する
  4. URLの画像を通知に表示する

通知をタップした際の処理

まずは通知をタップした時の処理をカスタマイズしていきます。
手順は以下の通りです。

  1. Serivce の実装
  2. Screen の実装
  3. main.dart の実装

この章では以下の動画のように、通知をタップすると同じ内容の通知が再度表示されるようなカスタムを行います。

https://youtube.com/shorts/1QmwjBLLYfY

Serivce の実装

まずは Service の実装です。前回の記事と重複する部分もありますが、それぞれ追加していく形で見てみます。まずは以下のように初期化の処理を追加していきます。

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:sample_flutter/flutter_local_notifications/const/notification_constants.dart';

class OnTapNotificationService {
  static final FlutterLocalNotificationsPlugin
      _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  // 通知サービスの初期化
  static Future<void> initialize() async {
    // Android 初期設定
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');

    // iOS 初期設定
    const DarwinInitializationSettings initializationSettingsIOS =
        DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );

    // 全体の初期設定
    const InitializationSettings initializationSettings =
        InitializationSettings(
      android: initializationSettingsAndroid,
      iOS: initializationSettingsIOS,
    );

    await _flutterLocalNotificationsPlugin.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
      onDidReceiveBackgroundNotificationResponse:
          _onDidReceiveBackgroundNotificationResponse,
    );

    if (Platform.isIOS) {
      await _requestIOSPermissions();
    } else if (Platform.isAndroid) {
      await _requestAndroidPermissions();
    } else {
      debugPrint('通知権限のリクエストはサポートされていません');
    }
  }
}

以下では FlutterLocalNotificationsPlugin をインスタンス化しています。
これに対してメソッドを実行することでローカル通知の初期化や表示、カスタマイズができるようになります。

class OnTapNotificationService {
  static final FlutterLocalNotificationsPlugin
      _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

以下では通知サービスの初期化処理を追加しています。
Android の初期設定は AndroidInitializationSettings、 iOS の初期設定は DarwinInitializationSettings で指定できます。
Android と iOS それぞれの初期設定を InitializationSettings でまとめることで、それぞれのプラットフォームの通知を一括で管理きます。

// 通知サービスの初期化
static Future<void> initialize() async {
  // Android 初期設定
  const AndroidInitializationSettings initializationSettingsAndroid =
      AndroidInitializationSettings('@mipmap/ic_launcher');

  // iOS 初期設定
  const DarwinInitializationSettings initializationSettingsIOS =
      DarwinInitializationSettings(
    requestAlertPermission: true,
    requestBadgePermission: true,
    requestSoundPermission: true,
  );

  // 全体の初期設定
  const InitializationSettings initializationSettings =
      InitializationSettings(
    android: initializationSettingsAndroid,
    iOS: initializationSettingsIOS,
  );

以下では通知サービスの初期化を行なっています。
先ほど定義した全体の初期化設定を第一引数に渡し、 onDidReceiveNotificationResponse ではアプリがフォアグラウンドの状態で通知をタップした際の処理、 onDidReceiveBackgroundNotificationResponse ではアプリがバックグラウンドの状態で通知をタップした際の処理を指定できます。

await _flutterLocalNotificationsPlugin.initialize(
  initializationSettings,
  onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
  onDidReceiveBackgroundNotificationResponse:
      _onDidReceiveBackgroundNotificationResponse,
);

以下では通知権限のリクエストを実装しています。
iOS は requestPermissions メソッド、 Android は resolvePlatformSpecificImplementation メソッドで通知権限のリクエストが行えます。

  if (Platform.isIOS) {
    await _requestIOSPermissions();
  } else if (Platform.isAndroid) {
    await _requestAndroidPermissions();
  } else {
    debugPrint('通知権限のリクエストはサポートされていません');
  }
}

// iOS 通知権限のリクエスト
static Future<void> _requestIOSPermissions() async {
  await _flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation<
          IOSFlutterLocalNotificationsPlugin>()
      ?.requestPermissions(
        alert: true,
        badge: true,
        sound: true,
      );
}

// Android 通知権限のリクエスト
static Future<void> _requestAndroidPermissions() async {
  await _flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.requestNotificationsPermission();
}

以下では通知がフォアグラウンドでタップされた際の処理を実装しています。
NotificationResponse では表示された通知の詳細情報を取得することができます。
以下の例では NotificationResponsepayload から titlebody を抜き出して再度通知を表示しています。

これで、通知をタップすると再度同じ通知が表示されるようになります。
一般的には、この payload に画面のパスや表示させるテキストを含めることで、通知をタップした際に別のページに遷移させることが多いかと思います。

// 通知のタップ時の処理
static void _onDidReceiveNotificationResponse(NotificationResponse response) {
  final payload = response.payload;
  final title = payload?.split('&')[0].split('=')[1];
  final body = payload?.split('&')[1].split('=')[1];
  showNotification(
    id: response.id ?? 0,
    title: title ?? '',
    body: body ?? '',
  );
}

以下では、アプリがバックグラウンドの状態で通知をタップした際の処理を実装しています。
以下の例では単純にそのレスポンスを表示させるだけにしています。

// バックグラウンドで通知を受け取った時の処理
static Future _onDidReceiveBackgroundNotificationResponse(
    NotificationResponse response) async {
  debugPrint('onDidReceiveBackgroundNotificationResponse: $response');
}

以下では通知を表示するためのメソッドを実装しています。
Android の通知の設定は AndroidNotificationDetails、 iOS の通知の設定は DarwinNotificationDetails で行うことができます。

// 通知の送信
static Future<void> showNotification({
  required int id,
  required String title,
  required String body,
}) async {
  // Android 用のスタイル情報
  const androidNotificationDetails = AndroidNotificationDetails(
    NotificationConstants.channelId,
    NotificationConstants.channelName,
    channelDescription: NotificationConstants.channelDescription,
    importance: Importance.max,
    priority: Priority.high,
  );

  // iOS 用のスタイル情報
  const iosNotificationDetails = DarwinNotificationDetails(
    presentAlert: true,
    presentBadge: true,
    presentSound: true,
  );

以下では、先ほど定義した Android と iOS の通知の設定をまとめて notificationDetails として定義しています。
また、 payload には titlebody を含めるようにしています。

これで、通知が表示された時にその通知の titlebody のデータを参照できるようになります。
通知の表示は show メソッドで行うことができます。

  const notificationDetails = NotificationDetails(
    android: androidNotificationDetails,
    iOS: iosNotificationDetails,
  );

  final String payload = 'title=$title&body=$body';

  await _flutterLocalNotificationsPlugin.show(
    id,
    title,
    body,
    notificationDetails,
    payload: payload,
  );
}
Service のコード
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:sample_flutter/flutter_local_notifications/const/notification_constants.dart';

class OnTapNotificationService {
  static final FlutterLocalNotificationsPlugin
      _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  // 通知サービスの初期化
  static Future<void> initialize() async {
    // Android 初期設定
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');

    // iOS 初期設定
    const DarwinInitializationSettings initializationSettingsIOS =
        DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );

    // 全体の初期設定
    const InitializationSettings initializationSettings =
        InitializationSettings(
      android: initializationSettingsAndroid,
      iOS: initializationSettingsIOS,
    );

    await _flutterLocalNotificationsPlugin.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse,
      onDidReceiveBackgroundNotificationResponse:
          _onDidReceiveBackgroundNotificationResponse,
    );

    if (Platform.isIOS) {
      await _requestIOSPermissions();
    } else if (Platform.isAndroid) {
      await _requestAndroidPermissions();
    } else {
      debugPrint('通知権限のリクエストはサポートされていません');
    }
  }

  // iOS 通知権限のリクエスト
  static Future<void> _requestIOSPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            IOSFlutterLocalNotificationsPlugin>()
        ?.requestPermissions(
          alert: true,
          badge: true,
          sound: true,
        );
  }

  // Android 通知権限のリクエスト
  static Future<void> _requestAndroidPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.requestNotificationsPermission();
  }

  // 通知のタップ時の処理
  static void _onDidReceiveNotificationResponse(NotificationResponse response) {
    final payload = response.payload;
    final title = payload?.split('&')[0].split('=')[1];
    final body = payload?.split('&')[1].split('=')[1];
    showNotification(
      id: response.id ?? 0,
      title: title ?? '',
      body: body ?? '',
    );
  }

  // バックグラウンドで通知を受け取った時の処理
  static Future _onDidReceiveBackgroundNotificationResponse(
      NotificationResponse response) async {
    debugPrint('onDidReceiveBackgroundNotificationResponse: $response');
  }

  // 通知の送信
  static Future<void> showNotification({
    required int id,
    required String title,
    required String body,
  }) async {
    // Android 用のスタイル情報
    const androidNotificationDetails = AndroidNotificationDetails(
      NotificationConstants.channelId,
      NotificationConstants.channelName,
      channelDescription: NotificationConstants.channelDescription,
      importance: Importance.max,
      priority: Priority.high,
    );

    // iOS 用のスタイル情報
    const iosNotificationDetails = DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
    );

    const notificationDetails = NotificationDetails(
      android: androidNotificationDetails,
      iOS: iosNotificationDetails,
    );

    final String payload = 'title=$title&body=$body';

    await _flutterLocalNotificationsPlugin.show(
      id,
      title,
      body,
      notificationDetails,
      payload: payload,
    );
  }
}

これで Service の実装は完了です。

Screen の実装

次は Screen の実装です。
コードは以下の通りで、ボタンを押すと通知が表示されるシンプルなものになっています。

import 'package:flutter/material.dart';
import 'package:sample_flutter/flutter_local_notifications/services/on_tap_notification_service.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ホーム'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                OnTapNotificationService.showNotification(
                  id: 0,
                  title: 'Hello World !',
                  body: 'Happy Coding! 🚀',
                );
              },
              child: const Text('タップした時の処理をカスタマイズ'),
            ),
          ],
        ),
      ),
    );
  }
}

main.dart の実装

最後に main.dart の実装を行います。
main.dart では作成した通知サービスの初期化を行う必要があります。
コードは以下の通りです。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sample_flutter/flutter_local_notifications/services/on_tap_notification_service.dart';
import 'package:sample_flutter/flutter_local_notifications/screens/notification_screen.dart';

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

  // 通知サービスの初期化
  await OnTapNotificationService.initialize();

  runApp(
    const MyApp(),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: const NotificationScreen(),
    );
  }
}

上記のコードで実行すると以下の動画のように、通知をタップした時に同じ内容の通知が表示されるようになります。

https://youtube.com/shorts/1QmwjBLLYfY

通知のカスタムに必要な設定

次に通知をさらにカスタムするために iOS 側で必要な設定についてまとめていきます。
通知に画像を添付するなどのカスタマイズを行う場合は NotificationServiceExtension を追加する必要があります。

まずは ios > Runner から Runner.xcodeproj を Xcode で開きます。
そして以下の画像のように File > New > Target ... を開きます。

iOS > Notification Service Extension を選択して Next を押します。

次に Extension の名前を登録します。
今回は「NotificationService」という名前にしておきます。
これで「Finish」を押します。

「Activate "NotificationService" scheme?」というダイアログが出るので「Activate」を押します。

これで通知の Extension が追加されました。

再度実行してみてエラーが出なければ完了です。

もしエラーが出る場合は以下のような手順を試していただければと思います。

  • flutter clean
  • flutter pub get
  • pod install
  • pod repo update
  • pod install --repo-update
  • 「Frameworks, Libraries, and Embedded Content」の項目の NotificationService を一度削除してから再度追加(下記の画像)

アセットに含まれる画像を通知に表示する

次にアセットにある画像を通知に表示する実装を行います。
実装は以下の手順で進めていきます。

  1. Service の実装
  2. Screen の実装
  3. main.dart の実装

この章では以下の動画のように画像を含む通知を表示し、その通知をタップすると、画像を含むページに遷移するまで実装を行います。

https://youtube.com/shorts/OFuJe1IVkVk

Service の実装

まずは Service の実装を進めていきます。
先にコードを提示します。前の章と同じ実装もありますが、変更した点をコメントの 1 ~ 4 で提示しています。その部分をより詳しくみていきます。

import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sample_flutter/flutter_local_notifications/const/notification_constants.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:sample_flutter/flutter_local_notifications/screens/asset_image_screen.dart';

class AssetImageNotificationService {
  static final FlutterLocalNotificationsPlugin
      _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  // 1. navigatorKey を受け取る
  static Future<void> initialize(GlobalKey<NavigatorState> navigatorKey) async {
    // Android 初期設定
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');

    // iOS 初期設定
    const DarwinInitializationSettings initializationSettingsIOS =
        DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    // 全体の初期設定
    const InitializationSettings initializationSettings =
        InitializationSettings(
      android: initializationSettingsAndroid,
      iOS: initializationSettingsIOS,
    );

    await _flutterLocalNotificationsPlugin.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: (NotificationResponse response) async {
        _onDidReceiveNotificationResponse(response, navigatorKey);
      },
      onDidReceiveBackgroundNotificationResponse:
          _onDidReceiveBackgroundNotificationResponse,
    );

    if (Platform.isIOS) {
      await _requestIOSPermissions();
    } else if (Platform.isAndroid) {
      await _requestAndroidPermissions();
    } else {
      debugPrint('通知権限のリクエストはサポートされていません');
    }
  }

  // iOS 通知権限のリクエスト
  static Future<void> _requestIOSPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            IOSFlutterLocalNotificationsPlugin>()
        ?.requestPermissions(
          alert: true,
          badge: true,
          sound: true,
        );
  }

  // Android 通知権限のリクエスト
  static Future<void> _requestAndroidPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.requestNotificationsPermission();
  }

  // 2. 通知のタップ時の処理
  static Future<void> _onDidReceiveNotificationResponse(
      NotificationResponse response,
      GlobalKey<NavigatorState> navigatorKey) async {
    final payload = response.payload;

    if (payload != null && payload.startsWith('image_asset_path=')) {
      debugPrint('payload: $payload');
      final imagePath = payload.replaceFirst('image_asset_path=', '');
      navigatorKey.currentState?.push(
        MaterialPageRoute(
          builder: (_) => AssetImageScreen(imageAssetPath: imagePath),
        ),
      );
    }
    debugPrint('onDidReceiveNotificationResponse: $response');
  }

  // バックグラウンドで通知を受け取った時の処理
  static Future<void> _onDidReceiveBackgroundNotificationResponse(
      NotificationResponse notificationResponse) async {
    debugPrint(
        'onDidReceiveBackgroundNotificationResponse: $notificationResponse');
  }

  // 3. アセット画像をコピーして保存する関数
  static Future<String?> _copyAssetImageToFile(
    String assetPath,
    String fileName,
  ) async {
    try {
      final ByteData byteData = await rootBundle.load(assetPath);
      final Uint8List bytes = byteData.buffer.asUint8List();

      final Directory directory = await getTemporaryDirectory();
      final String filePath = '${directory.path}/$fileName';
      final File file = File(filePath);
      await file.writeAsBytes(bytes);
      return filePath;
    } catch (e, stack) {
      debugPrint('アセット画像のコピー中にエラーが発生しました: $e');
      debugPrint('スタックトレース: $stack');
      return null;
    }
  }

  // 4. 通知の送信
  static Future<void> showNotification({
    required int id,
    required String title,
    required String body,
    String? payload,
    required String imageAssetPath, // アセット画像パスを指定
  }) async {
    String? finalPayload = payload;

    // Android 用のスタイル情報
    AndroidNotificationDetails androidNotificationDetails;
    // iOS 用のスタイル情報
    DarwinNotificationDetails? iosNotificationDetails;

    // アセット画像をコピー
    final String? assetImagePath =
        await _copyAssetImageToFile(imageAssetPath, 'asset_image.png');

    if (assetImagePath != null) {
      androidNotificationDetails = AndroidNotificationDetails(
        NotificationConstants.channelId,
        NotificationConstants.channelName,
        channelDescription: NotificationConstants.channelDescription,
        importance: Importance.max,
        priority: Priority.high,
        styleInformation: BigPictureStyleInformation(
          FilePathAndroidBitmap(assetImagePath),
          contentTitle: title,
          summaryText: body,
        ),
      );

      iosNotificationDetails = DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
        attachments: [
          DarwinNotificationAttachment(
            assetImagePath,
            hideThumbnail: false,
          ),
        ],
      );

      // ペイロードにアセット画像パスを含める
      finalPayload = 'image_asset_path=$imageAssetPath';
    } else {
      androidNotificationDetails = const AndroidNotificationDetails(
        NotificationConstants.channelId,
        NotificationConstants.channelName,
        channelDescription: NotificationConstants.channelDescription,
        importance: Importance.max,
        priority: Priority.high,
      );
      iosNotificationDetails = const DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
      );
    }

    NotificationDetails notificationDetails = NotificationDetails(
      android: androidNotificationDetails,
      iOS: iosNotificationDetails,
    );

    await _flutterLocalNotificationsPlugin.show(
      id,
      title,
      body,
      notificationDetails,
      payload: finalPayload,
    );
  }
}

それぞれ前の実装と異なる点を見ていきます。

まずは以下の初期化の部分で、 GlobalKey<NavigatorState> 型の navigatorKey を受け取るようにしています。
これは後述する実装で、通知をタップした際に画面遷移をするために使用します。

// 1. navigatorKey を受け取る
static Future<void> initialize(GlobalKey<NavigatorState> navigatorKey) async {
  // Android 初期設定
  const AndroidInitializationSettings initializationSettingsAndroid =
      AndroidInitializationSettings('@mipmap/ic_launcher');

以下では通知をフォアグラウンドの状態でタップした際の処理を実装しています。
ここれでは NotificationResponse から payload を抜き出し、その中から image_asset_path= の後に続く部分を imagePath として AssetImageScreen に渡しています。

また、画面遷移には navigatorKey.currentState?.push を用いています。
これで、受け取った通知をタップすると、その通知の payload に含まれる画像のパスを渡しながら AssetImageScreen 画面に遷移することができるようになります。

// 2. 通知のタップ時の処理
static Future<void> _onDidReceiveNotificationResponse(
    NotificationResponse response,
    GlobalKey<NavigatorState> navigatorKey) async {
  final payload = response.payload;

  if (payload != null && payload.startsWith('image_asset_path=')) {
    debugPrint('payload: $payload');
    final imagePath = payload.replaceFirst('image_asset_path=', '');
    navigatorKey.currentState?.push(
      MaterialPageRoute(
        builder: (_) => AssetImageScreen(imageAssetPath: imagePath),
      ),
    );
  }
  debugPrint('onDidReceiveNotificationResponse: $response');
}

以下では asset に含まれている画像をコピーして保存するためのメソッドを実装しています。
通知に表示する画像は AssetImage のようにパスを指定して表示することができず、一度デバイスにファイルとして書き込んでから使用する必要があります。
したがって、以下のような手順でデバイスに画像を書き込み、書き込んだパスを返しています。

  1. rootBundle.load でアセットに用意されている画像を読み込む
  2. 画像データを Uint8List のデータに変換
  3. getTemporaryDirectory メソッドでデバイス上の現在のディレクトリを取得
  4. 取得したディレクトリの直下にファイルの名前のパスを追加
  5. そのパスに対して、データを書き込み
  6. データを書き込んだディレクトリのパスを返す
// 3. アセット画像をコピーして保存する関数
static Future<String?> _copyAssetImageToFile(
  String assetPath,
  String fileName,
) async {
  try {
    final ByteData byteData = await rootBundle.load(assetPath);
    final Uint8List bytes = byteData.buffer.asUint8List();

    final Directory directory = await getTemporaryDirectory();
    final String filePath = '${directory.path}/$fileName';
    final File file = File(filePath);
    await file.writeAsBytes(bytes);
    return filePath;
  } catch (e, stack) {
    debugPrint('アセット画像のコピー中にエラーが発生しました: $e');
    debugPrint('スタックトレース: $stack');
    return null;
  }
}
rootBundle について

rootBundleimport 'package:flutter/services.dart' show rootBundle; のように定義しており、以下のような役割があります。

The [rootBundle] contains the resources that were packaged with the application when it was built. To add resources to the [rootBundle] for your application, add them to the assets subsection of the flutter section of your application's pubspec.yaml manifest.

(日本語訳)
rootBundleには、アプリケーションのビルド時にパッケージ化されたリソースが含まれています。アプリケーションのrootBundleにリソースを追加するには、pubspec.yamlのflutterセクション内にあるassetsサブセクションにそれらを追加してください。

つまり、pubspec.yaml が以下のようになっており、プロジェクトのディレクトリ構造が以下のようになっていれば、images ディレクトリ直下の hoge.png, fuga.png ファイルを rootBundle で参照できるということです。

pubspec.yaml
flutter:
  uses-material-design: true
  assets:
    - assets/images/
project-name
 |
 |- assets
 |     ∟ images
 |          ∟ hoge.png
 |          ∟ fuga.png
 |- android
 |- ios
 |- lib

以下では通知の表示機能を実装しています。
showNotification メソッドでは新たに imageAssetPath を受け付けるようにしています。

// 4. 通知の送信
static Future<void> showNotification({
  required int id,
  required String title,
  required String body,
  String? payload,
  required String imageAssetPath, // アセット画像パスを指定
}) async {

以下では通知を送信する部分で、先ほど定義した _copyAssetImageToFile を実行しています。
これで返り値である assetImagePath にはアセット画像が保存されているデバイス上のパスが代入されます。
Android の場合は AndroidNotificationDetailsBigPictureStyleInformation で画像付きの通知を作成することができ、第一引数に FilePathAndroidBitmap(assetImagePath) を渡すことで画像を表示できます。

// アセット画像をコピー
final String? assetImagePath =
    await _copyAssetImageToFile(imageAssetPath, 'asset_image.png');

if (assetImagePath != null) {
  androidNotificationDetails = AndroidNotificationDetails(
    NotificationConstants.channelId,
    NotificationConstants.channelName,
    channelDescription: NotificationConstants.channelDescription,
    importance: Importance.max,
    priority: Priority.high,
    styleInformation: BigPictureStyleInformation(
      FilePathAndroidBitmap(assetImagePath),
      contentTitle: title,
      summaryText: body,
    ),
  );

以下では iOS 側の設定を行なっています。
DarwinNotificationDetailsattachmentsDarwinNotificationAttachment を渡し、その第一引数に画像のデバイス上のパスを渡すと画像を表示できます。
また、 imageAssetPathfinalPayload に埋め込んでいます。
これで、通知が表示され、それをタップした際に表示されている画像のパスを参照することができるようになります。

iosNotificationDetails = DarwinNotificationDetails(
  presentAlert: true,
  presentBadge: true,
  presentSound: true,
  attachments: [
    DarwinNotificationAttachment(
      assetImagePath,
      hideThumbnail: false,
    ),
  ],
);

// ペイロードにアセット画像パスを含める
finalPayload = 'image_asset_path=$imageAssetPath';

以下では Android, iOS の通知の設定をまとめて設定しています。
また、 show メソッドの payloadfinalPayload を含めています。
これで通知に表示されている画像のパスを参照できるようになります。

  NotificationDetails notificationDetails = NotificationDetails(
    android: androidNotificationDetails,
    iOS: iosNotificationDetails,
  );

  await _flutterLocalNotificationsPlugin.show(
    id,
    title,
    body,
    notificationDetails,
    payload: finalPayload,
  );
}

これで Service の実装は完了です。

Screen の実装

次に Screen の実装に移ります。
この章の実装では、 navigatorKey.currentState?.push を使ってアセット画像のパスを渡して表示させる部分がありました。その画面を作成していきます。
コードは以下の通りで、パスを受け取って Image.asset を表示させるだけのシンプルなものになっています。

import 'package:flutter/material.dart';

class AssetImageScreen extends StatelessWidget {
  const AssetImageScreen({super.key, required this.imageAssetPath});

  final String imageAssetPath;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('画像表示'),
      ),
      body: Center(
        child: Image.asset(
          imageAssetPath,
          errorBuilder: (context, error, stackTrace) {
            return const Text('画像の読み込みに失敗しました');
          },
        ),
      ),
    );
  }
}

次に通知の表示を発火するための画面を実装します。
コードは以下の通りで、imageAssetPath'assets/images/vegetable.png' を指定しています。ここのパスによって表示する画像を切り替えることができます。

import 'package:flutter/material.dart';
import 'package:sample_flutter/flutter_local_notifications/services/asset_image_notification_service.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ホーム'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                AssetImageNotificationService.showNotification(
                  id: 0,
                  title: 'アセット画像を表示',
                  body: 'Asset に含まれている画像を表示します',
                  imageAssetPath: 'assets/images/vegetable.png',
                );
              },
              child: const Text('アセット画像を表示'),
            ),
          ],
        ),
      ),
    );
  }
}

main.dart の実装

最後に main.dart の編集を行います。
navigatorKey として GlobalKey<NavigatorState> をインスタンス化し、MaterialApp に渡しています。これで通知をタップした際の画面遷移が実現できます。

import 'package:flutter/material.dart';
import 'package:sample_flutter/flutter_local_notifications/services/asset_image_notification_service.dart';
import 'package:sample_flutter/flutter_local_notifications/screens/notification_screen.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

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

  // 通知サービスの初期化
  await AssetImageNotificationService.initialize(navigatorKey);

  runApp(
    const MyApp(),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      navigatorKey: navigatorKey,
      home: const NotificationScreen(),
    );
  }
}

これで以下の動画のように画像を含む通知を作成することができ、通知をタップするとその画像を表示する画面に遷移することがわかります。

https://youtube.com/shorts/OFuJe1IVkVk

URLの画像を通知に表示する

次にURLの画像を通知に表示する実装を行います。
前の章で実装したものと同じ部分が多々あるため、必要な部分だけかいつまんで見ていきたいと思います。
実装は以下の手順で進めていきます。

  1. Service の実装
  2. Screen の実装
  3. main.dart の実装

この章では、以下動画のようにURLで指定した画像が通知に表示され、それをタップすると画像を表示する画面に遷移するような実装を行います。(見た目は前の章の実装と変わりませんが...)

https://youtube.com/shorts/tAXZ10Pwi20

Service の実装

まずは Service の実装です。
コードは非常に長いので、折りたたんでいます。
前の章と異なる部分だけ見ていきます。

Service のコード
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:sample_flutter/flutter_local_notifications/const/notification_constants.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:sample_flutter/flutter_local_notifications/screens/network_image_screen.dart';

class NetworkImageNotificationService {
  static final FlutterLocalNotificationsPlugin
      _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();

  // 通知サービスの初期化
  static Future<void> initialize(GlobalKey<NavigatorState> navigatorKey) async {
    // Android 初期設定
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');

    // iOS 初期設定
    const DarwinInitializationSettings initializationSettingsIOS =
        DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );

    // 全体の初期設定
    const InitializationSettings initializationSettings =
        InitializationSettings(
      android: initializationSettingsAndroid,
      iOS: initializationSettingsIOS,
    );

    await _flutterLocalNotificationsPlugin.initialize(
      initializationSettings,
      onDidReceiveNotificationResponse: (NotificationResponse response) async {
        _onDidReceiveNotificationResponse(response, navigatorKey);
      },
      onDidReceiveBackgroundNotificationResponse:
          _onDidReceiveBackgroundNotificationResponse,
    );

    if (Platform.isIOS) {
      await _requestIOSPermissions();
    } else if (Platform.isAndroid) {
      await _requestAndroidPermissions();
    } else {
      debugPrint('通知権限のリクエストはサポートされていません');
    }
  }

  // iOS 通知権限のリクエスト
  static Future<void> _requestIOSPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            IOSFlutterLocalNotificationsPlugin>()
        ?.requestPermissions(
          alert: true,
          badge: true,
          sound: true,
        );
  }

  // Android 通知権限のリクエスト
  static Future<void> _requestAndroidPermissions() async {
    await _flutterLocalNotificationsPlugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.requestNotificationsPermission();
  }

  // (変更点) 1. 通知のタップ時の処理
  static void _onDidReceiveNotificationResponse(
      NotificationResponse response, GlobalKey<NavigatorState> navigatorKey) {
    final payload = response.payload;

    if (payload != null && payload.startsWith('image_url=')) {
      final imageUrl = payload.replaceFirst('image_url=', '');
      // 画像表示画面へ遷移
      navigatorKey.currentState?.push(
        MaterialPageRoute(
          builder: (_) => NetworkImageScreen(imageUrl: imageUrl),
        ),
      );
    }

    debugPrint('Notification Tapped with payload: $payload');
  }

  // バックグラウンドで通知を受け取った時の処理
  static Future _onDidReceiveBackgroundNotificationResponse(
      NotificationResponse response) async {
    debugPrint('onDidReceiveBackgroundNotificationResponse: $response');
  }

  // (変更点) 2. 画像をダウンロードして保存する関数
  static Future<String?> _downloadAndSaveImage(
      String url, String fileName) async {
    try {
      final Uri uri = Uri.parse(url);
      final http.Response response = await http.get(uri);

      if (response.statusCode == 200) {
        String extension = 'jpg'; // デフォルトの拡張子
        final contentType = response.headers['content-type'];
        if (contentType != null) {
          if (contentType.contains('jpeg')) {
            extension = 'jpg';
          } else if (contentType.contains('png')) {
            extension = 'png';
          } else if (contentType.contains('gif')) {
            extension = 'gif';
          }
        } else {
          // URLから拡張子を取得(存在する場合)
          final uriPath = uri.path;
          final uriExtension = uriPath.split('.').last;
          if (uriExtension == 'png' ||
              uriExtension == 'jpg' ||
              uriExtension == 'jpeg' ||
              uriExtension == 'gif') {
            extension = uriExtension;
          }
        }

        final Directory directory = await getTemporaryDirectory();
        final String filePath = '${directory.path}/$fileName.$extension';
        final File file = File(filePath);
        await file.writeAsBytes(response.bodyBytes);
        return filePath;
      } else {
        debugPrint('画像のダウンロードに失敗しました。ステータスコード: ${response.statusCode}');
        return null;
      }
    } catch (e) {
      debugPrint('画像のダウンロード中にエラーが発生しました: $e');
      return null;
    }
  }

  // 通知の送信
  static Future<void> showNotification({
    required int id,
    required String title,
    required String body,
    required String imageUrl, // ネットワーク画像URLを指定
    String? payload,
  }) async {
    String? finalPayload = payload;

    // Android 用のスタイル情報
    AndroidNotificationDetails androidNotificationDetails;
    // iOS 用のスタイル情報
    DarwinNotificationDetails? iosNotificationDetails;

    // ネットワーク画像をダウンロード
    final String? downloadedImagePath =
        await _downloadAndSaveImage(imageUrl, 'downloaded_image');

    if (downloadedImagePath != null) {
      androidNotificationDetails = AndroidNotificationDetails(
        NotificationConstants.channelId,
        NotificationConstants.channelName,
        channelDescription: NotificationConstants.channelDescription,
        importance: Importance.max,
        priority: Priority.high,
        styleInformation: BigPictureStyleInformation(
          FilePathAndroidBitmap(downloadedImagePath),
          contentTitle: title,
          summaryText: body,
        ),
      );

      iosNotificationDetails = DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
        attachments: [
          DarwinNotificationAttachment(
            downloadedImagePath,
            hideThumbnail: false,
          ),
        ],
      );

      // ペイロードに画像URLを含める
      finalPayload = 'image_url=$imageUrl';
    } else {
      // 画像のダウンロードに失敗した場合はシンプルな通知
      androidNotificationDetails = const AndroidNotificationDetails(
        NotificationConstants.channelId,
        NotificationConstants.channelName,
        channelDescription: NotificationConstants.channelDescription,
        importance: Importance.max,
        priority: Priority.high,
      );
      iosNotificationDetails = const DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
      );
    }

    NotificationDetails notificationDetails = NotificationDetails(
      android: androidNotificationDetails,
      iOS: iosNotificationDetails,
    );

    await _flutterLocalNotificationsPlugin.show(
      id,
      title,
      body,
      notificationDetails,
      payload: finalPayload,
    );
  }
}

以下では、URLから画像をダウンロードしてデバイスに保存する _downloadAndSaveImage メソッドを実装しています。受け取ったURLに対してGETメソッドを実行して画像を取得し、それぞれの拡張子に応じて保存処理を行なっています。

// 画像をダウンロードして保存する関数
static Future<String?> _downloadAndSaveImage(
    String url, String fileName) async {
  try {
    final Uri uri = Uri.parse(url);
    final http.Response response = await http.get(uri);

    if (response.statusCode == 200) {
      String extension = 'jpg'; // デフォルトの拡張子
      final contentType = response.headers['content-type'];
      if (contentType != null) {
        if (contentType.contains('jpeg')) {
          extension = 'jpg';
        } else if (contentType.contains('png')) {
          extension = 'png';
        } else if (contentType.contains('gif')) {
          extension = 'gif';
        }
      } else {
        // URLから拡張子を取得(存在する場合)
        final uriPath = uri.path;
        final uriExtension = uriPath.split('.').last;
        if (uriExtension == 'png' ||
            uriExtension == 'jpg' ||
            uriExtension == 'jpeg' ||
            uriExtension == 'gif') {
          extension = uriExtension;
        }
      }
      // 画像をデバイスに登録する流れは前の章と同様
      final Directory directory = await getTemporaryDirectory();
      final String filePath = '${directory.path}/$fileName.$extension';
      final File file = File(filePath);
      await file.writeAsBytes(response.bodyBytes);
      return filePath;

以下では通知を表示する showNotification メソッドを実装しています。
引数として画像のURLを受け取り、それを先ほど実装した _downloadAndSaveImage メソッドに渡しています。これで渡した画像のURLから画像がダウンロードされ、デバイスにコピーされ、そのパスが返り値 downloadedImagePath として戻ってきます。

// 通知の送信
static Future<void> showNotification({
  required int id,
  required String title,
  required String body,
  required String imageUrl, // ネットワーク画像URLを指定
  String? payload,
}) async {
  String? finalPayload = payload;

  // Android 用のスタイル情報
  AndroidNotificationDetails androidNotificationDetails;
  // iOS 用のスタイル情報
  DarwinNotificationDetails? iosNotificationDetails;

  // ネットワーク画像をダウンロード
  final String? downloadedImagePath =
      await _downloadAndSaveImage(imageUrl, 'downloaded_image');

前の章と同様にデバイス上の画像のパスである downloadedImagePathDarwinNotificationAttachment に渡すことで、通知に画像を表示させることができます。
また、 finalPayload に画像のURLを含めておくことで、通知がタップされた時にその画像のURLを参照することができます。

iosNotificationDetails = DarwinNotificationDetails(
  presentAlert: true,
  presentBadge: true,
  presentSound: true,
  attachments: [
    DarwinNotificationAttachment(
      downloadedImagePath,
      hideThumbnail: false,
    ),
  ],
);

// ペイロードに画像URLを含める
finalPayload = 'image_url=$imageUrl';

これで Service の実装は完了です。

Screen の実装

次に Screen の実装を行います。
コードは以下の通りで、画像のURLを受けとって表示させるだけの画面になっています。

import 'package:flutter/material.dart';

class NetworkImageScreen extends StatelessWidget {
  const NetworkImageScreen({super.key, required this.imageUrl});

  final String imageUrl;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('画像表示'),
      ),
      body: Center(
        child: Image.network(
          imageUrl,
          errorBuilder: (context, error, stackTrace) {
            return const Text('画像の読み込みに失敗しました');
          },
        ),
      ),
    );
  }
}

通知を発火させる画面は以下のように実装します。
ボタンを押すとURLの画像を含む通知が表示されるようにしています。

import 'package:flutter/material.dart';
import 'package:sample_flutter/flutter_local_notifications/services/network_image_notiifcation_service.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ホーム'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () {
                NetworkImageNotificationService.showNotification(
                  id: 0,
                  title: 'ネットワーク画像を表示',
                  body: 'ネットワーク上の画像を表示します',
                  imageUrl:
                      'https://cdn.pixabay.com/photo/2015/12/16/17/41/bell-1096280_1280.png',
                );
              },
              child: const Text('ネットワーク画像を表示'),
            ),
          ],
        ),
      ),
    );
  }
}

main.dart の実装

最後に main.dart の実装を行います。
コードは以下の通りです。
通知サービスの初期化を行い、navigatorKey を渡してやることで、通知のタップ時に画面遷移が行えるようになります。

import 'package:flutter/material.dart';
import 'package:sample_flutter/flutter_local_notifications/services/network_image_notiifcation_service.dart';
import 'package:sample_flutter/flutter_local_notifications/screens/notification_screen.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

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

  // 通知サービスの初期化
  await NetworkImageNotificationService.initialize(navigatorKey);

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      navigatorKey: navigatorKey,
      home: const NotificationScreen(),
    );
  }
}

これで実行すると以下のようにURLで指定した画像が通知に表示され、それをタップすると画像を表示する画面に遷移することがわかります。

https://youtube.com/shorts/tAXZ10Pwi20

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は flutter_local_notifications パッケージを使ってローカル通知を行う方法をまとめました。
Extension も追加することでさらにカスタマイズできることがわかりました。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://pub.dev/packages/flutter_local_notifications

https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications#-ios-setup

https://flutter.salon/plugin/flutter_local_notifications/

Discussion