Flutter Push-уведомления, том числе в Web

Добрый день!

Хотел написать статью, обобщающую то, что я нашёл в интернете. Может кому-то она покажется слишком простой, может ненужной, а может наоборот вызовет обсуждение, на что я крайне надеюсь.

В двух словах о чём статья

С нуля мы создадим flutter-проект с подключением к push-уведомлениям. Будем отправлять уведомления не только на Android, IOS, но и на наш веб-сайт, который может рассылать уведомления в т.ч. на мобильные устройства. Нам потребуется дополнительно лишь небольшой хостинг с mysql БД и php.

582daf28a4df3136c6fbfff37e5b86b1.png

Предыстория

Очень часто когда говорят про 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

    в терминале поочерёдно пишем команды и отвечаем на вопросы, которые задаёт система

4. На этом шаге выбираем созданный нами проект firebase (у меня это flutter-notification-web-acf68 (flutter-notification-web)), выбираем все платформы и (при необходимости) ставим какое-нибудь имя Android app (в моём случае com.example.flutter_firebase_notification_with_web)

8de7aacd7d2678a6ca57286c15779231.png

  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

  • В блок body

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

  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, которые в будущем будем использовать

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 дляподключения и отключения подписки для веба для топиков

query($sql);


?>
  1. test.php это файл отправки на сервер сообщения. Здесь необходимо будет вписать имя файла (14 строка) , полученное несколькими шагами ранее

 $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»:

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

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

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

Заключение

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

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

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

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

© Habrahabr.ru