【アプリ開発日記36週目】Flutterで指定時刻にプッシュ通知
ワイ記法というのがあるらしい。面白かったので今回はワイ記法で行きます!
ワイ「今までウェブアプリ作ってきたし」
「スマホアプリも何とかなるやろ」
(3時間後)
「わっかんねーーーー!」
できるようになったこと
ボタンを押した瞬間通知が来る
ボタンを押した〇秒後に通知が来る
指定した時刻に通知が来る
スケジュールのキャンセル
〇分/週おきに通知が来る
予約済みの通知を確認する
※ 日記です! たまたまうまくいっただけで内容が間違ってる可能性もありますがご了承くださいm(_ _)m
本題
ワイ「そもそもFuture型とかテキトーにやり過ごしてきたのも良くないけど」
「なんで『プッシュ通知』とかいうスマホならではの機能あるんや」
ネコ「それがスマホの強みやろ」
ワイ「なんで猫がしゃべるんや」
ネコ「ええやろ♪」
ワイ「しばらく静かにしとってな、で通知ってそもそも2種類あるんや……」
ワイ「フォームで登録した時間に毎日通知欲しい。今回は、ローカル通知で十分間に合うやろ」(※間に合いました)
【種類】
ボタンを押した瞬間通知が来る
ボタンを押した〇秒後に通知が来る
指定した時刻に通知が来る
スケジュールのキャンセル
〇分/週おきに通知が来る
予約済みの通知を確認する
ワイ「ふむふむ、3つ目と5つ目を組み合わせれば「毎日〇時に通知が来る」といった機能も実装できそうやな」
「けどドキュメント見た感じ癖強そうやし……」
「1つ目からいくか」
「使わなそうやけど」
1,ボタンを押した瞬間通知が来る
【ドキュメント】
ワイ「よくわからんから」
「とりあえずボタン作るか」
ElevatedButton(
onPressed: () async {},
child: const Text("今すぐ通知"),
),
「これ押したら通知来るんやな」
「で、プラグインをインストール」
flutter pub add ○○
flutter_local_notifications
flutter_native_timezone
path_provider
rxdart
http
timezone
import 'dart:io';
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/subjects.dart';
import 'package:http/http.dart' as http;
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter/material.dart';
「できた」
ネコ「当たり前や」
ワイ(必要なプラグイン見つけるのに1日かかってるんや)
ワイ「処理は一つのクラスに入れとけばよさそうやな」
「とりあえず最初の設定はこんな感じになるらしいな」
「めっちゃ多いし別ファイルつくっとこ」
class NotificationService {
NotificationService();
final _localNotifications = FlutterLocalNotificationsPlugin();
final BehaviorSubject<String> behaviorSubject = BehaviorSubject();
// 0-1、ここで定義した処理を main.dart から呼び出せる
Future<void> initializePlatformNotifications() async {
// アセットの名前がアプリのアイコンとして表示
// android/app/src/main/res/drawable をフォルダーに追加する必要あり
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('justwater');
final IOSInitializationSettings initializationSettingsIOS =
IOSInitializationSettings(
requestSoundPermission: true,
requestBadgePermission: true,
requestAlertPermission: true,
onDidReceiveLocalNotification: onDidReceiveLocalNotification);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _localNotifications.initialize(initializationSettings,
onSelectNotification: selectNotification);
// タイムゾーン設定
tz.initializeTimeZones();
tz.setLocalLocation(
tz.getLocation('Asia/Tokyo'
// await FlutterNativeTimezone.getLocalTimezone(),
),
);
}
// 0-2、
void onDidReceiveLocalNotification(
int id, String? title, String? body, String? payload) {
debugPrint('id ----- $id');
}
// 0-3、リスナーが受け取るデータ ストリーム イベントにペイロードを追加
void selectNotification(String? payload) {
debugPrint("selectNotification payload ----- $payload");
if (payload != null && payload.isNotEmpty) {
behaviorSubject.add(payload);
}
}
// 各プラットフォームの通知のプッシュの見た目設定
// ここに次をコピペ
// 処理1(Drink Now)
// ここに処理1を書く
// 処理2
// ここに処理2を書く
}
ワイ「ここで初期設定するんや、androidとiOSそれぞれの。」
「そんでもって最初に定義した『_localNotifications』はこの後にもよく出てくるなぁ」
「で、次はandroidとiOSそれぞれの通知のデザインするんや、この『_notificationDetails()』もあとでめっちゃ出てきとるなぁ」
「毎回デザイン設定かくの面倒やから最初にテンプレ作ってるんや!」
// 各プラットフォームの通知のプッシュの見た目設定
_downloadAndSaveFile(String url, String fileName) async {
final Directory directory = await getApplicationDocumentsDirectory();
final String filePath = '${directory.path}/$fileName.png';
var response = await http.get(Uri.parse(url));
var file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
return filePath;
}
Future<NotificationDetails> _notificationDetails() async {
// final bigPicture = const DrawableResourceAndroidBitmap('justwater');
final bigPicture = await _downloadAndSaveFile(
'https://images.unsplash.com/photo-1624948465027-6f9b51067557?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1470&q=80',
'drinkwater');
AndroidNotificationDetails androidPlatformChannelSpecifics =
AndroidNotificationDetails(
'channel id',
'channel name',
groupKey: 'com.example.flutter_push_notifications',
channelDescription: 'channel description',
importance: Importance.max,
priority: Priority.max,
playSound: true,
ticker: 'ticker',
largeIcon: const DrawableResourceAndroidBitmap('justwater'),
styleInformation: BigPictureStyleInformation(
FilePathAndroidBitmap(bigPicture),
hideExpandedLargeIcon: false,
),
color: const Color(0xff2196f3),
);
IOSNotificationDetails iosNotificationDetails = IOSNotificationDetails(
threadIdentifier: "thread1",
attachments: <IOSNotificationAttachment>[
IOSNotificationAttachment(bigPicture)
]);
final details = await _localNotifications.getNotificationAppLaunchDetails();
if (details != null && details.didNotificationLaunchApp) {
behaviorSubject.add(details.payload!);
}
NotificationDetails platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics, iOS: iosNotificationDetails);
return platformChannelSpecifics;
}
「ちと独特すぎんか……?」
「初めて見るものばかりなんやけど」
「とりあえずあと処理かけば終わりや」
// 処理1(Drink Now):タイトル、本文、ペイロード、およびプラットフォーム固有の構成を含む通知を表示
Future<void> showLocalNotification({
required int id,
required String title,
required String body,
required String payload,
}) async {
// 各プラットフォームの通知のプッシュの見た目設定
final platformChannelSpecifics = await _notificationDetails();
// リアルタイムでローカル通知
await _localNotifications.show(
id,
title,
body,
platformChannelSpecifics,
payload: payload,
);
}
「あれ、動かん」
ネコ「ボタンの中身、空やで」
ワイ「あ」
ネコ「ちなみにmain.dartでも読み込んださっきのclass初期化しないとあかんで」
main.dart 全文
// https://blog.codemagic.io/flutter-local-notifications/
import 'package:{プロジェクト名}/notification_service.dart';(class入った別ファイル)
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Just Water',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// 作成した notification_service.dart からインスタンスを作成(初期化)
late final NotificationService notificationService;
@override
void initState() {
notificationService = NotificationService();
listenToNotificationStream();
notificationService.initializePlatformNotifications();
super.initState();
}
// behaviorSubject(payloadが追加されたら)ページ遷移
void listenToNotificationStream() =>
notificationService.behaviorSubject.listen((payload) {
debugPrint('main _ behaviorSubject.listen');
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MySecondScreen(payload: payload)));
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("JustWater"),
centerTitle: true,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.only(bottom: 100),
// Update with local image
child: Image.asset("images/water/justwater.png", scale: 0.6),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// 1,通知が来て押すと次の画面へ
ElevatedButton(
onPressed: () async {
//
await notificationService.showLocalNotification(
id: 0,
title: "Drink Water",
body: "Time to drink some water!",
payload: "You just took water! Huurray!");
},
child: const Text("今すぐ通知"),
),
// 2,通知が 2 秒ごとに送信
ElevatedButton(
onPressed: () async {},
child: const Text("2秒ごと通知"))
],
),
],
),
);
}
}
class MySecondScreen extends StatelessWidget {
final String payload;
const MySecondScreen({Key? key, required this.payload}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("JustWater"),
centerTitle: true,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: const EdgeInsets.only(bottom: 100),
child: Image.asset(
"images/water/justwater.png",
),
),
Text(payload)
],
),
),
);
}
}
ワイ「通知きたー!」
ネコ「だいぶ説明はしょったな」
ワイ「notificationService.behaviorSubject.listenのlistenっていうのは『変化したとき感知して中の処理をする』らしいで」
ネコ「behaviorSubjectってなんや、聞き覚えあるけど」
ワイ「一番最初に定義したやつやな、それで_notificationDetails()内に入ってる『behaviorSubject.add(details.payload!);』が動作したときに中の処理がされるとか」
ネコ「つまりページ遷移するってことやな」
2,ボタンを押した〇秒後に通知が来る
ワイ「もう10,000字なんやけど」
ネコ「でもここからは処理かいてくだけやで!」
// 2-1(Schedule),特定のタイムゾーンに関連する指定された時間に通知を表示
Future<void> showScheduledLocalNotification({
required int id,
required String title,
required String body,
required String payload,
required int seconds,
}) async {
final platformChannelSpecifics = await _notificationDetails();
// スケジュールしてローカル通知
await _localNotifications.zonedSchedule(
id,
title,
body,
tz.TZDateTime.now(tz.local).add(Duration(seconds: seconds)),
platformChannelSpecifics, // 各プラットフォームの通知のプッシュの見た目設定
payload: payload,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
androidAllowWhileIdle: true,
);
}
// 2-2、periodicallyShow : 定期的な通知を表示
Future<void> showPeriodicLocalNotification({
required int id,
required String title,
required String body,
required String payload,
}) async {
final platformChannelSpecifics = await _notificationDetails();
await _localNotifications.periodicallyShow(
id,
title,
body,
RepeatInterval.everyMinute,
platformChannelSpecifics,
payload: payload,
androidAllowWhileIdle: true,
);
}
ボタン(main.dart)
// 2,2秒後に通知
ElevatedButton(
onPressed: () async {
await notificationService.showScheduledLocalNotification(
id: 1,
title: "Drink Water",
body: "Time to drink some water!",
payload: "You just took water! Huurray!",
seconds: 2);
},
child: const Text("2秒後に通知"))
ワイ「ホーム画面にいても通知くるやん!」
ネコ「いい感じやん」
ワイ「…お互い関西弁の語彙少なすぎるなぁ」
ネコ「関西弁好きやけど、使いこなすのはめちゃくちゃムズいな」
3,指定した時刻に通知が来る
ワイ「一通り試した後に実際にアプリ作ってみたんやけど、やっぱりこの処理が一番使いそうやな」
「このサイト、一つの処理でまとめて複数の通知登録もできるから一度見ておくといいかもしれんな」
「仕組みはこの処理を for してるだけやけど」
tz.TZDateTime _convertTime(int hour, int minutes) {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduleDate = tz.TZDateTime(
tz.local,
now.year,
now.month,
now.day,
hour,
minutes,
);
if (scheduleDate.isBefore(now)) {
scheduleDate = scheduleDate.add(const Duration(days: 1));
}
return scheduleDate;
}
scheduledNotification({
required int hour,
required int minutes,
required int id,
required String sound,
}) async {
await _localNotifications.zonedSchedule(
id,
"It's time to drink water!",
'After drinking, touch the cup to confirm',
_convertTime(hour, minutes),
NotificationDetails(
android: AndroidNotificationDetails(
'your channel id $sound',
'your channel name',
channelDescription: 'your channel description',
importance: Importance.max,
priority: Priority.high,
// sound: RawResourceAndroidNotificationSound(sound),
),
// iOS: IOSNotificationDetails(sound: '$sound.mp3'),
iOS: const IOSNotificationDetails(),
),
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time, // 毎日同じ時間に
// matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime // 毎週同じ時間に
payload: 'It could be anything you pass',
);
}
4,スケジュールのキャンセル
ワイ「うまく行きすぎて通知が山みたいになっとる」
ネコ「こんなときは Allキャンセルで一発や!」
void cancelSingleNotifications() => _localNotifications.cancel(0);
void cancelAllNotifications() => _localNotifications.cancelAll();
5,〇分/週おきに通知が来る
ワイ「これで文句なし!……と言いたいけど、毎週来てほしい通知もfor文で複数登録するのは面倒やなぁ」
ネコ「そんな時のために、【毎分~毎週ごとに通知】機能が備わってるらしいで!」
ワイ「感謝ーーー!!!」
// 毎分通知
Future<void> regularSchedule() async {
final platformChannelSpecifics = await _notificationDetails();
await _localNotifications.periodicallyShow(0, 'repeating title',
'repeating body', RepeatInterval.everyMinute, platformChannelSpecifics,
androidAllowWhileIdle: true);
}
// 毎週同じ時刻に通知(バックグラウンドも可)
scheduledNotificationPerDay({
required timelist,
}) async {
var timelist = [
{'hour': 7, 'minutes': 0},
{'hour': 19, 'minutes': 0}
];
final platformChannelSpecifics = await _notificationDetails();
for (int i = 0; i < timelist.length; i++) {
await _localNotifications.zonedSchedule(
i + 1000, // id 重複したら上書き
"薬を飲む時間です!",
'',
_convertTime(int.parse(timelist[i]['hour'].toString()),
int.parse(timelist[i]['minutes'].toString())),
platformChannelSpecifics,
androidAllowWhileIdle: true,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime,
payload: 'payload!',
);
}
}
ワイ「ここには分と週の例かいたけど、実際は秒とか日ごとのオプションもあるから、ドキュメントぜひ見てや!」
6,予約済みの通知を確認する
ワイ「足したり引いたりできるのはいいんやけど……」
ネコ「まだ不満出てくるとはなかなかやな」
ワイ「いろいろイジりすぎてどの通知が今登録されてるのか分からん!」
ネコ「たしかに突然昔登録した通知が来ても怖いもんな」
ネコ「いよいよ最後や」
ワイ「!」
ネコ「もう書いてる人の体力がなくなりかけてる」
ワイ「あかーーーん!!」
getScheduled() async {
final List<PendingNotificationRequest> pendingNotificationRequests =
await _localNotifications.pendingNotificationRequests();
debugPrint('予約済みの通知');
for (var pendingNotificationRequest in pendingNotificationRequests) {
debugPrint(
'予約済みの通知: [id: ${pendingNotificationRequest.id}, title: ${pendingNotificationRequest.title}, body: ${pendingNotificationRequest.body}, payload: ${pendingNotificationRequest.payload}]');
}
}
ワイ「意外とシンプルなんやな!」
ネコ「今回は導入が一番の山場なんや。そこさえ乗り越えればカスタマイズ性は抜群やで」
ワイ「まだミスも多いけど、通知きたら一気にアプリ感出てくるわ!」
ネコ「ま、来週はノーコードの話になるんやけど。。。」
ワイ「!?」
おわりに
関西弁、本当に難しいんですね。。。ある先輩が話す大阪弁がめちゃくちゃ大好きで、それからというものずっと憧れているのですが…まだまだ遠い世界なのかもしれません笑
ローカル通知、デフォルトの機能では思うように実装できず苦労しました。結果的には上記のように何とかまとまり、その後審査(クローズドテスト)に通ってからも無事実機で動作を確認できたときのテンションの上がり方は尋常じゃなかったです。
まだまだ実装したい機能もありますが、Twitterコーディング垢を広げていくうちに「FlutterFlow」というものを見つけました。これが、Flutterのノーコードツールだったのです。しかもかなり使いやすい。。。
来週はこのツールに触れてみます! 今回もおつかれさまでした!