I want to share a working implementation of OneSignal push notifications when the backend is Supabase. FlutterFlow doesn't support push notifications for this setup.
The implementation consists of three custom actions. This is enough for Android. iOS requires an additional step - a small adjustment in Xcode.
Earlier, I used this approach a couple of times - https://community.flutterflow.io/discussions/post/onesignal-integration-for-notifications---notifications-for-supabase-yd2c5sa4jzoQr1W
But it stopped working for me, and I don't want to go deep into Xcode development. I found a simpler version of this implementation. However, my implementation has a limitation - it doesn't support images and buttons. It supports a simple clickable text push notification and deep links. Also, in the implementation, deep links work on a cold start.
So the implementation consists of 3 custom actions. They subscribe the device with OneSignal, launch a background service (push notification will launch the closed app), and ask permission for the push notifications. All of them together set up the device to receive push notifications. The app itself doesn't send push notifications - it calls an Edge Function that actually sends the push notifications. It is needed for storing the OneSignal API key in the backend.
Custom Action 1 - initializeOnesignal
This custom action should be called from the main.dart. and before the user is logged in. I.e. it is called when the app is launched. This function do the following:
Initializes and configures OneSignal for push notifications in the app
Sets up notification click handling to process deep links
Manages a queue system for deep links to ensure proper navigation
Tracks user subscription IDs and updates them in Supabase database
Provides error handling and logging for debugging
Implements retry logic when attempting to get a valid navigation context
Enables navigation to specific screens based on notification payload data
Maintains subscription state observation for authentication changes
// Automatic FlutterFlow imports
import '/backend/schema/structs/index.dart';
import '/backend/schema/enums/enums.dart';
import '/backend/supabase/supabase.dart';
import '/actions/actions.dart' as action_blocks;
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/actions/index.dart'; // Imports other custom actions
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
import 'package:onesignal_flutter/onesignal_flutter.dart';
import 'dart:collection';
// Store pending deep link data
// Global queue for deep links
final _deepLinkQueue = Queue<Map<String, String>>();
bool _isProcessingQueue = false;
// Part 1: Called from main.dart before login
Future<void> initializeOnesignal() async {
debugPrint('### Initializing OneSignal basic setup');
try {
// Basic initialization
OneSignal.initialize("ONESIGNAL-APP-ID");
OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
// Set up notification click handling
OneSignal.Notifications.addClickListener((event) {
debugPrint('### Notification clicked, processing payload');
handleNotificationClick(event.notification);
});
// Set up subscription observer for future auth changes
_setupSubscriptionObserver();
debugPrint('### OneSignal basic setup completed');
} catch (e, stackTrace) {
debugPrint('### ERROR initializing OneSignal: $e');
debugPrint('### Stack trace: $stackTrace');
}
}
void _setupSubscriptionObserver() {
OneSignal.User.pushSubscription.addObserver((state) {
debugPrint('### Subscription state changed: ${state.current.id}');
if (state.current.id != null) {
final supabase = Supabase.instance.client;
final user = supabase.auth.currentUser;
if (user != null) {
_updateSubscriptionId(user.id, state.current.id!);
}
}
});
}
Future<void> _updateSubscriptionId(String userId, String subscriptionId) async {
await Supabase.instance.client.from('user_push_tokens').upsert({
'user_id': userId,
'push_token': subscriptionId,
'updated_at': DateTime.now().toIso8601String(),
});
}
void handleNotificationClick(OSNotification notification) {
final data = notification.additionalData;
if (data == null) return;
final deepLink = {
'itemId': data['itemId']?.toString() ?? '',
'screenName': data['screenName']?.toString() ?? '',
};
_deepLinkQueue.add(deepLink);
_processDeepLinkQueue();
}
Future<void> _processDeepLinkQueue() async {
if (_deepLinkQueue.isEmpty || _isProcessingQueue) return;
_isProcessingQueue = true;
try {
final deepLink = _deepLinkQueue.removeFirst();
final itemId = deepLink['itemId'];
final screenName = deepLink['screenName'];
if (itemId == null || screenName == null) return;
final context = await _getValidContext();
if (context == null) return;
context.goNamed(screenName, pathParameters: {'itemId': itemId});
} finally {
_isProcessingQueue = false;
}
}
Future<BuildContext?> _getValidContext() async {
for (int i = 0; i < 5; i++) {
final context = appNavigatorKey.currentContext;
if (context != null && context.mounted) return context;
await Future.delayed(const Duration(milliseconds: 500));
}
return null;
}
Custom Action 2 - syncOneSignalAfterLogin
The main role of this function is to store the subscription id in Supabase and make the deep links work reliably:
- Synchronizes OneSignal push notification service after user login
- Associates user email with OneSignal for targeting notifications
- Retrieves and stores the user's OneSignal subscription ID
- Updates user profile in Supabase database with the subscription ID
- Processes any pending deep links from notification clicks
- Implements retry logic for obtaining subscription IDs
- Provides robust error handling and detailed logging
- Manages a queue system to handle notification navigation requests
- Validates navigation context before attempting screen transitions
- Ensures proper navigation to specific screens with relevant parameters
// Automatic FlutterFlow imports
import '/backend/schema/structs/index.dart';
import '/backend/schema/enums/enums.dart';
import '/backend/supabase/supabase.dart';
import '/actions/actions.dart' as action_blocks;
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/actions/index.dart'; // Imports other custom actions
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
import 'index.dart';
import '/flutter_flow/custom_functions.dart';
import 'package:onesignal_flutter/onesignal_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart' show SupabaseClient;
import 'dart:collection';
// Shared queue state (move to a separate state management file in a real app)
final _deepLinkQueue = Queue<Map<String, dynamic>>();
bool _isProcessingQueue = false;
Future<void> syncOneSignalAfterLogin() async {
final user = Supabase.instance.client.auth.currentUser;
if (user?.email == null) {
debugPrint('### Cannot sync OneSignal - no user email');
return;
}
debugPrint('### Starting OneSignal sync for user: ${user!.email}');
try {
await OneSignal.login(user.email!);
debugPrint('### OneSignal login successful');
for (int i = 0; i < 5; i++) {
final subscriptionId = OneSignal.User.pushSubscription.id;
if (subscriptionId != null) {
await _updateUserProfile(user.id, subscriptionId);
debugPrint('### Subscription ID updated: $subscriptionId');
await _processDeepLinkQueue();
break;
}
debugPrint('### Waiting for subscription ID, attempt ${i + 1}');
await Future.delayed(const Duration(seconds: 1));
}
} catch (e, stackTrace) {
debugPrint('### ERROR syncing OneSignal user: $e');
debugPrint('### Stack trace: $stackTrace');
}
}
// Helper functions
Future<void> _updateSubscriptionId(String userId, String subscriptionId) async {
// Add your Supabase update logic here
await Supabase.instance.client.from('user_push_tokens').upsert({
'user_id': userId,
'push_token': subscriptionId,
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<void> _updateUserProfile(String userId, String subscriptionId) async {
debugPrint('### Updating user_profile.onesignal_subscription_id');
try {
final response = await Supabase.instance.client
.from('user_profile')
.update({'onesignal_subscription_id': subscriptionId})
.eq('user_id', userId)
.select()
.single();
debugPrint('### Updated user_profile successfully: ${response['id']}');
} catch (e) {
debugPrint('### ERROR updating user_profile: $e');
}
}
Future<BuildContext?> _getValidContext() async {
// Get the navigator key from your app's state
if (appNavigatorKey.currentContext != null) {
return appNavigatorKey.currentContext;
}
return null;
}
void handleNotificationClick(OSNotification notification) {
final data = notification.additionalData;
if (data == null) return;
final deepLink = {
'itemId': data['itemId'],
'screenName': data['screenName'],
};
_deepLinkQueue.add(deepLink);
_processDeepLinkQueue();
}
Future<void> _processDeepLinkQueue() async {
if (_deepLinkQueue.isEmpty || _isProcessingQueue) {
debugPrint('### Deep link queue empty or already processing');
return;
}
debugPrint('### Processing deep link queue: ${_deepLinkQueue.length} items');
_isProcessingQueue = true;
try {
final deepLink = _deepLinkQueue.first; // Peek first
final itemId = deepLink['itemId'];
final screenName = deepLink['screenName'];
if (itemId == null || screenName == null) {
debugPrint('### Invalid deep link data, removing from queue');
_deepLinkQueue.removeFirst();
return;
}
final context = await _getValidContext();
if (context == null) {
debugPrint('### No valid context available for navigation');
return;
}
// Remove from queue only if we're going to navigate
_deepLinkQueue.removeFirst();
debugPrint('### Navigating to: $screenName with itemId: $itemId');
context.goNamed(screenName, pathParameters: {'itemId': itemId});
} catch (e, stackTrace) {
debugPrint('### ERROR processing deep link: $e');
debugPrint('### Stack trace: $stackTrace');
} finally {
_isProcessingQueue = false;
// Try processing again in case we skipped due to auth
Future.delayed(const Duration(milliseconds: 500), _processDeepLinkQueue);
}
}
Custom Action 3 - askPushNotificationPermission
This one is the simplest. It just prompt the push notification permission dialog. In my app this action is called right after the login. And also this function can be called by a button on a settings screen.
// Automatic FlutterFlow imports
import '/backend/schema/structs/index.dart';
import '/backend/schema/enums/enums.dart';
import '/backend/supabase/supabase.dart';
import '/actions/actions.dart' as action_blocks;
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/actions/index.dart'; // Imports other custom actions
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
import 'package:onesignal_flutter/onesignal_flutter.dart';
Future<void> askPushNotificationPermission() async {
// Fetch current native permission status.
bool currentPermissionStatus = await OneSignal.Notifications.permission;
// If permission is already granted, update state and return.
if (currentPermissionStatus) {
FFAppState().pushNotificationPermissionAsked = true;
return;
}
// If we've already asked according to app state, do nothing.
if (FFAppState().pushNotificationPermissionAsked) {
return;
}
try {
// Request permission; native API typically won't re-show dialog if already granted.
await OneSignal.Notifications.requestPermission(true);
// Update persistent state regardless of result.
FFAppState().pushNotificationPermissionAsked = true;
} catch (e) {
FFAppState().pushNotificationPermissionAsked = true;
}
}
// Set your action name, define your arguments and return parameter,
// and then add the boilerplate code using the green button on the right!
The Xcode changes in the iOS project
Publish the code to Github. Download the repository. This is described here https://community.flutterflow.io/discussions/post/onesignal-integration-for-notifications---notifications-for-supabase-yd2c5sa4jzoQr1W
When you open the project in the Xcode just do two changes:
- Add Push Notifications Capability:
- Select the "Runner" target
- Go to the "Signing & Capabilities" tab
- Click "+" to add a capability
- Select "Push Notifications"
- Add Background Modes:
- While still in Capabilities, add "Background Modes"
- Check "Remote notifications"
That's it. Then push the changes and deploy the iOS app from GitHub.