Flutter SDK

seenn_flutter — Reactive streams, Live Activity (iOS), Ongoing Notification (Android), ETA countdown

#Installation

flutter pub add seenn_flutter

Or add to your pubspec.yaml:

dependencies:
  seenn_flutter: ^0.3.0

#Initialization

Seenn.init({apiKey, userId, config?})

Initialize the SDK once at app startup. Must be called before using any other SDK features.

Parameters
apiKey String Your public API key (pk_*)required
userId String Unique identifier for the current userrequired
config SeennConfig? Optional configuration options
import 'package:seenn_flutter/seenn_flutter.dart';

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

  // Initialize Seenn SDK
  await Seenn.init(
    apiKey: 'pk_live_xxx',  // Your public API key
    userId: 'user_123',     // Your user ID
  );

  runApp(const MyApp());
}

#Configuration Options

await Seenn.init(
  apiKey: 'pk_live_xxx',
  userId: 'user_123',
  config: SeennConfig(
    apiUrl: 'https://api.seenn.io',
    timeout: Duration(seconds: 30),
    debug: true,
  ),
);

// Or use development config for local testing
await Seenn.init(
  apiKey: 'pk_test_xxx',
  userId: 'test_user',
  config: SeennConfig.development(
    apiUrl: 'http://localhost:3001',
  ),
);

#Real-time Updates

The SDK uses polling to receive job updates from your backend:

Aspect Details
How it works SDK fetches job state via HTTP at intervals (default 5s)
Backend requirement GET /v1/jobs/:id endpoint only
Latency Poll interval (configurable, default 5s)
Best for All backends: Firebase, Supabase, custom, Seenn Cloud

#Polling Configuration

Configure polling interval and subscribe to job updates:

import 'package:seenn_flutter/seenn_flutter.dart';

await Seenn.init(
  apiKey: 'your-api-key',
  userId: 'user_123',
  config: SeennConfig.selfHosted(
    apiUrl: 'https://api.yourcompany.com',
    basePath: '/v1',
    pollInterval: Duration(seconds: 5),
  ),
);

// Subscribe to job updates (starts polling)
Seenn.instance.subscribeJob('job_123');

// Listen to updates
Seenn.instance.jobs.stream('job_123').listen((job) {
  print('Progress: \${job?.progress}%');
});

// Unsubscribe when done
Seenn.instance.unsubscribeJob('job_123');
Backend Requirements: Only GET /v1/jobs/:id endpoint needed. See API Reference.
Polling + Live Activity are independent: Polling updates the in-app UI via HTTP. Live Activity updates the Lock Screen via APNs push. They work on separate channels and can be used together or independently.

#jobs.subscribe()

Seenn.instance.jobs.subscribe(jobId) → JobTracker

Subscribe to updates for a specific job. Returns a JobTracker with convenient streams.

final tracker = Seenn.instance.jobs.subscribe('job_123');

// Listen to all updates
tracker.onUpdate.listen((job) {
  print('Job updated: \${job.status}');
});

// Listen to progress changes only
tracker.onProgress.listen((update) {
  print('Progress: \${update.progress}%');
  print('Message: \${update.message}');
  if (update.stage != null) {
    print('Stage: \${update.stage.current}/\${update.stage.total}');
  }
});

// Listen to completion
tracker.onComplete.listen((job) {
  print('Completed! URL: \${job.resultUrl}');
});

// Listen to failures
tracker.onFailed.listen((job) {
  print('Failed: \${job.errorMessage}');
});

// Listen to terminal state (completed or failed)
tracker.onTerminal.listen((job) {
  print('Job finished with status: \${job.status}');
});

#JobTracker

The JobTracker class provides multiple streams and properties for tracking a job:

Property / Method Type Description
onUpdate Stream<SeennJob> All job updates
onProgress Stream<ProgressUpdate> Progress changes only (deduplicated)
onChildProgress Stream<ChildProgressUpdate> Child job progress (for parent jobs)
onComplete Stream<SeennJob> Emits when job completes
onFailed Stream<SeennJob> Emits when job fails
onTerminal Stream<SeennJob> Emits on completion or failure
current SeennJob? Current job state (sync)
isCompleted bool Whether job is completed
isFailed bool Whether job has failed
isTerminal bool Whether job is in terminal state

#Using with StreamBuilder

The SDK integrates naturally with Flutter's StreamBuilder:

class JobProgressWidget extends StatelessWidget {
  final String jobId;

  const JobProgressWidget({required this.jobId});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<SeennJob?>(
      stream: Seenn.instance.jobs.stream(jobId),
      builder: (context, snapshot) {
        final job = snapshot.data;

        if (job == null) {
          return const CircularProgressIndicator();
        }

        return Column(
          children: [
            Text(job.title),
            LinearProgressIndicator(value: job.progress / 100),
            Text('\${job.progress}%'),
            if (job.message != null)
              Text(job.message!),
            if (job.status == JobStatus.completed)
              ElevatedButton(
                onPressed: () => openUrl(job.resultUrl!),
                child: const Text('Download'),
              ),
          ],
        );
      },
    );
  }
}

#jobs.all$

Seenn.instance.jobs.all$ → Stream<Map<String, SeennJob>>

Stream of all jobs for the current user. Updates whenever any job changes.

StreamBuilder<Map<String, SeennJob>>(
  stream: Seenn.instance.jobs.all$,
  builder: (context, snapshot) {
    final jobs = snapshot.data?.values.toList() ?? [];

    return ListView.builder(
      itemCount: jobs.length,
      itemBuilder: (context, index) {
        final job = jobs[index];
        return ListTile(
          title: Text(job.title),
          subtitle: Text('\${job.progress}% - \${job.status}'),
        );
      },
    );
  },
);

#Standalone Mode (BYO Backend)

Already have your own backend with complete job state management and APNs push? Use standalone mode to control Live Activities without any Seenn server connection. No Seenn.init() required.

Self-Hosted vs Standalone — What's the difference?
  • Self-Hosted / Polling Mode: You implement Seenn's REST API spec (GET /v1/jobs/:id). The SDK polls your backend and manages state internally. Use Seenn.init() with your custom baseUrl.
  • Standalone Mode: You manage everything yourself — your own database, your own state management, your own APNs push. The SDK is just a thin native bridge to iOS Live Activity. No Seenn.init() needed.

#Basic Usage

import 'package:seenn_flutter/seenn_flutter.dart';

// No Seenn.init() needed!

// Start a Live Activity when job begins
final result = await LiveActivity.start(
  jobId: 'job_123',
  title: 'Processing your video...',
  jobType: 'video-processing',
  initialProgress: 0,
);

if (result.success) {
  print('Activity started: ${result.activityId}');
}

// Update from your own state management
await LiveActivity.update(
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Encoding frames...',
);

// End with CTA button
await LiveActivity.end(
  jobId: 'job_123',
  finalProgress: 100,
  finalStatus: 'completed',
  message: 'Video ready!',
  ctaButton: LiveActivityCTAButton(
    text: 'Watch Now',
    deepLink: 'myapp://videos/job_123',
  ),
);

#Push Token Handling

When your backend handles APNs push for Live Activity updates:

import 'package:seenn_flutter/seenn_flutter.dart';

// Initialize push token listener (call once at app startup)
LiveActivity.initialize();

// Listen for Live Activity push tokens
final subscription = LiveActivity.onPushToken.listen((token) {
  // Send token to your backend
  myApi.registerLiveActivityToken(
    jobId: token.jobId,
    token: token.token,
  );
});

// Don't forget to cancel when done
subscription.cancel();
LiveActivity.dispose();

#LiveActivity API Reference

Method Description
LiveActivity.start(...) Start a new Live Activity
LiveActivity.update(...) Update an existing Live Activity
LiveActivity.end(...) End a Live Activity with optional CTA
LiveActivity.isSupported() Check if Live Activities are supported
LiveActivity.areActivitiesEnabled() Check if user has enabled Live Activities
LiveActivity.isActive(jobId) Check if a specific activity is active
LiveActivity.getActiveIds() Get all active Live Activity job IDs
LiveActivity.cancel(jobId) Cancel a specific activity immediately
LiveActivity.cancelAll() Cancel all active activities
LiveActivity.onPushToken Stream of push token events
LiveActivity.initialize() Initialize push token listener
LiveActivity.dispose() Clean up resources

#APNs Payload Format

Your backend sends APNs push updates directly to iOS. See React Native docs for the full payload format and ContentState fields.

Widget Extension Still Required: You still need the iOS Widget Extension for Live Activities to display. The standalone mode only changes how you interact with it from Dart — the native setup remains the same. See Live Activity Setup.

#Connection State

Monitor the connection status:

// Check if connected (sync)
if (Seenn.instance.isConnected) {
  print('Connected to Seenn');
}

// Stream of connection state changes
StreamBuilder<SeennConnectionState>(
  stream: Seenn.instance.connectionState$,
  builder: (context, snapshot) {
    final state = snapshot.data ?? SeennConnectionState.disconnected;

    return Icon(
      state.isConnected ? Icons.cloud_done : Icons.cloud_off,
      color: state.isConnected ? Colors.green : Colors.red,
    );
  },
);

#Connection States

State Description
connected Connection is active and polling for updates
connecting Attempting to establish connection
reconnecting Reconnecting after connection loss
disconnected Not connected (initial state or after dispose)

#SeennJob Model

The job model contains all job data:

Property Type Description
jobId String Unique job identifier (ULID)
userId String User who owns this job
status JobStatus pending | queued | running | completed | failed | cancelled
title String Human-readable title
jobType String Job type for categorization
workflowId String? Workflow ID for ETA tracking
progress int Progress percentage (0-100)
message String? Current status message
stage StageInfo? Current stage (name, current, total)
queue QueueInfo? Queue position info
estimatedCompletionAt String? ISO 8601 estimated completion time
etaConfidence double? ETA confidence score (0.0 - 1.0)
etaBasedOn int? Historical jobs used for ETA
result JobResult? Result data (type, url, data)
error JobError? Error details (code, message)
parent ParentInfo? Parent job info (if child)
children ChildrenStats? Children stats (if parent)
resultUrl String? Helper: result.url
errorMessage String? Helper: error.message
etaRemaining int? Helper: remaining ms
etaFormatted String? Helper: "2m 30s"
isTerminal bool Helper: completed/failed/cancelled
isParent bool Helper: has children
isChild bool Helper: has parent

#iOS Live Activity iOS

Display job progress on the Lock Screen and Dynamic Island (iOS 16.2+).

Why iOS 16.2? While Live Activities were introduced in iOS 16.1, the push token API (pushType: .token) and ActivityContent struct required for remote backend updates were added in iOS 16.2.

#Setup

Live Activity requires native code setup due to iOS module isolation. Follow these steps:

Step 1: Add to your ios/Runner/Info.plist:

<key>NSSupportsLiveActivities</key>
<true/>

Step 2: Copy the bridge files from the SDK example to your project:

  • SeennJobAttributes.swiftios/Runner/
  • SeennLiveActivityBridgeImpl.swiftios/Runner/

Find templates at: github.com/seenn-io/flutter/example/ios/Runner

Step 3: Register the bridge in your AppDelegate.swift:

import seenn_flutter

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

    // Register Live Activity bridge
    if #available(iOS 16.2, *) {
      SeennLiveActivityRegistry.shared.register(SeennLiveActivityBridgeImpl.shared)
    }

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

Step 4: Create a Widget Extension for the Lock Screen UI. See the example project for a complete Widget Extension template.

Why the bridge pattern? iOS requires the ActivityAttributes type to be defined in your app, not in a library. The SDK provides the bridge protocol, you provide the implementation. This ensures type identity between your app and Widget Extension.

#Usage

// Start Live Activity
final result = await Seenn.instance.liveActivity.startActivity(
  jobId: 'job_123',
  title: 'Processing video...',
  jobType: 'video_render',
);

if (result.success) {
  print('Activity started: \${result.activityId}');
}

// Update Live Activity
await Seenn.instance.liveActivity.updateActivity(
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Encoding frames...',
);

// End Live Activity
await Seenn.instance.liveActivity.endActivity(
  jobId: 'job_123',
  finalProgress: 100,
  finalStatus: 'completed',
  message: 'Your video is ready!',
);

#CTA Button (v0.5.0+)

Add a tappable button to your Live Activity when a job completes or fails:

await Seenn.instance.liveActivity.endActivity(
  jobId: 'job_123',
  finalProgress: 100,
  finalStatus: 'completed',
  message: 'Your Photos are Ready!',
  ctaButton: LiveActivityCTAButton(
    text: 'See Your Photos ✨',
    deepLink: 'myapp://jobs/job_123/results',
    style: LiveActivityCTAButtonStyle.primary,
    backgroundColor: '#FFFFFF',
    textColor: '#000000',
    cornerRadius: 20,
  ),
);

#Auto-Sync with Job

// Start and auto-track Live Activity
final job = Seenn.instance.jobs.get('job_123');
await Seenn.instance.startLiveActivityForJob(job!);

// The SDK will automatically update/end the Live Activity
// when job state changes via polling

#Rich Push with Images iOS

Display images (avatars, thumbnails) in push notifications. This requires a Notification Service Extension — a separate iOS extension that downloads and attaches images to notifications before they're displayed.

What it does: The extension intercepts incoming push notifications, downloads any images from the payload, and attaches them to the notification. Without this extension, iOS cannot display images in push notifications.

#Setup

Step 1: Create the extension in Xcode:

  1. Open ios/Runner.xcworkspace in Xcode
  2. File → New → Target → Notification Service Extension
  3. Name: NotificationServiceExtension
  4. Language: Swift

Step 2: Replace the generated code in NotificationServiceExtension/NotificationService.swift:

import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Look for image URL in payload
        let userInfo = request.content.userInfo
        let imageUrlString = userInfo["senderAvatar"] as? String
            ?? userInfo["imageUrl"] as? String
            ?? userInfo["image"] as? String

        guard let urlString = imageUrlString,
              let imageUrl = URL(string: urlString) else {
            contentHandler(bestAttemptContent)
            return
        }

        // Download and attach image
        downloadImage(from: imageUrl) { attachment in
            if let attachment = attachment {
                bestAttemptContent.attachments = [attachment]
            }
            contentHandler(bestAttemptContent)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        if let contentHandler = contentHandler,
           let bestAttemptContent = bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }

    private func downloadImage(
        from url: URL,
        completion: @escaping (UNNotificationAttachment?) -> Void
    ) {
        let task = URLSession.shared.downloadTask(with: url) { localUrl, response, error in
            guard let localUrl = localUrl, error == nil else {
                completion(nil)
                return
            }

            let fileManager = FileManager.default
            let tempDir = fileManager.temporaryDirectory
            let fileName = url.lastPathComponent
            let destUrl = tempDir.appendingPathComponent(fileName)

            try? fileManager.removeItem(at: destUrl)
            do {
                try fileManager.moveItem(at: localUrl, to: destUrl)
                let attachment = try UNNotificationAttachment(
                    identifier: "image",
                    url: destUrl,
                    options: nil
                )
                completion(attachment)
            } catch {
                completion(nil)
            }
        }
        task.resume()
    }
}

Step 3: Build and run your app

#Payload Format

Include an image URL in your push payload. The extension looks for these fields (in order):

{
  "aps": {
    "alert": {
      "title": "New message from Sarah",
      "body": "Hey! Check out this photo"
    },
    "sound": "default",
    "mutable-content": 1  // Required for extension to run
  },
  "senderAvatar": "https://example.com/avatar.jpg"  // Image URL
}

Supported image URL fields (checked in order):

  • senderAvatar — User avatar/profile image
  • imageUrl — Generic image URL
  • image — Fallback field
Important: The mutable-content: 1 flag is required. Without it, iOS won't invoke the Notification Service Extension and images won't be downloaded.

#Supported Image Formats

FormatExtensionNotes
JPEG.jpg, .jpegBest for photos
PNG.pngSupports transparency
GIF.gifStatic only (no animation in notifications)
WebP.webpiOS 14+

#Provisional Push (iOS 12+) iOS

Request push notification permission without showing a prompt. Users see "quiet" notifications in Notification Center only — no sounds or banners.

When to use: Provisional push is ideal for non-critical updates like job progress. Higher opt-in rates because users aren't interrupted with a permission prompt.

#Usage

import 'package:seenn_flutter/seenn_flutter.dart';

// Check current authorization status
final status = await LiveActivity.getPushAuthorizationStatus();
print(status.status);       // PushAuthorizationStatus.provisional
print(status.isProvisional); // true if quiet notifications

// Request provisional push — no prompt shown!
final granted = await LiveActivity.requestProvisionalPushAuthorization();
if (granted) {
  print('Provisional push enabled');
}

// Later: upgrade to full push (shows prompt)
if (status.canRequestFullAuthorization) {
  final upgraded = await LiveActivity.upgradeToStandardPush();
  if (upgraded) {
    print('Full push access granted');
  }
}

#Authorization Status Values

StatusDescription
notDeterminedPermission never requested
deniedUser denied permission
authorizedFull push access (alerts, sounds, badges)
provisionalQuiet notifications only (Notification Center)
ephemeralApp Clips only (iOS 14+)
Note: Live Activity doesn't require push permission — it uses a separate system. Provisional push is for standard notifications, which can complement Live Activity for users who prefer traditional push.

#Device Push Token (v0.8.0+)

When you request provisional or standard push authorization, the SDK automatically registers for remote notifications and provides the device push token:

import 'package:seenn_flutter/seenn_flutter.dart';

// Initialize push token listener
LiveActivity.initialize();

// Listen for both Live Activity and device push tokens
final subscription = LiveActivity.onPushToken.listen((event) {
  if (event.isLiveActivity) {
    // Live Activity token - for updating a specific Live Activity via APNs
    print('Live Activity token for \${event.jobId}: \${event.token}');
    myBackend.registerLiveActivityToken(event.jobId!, event.token);
  } else if (event.isDevice) {
    // Device token - for regular push notifications
    print('Device push token: \${event.token}');
    myBackend.registerDevicePushToken(event.token);
  }
});

// Request provisional push - token will be emitted via onPushToken
await LiveActivity.requestProvisionalPushAuthorization();

// Don't forget to cancel when done
subscription.cancel();
Token TypeDescriptionjobId
LiveActivityPushTokenType.liveActivityAPNs token for updating a specific Live ActivityPresent
LiveActivityPushTokenType.deviceAPNs token for regular push notificationsAbsent
OneSignal/Firebase Compatibility: The SDK uses method swizzling to capture device tokens without interfering with other push SDKs. Both Seenn and your other push provider will receive the token.

#Android Ongoing Notification Android

Display persistent progress notification in the notification drawer (Android 5.0+).

final notificationService = OngoingNotificationService();

// Start notification
final result = await notificationService.startNotification(
  jobId: 'job_123',
  title: 'Processing',
  jobType: 'video_render',
  initialMessage: 'Starting...',
);

// Update progress
await notificationService.updateNotification(
  jobId: 'job_123',
  progress: 50,
  status: 'running',
  message: 'Halfway there...',
);

// End notification
await notificationService.endNotification(
  jobId: 'job_123',
  finalProgress: 100,
  finalStatus: 'completed',
  message: 'Your video is ready!',
);

#Cross-Platform Notifications

Use JobNotificationService for unified iOS + Android handling:

final jobNotification = JobNotificationService();

// Automatically uses Live Activity on iOS, Ongoing Notification on Android
await jobNotification.start(
  jobId: 'job_123',
  title: 'Processing',
  jobType: 'video_render',
);

// Sync with job updates
tracker.onUpdate.listen((job) async {
  await jobNotification.syncWithJob(job);
});

#ETA Countdown

Real-time countdown with server sync and confidence scores.

import 'package:seenn_flutter/seenn_flutter.dart';

// Using convenience function
final countdown = etaCountdownStream(job);

countdown.listen((state) {
  print('Remaining: \${state.formatted}');   // "2m 30s"
  print('Past due: \${state.isPastDue}');    // false
  print('Confidence: \${state.confidence}'); // 0.85
  print('Based on: \${state.basedOn} jobs'); // 42
});

#EtaCountdownService

For more control, use the service directly:

final etaService = EtaCountdownService();

// Start countdown
etaService.startCountdown(job);

// Listen to updates
etaService.stream.listen((state) {
  print('ETA: \${state.formatted}');
});

// Update when job changes from server
tracker.onUpdate.listen((job) {
  etaService.updateJob(job);
});

// Clean up
etaService.dispose();

#EtaCountdownState

Property Type Description
remaining int Remaining time in milliseconds
formatted String Human-readable format ("2m 30s")
isPastDue bool True if past estimated time
confidence double? ETA confidence (0.0 - 1.0)
basedOn int? Number of historical jobs used
hasEta bool Whether ETA is available

#Parent-Child Jobs

Track hierarchical job relationships for batch processing.

// Get parent jobs
final parents = Seenn.instance.jobs.parents;

// Get children of a specific parent
final children = Seenn.instance.jobs.childrenOf('parent_job_id');

// Stream of parent jobs
Seenn.instance.jobs.parents$.listen((parents) {
  print('Parent jobs: \${parents.length}');
});

// Track child progress on a parent job
final tracker = Seenn.instance.jobs.subscribe('parent_job_id');

tracker.onChildProgress.listen((update) {
  print('Children: \${update.completed}/\${update.total}');
  print('Failed: \${update.failed}');
  print('Running: \${update.running}');
  print('Percent: \${update.percentComplete}%');
});

#Parent-Child Models

Model Properties Description
ParentInfo parentJobId, childIndex Info for child jobs
ChildrenStats total, completed, failed, running, pending Aggregate stats for parent jobs
ChildProgressUpdate parentProgress, percentComplete, allDone Progress stream data

#Cleanup

// Call when user logs out or app closes
await Seenn.dispose();

// Update user ID (e.g., after user switch or login)
await Seenn.setUserToken(newUserId);

// Force reconnect
await Seenn.instance.reconnect();