flutterdeep-linkingfirebase-dynamic-linksflutter-local-notification

Flutter local notification deeplink when app terminated


My app uses flutter_local_notification plugin.

I need to make a transition on particular screen when my app is terminated. Everything works fine when the app isn't closed. This is what I've tried.

I scoured the plugins' docs but didn't find the answer. Is it possible to do so without push notifications?

Maybe i should use native channels to run the actions?

    import 'dart:async';
    
    import 'package:firebase_messaging/firebase_messaging.dart';
    import 'package:flutter_local_notifications/flutter_local_notifications.dart';
    import 'package:flutter_native_timezone/flutter_native_timezone.dart';
    import 'package:like_cucumber/core/like_cucumber.dart';
    import 'package:like_cucumber/data/mappers/task_notification_payload_mapper.dart';
    import 'package:like_cucumber/presentation/tab_bar/tab_bar/tab_bar.dart';
    import 'package:like_cucumber/presentation/tab_bar/tasks_tab/task_info.dart';
    import 'package:timezone/data/latest_all.dart' as tz;
    import 'package:timezone/timezone.dart' as tz;
    
    @pragma('vm:entry-point')
    void onDidReceiveBackgroundNotification(NotificationResponse response) async {
      final payload = response.payload;
      if (payload == null) return;
      final task = TaskNotificationPayloadMapper.fromPayload(payload);
      navigatorKey.currentState?.push(TabBarPage.getRoute(initialTabIndex: 1));
      navigatorKey.currentState?.push(TaskInfoPage.getRoute(
        task: task,
      ));
    }
    
    class LocalNotificationsService {
      factory LocalNotificationsService() => _instance;
      LocalNotificationsService._();
      static final _instance = LocalNotificationsService._();
    
      final _plugin = FlutterLocalNotificationsPlugin();
      final _messaging = FirebaseMessaging.instance;
    
      Future<void> init() async {
        await _requestPermission();
        await _initCurrentTimezone();
        await _initPlugin();
      }
    
      Future<void> _requestPermission() => _messaging.requestPermission();
    
      Future<void> _initCurrentTimezone() async {
        tz.initializeTimeZones();
        final localName = await FlutterNativeTimezone.getLocalTimezone();
        tz.setLocalLocation(tz.getLocation(localName));
      }
    
      Future<void> _initPlugin() async {
        const DarwinInitializationSettings iosSettings =
            DarwinInitializationSettings();
        const initSettings = InitializationSettings(iOS: iosSettings);
        await _plugin.initialize(
          initSettings,
          onDidReceiveBackgroundNotificationResponse:
              onDidReceiveBackgroundNotification,
          onDidReceiveNotificationResponse: (response) {
            _notificationListenerController.add(response);
          },
        );
      }
    
      late Stream<NotificationResponse> notificationListener =
          _notificationListenerController.stream.asBroadcastStream();
      final _notificationListenerController =
          StreamController<NotificationResponse>();
    
      NotificationDetails get _notificationDetails =>
          const NotificationDetails(iOS: DarwinNotificationDetails());
    
      Future<void> scheduleNotification({
        required int id,
        required String title,
        required String message,
        required DateTime dateTime,
        String? payload,
      }) async {
        await _plugin.zonedSchedule(
          id,
          title,
          message,
          _convertToTZDate(dateTime),
          _notificationDetails,
          uiLocalNotificationDateInterpretation:
              UILocalNotificationDateInterpretation.absoluteTime,
          matchDateTimeComponents: DateTimeComponents.dateAndTime,
          payload: payload,
        );
      }
    
      tz.TZDateTime _convertToTZDate(DateTime date) => tz.TZDateTime(
            tz.local,
            date.year,
            date.month,
            date.day,
            date.hour,
            date.minute,
            date.second,
          );
    
      Future<void> cancel(int id) => _plugin.cancel(id);
      Future<void> cancelAll() => _plugin.cancelAll();
    }

My info.plist has permissions (however I don't think it's required):

<key>UIBackgroundModes</key>
    <array>
        <string>fetch</string>
        <string>remote-notification</string>
    </array>

I appreciate any help.


Solution

  • Ok, finally I double check the docs and finished up with this:

    main.dart

    
    String? payload;
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
    
      await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform,
      );
    
      // some initialization code
    
      await _checkDynamicLinks();
    
      runApp(LikeCucumber(payload: payload));
    }
    
    
    Future<void> _checkDynamicLinks() async {
      final plugin = FlutterLocalNotificationsPlugin();
      await LocalNotificationsService().init(plugin);
      await _getNotificationAppLaunchDetails(plugin);
    }
    
    Future<void> _getNotificationAppLaunchDetails(
      FlutterLocalNotificationsPlugin plugin,
    ) async {
      final details = await plugin.getNotificationAppLaunchDetails();
      if (details?.notificationResponse != null) {
        payload = details!.notificationResponse?.payload;
      }
    }
    


    like_cucumber.dart

    final navigatorKey = GlobalKey<NavigatorState>();
    
    class LikeCucumber extends StatelessWidget {
      const LikeCucumber({required this.payload, Key? key}) : super(key: key);
    
      final String? payload;
    
      @override
      Widget build(BuildContext context) {
        return MultiProvider(
         
          child: MaterialApp(
            // ...
            navigatorKey: navigatorKey,
            onGenerateRoute: (settings) =>
                AppRouter.onGenerateRoute(settings, payload),
            initialRoute: LaunchPage.routeName,
          ),
        );
      }
    }
    


    app_router.dart

      class AppRouter {
      AppRouter._();
      // когда переходит по диплинку, то не переносит данные
      // нужно не делать переход по экранам
      static Route? onGenerateRoute(RouteSettings settings, [String? payload]) {
        switch (settings.name) {
          case LaunchPage.routeName:
            return LaunchPage.getRoute(sl(), payload);
          // ...
        }
        return null;
      }
    
     // ...
    }
    


    launch_page.dart

    
    class LaunchPage extends StatefulWidget {
      const LaunchPage._(
          {required this.userAuthStateRepo,
          required this.notificationDetailPayload});
    
      static const String routeName = '/';
      final UserAuthStateRepo userAuthStateRepo;
      final String? notificationDetailPayload;
    
      static getRoute(
          UserAuthStateRepo userAuthStateRepo, String? notificationDetailPayload) {
        return MaterialPageRoute(
          settings: const RouteSettings(name: routeName),
          builder: (_) => LaunchPage._(
            userAuthStateRepo: userAuthStateRepo,
            notificationDetailPayload: notificationDetailPayload,
          ),
        );
      }
    
      @override
      State<LaunchPage> createState() => _LaunchPageState();
    }
    
    class _LaunchPageState extends State<LaunchPage> {
      @override
      void initState() {
        WidgetsBinding.instance.addPostFrameCallback((_) async => await 
        _goTo());
        super.initState();
      }
    
      Future<void> _goTo() async {
        // ...
       _goToTabBar();
      }
    
    
      void _goToTabBar() {
        final task = _taskFromDeeplink;
        (task == null) ? _goToInitialTabBarPage() : _goToTaskDetail(task);
      }
    
    
    
      Task? get _taskFromDeeplink {
        final payload = widget.notificationDetailPayload;
        if (payload == null) return null;
        final task = TaskNotificationPayloadMapper.fromPayload(payload);
        return task;
      }
    
      void _goToTaskDetail(Task task) {
        navigatorKey.currentState?.pushAndRemoveUntil(
          TabBarPage.getRoute(initialTabIndex: 1),
          (_) => false,
        );
        navigatorKey.currentState?.push(TaskInfoPage.getRoute(task: task));
      }
    
    
    
      @override
      Widget build(BuildContext context) {
        // ...
      }
    }
    


    tasks_info_page.dart

    class TaskInfoPage extends StatelessWidget {
      const TaskInfoPage._({required Task task});
    
      static getRoute({
        required Task task,
      }) {
        return MaterialPageRoute(
          settings: const RouteSettings(name: routeName),
          builder: (_) => ChangeNotifierProvider(
            create: (context) => TaskInfoPageModel(
              task: task,
              tasksRepo: sl(),
              tasksCompleter: sl(),
            ),
            child: TaskInfoPage._(task: task),
          ),
        );
      }
    
    // ...
    

    The key points of my issue and the solution:

    1. You taps the notification then the app is not running.
    2. The app starts launching from main().
    3. It looks for new local notifications that could trigger the launching.
    4. It finds the notification.
    5. It launches material app and passes data to LaunchPage.
    6. The LaunchPage handles the payload and passes it to the screen.

    For sure this is a workaround, there is no good architecture solution here. However this works. If you have any better approaches how to organize this I will be happy to see them.