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.