Flutter Push-уведомления, том числе в Web
Добрый день!
Хотел написать статью, обобщающую то, что я нашёл в интернете. Может кому-то она покажется слишком простой, может ненужной, а может наоборот вызовет обсуждение, на что я крайне надеюсь.
В двух словах о чём статья
С нуля мы создадим flutter-проект с подключением к push-уведомлениям. Будем отправлять уведомления не только на Android, IOS, но и на наш веб-сайт, который может рассылать уведомления в т.ч. на мобильные устройства. Нам потребуется дополнительно лишь небольшой хостинг с mysql БД и php.
Предыстория
Очень часто когда говорят про Flutter подразумевают Andorid и IOS приложения. Это, конечно, в чём то правильно, но Flutter может компилировать свой код ещё и под Windows, Linux, MacOS и Web. Приложениями для Desktop люди пользуются последнее время не часто, т.к. мобильные телефоны слишком плотно вошли в нашу жизнь. А в мобильных телефонах есть ещё браузер, а не только, собственно, приложения.
У меня появилась мысль — почему бы не сделать одно приложение, которое одинаково хорошо работало бы на всех мобильных устройствах. Вроде бы Flutter именно для этого, но есть большая проблема публикации в сторах из-за известных событий (частникам это ещё боле-мене доступно, а компаниям уже нет, т.к. с регистрацией большие проблемы, а потом ещё возьмут да заблокируют…). Rustore? ну да, выход, а яблокофилы что делать будут?
Выход Web-приложение, которое конвертируется в PWA (отдельная иконка в IOS и Android, которая запускает якобы приложение, на самом деле это безрамочный браузер со всеми вытекающими плюсами и минусами). Понятно, что можно было делать на любой другой платформе подобный функционал, но мало ли когда-нибудь всё таки зарегистрируемся в сторах… Потому Flutter.
Push-уведомления
Для IOS и Android миллион статей и туториалов написано как работать во Flutter с уведомлениями, но Web всё время обходится стороной. Моё решение не идеально, возможно вы мне что-нибудь подскажете), но на сколько я понял, у веба в плане уведомлений есть большое количество ограничений.
Первые несколько шагов будут достаточно общими для всех статей про уведомления, но не упомянуть их здесь будет странным:
Создаём проект firebase https://console.firebase.google.com/u/0/
Создаём проект на flutter на все возможные платформы
Создание проекта flutter
Используя документацию https://firebase.google.com/docs/flutter/setup? platform=web
в терминале поочерёдно пишем команды и отвечаем на вопросы, которые задаёт система
4. На этом шаге выбираем созданный нами проект firebase (у меня это flutter-notification-web-acf68 (flutter-notification-web)), выбираем все платформы и (при необходимости) ставим какое-нибудь имя Android app (в моём случае com.example.flutter_firebase_notification_with_web)
В папке с проектом создастся файл firebase_options.dart
В проект устанавливаем необходимые пакеты (команды для терминала):
flutter pub add firebase_core
flutter pub add go_router
flutter pub add firebase_messaging
flutter pub add flutter_local_notifications
flutter pub add dio
flutter pub add url_strategy
А теперь перейдём к настройке уведомлений для web:
В проекте в web/index.html необходимо добавить следующие строки в нужные места:
В блок head
В блок body
•
index.html
Создаём файл firebase-messaging-sw.js рядом с файлом index.html с вот таким содержимым (внимание! строки 6–13 надо взять из файла firebase_options.dart из переменной static const FirebaseOptions web):
importScripts('https://www.gstatic.com/firebasejs/11.0.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/11.0.0/firebase-analytics-compat.js');
importScripts('https://www.gstatic.com/firebasejs/11.0.0/firebase-messaging-compat.js');
firebase.initializeApp({
// эту часть берём из файла firebase_options.dart из переменной static const FirebaseOptions web
apiKey: 'AIzaSyCWRk',
appId: '1:131139059',
messagingSenderId: '13',
projectId: 'flutter-no',
authDomain: 'flutter-n.com',
storageBucket: 'fluttefirebasestorage.app',
measurementId: 'G-2PZ1',
});
messaging.onBackgroundMessage((message) => {
console.log("onBackgroundMessage", message);
});
firebase_options.dart
Переходим непосредственно к коду на flutter. Я не стал заморачиваться со структурой, всё будет находиться в папке lib для простоты
main.dart — основной входной файл для нашего кода. Будет минималистичным. Основное на что нужно обратить, это на создание navigatorKey –ключ, который нам поможет при навигации
import 'package:flutter/material.dart';
import 'package:url_strategy/url_strategy.dart';
import 'package:go_router/go_router.dart';
import 'app.dart';
import 'firebase.dart';
final GlobalKey navigatorKey = GlobalKey();
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
GoRouter.optionURLReflectsImperativeAPIs = true;
setPathUrlStrategy();
await firebase_init();
runApp(const App());
}
app.dart – главный класс для нашего приложения. Здесь мы создаём переменную router для навигации с помощью GoRouter, а также подключаем сервис навигации (NavigatorService), который опишем позже
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'router.dart';
class App extends StatelessWidget {
static NavigationService? navigationService;
const App({super.key});
@override
Widget build(BuildContext context) {
final GoRouter router = AppRouter().router;
navigationService = NavigationService(router);
return MaterialApp.router(
routerDelegate: router.routerDelegate,
routeInformationParser: router.routeInformationParser,
routeInformationProvider: router.routeInformationProvider,
debugShowCheckedModeBanner: false,
);
}
}
router.dart — класс навигации нашего приложения. Тут мы описываем все маршруты нашего приложения, добавляем navigatorKey, а внизу файла описан Сервис навигации NavigationService
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'main.dart';
import 'mainpage.dart';
import 'pages.dart';
class AppRouter {
GoRouter get router => _goRouter;
late final GoRouter _goRouter =
GoRouter(
navigatorKey: navigatorKey,
routes:[
GoRoute(
path: "/",
name: "main",
builder: (BuildContext context1, state1) => const MainPage()),
GoRoute(
path: "/page1",
name: "page1",
builder: (BuildContext context1, state1) => const Page1()),
GoRoute(
path: "/page2",
name: "page2",
builder: (BuildContext context1, state1) => const Page2()),
GoRoute(
path: "/page3",
name: "page3",
builder: (BuildContext context1, state1) => const Page3()),
GoRoute(
path: "/page4",
name: "page4",
builder: (BuildContext context1, state1) => const Page4()),
],
);
}
class NavigationService {
final GoRouter _router;
NavigationService(this._router);
void navigateTo(String route) {
_router.go(route);
}
}
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'firebase.dart';
class MainPage extends StatelessWidget {
const MainPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Container(
color: Colors.redAccent,
child: Center(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
//
PushNotifications.fcmSubscribe('im1');
},
child: Text("Подписаться")),
ElevatedButton(
onPressed: () {
//
PushNotifications.fcmUnSubscribe('im1');
},
child: Text("Отписаться"))
],
),
ElevatedButton(
onPressed: () {
//
Dio dio = Dio();
var info = {
"message": "message",
"title": "title",
"link": "link",
"topic": "topic",
};
dio.post(
'https://mysite.ru/api/test.php',
data: FormData.fromMap(info),
);
},
child: Text("Отправить сообщение"))
],
))),
));
}
}
import 'package:flutter/material.dart';
class Page1 extends StatelessWidget {
const Page1({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
color: Colors.lightGreenAccent,
child: const Text("Foreground"))));
}
}
class Page2 extends StatelessWidget {
const Page2({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
color: Colors.lightBlueAccent,
child: const Text("From terminated"))));
}
}
class Page3 extends StatelessWidget {
const Page3({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
color: Colors.yellowAccent,
child: const Text("On tap Background"))));
}
}
class Page4 extends StatelessWidget {
const Page4({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Container(
color: Colors.black,
child: const Text("WEB on tap",
style: TextStyle(color: Colors.white)))));
}
}
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'app.dart';
import 'firebase_options.dart';
import 'main.dart';
class PushNotifications {
static String? token;
static final _firebaseMessaging = FirebaseMessaging.instance;
static final FlutterLocalNotificationsPlugin
_flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
// request notification permission
static Future init() async {
await _firebaseMessaging.requestPermission(
alert: true,
announcement: true,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
token=await getFCMToken();
}
static void fcmSubscribe(String topic) {
if (token==null) return;
if (kIsWeb)
{
Dio dio = Dio();
var info = {
"topic": topic,
"token": token,
"subscribe": 1,
};
dio.post(
'https://mysite.ru/api/subscribetotopic.php',
data: FormData.fromMap(info),
);
}
else
_firebaseMessaging.subscribeToTopic(topic);
}
static void fcmUnSubscribe(String topic) {
if (token==null) return;
if (kIsWeb)
{
Dio dio = Dio();
var info = {
"topic": topic,
"token": token,
"subscribe":0,
};
dio.post(
'https://mysite.ru/api/subscribetotopic.php',
data: FormData.fromMap(info),
);
}
else
_firebaseMessaging.unsubscribeFromTopic(topic);
}
// get the fcm device token
static Future getFCMToken({int maxRetires = 2}) async {
try {
String? token=await _firebaseMessaging.getToken();;
print("device token: $token");
return token;
} catch (e) {
if (maxRetires > 0) {
await Future.delayed(Duration(seconds: 10));
return getFCMToken(maxRetires: maxRetires - 1);
} else {
return null;
}
}
}
// initalize local notifications
static Future localNotiInit() async {
// initialise the plugin. app_icon needs to be a added as a drawable resource to the Android head project
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
onDidReceiveLocalNotification: (id, title, body, payload) => null,
);
final LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(defaultActionName: 'Open notification');
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
linux: initializationSettingsLinux);
_flutterLocalNotificationsPlugin.initialize(initializationSettings,
onDidReceiveNotificationResponse: onNotificationTap,
onDidReceiveBackgroundNotificationResponse: onNotificationTap,
);
}
// on tap local notification in foreground
static void onNotificationTap(NotificationResponse notificationResponse) {
print(notificationResponse.payload);
App.navigationService!.navigateTo("/page4");
}
// show a simple notification
static Future showSimpleNotification({
required String title,
required String body,
required String payload,
}) async {
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails('your channel id', 'your channel name',
channelDescription: 'your channel description',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker');
const NotificationDetails notificationDetails =
NotificationDetails(android: androidNotificationDetails);
await _flutterLocalNotificationsPlugin
.show(0, title, body, notificationDetails, payload: payload);
}
}
Future _firebaseBackgroundMessage(RemoteMessage message) async {
print("background");
App.navigationService!.navigateTo("/page3");
if (message.notification != null) {
print("Some notification Received");
}
}
// to handle notification on foreground on web platform
void showNotification(
{required String title, required String body, required String route}) {
showDialog(
context: navigatorKey.currentContext!,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () {
App.navigationService!.navigateTo(route);
},
child: Text("Ok"))
],
),
);
}
Future firebase_init() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print("Background Notification Tapped");
App.navigationService!.navigateTo("/page3");
// if (message.notification != null) {
//
// App.navigationService!.navigateTo("/page3");
// }
});
PushNotifications.init();
// only initialize if platform is not web
if (!kIsWeb) {
PushNotifications.localNotiInit();
}
// Listen to background notifications
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundMessage);
// to handle foreground notifications
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
String payloadData = jsonEncode(message.data);
print(message.data);
print("Got a message in foreground");
//if (message.notification != null) {
if (kIsWeb) {
showNotification(
title: message.notification!.title!,
body: message.notification!.body!,
route: "/page4");
} else {
App.navigationService!.navigateTo("/page1");
PushNotifications.showSimpleNotification(
title: message.notification!.title!,
body: message.notification!.body!,
payload: payloadData);
}
//}
});
// for handling in terminated state
final RemoteMessage? message =
await FirebaseMessaging.instance.getInitialMessage();
if (message != null) {
print("Launched from terminated state");
Future.delayed(Duration(seconds: 1), () {
App.navigationService!.navigateTo("/page2");
});
}
}
Настройка сервера для работы с уведомлениями
По flutter всё) теперь делаем небольшую инфраструктуру для уведомлений. Предполагается, что все уведомления мы будем инициировать с помощью сайта и сервера на php. Проблема в том, что если использовать уведомления с помощью подписки на topic (на темы), то данная функция в вебе просто не работает (выдаёт ошибку). В вебе можно присылать уведомления только на устройство (токен). А нам надо… я предлагаю вот такой выход: хранить все токены с привязкой к топикам в БД и при необходимости отправлять нужные уведомления на устройство. P.S. не ругайте почти полное отсутствие секьюрности, это всё-таки обучающая статья…
Скачиваем библиотеку для работы с уведомлениями для php. Раньше можно было без неё обойтись, но летом google всё поменял:
composer require google/apiclient
Создаём в mysql БД таблицу subscribeToTopic
Скачиваем файл с настройками firebase для php (жирным выделено название проекта)
https://console.firebase.google.com/u/0/project/flutter-notification-web-acf68/settings/serviceaccounts/adminsdk
Копируем файл на сервер. Имя запоминаем (оно полу-рандомное) — оно нам ещё понадобится
adminsdk
Создаём файл connect.php и файл с функциями functions.php, которые в будущем будем использовать
function getAccessToken($serviceAccountPath) {
$client = new Client();
$client->setAuthConfig($serviceAccountPath);
$client->addScope('https://www.googleapis.com/auth/firebase.messaging');
$client->useApplicationDefaultCredentials();
$token = $client->fetchAccessTokenWithAssertion();
return $token['access_token'];
}
function sendMessage($accessToken, $message) {
$url = 'https://fcm.googleapis.com/v1/projects/ flutter-notification-web-acf68/messages:send';
$headers = [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['message' => $message]));
$response = curl_exec($ch);
if ($response === false) {
throw new Exception('Curl error: ' . curl_error($ch));
}
curl_close($ch);
return json_decode($response, true);
}
Файл subscribetotopic.php дляподключения и отключения подписки для веба для топиков
require('connect.php');
if (!isset($_POST['topic']))die;
if (!isset($_POST['token']))die;
if (!isset($_POST['subscribe']))die;
if ($_POST['subscribe']==1)
$sql="INSERT INTO `subscribeToTopic`(`topic`, `token`) VALUES ('".$_POST['topic']."','".$_POST['token']."')";
else
$sql="delete from `subscribeToTopic where topic='".$_POST['topic']."' and token='".$_POST['token']."')";
$conn->query($sql);
?>
test.php это файл отправки на сервер сообщения. Здесь необходимо будет вписать имя файла (14 строка) , полученное несколькими шагами ранее
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
require('functions.php');
require('connect.php');
$mess="message";
$title="title";
$link="link";
$topic="im1";
// Path to your service account JSON key file
$serviceAccountPath = "MYJSON.json";
// Example message payload
$message = [
'topic' => $topic,
'notification' => array(
'body' => $mess,
'title' => $title,
'image'=> ''
),
'data' => array(
'lnk' => '/'.$link,
'message' => $mess,
'title' => $title,
'image'=> ''
)
];
$accessToken = getAccessToken($serviceAccountPath);
$response = sendMessage($accessToken, $message);
unset($message['topic']);
$result=$conn->query("select token from subscribeToTopic where topic='".$topic."'");
while ($r = $result->fetch_row()) {
$message['token']=$r[0];
$response = sendMessage($accessToken, $message);
}
?>
Тестирование
Теперь можно сделать и мобильное приложение и веб и протестировать.
Запускаем, подписываемся на топик с помощью кнопки »Подписаться»
Сворачиваем приложение (браузер), переходим по ссылке mysite.ru/api/test.php для отправки уведомления
В мобильном приложении можно перейти по уведомлению, перейдём на страницу с текстом «On tap Background»
Теперь при открытом приложении отправляем уведомления — приложение перейдёт к странице «Foreground»
Закроем приложение, отправим уведомления, перейдём по уведомлению — будет страница «From terminated»
Браузер…, а вот тут всё сложнее. В нашем случае при открытом окне и попытке отправить уведомление будет вот такое окно, при клике на OK перейдёт к странице «WEB on tap»:
В background будут приходить уведомления, но с ними толком ничего сделать нельзя (или может я чего не знаю), кроме как вывести в консоль. Хотелось бы чтобы тоже переход какой-то был. Но javascript’овский location.href не работает в файле firebase-messaging-sw.js
Terminated режима в браузере просто не существует
Кстати в мобильном браузере (напр. Chrome) можно перейти на сайт, который вы опубликовали и нажать кнопку «Добавить на гл. экран» и будет та иконка, о которой я говорил. Оповещения будут также приходить, как и на обычное мобильное приложение
Заключение
В браузере можно работать с уведомлениями, примерно также как и с мобильными приложениями, правда есть небольшие ограничения, которые хотелось бы обойти.
Как видите, здесь код, который по идее работает для всех мобильных приложений и веба, что в целом может помощь в масштабировании проекта.
P.S. кто-нибудь знает что делать, чтобы в мобильном браузере принудительно выключать «Версию для ПК» для flutter-сайтов, либо делать какое-то уведомление, чтобы пользователи выключали данный режим, ибо система просто выдаёт белый экран?