Flutter SDK - Supporting Push Notifications

Supporting Push Notifications

Track user interactions with push notifications to measure re-engagement campaigns and attribute conversions accurately by integrating Firebase Cloud Messaging (FCM) with the Singular SDK.

Follow the implementation guidelines below to ensure notification data is correctly passed to the Singular SDK for proper attribution.

Why Track Push Notifications: Push notifications drive re-engagement, but tracking requires correct integration. Singular ensures users who interact with notifications are properly attributed, optimizing marketing campaigns and engagement strategies.


Implementation Guide

Set Up Firebase Cloud Messaging

Install Firebase packages and configure platform-specific settings for push notification support in your Flutter application.

Install Firebase Packages

Add the Firebase dependencies to your pubspec.yaml file for core functionality and messaging support.

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.24.2
  firebase_messaging: ^14.7.10
  singular_flutter_sdk: ^1.8.0

After adding dependencies, run flutter pub get to install the packages.


iOS Configuration

Register your iOS app in Firebase and configure push notification capabilities in Xcode.

  1. Register iOS App: Create an iOS app in your Firebase Console project
  2. Add Configuration File: Download GoogleService-Info.plist and add it to the Xcode Runner folder
  3. Enable Capabilities: In Xcode project settings, enable Push Notifications capability
  4. Enable Background Modes: Enable Background Modes and check Remote notifications

Android Configuration

Register your Android app in Firebase and add the configuration file to your project.

  1. Register Android App: Create an Android app in your Firebase Console project
  2. Add Configuration File: Download google-services.json and place it in android/app/
  3. Verify Dependencies: Ensure Firebase messaging dependencies are added and permissions are granted in AndroidManifest.xml

Initialize Firebase in Flutter

Configure Firebase to initialize before running your Flutter app and set up the background message handler for notifications received when the app is not in the foreground.

Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';

// Background message handler (must be top-level function)
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Background message: ${message.messageId}');
  print('Data: ${message.data}');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Firebase
  await Firebase.initializeApp();

  // Set background message handler
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MyApp());
}

Configure Push Link Paths

Define the JSON paths where Singular tracking links are located within your push notification payload structure.

Configure push link paths by passing arrays of strings that specify the key path to the Singular link in your notification data structure. Each path is an array representing the nested structure of keys.

Dart
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';

SingularConfig config = SingularConfig(
  'YOUR_SDK_KEY',
  'YOUR_SDK_SECRET'
);

// Configure paths where Singular links are located in push payload
config.pushNotificationsLinkPaths = [
  ['sng_link'],                              // Top-level key
  ['path', 'to', 'url'],                     // Nested path
  ['rootObj', 'nestedObj', 'singularLink']   // Deep nested path
];

Singular.start(config);

Path Configuration Examples:

  • Simple Keys: Use ['sng_link'] for top-level keys in the payload
  • Nested Keys: Use ['rootObj', 'nestedObj', 'key'] to traverse nested JSON structures
  • Multiple Paths: Define multiple path arrays to check different possible locations for Singular links

Configuration Property:

List<List<String>>? pushNotificationsLinkPaths

For complete configuration documentation, see pushNotificationsLinkPaths reference.


Platform-Specific Handling

Handling Push Notifications in Flutter

Implement Firebase message listeners to capture notification data in foreground and background states, then pass the data to Singular for attribution tracking.

Dart
import 'package:flutter/material.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:singular_flutter_sdk/singular.dart';

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;

  @override
  void initState() {
    super.initState();
    setupPushNotifications();
  }

  void setupPushNotifications() async {
    // Request permission for iOS
    NotificationSettings settings = await _firebaseMessaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    print('User granted permission: ${settings.authorizationStatus}');

    // Handle foreground notifications
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('Foreground message received: ${message.messageId}');
      handleForegroundNotification(message);
    });

    // Handle notifications that opened the app from background
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('Notification opened app from background: ${message.messageId}');
      handleBackgroundNotification(message);
    });

    // Check for notification that launched the app (terminated state)
    RemoteMessage? initialMessage = await _firebaseMessaging.getInitialMessage();
    if (initialMessage != null) {
      print('App launched from notification: ${initialMessage.messageId}');
      handleTerminatedNotification(initialMessage);
    }
  }

  void handleForegroundNotification(RemoteMessage message) {
    String title = message.notification?.title ?? '';
    String body = message.notification?.body ?? '';
    Map<String, dynamic> data = message.data;

    print('Title: $title');
    print('Body: $body');
    print('Data: $data');

    // Pass notification data to Singular
    if (data.isNotEmpty) {
      Singular.handlePushNotification(data);
    }

    // Display local notification or custom UI
    displayLocalNotification(title, body, data);
  }

  void handleBackgroundNotification(RemoteMessage message) {
    print('Processing background notification: ${message.messageId}');

    // Pass notification data to Singular
    if (message.data.isNotEmpty) {
      Singular.handlePushNotification(message.data);
    }

    // Navigate to specific screen based on notification data
    navigateFromNotification(message.data);
  }

  void handleTerminatedNotification(RemoteMessage message) {
    print('Processing terminated state notification: ${message.messageId}');

    // Pass notification data to Singular
    if (message.data.isNotEmpty) {
      Singular.handlePushNotification(message.data);
    }

    // Navigate to specific screen
    navigateFromNotification(message.data);
  }

  void displayLocalNotification(
    String title, 
    String body, 
    Map<String, dynamic> data
  ) {
    // Your notification display logic
    print('Displaying notification: $title - $body');
  }

  void navigateFromNotification(Map<String, dynamic> data) {
    // Your navigation logic based on notification data
    final route = data['route'];
    print('Navigating to: $route');
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

Method Signature:

static void handlePushNotification(Map<String, dynamic> notificationData)

For complete method documentation, see handlePushNotification reference.


iOS Native Configuration

App in Terminated State

Configure your iOS AppDelegate to pass launch options to the Singular SDK for automatic push tracking when the app opens from a terminated state.

In AppDelegate.m or AppDelegate.swift, pass launch options to the Singular SDK:

Objective-C Implementation

AppDelegate.m
// Import at the top of the file
#import "SingularAppDelegate.h"

- (BOOL)application:(UIApplication *)application 
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [GeneratedPluginRegistrant registerWithRegistry:self];

    // Pass launch options to Singular for push tracking
    [SingularAppDelegate shared].launchOptions = launchOptions;

    return [super application:application 
        didFinishLaunchingWithOptions:launchOptions];
}

Swift Implementation

AppDelegate.swift
import singular_flutter_sdk

override func application(_ application: UIApplication, 
    didFinishLaunchingWithOptions 
    launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    GeneratedPluginRegistrant.register(with: self)

    // Pass launch options to Singular for push tracking
    if let singularAppDelegate = SingularAppDelegate.shared() {
        singularAppDelegate.launchOptions = launchOptions
    }

    return super.application(application, 
        didFinishLaunchingWithOptions: launchOptions)
}

Automatic Handling: When users tap push notifications while your app is not running, Singular automatically captures the notification payload during app launch through the launch options.


Android Native Configuration

App in Background or Foreground

Configure your Android MainActivity to pass notification intents to the Singular SDK when the app is in background or foreground states.

In your MainActivity, override onNewIntent to pass intents to Singular:

Java Implementation

MainActivity.java
// Add imports at the top
import android.content.Intent;
import com.singular.flutter_sdk.SingularBridge;

// Add to MainActivity class
@Override
public void onNewIntent(Intent intent) {
    if(intent.getData() != null) {
        setIntent(intent);
        super.onNewIntent(intent);

        // Pass intent to Singular for push tracking
        SingularBridge.onNewIntent(intent);
    }
}

Kotlin Implementation

MainActivity.kt
// Add imports at the top
import android.content.Intent
import com.singular.flutter_sdk.SingularBridge

// Add to MainActivity class
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    if (intent.data != null) {
        setIntent(intent)

        // Pass intent to Singular for push tracking
        SingularBridge.onNewIntent(intent)
    }
}

App in Terminated State

No additional configuration is required for Android apps in terminated state. The Flutter bridge layer handles this scenario automatically when users tap notifications.

Automatic Handling: When users tap push notifications while your app is not running, Singular automatically captures the notification data through the native bridge integration.


Validation Guide

Verify Payload in Start Session

Confirm that push notification links are correctly passed to Singular by inspecting the start session API call.

The Singular SDK includes the push notification payload under the singular_link parameter in the start session request when users tap notifications.

Example Start Session Request:

https://sdk-api-v1.singular.net/api/v1/start?
a=<SDK-Key>
&singular_link=https://singularassist2.sng.link/C4nw9/r1m0?_dl=singular%3A%2F%2Ftest&_smtype=3
&i=net.singular.sampleapp
&s=1740905574084
&sdk=Singular/Flutter-v1.8.0

Alternative Verification: Use the Singular SDK Console to verify push notification tracking. Check the Deeplink URL field to confirm the tracking link is captured correctly.


Advanced Configuration

ESP Domain Configuration

Configure external domains if you wrap Singular links within Email Service Provider (ESP) or other third-party domains.

Dart
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';

// Configure ESP domains for wrapped Singular links
SingularConfig config = SingularConfig(
  'YOUR_SDK_KEY',
  'YOUR_SDK_SECRET'
);

config.espDomains = ['sl.esp.link', 'custom.domain.com'];

Singular.start(config);

Configuration Property:

List<String>? espDomains

Security Note: By default, only sng.link domains predefined in the Singular Manage Links page are permitted. Configure ESP domains explicitly if using wrapped links.

For complete configuration documentation, see espDomains reference.


Dynamic Deep Link Routing

Implement multiple deep link destinations from a single notification by configuring one Singular tracking link with dynamic redirect overrides.

Use Case Example: A breaking news notification with multiple action options

  • Read Latest News: newsapp://article?id=12345
  • Trending Topics: newsapp://trending
  • Sports: newsapp://sports

Instead of creating multiple tracking links, use one Singular link and override redirects dynamically based on user selection. See Overriding Redirects in Singular Tracking Links for implementation details.


Complete Implementation Example

Comprehensive push notification implementation with Firebase setup, Singular configuration, and platform-specific handlers for Flutter apps.

Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:singular_flutter_sdk/singular.dart';
import 'package:singular_flutter_sdk/singular_config.dart';

// Background message handler (top-level function)
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Background message: ${message.messageId}');

  // Singular handles background notifications automatically
  if (message.data.isNotEmpty) {
    print('Background notification data: ${message.data}');
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Firebase
  await Firebase.initializeApp();

  // Set background message handler
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;

  @override
  void initState() {
    super.initState();
    initializeSingular();
    setupPushNotifications();
  }

  void initializeSingular() {
    // Configure Singular SDK
    SingularConfig config = SingularConfig(
      'YOUR_SDK_KEY',
      'YOUR_SDK_SECRET'
    );

    // Configure push link paths
    config.pushNotificationsLinkPaths = [
      ['sng_link'],
      ['data', 'url'],
      ['rootObj', 'nestedObj', 'singularLink']
    ];

    // Configure ESP domains if needed
    config.espDomains = ['sl.esp.link'];

    // Enable logging for debugging
    config.enableLogging = true;

    // Initialize SDK
    Singular.start(config);
  }

  void setupPushNotifications() async {
    // Request permission (iOS)
    NotificationSettings settings = await _firebaseMessaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      print('User granted permission');
    }

    // Get FCM token
    String? token = await _firebaseMessaging.getToken();
    print('FCM Token: $token');

    // Handle foreground notifications
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      print('Foreground notification: ${message.messageId}');
      handleForegroundNotification(message);
    });

    // Handle notification that opened app from background
    FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
      print('Notification opened app: ${message.messageId}');
      handleBackgroundNotification(message);
    });

    // Check for notification that launched the app
    RemoteMessage? initialMessage = await _firebaseMessaging.getInitialMessage();
    if (initialMessage != null) {
      print('App launched from notification: ${initialMessage.messageId}');
      handleTerminatedNotification(initialMessage);
    }
  }

  void handleForegroundNotification(RemoteMessage message) {
    final title = message.notification?.title ?? '';
    final body = message.notification?.body ?? '';
    final data = message.data;

    print('Foreground - Title: $title, Body: $body');

    // Pass notification data to Singular
    if (data.isNotEmpty) {
      Singular.handlePushNotification(data);
    }

    // Display notification to user
    showNotificationDialog(title, body, data);
  }

  void handleBackgroundNotification(RemoteMessage message) {
    print('Processing background notification');

    // Pass notification data to Singular
    if (message.data.isNotEmpty) {
      Singular.handlePushNotification(message.data);
    }

    // Navigate based on notification data
    navigateFromNotification(message.data);
  }

  void handleTerminatedNotification(RemoteMessage message) {
    print('Processing terminated state notification');

    // Pass notification data to Singular
    if (message.data.isNotEmpty) {
      Singular.handlePushNotification(message.data);
    }

    // Navigate based on notification data
    navigateFromNotification(message.data);
  }

  void showNotificationDialog(
    String title, 
    String body, 
    Map<String, dynamic> data
  ) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(body),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
              navigateFromNotification(data);
            },
            child: Text('Open'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: Text('Dismiss'),
          ),
        ],
      ),
    );
  }

  void navigateFromNotification(Map<String, dynamic> data) {
    // Parse notification data for routing
    final route = data['route'];
    final productId = data['product_id'];

    print('Navigating to: $route');

    // Navigate to appropriate screen
    if (route == 'product' && productId != null) {
      navigatorKey.currentState?.pushNamed('/product/$productId');
    } else if (route == 'promo') {
      navigatorKey.currentState?.pushNamed('/promo');
    } else {
      navigatorKey.currentState?.pushNamed('/');
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      navigatorKey: navigatorKey,
      initialRoute: '/',
      routes: {
        '/': (context) => HomeScreen(),
        '/product': (context) => ProductScreen(),
        '/promo': (context) => PromoScreen(),
      },
    );
  }
}

// Placeholder screens
class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(child: Text('Home Screen')),
    );
  }
}

class ProductScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Product')),
      body: Center(child: Text('Product Screen')),
    );
  }
}

class PromoScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Promo')),
      body: Center(child: Text('Promo Screen')),
    );
  }
}

Important Considerations

Implementation Notes

  • No Callback Handler: Unlike singularLinksHandler, the push notification feature does not provide payload callbacks. Implement your own deep linking logic to route users to specific content within your app
  • Attribution Flow: When users tap notifications, Singular retrieves the payload and includes it in the start session event triggered by SDK initialization. The backend processes this data to attribute the push notification touchpoint and register re-engagement tracking
  • Domain Restrictions: Only Singular link domains (sng.link) from the Manage Links page are permitted by default. Configure ESP domains explicitly for wrapped links using espDomains
  • Platform Differences: iOS requires AppDelegate configuration for terminated state, while Android handles it automatically through the bridge module
  • Testing: Enable SDK logging during development to verify push notification data is correctly captured and processed

Success: By following these steps, your app now tracks push notification interactions with Singular, improving campaign performance insights and ensuring accurate re-engagement attribution.