Добрый день!
Хотел написать статью, обобщающую то, что я нашёл в интернете. Может кому-то она покажется слишком простой, может ненужной, а может наоборот вызовет обсуждение, на что я крайне надеюсь.
В двух словах о чём статья
С нуля мы создадим 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 на все возможные платформы
-
Используя документацию https://firebase.google.com/docs/flutter/setup?platform=web
в терминале поочерёдно пишем команды и отвечаем на вопросы, которые задаёт система
-
firebase login
-
dart pub global activate flutterfire_cli
-
flutterfire configure
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
<script> const serviceWorkerVersion = '{{flutter_service_worker_version}}'; </script>
-
В блок body
•<script> if ('serviceWorker' in navigator) { window.addEventListener('load', function (ev) { navigator.serviceWorker.register('/firebase-messaging-sw.js'); var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion; _flutter.loader.load({ serviceWorker: { serviceWorkerVersion: serviceWorkerVersion, }, onEntrypointLoaded: function (engineInitializer) { engineInitializer.initializeEngine().then(function (appRunner) { appRunner.runApp(); }); } }); }); } </script>
-
Создаём файл 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); });
-
Переходим непосредственно к коду на 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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); Future<void> 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); } }
-
mainpage.dart — главная страница нашего сайта
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("Отправить сообщение")) ], ))), )); } }
-
pages.dart — простенькие странички для теста
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))))); } }
-
firebase.dart — основной класс для работы с уведомлениями
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<void> 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 (жирным выделено название проекта)
Копируем файл на сервер. Имя запоминаем (оно полу-рандомное) — оно нам ещё понадобится
-
Создаём файл connect.php и файл с функциями functions.php, которые в будущем будем использовать
-
<?php $conn = mysqli_connect("localhost", "myuser", "mypass", "mybd"); mysqli_set_charset($conn, "utf8"); if (!$conn) { echo "Ошибка: Невозможно установить соединение с MySQL." . PHP_EOL; echo "Код ошибки errno: " . mysqli_connect_errno() . PHP_EOL; echo "Текст ошибки error: " . mysqli_connect_error() . PHP_EOL; exit; } ?>
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-сайтов, либо делать какое-то уведомление, чтобы пользователи выключали данный режим, ибо система просто выдаёт белый экран?
Жду Ваших комментариев!)))
ссылка на оригинал статьи https://habr.com/ru/articles/857678/
Добавить комментарий