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 всё время обходится стороной. Моё решение не идеально, возможно вы мне что-нибудь подскажете) но на сколько я понял, у веба в плане уведомлений есть большое количество ограничений.

Первые несколько шагов будут достаточно общими для всех статей про уведомления, но не упомянуть их здесь будет странным:

  1. Создаём проект firebase https://console.firebase.google.com/u/0/

  2. Создаём проект на flutter на все возможные платформы

    Создание проекта flutter

    Создание проекта flutter
  3. Используя документацию 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)

  1. В папке с проектом создастся файл firebase_options.dart

  2. В проект устанавливаем необходимые пакеты (команды для терминала):

  • 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:

  1. В проекте в 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>
index.html

index.html
  1. Создаём файл 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

firebase_options.dart
  1. Переходим непосредственно к коду на 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. не ругайте почти полное отсутствие секьюрности, это всё-таки обучающая статья…

  1. Скачиваем библиотеку для работы с уведомлениями для php. Раньше можно было без неё обойтись, но летом google всё поменял:

    composer require google/apiclient

  2. Создаём в mysql БД таблицу subscribeToTopic

  3. Скачиваем файл с настройками firebase для php (жирным выделено название проекта)

    https://console.firebase.google.com/u/0/project/flutter-notification-web-acf68/settings/serviceaccounts/adminsdk

    Копируем файл на сервер. Имя запоминаем (оно полу-рандомное) — оно нам ещё понадобится

    adminsdk

    adminsdk
    1. Создаём файл 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); }
  1. Файл 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);   ?> 
  1. 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); }   ?> 

Тестирование

Теперь можно сделать и мобильное приложение и веб и протестировать.

  1. Запускаем, подписываемся на топик с помощью кнопки «Подписаться«

  2. Сворачиваем приложение (браузер), переходим по ссылке mysite.ru/api/test.php для отправки уведомления

  3. В мобильном приложении можно перейти по уведомлению, перейдём на страницу с текстом «On tap Background»

  4. Теперь при открытом приложении отправляем уведомления — приложение перейдёт к странице «Foreground»

  5. Закроем приложение, отправим уведомления, перейдём по уведомлению — будет страница «From terminated»

  6. Браузер… а вот тут всё сложнее. В нашем случае при открытом окне и попытке отправить уведомление будет вот такое окно, при клике на OK перейдёт к странице «WEB on tap»:

    1. В background будут приходить уведомления, но с ними толком ничего сделать нельзя (или может я чего не знаю), кроме как вывести в консоль. Хотелось бы чтобы тоже переход какой-то был. Но javascript’овский location.href не работает в файле firebase-messaging-sw.js

    2. Terminated режима в браузере просто не существует

    3. Кстати в мобильном браузере (напр. Chrome) можно перейти на сайт, который вы опубликовали и нажать кнопку «Добавить на гл. экран» и будет та иконка, о которой я говорил. Оповещения будут также приходить, как и на обычное мобильное приложение

Заключение

В браузере можно работать с уведомлениями, примерно также как и с мобильными приложениями, правда есть небольшие ограничения, которые хотелось бы обойти.

Как видите, здесь код, который по идее работает для всех мобильных приложений и веба, что в целом может помощь в масштабировании проекта.

P.S. кто-нибудь знает что делать, чтобы в мобильном браузере принудительно выключать «Версию для ПК» для flutter-сайтов, либо делать какое-то уведомление, чтобы пользователи выключали данный режим, ибо система просто выдаёт белый экран?

Жду Ваших комментариев!)))


ссылка на оригинал статьи https://habr.com/ru/articles/857678/