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
Initialize the SDK once at app startup. Must be called before using any other SDK features.
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');
GET /v1/jobs/:id endpoint needed.
See API Reference.
#jobs.subscribe()
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$
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 / Polling Mode: You implement Seenn's REST API spec (
GET /v1/jobs/:id). The SDK polls your backend and manages state internally. UseSeenn.init()with your custombaseUrl. - 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.
#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
Display job progress on the Lock Screen and Dynamic Island (iOS 16.2+).
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.swift→ios/Runner/SeennLiveActivityBridgeImpl.swift→ios/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.
#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
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.
#Setup
Step 1: Create the extension in Xcode:
- Open
ios/Runner.xcworkspacein Xcode - File → New → Target → Notification Service Extension
- Name:
NotificationServiceExtension - 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 imageimageUrl— Generic image URLimage— Fallback field
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
| Format | Extension | Notes |
|---|---|---|
| JPEG | .jpg, .jpeg | Best for photos |
| PNG | .png | Supports transparency |
| GIF | .gif | Static only (no animation in notifications) |
| WebP | .webp | iOS 14+ |
#Provisional Push (iOS 12+)
Request push notification permission without showing a prompt. Users see "quiet" notifications in Notification Center only — no sounds or banners.
#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
| Status | Description |
|---|---|
notDetermined | Permission never requested |
denied | User denied permission |
authorized | Full push access (alerts, sounds, badges) |
provisional | Quiet notifications only (Notification Center) |
ephemeral | App Clips only (iOS 14+) |
#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 Type | Description | jobId |
|---|---|---|
LiveActivityPushTokenType.liveActivity | APNs token for updating a specific Live Activity | Present |
LiveActivityPushTokenType.device | APNs token for regular push notifications | Absent |
#Android Ongoing Notification
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();