For a Flutter mobile only project, invoking native code for the platform goes through MethodChannel. For a Flutter web project, we can use dart:html[1] package to interact with browser DOM. But when we are using same codebase for both mobile and web, including web specific packages directly will fail to build. The solution to this is to use a factory pattern to hide the actual class and instantiate the specific class based on the platform. This makes the compiler happy and the build to work.

This specific example is based on the use case of displaying push notification badge count in the navbar which is in the Flutter side but the Firebase web push is received in the service worker which is in the JavaScript side of things. So we need to bridge between these two platforms so that JavaScript invokes a Dart method on receiving a web push notification and it updates the badge count in the app bar.

flutter-app
├── lib
│   ├── providers
│   │   └── app_bar_provider.dart
│   ├── utils
│   │   ├── platform_bridge
│   │   │   ├── js_bridge.dart
│   │   │   ├── mobile_bridge.dart
│   │   │   ├── platform_bridge_interface.dart
│   │   │   └── platform_bridge_stub.dart
│   │   └── main.dart
│   └── view
│       └── widgets
│           └── app_bar_widget.dart
└── web
    ├── firebase-messaging-sw.js
    └── index.html

The above folder structure shows only the relevant files.

// platform_bridge_interface.dart

import 'package:flutter_app/utils/platform_bridge/platform_bridge_stub.dart'
    // ignore: uri_does_not_exist
    if (dart.library.io) 'package:flutter_app/utils/platform_bridge/mobile_bridge.dart'
    // ignore: uri_does_not_exist
    if (dart.library.html) 'package:flutter_app/utils/platform_bridge/js_bridge.dart';

abstract class PlatformBridge {
  void initBridge() {}

  Future<void> setBadgeCount(int count) async {}

  void showNotification(String title, String body) {}

  factory PlatformBridge() => getPlatformBridge();
}

This is the abstract class which will be implemented by js_bridge which is for web and mobile_bridge which is for mobile.

// js_bridge.dart

// ignore_for_file: avoid_web_libraries_in_flutter

import 'dart:html' as html;
import 'dart:js' as js;
import 'dart:js_util';

import 'package:flutter_app/main.dart';
import 'package:flutter_app/providers/app_bar_provider.dart';
import 'package:flutter_app/utils/platform_bridge/platform_bridge_interface.dart';
import 'package:flutter_app/utils/shared_prefs.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';

class JSBridge implements PlatformBridge {
  /// Exposes Dart function to JavaScript window object.
  @override
  void initBridge() {
    if (kIsWeb) {
      // Registers setBadgeCount method in browser window object mapped to setBadgeCount dart method.
      setProperty(html.window, 'setBadgeCount', allowInterop(setBadgeCount));
    }
  }

  /// Handle communication from JavaScript web
  @override
  Future<void> setBadgeCount(int count) async {
    await SharedPrefs.setNotificationUnreadCount(count.toString());
    var context = globalNavigatorKey.currentContext;
    if (context != null && context.mounted) {
      var appBarProvider = Provider.of<AppBarProvider>(context, listen: false);
      appBarProvider.updateBadgeCount();
    }
  }

  /// Calls showNotification function defined in JavaScript to display web notification
  @override
  void showNotification(String title, String body) {
    if (kIsWeb) js.context.callMethod('showNotification', [title, body]);
  }
}

PlatformBridge getPlatformBridge() => JSBridge();
// mobile_bridge.dart

import 'package:flutter_app/common/console_log.dart';
import 'package:flutter_app/utils/platform_bridge/platform_bridge_interface.dart';

class MobileBridge implements PlatformBridge {
  @override
  void initBridge() {
    debugLog('mobile: initBridge unsupported');
  }

  @override
  Future<void> setBadgeCount(int count) async {
    debugLog('mobile: setBadgeCount unsupported');
  }

  @override
  void showNotification(String title, String body) {
    debugLog('mobile: showNotification unsupported');
  }
}

PlatformBridge getPlatformBridge() => MobileBridge();

Here the methods are not implemented because these are not relevant to the mobile platform.

// platform_bridge_stub.dart

import "package:flutter_app/utils/platform_bridge/platform_bridge_interface.dart";

PlatformBridge getPlatformBridge() => throw UnsupportedError('Cannot create a PlatformBridge without the packages dart:html for web');

To use this, we can add the below code to the main.dart file.

// main.dart
  
import 'package:flutter_app/utils/platform_bridge/platform_bridge_interface.dart';

void main() async {
  await runZonedGuarded(() async {
    WidgetsFlutterBinding.ensureInitialized();
    
    PlatformBridge().initBridge();
    
    // ..
  }
}

This initializes the bridge. The class the PlatformBridge sees depends on the import. The standard way is to check if dart.library.html is available which means it is web, else if dart.library.io is present then it is mobile.

Inter platform communication

// index.html script block
  
if ("serviceWorker" in navigator) {
    window.addEventListener("load", function () {
        navigator.serviceWorker.register("/firebase-messaging-sw.js");
    });
} else {
    console.error('serviceWorker not in navigator');
}

/// Communication between service worker and main script
const broadcastChannel = new BroadcastChannel('sw-com-channel');

/// Invoke a dart method to update the notification badge count on receiving a 
/// web push notification when the browser is in the background
function sendMessageToFlutter(badgeCount) {
    try {
        if (window.hasOwnProperty('setBadgeCount')) {
            var count;
            try {
                count = parseInt(badgeCount);
            } catch (e) {
                console.log(e);
            }
            if (typeof count == 'number') {
                window.setBadgeCount(count);
            }
        }
    } catch (e) {
        console.log(e);
    }
}

/// Listener for messages from service worker
broadcastChannel.addEventListener('message', (event) => {
    var data = event.data;
    if (data.type == 'unreadCount') {
        sendMessageToFlutter(data.data);
    }
});

/// When the browser tab is active and visible on the desktop, the web push 
/// notifications are received by the flutter onMessage handler. In such cases 
/// to show the notification we need to invoke this function from dart.
function showNotification(title, body) {
  let icon = 'icons/Icon-192.png';
  let notification = new Notification(title, {body, icon});
  return notification;
}

Add this script to the index.html.

// firebase-messaging-sw.js

const firebaseConfig = {
  apiKey: "",
  // ..
}

firebase.initializeApp(firebaseConfig);
const messaging = firebase.messaging();

/// Communication between service worker and main script
const broadcastChannel = new BroadcastChannel('sw-com-channel');

/// When the browser tab is in the background either by switching to another tab
/// or browser is not visible on the desktop, web push notifications are handled
/// by this method.
/// In this case the flutter onMessage handler will not be invoked. Here we
/// display the notification and to update the badge count which has to happen 
/// from the flutter end, we need to communicate to the main script which has a 
/// dart callback function registered which can be invoked from JavaScript to 
/// update the badge count.
messaging.onBackgroundMessage((event) => {
    return displayPush(event);
});

function displayPush(event) {
    if (event.hasOwnProperty('data')) {
        try {
            broadcastChannel.postMessage({type: 'unreadCount', data: event.data['unread']});
        } catch (e) {
            console.log(e);
        }
        return self.registration.showNotification(event.data['title'], {
            body: event.data['body'],
            icon: 'icons/Icon-192.png'
        });
    }
}

UI

Here we are using Provider for state management.

// app_bar_provider.dart

import 'package:flutter_app/utils/shared_prefs.dart';
import 'package:flutter/foundation.dart';

class AppBarProvider extends ChangeNotifier {
  var badgeCount = 0;

  Future<void> updateBadgeCount() async {
    badgeCount = int.tryParse(SharedPrefs.getNotificationUnreadCount ?? '0') ?? 0;
    notifyListeners();
  }
}

Corresponding widget snippet is given below.

// app_bar_widget.dart

import 'package:flutter_app/providers/app_bar_provider.dart';
import 'package:flutter_app/providers/theme_provider.dart';
import 'package:flutter_app/services/app_service.dart';
import 'package:flutter_app/utils/enums/font_style_enums.dart';
import 'package:flutter_app/view/widgets/visibility_widget.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'text_widget.dart';

class AppBarWidget extends StatefulWidget implements PreferredSizeWidget {
  final String title;
  final double height;
  final Function()? notificationCallBack;
  final bool showBell;
  const AppBarWidget({super.key, required this.title, this.height = kToolbarHeight, this.notificationCallBack, this.showBell = false});

  @override
  State<AppBarWidget> createState() => _AppBarWidgetState();

  @override
  Size get preferredSize => Size.fromHeight(height);
}

class _AppBarWidgetState extends State<AppBarWidget> {
  @override
  Widget build(BuildContext context) {
    return Consumer<ThemeProvider>(builder: (context, theme, child) {
      return Consumer<AppBarProvider>(builder: (context, provider, child) {
        return AppBar(
          title: TextWidget(
            widget.title,
            color: theme.systemBackgroundColor,
            fontSize: 16,
            styling: FontStyling.medium,
          ),
          flexibleSpace: Container(
            decoration: const BoxDecoration(color: theme.appBarColor),
          ),
          centerTitle: true,
          actions: [
            Row(
              children: [
                VisibilityWidget(
                  visible: widget.showBell,
                  child: IconButton(
                    icon: Stack(
                      children: [
                        const Icon(Icons.notifications_none),
                        Visibility(
                          visible: provider.badgeCount > 0,
                          child: Positioned(
                            right: 0,
                            child: Badge.count(count: provider.badgeCount),
                          ),
                        )
                      ],
                    ),
                    onPressed: () {
                      widget.notificationCallBack!();
                    },
                  ),
                ),
              ],
            ),
          ],
        );
      });
    });
  }
}

This shows an app bar with title and a bell icon with icon count on the right.


NB: This is a long overdue post. The dart:html is deprecated. See Migrate to package:web article for more details.