Line data Source code
1 : import 'dart:convert';
2 : import 'package:flutter/foundation.dart';
3 :
4 : /// Record from the persistent task store.
5 : @immutable
6 : class TaskRecord {
7 0 : const TaskRecord({
8 : required this.taskId,
9 : this.tag,
10 : required this.status,
11 : required this.workerClassName,
12 : this.workerConfig,
13 : this.resultData,
14 : required this.createdAt,
15 : required this.updatedAt,
16 : });
17 :
18 : final String taskId;
19 : final String? tag;
20 :
21 : /// Status string: pending / running / completed / failed / cancelled / paused
22 : final String status;
23 :
24 : final String workerClassName;
25 :
26 : /// Raw worker configuration (often sanitized/redacted by native side).
27 : final String? workerConfig;
28 :
29 : /// Optional result data (null or decoded from JSON stored by the native side).
30 : final Map<String, dynamic>? resultData;
31 :
32 : final DateTime createdAt;
33 : final DateTime updatedAt;
34 :
35 0 : factory TaskRecord.fromMap(Map<String, dynamic> m) => TaskRecord(
36 0 : taskId: m['taskId'] as String,
37 0 : tag: m['tag'] as String?,
38 0 : status: m['status'] as String? ?? 'unknown',
39 0 : workerClassName: m['workerClassName'] as String? ?? '',
40 0 : workerConfig: m['workerConfig'] as String?,
41 0 : resultData: m['resultData'] == null
42 : ? null
43 0 : : (m['resultData'] is Map
44 0 : ? Map<String, dynamic>.from(m['resultData'] as Map)
45 0 : : (m['resultData'] is String
46 0 : ? (() {
47 0 : final decoded = jsonDecode(m['resultData'] as String);
48 0 : if (decoded is Map) {
49 0 : return Map<String, dynamic>.from(decoded);
50 0 : } else if (decoded is List) {
51 : // Wrap list in a map for TaskEvent compatibility
52 0 : return {'items': decoded};
53 : }
54 0 : return <String, dynamic>{};
55 0 : })()
56 : : null)),
57 0 : createdAt: DateTime.fromMillisecondsSinceEpoch(
58 0 : (m['createdAt'] as num).toInt()),
59 0 : updatedAt: DateTime.fromMillisecondsSinceEpoch(
60 0 : (m['updatedAt'] as num).toInt()),
61 : );
62 :
63 0 : Map<String, dynamic> toMap() => {
64 0 : 'taskId': taskId,
65 0 : 'tag': tag,
66 0 : 'status': status,
67 0 : 'workerClassName': workerClassName,
68 0 : 'workerConfig': workerConfig,
69 0 : 'resultData': resultData,
70 0 : 'createdAt': createdAt.millisecondsSinceEpoch,
71 0 : 'updatedAt': updatedAt.millisecondsSinceEpoch,
72 : };
73 :
74 0 : @override
75 0 : String toString() => 'TaskRecord(taskId: $taskId, status: $status, '
76 0 : 'workerClassName: $workerClassName, tag: $tag)';
77 : }
78 :
79 : /// Result of scheduling a task.
80 : ///
81 : /// Returned by [NativeWorkManager.enqueue] to indicate whether the OS
82 : /// accepted the task for scheduling.
83 : ///
84 : /// ## Success Case
85 : ///
86 : /// ```dart
87 : /// final result = await NativeWorkManager.enqueue(
88 : /// taskId: 'sync-data',
89 : /// trigger: TaskTrigger.oneTime(),
90 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
91 : /// );
92 : ///
93 : /// if (result == ScheduleResult.accepted) {
94 : /// print('Task scheduled successfully');
95 : /// }
96 : /// ```
97 : ///
98 : /// ## Handling Rejection
99 : ///
100 : /// ```dart
101 : /// final result = await NativeWorkManager.enqueue(
102 : /// taskId: 'upload-large-file',
103 : /// trigger: TaskTrigger.oneTime(),
104 : /// worker: NativeWorker.httpUpload(
105 : /// url: 'https://api.example.com/upload',
106 : /// filePath: '/data/large-file.zip',
107 : /// ),
108 : /// );
109 : ///
110 : /// switch (result) {
111 : /// case ScheduleResult.accepted:
112 : /// showNotification('Upload scheduled');
113 : /// break;
114 : /// case ScheduleResult.rejectedOsPolicy:
115 : /// showError('Device cannot schedule tasks (low battery?)');
116 : /// break;
117 : /// case ScheduleResult.throttled:
118 : /// showWarning('Too many tasks - try again later');
119 : /// break;
120 : /// }
121 : /// ```
122 : ///
123 : /// ## Why Tasks Get Rejected
124 : ///
125 : /// **rejectedOsPolicy:**
126 : /// - Device in power save mode
127 : /// - Too many tasks already scheduled
128 : /// - App in background restrictions (Android)
129 : /// - Constraints too restrictive
130 : ///
131 : /// **throttled:**
132 : /// - Too many enqueue() calls in short period
133 : /// - OS rate limiting to prevent abuse
134 : /// - Typical limit: ~500 tasks per hour
135 : ///
136 : /// ## Best Practices
137 : ///
138 : /// ✅ **Do** check the result and handle rejections gracefully
139 : /// ✅ **Do** implement retry logic with exponential backoff
140 : /// ✅ **Do** inform users if critical tasks can't be scheduled
141 : ///
142 : /// ❌ **Don't** assume tasks are always accepted
143 : /// ❌ **Don't** schedule hundreds of tasks rapidly
144 : /// ❌ **Don't** ignore throttling errors
145 : ///
146 : /// See also: [NativeWorkManager.enqueue]
147 : enum ScheduleResult {
148 : /// Task was successfully scheduled.
149 : ///
150 : /// The OS accepted the task and will execute it according to the trigger
151 : /// and constraints. This is the normal success case.
152 : accepted,
153 :
154 : /// Task was rejected due to OS policy.
155 : ///
156 : /// Common causes:
157 : /// - Device in power save mode
158 : /// - Too many tasks already scheduled
159 : /// - Background execution restrictions
160 : /// - Constraints cannot be satisfied
161 : rejectedOsPolicy,
162 :
163 : /// Task was throttled (too many requests).
164 : ///
165 : /// The app is scheduling tasks too rapidly. The OS rejected this task
166 : /// to prevent resource abuse. Wait and retry with exponential backoff.
167 : throttled,
168 : }
169 :
170 : /// Policy for handling existing tasks with the same ID.
171 : ///
172 : /// When scheduling a task with an ID that already exists, this policy
173 : /// determines whether to keep the existing task or replace it with the new one.
174 : ///
175 : /// ## Keep Existing Task
176 : ///
177 : /// ```dart
178 : /// // Schedule initial sync
179 : /// await NativeWorkManager.enqueue(
180 : /// taskId: 'daily-sync',
181 : /// trigger: TaskTrigger.periodic(Duration(hours: 24)),
182 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
183 : /// existingPolicy: ExistingTaskPolicy.keep, // Prevent duplicate
184 : /// );
185 : ///
186 : /// // Later, user changes settings - but keep the original task running
187 : /// await NativeWorkManager.enqueue(
188 : /// taskId: 'daily-sync', // Same ID
189 : /// trigger: TaskTrigger.periodic(Duration(hours: 12)),
190 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
191 : /// existingPolicy: ExistingTaskPolicy.keep, // Original 24h task continues
192 : /// );
193 : /// ```
194 : ///
195 : /// ## Replace Existing Task
196 : ///
197 : /// ```dart
198 : /// // Schedule initial sync
199 : /// await NativeWorkManager.enqueue(
200 : /// taskId: 'daily-sync',
201 : /// trigger: TaskTrigger.periodic(Duration(hours: 24)),
202 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
203 : /// );
204 : ///
205 : /// // User changes settings - update the task immediately
206 : /// await NativeWorkManager.enqueue(
207 : /// taskId: 'daily-sync', // Same ID
208 : /// trigger: TaskTrigger.periodic(Duration(hours: 12)),
209 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
210 : /// existingPolicy: ExistingTaskPolicy.replace, // Cancels 24h, starts 12h
211 : /// );
212 : /// ```
213 : ///
214 : /// ## When to Use Keep
215 : ///
216 : /// Use `ExistingTaskPolicy.keep` when:
217 : /// - Task is idempotent (safe to run multiple times)
218 : /// - You want to ensure at least one execution happens
219 : /// - Avoiding duplicate work is critical (e.g., expensive API calls)
220 : /// - Initial scheduling during app install
221 : ///
222 : /// **Example:** One-time data migration
223 : /// ```dart
224 : /// await NativeWorkManager.enqueue(
225 : /// taskId: 'v2-migration',
226 : /// trigger: TaskTrigger.oneTime(),
227 : /// worker: DartWorker(callbackId: 'migrateData'),
228 : /// existingPolicy: ExistingTaskPolicy.keep, // Don't duplicate if already scheduled
229 : /// );
230 : /// ```
231 : ///
232 : /// ## When to Use Replace
233 : ///
234 : /// Use `ExistingTaskPolicy.replace` when:
235 : /// - User changed settings/preferences
236 : /// - Task configuration needs updating
237 : /// - Old parameters are no longer valid
238 : /// - Cancelling and rescheduling is intentional
239 : ///
240 : /// **Example:** User changes sync frequency
241 : /// ```dart
242 : /// // User updates setting: hourly → every 6 hours
243 : /// await NativeWorkManager.enqueue(
244 : /// taskId: 'background-sync',
245 : /// trigger: TaskTrigger.periodic(newInterval),
246 : /// worker: NativeWorker.httpSync(url: syncUrl),
247 : /// existingPolicy: ExistingTaskPolicy.replace, // Apply new frequency immediately
248 : /// );
249 : /// ```
250 : ///
251 : /// ## Comparison
252 : ///
253 : /// | Scenario | Keep | Replace |
254 : /// |----------|------|---------|
255 : /// | Task already exists | New request ignored | Old task cancelled, new scheduled |
256 : /// | Task not found | New task scheduled | New task scheduled |
257 : /// | Typical use case | Prevent duplicates | Update configuration |
258 : ///
259 : /// ## Default Behavior
260 : ///
261 : /// If policy is not specified, `ExistingTaskPolicy.replace` is used by default.
262 : /// This ensures the latest configuration is always applied.
263 : ///
264 : /// See also: [NativeWorkManager.enqueue]
265 : enum ExistingTaskPolicy {
266 : /// Keep the existing task, ignore the new one.
267 : ///
268 : /// If a task with the same ID already exists, the new enqueue request
269 : /// is silently ignored. The existing task continues unchanged.
270 : ///
271 : /// Useful when you want to prevent duplicate tasks from being scheduled.
272 : keep,
273 :
274 : /// Replace the existing task with the new one.
275 : ///
276 : /// If a task with the same ID already exists, it is cancelled and replaced
277 : /// with the new task. Use this when updating task configuration.
278 : replace,
279 : }
280 :
281 : /// Current status of a task.
282 : ///
283 : /// Represents the lifecycle state of a scheduled task. Query task status
284 : /// using [NativeWorkManager.getTaskStatus].
285 : ///
286 : /// ## Task Lifecycle
287 : ///
288 : /// ```
289 : /// PENDING → RUNNING → COMPLETED
290 : /// → FAILED
291 : /// → CANCELLED
292 : /// ```
293 : ///
294 : /// ## Checking Task Status
295 : ///
296 : /// ```dart
297 : /// final status = await NativeWorkManager.getTaskStatus(taskId: 'upload-photos');
298 : ///
299 : /// switch (status) {
300 : /// case TaskStatus.pending:
301 : /// print('Waiting for WiFi...');
302 : /// break;
303 : /// case TaskStatus.running:
304 : /// print('Upload in progress...');
305 : /// break;
306 : /// case TaskStatus.completed:
307 : /// print('Upload finished!');
308 : /// break;
309 : /// case TaskStatus.failed:
310 : /// print('Upload failed - will retry');
311 : /// break;
312 : /// case TaskStatus.cancelled:
313 : /// print('Upload cancelled by user');
314 : /// break;
315 : /// case null:
316 : /// print('Task not found');
317 : /// break;
318 : /// }
319 : /// ```
320 : ///
321 : /// ## Monitoring Multiple Tasks
322 : ///
323 : /// ```dart
324 : /// Future<void> checkUploads() async {
325 : /// final tasks = await NativeWorkManager.getTasksByTag(tag: 'upload');
326 : ///
327 : /// final pending = tasks.where((t) => t.status == TaskStatus.pending).length;
328 : /// final running = tasks.where((t) => t.status == TaskStatus.running).length;
329 : /// final completed = tasks.where((t) => t.status == TaskStatus.completed).length;
330 : ///
331 : /// print('Uploads: $pending pending, $running active, $completed done');
332 : /// }
333 : /// ```
334 : ///
335 : /// ## Status Transitions
336 : ///
337 : /// **PENDING → RUNNING:**
338 : /// - Constraints are met (network, battery, etc.)
339 : /// - OS scheduler starts execution
340 : /// - Task begins doing work
341 : ///
342 : /// **RUNNING → COMPLETED:**
343 : /// - Worker returns success result
344 : /// - All work finished successfully
345 : /// - Task removed from queue
346 : ///
347 : /// **RUNNING → FAILED:**
348 : /// - Worker throws exception
349 : /// - Network error, timeout, etc.
350 : /// - OS may retry automatically (periodic tasks)
351 : ///
352 : /// **ANY → CANCELLED:**
353 : /// - [NativeWorkManager.cancel] called
354 : /// - [NativeWorkManager.cancelByTag] called
355 : /// - [NativeWorkManager.cancelAll] called
356 : /// - Task removed from queue immediately
357 : ///
358 : /// ## Important Notes
359 : ///
360 : /// - **Completed tasks** are automatically removed after a short period (OS-dependent)
361 : /// - **Failed periodic tasks** may be retried automatically by the OS
362 : /// - **Cancelled tasks** cannot be resumed - must enqueue again
363 : /// - **Running tasks** may take time to fully stop when cancelled
364 : ///
365 : /// See also:
366 : /// - [NativeWorkManager.getTaskStatus] - Query status
367 : /// - [NativeWorkManager.events] - Listen for completion events
368 : /// - [TaskEvent] - Task completion notification
369 : enum TaskStatus {
370 : /// Task is waiting to be executed.
371 : ///
372 : /// The task is scheduled but constraints are not yet met
373 : /// (e.g., waiting for WiFi, charging, etc.).
374 : pending,
375 :
376 : /// Task is currently running.
377 : ///
378 : /// The worker is actively executing. Listen to [NativeWorkManager.progress]
379 : /// for real-time progress updates.
380 : running,
381 :
382 : /// Task completed successfully.
383 : ///
384 : /// The worker finished and returned success. Completed tasks are
385 : /// automatically removed from the queue after a short period.
386 : completed,
387 :
388 : /// Task failed.
389 : ///
390 : /// The worker threw an exception or returned failure. For periodic tasks,
391 : /// the OS may automatically retry. For one-time tasks, the task is marked
392 : /// as failed and removed from the queue.
393 : failed,
394 :
395 : /// Task was cancelled.
396 : ///
397 : /// The task was explicitly cancelled via [NativeWorkManager.cancel],
398 : /// [NativeWorkManager.cancelByTag], or [NativeWorkManager.cancelAll].
399 : /// Cancelled tasks are removed from the queue and cannot be resumed.
400 : cancelled,
401 :
402 : /// Task is paused.
403 : ///
404 : /// The task was paused via [NativeWorkManager.pause] or
405 : /// [NativeWorkManager.pauseByTag]. Resume with [NativeWorkManager.resume].
406 : paused,
407 : }
408 :
409 : /// Typed error codes for failed [TaskEvent]s.
410 : ///
411 : /// Maps the raw error-code string from the native side into a Dart enum so
412 : /// callers can switch on structured values instead of comparing arbitrary
413 : /// strings from [TaskEvent.message].
414 : ///
415 : /// ## Usage
416 : ///
417 : /// ```dart
418 : /// NativeWorkManager.events.listen((event) {
419 : /// if (!event.success) {
420 : /// switch (event.errorCode) {
421 : /// case NativeWorkManagerError.networkError:
422 : /// retryLater(event.taskId);
423 : /// case NativeWorkManagerError.timeout:
424 : /// showTimeoutDialog();
425 : /// case NativeWorkManagerError.securityViolation:
426 : /// log('Blocked by security policy: ${event.message}');
427 : /// case NativeWorkManagerError.unknown:
428 : /// case null:
429 : /// log('Unclassified failure: ${event.message}');
430 : /// }
431 : /// }
432 : /// });
433 : /// ```
434 : enum NativeWorkManagerError {
435 : /// A network-layer failure (DNS, TCP, SSL, etc.).
436 : networkError,
437 :
438 : /// The worker exceeded its allowed execution time.
439 : timeout,
440 :
441 : /// The server returned a 4xx client-error response.
442 : httpClientError,
443 :
444 : /// The server returned a 5xx server-error response.
445 : httpServerError,
446 :
447 : /// The file was not found or could not be read/written.
448 : fileNotFound,
449 :
450 : /// Insufficient storage space to complete the operation.
451 : insufficientStorage,
452 :
453 : /// The request was blocked by the security validator
454 : /// (e.g. SSRF attempt, invalid URL scheme).
455 : securityViolation,
456 :
457 : /// The task was cancelled before it could complete.
458 : cancelled,
459 :
460 : /// The native worker threw an unhandled exception.
461 : workerException,
462 :
463 : /// Error string received from native is not recognised by this version of
464 : /// the Dart library. Check [TaskEvent.message] for the raw value.
465 : unknown;
466 :
467 : /// Parse the raw error-code string sent by the native side.
468 0 : static NativeWorkManagerError fromString(String? raw) => switch (raw) {
469 0 : 'NETWORK_ERROR' => networkError,
470 0 : 'TIMEOUT' => timeout,
471 0 : 'HTTP_CLIENT_ERROR' => httpClientError,
472 0 : 'HTTP_SERVER_ERROR' => httpServerError,
473 0 : 'FILE_NOT_FOUND' => fileNotFound,
474 0 : 'INSUFFICIENT_STORAGE' => insufficientStorage,
475 0 : 'SECURITY_VIOLATION' => securityViolation,
476 0 : 'CANCELLED' => cancelled,
477 0 : 'WORKER_EXCEPTION' => workerException,
478 : _ => unknown,
479 : };
480 :
481 : /// The canonical string exchanged over the platform channel.
482 0 : String get rawValue => switch (this) {
483 0 : networkError => 'NETWORK_ERROR',
484 0 : timeout => 'TIMEOUT',
485 0 : httpClientError => 'HTTP_CLIENT_ERROR',
486 0 : httpServerError => 'HTTP_SERVER_ERROR',
487 0 : fileNotFound => 'FILE_NOT_FOUND',
488 0 : insufficientStorage => 'INSUFFICIENT_STORAGE',
489 0 : securityViolation => 'SECURITY_VIOLATION',
490 0 : cancelled => 'CANCELLED',
491 0 : workerException => 'WORKER_EXCEPTION',
492 0 : unknown => 'UNKNOWN',
493 : };
494 : }
495 :
496 : /// Event emitted for task lifecycle transitions (started, completed, failed).
497 : ///
498 : /// Listen to [NativeWorkManager.events] to receive notifications when
499 : /// background tasks start or finish executing. Useful for updating UI, logging,
500 : /// or triggering follow-up actions.
501 : ///
502 : /// ## Distinguishing Event Types
503 : ///
504 : /// Check [isStarted] to determine whether this is a lifecycle notification or
505 : /// a completion event:
506 : ///
507 : /// ```dart
508 : /// NativeWorkManager.events.listen((event) {
509 : /// if (event.isStarted) {
510 : /// print('Task ${event.taskId} began executing');
511 : /// return;
512 : /// }
513 : /// if (event.success) {
514 : /// print('Task ${event.taskId} completed');
515 : /// } else {
516 : /// print('Task ${event.taskId} failed: ${event.message}');
517 : /// }
518 : /// });
519 : /// ```
520 : ///
521 : /// ## Basic Event Listening
522 : ///
523 : /// ```dart
524 : /// void initState() {
525 : /// super.initState();
526 : ///
527 : /// // Listen to all task completions
528 : /// NativeWorkManager.events.listen((event) {
529 : /// if (event.success) {
530 : /// print('✅ Task ${event.taskId} completed');
531 : /// if (event.resultData != null) {
532 : /// print('Result: ${event.resultData}');
533 : /// }
534 : /// } else {
535 : /// print('❌ Task ${event.taskId} failed: ${event.message}');
536 : /// }
537 : /// });
538 : /// }
539 : /// ```
540 : ///
541 : /// ## Filtering Specific Tasks
542 : ///
543 : /// ```dart
544 : /// NativeWorkManager.events
545 : /// .where((event) => event.taskId.startsWith('sync-'))
546 : /// .listen((event) {
547 : /// if (event.success) {
548 : /// showNotification('Sync completed');
549 : /// refreshUI();
550 : /// } else {
551 : /// showError('Sync failed: ${event.message}');
552 : /// }
553 : /// });
554 : /// ```
555 : ///
556 : /// ## Handling Different Task Types
557 : ///
558 : /// ```dart
559 : /// NativeWorkManager.events.listen((event) {
560 : /// switch (event.taskId) {
561 : /// case 'download-images':
562 : /// if (event.success) {
563 : /// final count = event.resultData?['downloaded_count'];
564 : /// print('Downloaded $count images');
565 : /// }
566 : /// break;
567 : ///
568 : /// case 'upload-logs':
569 : /// if (event.success) {
570 : /// clearLocalLogs();
571 : /// } else {
572 : /// scheduleRetry();
573 : /// }
574 : /// break;
575 : ///
576 : /// case 'sync-contacts':
577 : /// if (event.success) {
578 : /// updateLastSyncTime(event.timestamp);
579 : /// }
580 : /// break;
581 : /// }
582 : /// });
583 : /// ```
584 : ///
585 : /// ## Extracting Result Data
586 : ///
587 : /// ```dart
588 : /// // Worker returns data
589 : /// @pragma('vm:entry-point')
590 : /// Future<WorkerResult> processData(WorkerInput input) async {
591 : /// final result = await heavyComputation();
592 : /// return WorkerResult.success(data: {
593 : /// 'processed_items': result.count,
594 : /// 'total_size': result.sizeInBytes,
595 : /// 'duration_ms': result.durationMs,
596 : /// });
597 : /// }
598 : ///
599 : /// // Listen for results
600 : /// NativeWorkManager.events
601 : /// .where((e) => e.taskId == 'process-data')
602 : /// .listen((event) {
603 : /// if (event.success && event.resultData != null) {
604 : /// final items = event.resultData!['processed_items'];
605 : /// final size = event.resultData!['total_size'];
606 : /// print('Processed $items items ($size bytes)');
607 : /// }
608 : /// });
609 : /// ```
610 : ///
611 : /// ## Error Handling
612 : ///
613 : /// ```dart
614 : /// NativeWorkManager.events.listen((event) {
615 : /// if (!event.success) {
616 : /// // Log error for analytics
617 : /// analytics.logError(
618 : /// taskId: event.taskId,
619 : /// error: event.message ?? 'Unknown error',
620 : /// timestamp: event.timestamp,
621 : /// );
622 : ///
623 : /// // Notify user for critical tasks
624 : /// if (event.taskId == 'backup-critical-data') {
625 : /// showCriticalErrorDialog(event.message);
626 : /// }
627 : ///
628 : /// // Implement retry logic
629 : /// if (shouldRetry(event.taskId)) {
630 : /// scheduleRetry(event.taskId, exponentialBackoff: true);
631 : /// }
632 : /// }
633 : /// });
634 : /// ```
635 : ///
636 : /// ## Event Fields
637 : ///
638 : /// - [taskId]: Unique identifier of the completed task
639 : /// - [success]: `true` if task completed successfully, `false` if failed
640 : /// - [message]: Error message if failed, or optional success message
641 : /// - [resultData]: Custom data returned by the worker (if any)
642 : /// - [timestamp]: When the task completed execution
643 : ///
644 : /// ## Platform Behavior
645 : ///
646 : /// **Android:**
647 : /// - Events delivered via WorkManager's Result mechanism
648 : /// - May be delayed if app is in background
649 : /// - Guaranteed delivery when app comes to foreground
650 : ///
651 : /// **iOS:**
652 : /// - Events delivered when app is active
653 : /// - Background completion may not trigger immediate event
654 : /// - Events batched if app was terminated
655 : ///
656 : /// ## Important Notes
657 : ///
658 : /// - Events are only delivered **while the app is running**
659 : /// - If app is terminated, events are **not persisted**
660 : /// - For critical outcomes, persist state in the **worker itself**
661 : /// - Events are **fire-and-forget** (no replay mechanism)
662 : /// - Use [NativeWorkManager.getTaskStatus] to check status if you miss events
663 : ///
664 : /// ## Best Practices
665 : ///
666 : /// ✅ **Do** listen to events for UI updates and logging
667 : /// ✅ **Do** filter events by taskId or patterns for specific handling
668 : /// ✅ **Do** persist important results in the worker, not just events
669 : /// ✅ **Do** handle both success and failure cases
670 : ///
671 : /// ❌ **Don't** rely on events for critical state management
672 : /// ❌ **Don't** assume events arrive in order (parallel tasks)
673 : /// ❌ **Don't** expect events if app is terminated
674 : ///
675 : /// See also:
676 : /// - [NativeWorkManager.events] - Stream of task events
677 : /// - [TaskProgress] - Progress updates during execution
678 : /// - [TaskStatus] - Current task status
679 : @immutable
680 : class TaskEvent {
681 8 : const TaskEvent({
682 : required this.taskId,
683 : required this.success,
684 : this.message,
685 : this.errorCode,
686 : this.resultData,
687 : required this.timestamp,
688 : this.isStarted = false,
689 : this.workerType,
690 : });
691 :
692 : /// ID of the task.
693 : final String taskId;
694 :
695 : /// `true` when the native worker has just begun execution.
696 : ///
697 : /// When this flag is set the event is a **lifecycle notification**, not a
698 : /// completion event. [success], [message], [errorCode], and [resultData] are
699 : /// irrelevant for started events. [workerType] carries the worker class name.
700 : ///
701 : /// Use this to implement `onTaskStart` semantics without depending on
702 : /// progress events — reliable even for fast workers that emit no progress.
703 : final bool isStarted;
704 :
705 : /// Worker class name, set when [isStarted] is `true`.
706 : ///
707 : /// Examples: `'HttpDownloadWorker'`, `'HttpUploadWorker'`, `'DartCallbackWorker'`.
708 : /// `null` for completion events.
709 : final String? workerType;
710 :
711 : /// Whether the task succeeded.
712 : final bool success;
713 :
714 : /// Optional message (error message if failed).
715 : final String? message;
716 :
717 : /// Typed error code for failed tasks.
718 : ///
719 : /// `null` when [success] is `true` or when the native side did not supply an
720 : /// error code (e.g. older plugin versions). Switch on this field to handle
721 : /// failure categories without parsing [message] strings.
722 : final NativeWorkManagerError? errorCode;
723 :
724 : /// Optional result data from the worker.
725 : final Map<String, dynamic>? resultData;
726 :
727 : /// When the event occurred.
728 : final DateTime timestamp;
729 :
730 : /// Create from platform channel map.
731 : ///
732 : /// FIX M5: Uses null-safe access on every field. A version mismatch between
733 : /// native and Dart (or a platform bug) could send null for required fields;
734 : /// an unchecked cast would throw and close the EventChannel stream silently.
735 5 : factory TaskEvent.fromMap(Map<String, dynamic> map) {
736 5 : final started = (map['isStarted'] as bool?) ?? false;
737 5 : final success = (map['success'] as bool?) ?? false;
738 5 : final rawErrorCode = map['errorCode'] as String?;
739 5 : return TaskEvent(
740 5 : taskId: (map['taskId'] as String?) ?? '',
741 : isStarted: started,
742 5 : workerType: map['workerType'] as String?,
743 : success: success,
744 5 : message: map['message'] as String?,
745 : // Only parse errorCode on failure; ignore stray codes on success.
746 : errorCode: (!success && !started && rawErrorCode != null)
747 0 : ? NativeWorkManagerError.fromString(rawErrorCode)
748 : : null,
749 10 : resultData: map['resultData'] is Map
750 8 : ? Map<String, dynamic>.from(map['resultData'] as Map)
751 : : null,
752 5 : timestamp: map['timestamp'] != null
753 5 : ? DateTime.fromMillisecondsSinceEpoch(
754 10 : (map['timestamp'] as num).toInt())
755 3 : : DateTime.now(),
756 : );
757 : }
758 :
759 : /// Convert to map.
760 6 : Map<String, dynamic> toMap() => {
761 6 : 'taskId': taskId,
762 3 : if (isStarted) 'isStarted': isStarted,
763 3 : if (workerType != null) 'workerType': workerType,
764 6 : 'success': success,
765 6 : 'message': message,
766 3 : if (errorCode != null) 'errorCode': errorCode!.rawValue,
767 6 : 'resultData': resultData,
768 9 : 'timestamp': timestamp.millisecondsSinceEpoch,
769 : };
770 :
771 3 : @override
772 : bool operator ==(Object other) =>
773 : identical(this, other) ||
774 2 : other is TaskEvent &&
775 6 : taskId == other.taskId &&
776 6 : isStarted == other.isStarted &&
777 6 : workerType == other.workerType &&
778 6 : success == other.success &&
779 6 : errorCode == other.errorCode &&
780 6 : message == other.message &&
781 6 : _mapsEqual(resultData, other.resultData) &&
782 6 : timestamp == other.timestamp;
783 :
784 2 : static bool _mapsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
785 : if (identical(a, b)) return true;
786 0 : if (a == null || b == null) return a == b;
787 0 : if (a.length != b.length) return false;
788 0 : for (final key in a.keys) {
789 0 : if (!b.containsKey(key) || b[key] != a[key]) return false;
790 : }
791 : return true;
792 : }
793 :
794 2 : @override
795 2 : int get hashCode => Object.hash(
796 2 : taskId,
797 2 : isStarted,
798 2 : workerType,
799 2 : success,
800 2 : errorCode,
801 2 : message,
802 2 : resultData == null
803 : ? null
804 0 : : Object.hashAll(
805 0 : resultData!.entries.map((e) => Object.hash(e.key, e.value))),
806 2 : timestamp);
807 :
808 1 : @override
809 1 : String toString() => isStarted
810 0 : ? 'TaskEvent(taskId: $taskId, isStarted: true, workerType: $workerType, timestamp: $timestamp)'
811 1 : : 'TaskEvent('
812 1 : 'taskId: $taskId, '
813 1 : 'success: $success, '
814 1 : '${errorCode != null ? "errorCode: ${errorCode!.rawValue}, " : ""}'
815 1 : 'message: $message, '
816 1 : 'timestamp: $timestamp)';
817 : }
818 :
819 : /// Progress update during task execution.
820 : ///
821 : /// Workers can report progress during long-running operations. Listen to
822 : /// [NativeWorkManager.progress] to receive real-time updates and show
823 : /// progress bars or status messages in your UI.
824 : ///
825 : /// ## Reporting Progress from Worker
826 : ///
827 : /// ```dart
828 : /// @pragma('vm:entry-point')
829 : /// Future<WorkerResult> downloadFiles(WorkerInput input) async {
830 : /// final urls = input.data['urls'] as List<String>;
831 : /// final total = urls.length;
832 : ///
833 : /// for (var i = 0; i < urls.length; i++) {
834 : /// // Report progress
835 : /// await input.reportProgress(
836 : /// progress: ((i + 1) / total * 100).round(),
837 : /// message: 'Downloading file ${i + 1} of $total',
838 : /// currentStep: i + 1,
839 : /// totalSteps: total,
840 : /// );
841 : ///
842 : /// await downloadFile(urls[i]);
843 : /// }
844 : ///
845 : /// return WorkerResult.success();
846 : /// }
847 : /// ```
848 : ///
849 : /// ## Listening to Progress Updates
850 : ///
851 : /// ```dart
852 : /// void initState() {
853 : /// super.initState();
854 : ///
855 : /// // Listen to progress for all tasks
856 : /// NativeWorkManager.progress.listen((progress) {
857 : /// setState(() {
858 : /// _currentProgress = progress.progress;
859 : /// _statusMessage = progress.message ?? 'Processing...';
860 : /// });
861 : /// });
862 : /// }
863 : ///
864 : /// @override
865 : /// Widget build(BuildContext context) {
866 : /// return Column(
867 : /// children: [
868 : /// LinearProgressIndicator(value: _currentProgress / 100),
869 : /// Text(_statusMessage),
870 : /// if (_currentStep != null && _totalSteps != null)
871 : /// Text('Step $_currentStep of $_totalSteps'),
872 : /// ],
873 : /// );
874 : /// }
875 : /// ```
876 : ///
877 : /// ## Filtering Progress by Task
878 : ///
879 : /// ```dart
880 : /// // Only listen to specific task's progress
881 : /// NativeWorkManager.progress
882 : /// .where((p) => p.taskId == 'bulk-upload')
883 : /// .listen((progress) {
884 : /// print('Upload: ${progress.progress}% - ${progress.message}');
885 : ///
886 : /// if (progress.currentStep != null && progress.totalSteps != null) {
887 : /// print('File ${progress.currentStep}/${progress.totalSteps}');
888 : /// }
889 : /// });
890 : /// ```
891 : ///
892 : /// ## Multi-Step Task with Progress
893 : ///
894 : /// ```dart
895 : /// @pragma('vm:entry-point')
896 : /// Future<WorkerResult> processImages(WorkerInput input) async {
897 : /// final images = input.data['images'] as List<String>;
898 : /// final steps = ['Download', 'Resize', 'Compress', 'Upload'];
899 : /// final totalSteps = images.length * steps.length;
900 : /// var currentStep = 0;
901 : ///
902 : /// for (var image in images) {
903 : /// // Download
904 : /// currentStep++;
905 : /// await input.reportProgress(
906 : /// progress: (currentStep / totalSteps * 100).round(),
907 : /// message: 'Downloading $image',
908 : /// currentStep: currentStep,
909 : /// totalSteps: totalSteps,
910 : /// );
911 : /// await downloadImage(image);
912 : ///
913 : /// // Resize
914 : /// currentStep++;
915 : /// await input.reportProgress(
916 : /// progress: (currentStep / totalSteps * 100).round(),
917 : /// message: 'Resizing $image',
918 : /// currentStep: currentStep,
919 : /// totalSteps: totalSteps,
920 : /// );
921 : /// await resizeImage(image);
922 : ///
923 : /// // Compress
924 : /// currentStep++;
925 : /// await input.reportProgress(
926 : /// progress: (currentStep / totalSteps * 100).round(),
927 : /// message: 'Compressing $image',
928 : /// currentStep: currentStep,
929 : /// totalSteps: totalSteps,
930 : /// );
931 : /// await compressImage(image);
932 : ///
933 : /// // Upload
934 : /// currentStep++;
935 : /// await input.reportProgress(
936 : /// progress: (currentStep / totalSteps * 100).round(),
937 : /// message: 'Uploading $image',
938 : /// currentStep: currentStep,
939 : /// totalSteps: totalSteps,
940 : /// );
941 : /// await uploadImage(image);
942 : /// }
943 : ///
944 : /// return WorkerResult.success();
945 : /// }
946 : /// ```
947 : ///
948 : /// ## Progress with Network Upload
949 : ///
950 : /// ```dart
951 : /// @pragma('vm:entry-point')
952 : /// Future<WorkerResult> uploadLargeFile(WorkerInput input) async {
953 : /// final file = File(input.data['filePath']);
954 : /// final fileSize = await file.length();
955 : /// var uploaded = 0;
956 : ///
957 : /// await uploadWithProgress(
958 : /// file,
959 : /// onProgress: (bytes) {
960 : /// uploaded += bytes;
961 : /// final progress = (uploaded / fileSize * 100).round();
962 : ///
963 : /// input.reportProgress(
964 : /// progress: progress,
965 : /// message: 'Uploaded ${uploaded ~/ 1024}KB / ${fileSize ~/ 1024}KB',
966 : /// );
967 : /// },
968 : /// );
969 : ///
970 : /// return WorkerResult.success();
971 : /// }
972 : /// ```
973 : ///
974 : /// ## Progress Fields
975 : ///
976 : /// - [taskId]: Identifier of the task reporting progress
977 : /// - [progress]: Percentage (0-100) of completion
978 : /// - [message]: Optional human-readable status message
979 : /// - [currentStep]: Current step number (for multi-step tasks)
980 : /// - [totalSteps]: Total number of steps (for multi-step tasks)
981 : ///
982 : /// ## Platform Behavior
983 : ///
984 : /// **Android:**
985 : /// - Progress delivered via WorkManager's setProgress API
986 : /// - Updates throttled to ~1 per second to conserve resources
987 : /// - Reliable delivery while app is active
988 : ///
989 : /// **iOS:**
990 : /// - Progress delivered when app is active or backgrounded
991 : /// - Updates batched if app is suspended
992 : /// - May be delayed for terminated apps
993 : ///
994 : /// ## Important Notes
995 : ///
996 : /// - Progress updates are **best-effort** delivery
997 : /// - Not guaranteed if app is terminated
998 : /// - Updates may be **throttled** by the OS
999 : /// - Don't rely on receiving every single update
1000 : /// - Progress is **optional** - tasks work without it
1001 : ///
1002 : /// ## Performance Tips
1003 : ///
1004 : /// ✅ **Do** report progress at meaningful intervals (e.g., every file, every 5%)
1005 : /// ✅ **Do** include useful messages for users
1006 : /// ✅ **Do** use currentStep/totalSteps for multi-step tasks
1007 : ///
1008 : /// ❌ **Don't** report progress on every byte (too frequent)
1009 : /// ❌ **Don't** report progress more than once per second
1010 : /// ❌ **Don't** report progress for tasks under 5 seconds
1011 : /// ❌ **Don't** block worker execution waiting for progress delivery
1012 : ///
1013 : /// ## When to Use Progress
1014 : ///
1015 : /// **Good for:**
1016 : /// - File uploads/downloads (show bytes transferred)
1017 : /// - Batch processing (show items processed)
1018 : /// - Multi-step workflows (show current step)
1019 : /// - Long operations (>10 seconds)
1020 : ///
1021 : /// **Not needed for:**
1022 : /// - Quick tasks (<5 seconds)
1023 : /// - Tasks with no intermediate steps
1024 : /// - Tasks running while app is terminated
1025 : ///
1026 : /// See also:
1027 : /// - [NativeWorkManager.progress] - Stream of progress updates
1028 : /// - [NativeWorkManager.reportDartWorkerProgress] - Report from DartWorker callback
1029 : /// - [TaskEvent] - Task completion notification
1030 : @immutable
1031 : class TaskProgress {
1032 6 : const TaskProgress({
1033 : required this.taskId,
1034 : required this.progress,
1035 : this.message,
1036 : this.currentStep,
1037 : this.totalSteps,
1038 : this.bytesDownloaded,
1039 : this.totalBytes,
1040 : this.networkSpeed,
1041 : this.timeRemaining,
1042 : });
1043 :
1044 : /// ID of the task.
1045 : final String taskId;
1046 :
1047 : /// Progress percentage (0-100).
1048 : final int progress;
1049 :
1050 : /// Optional status message.
1051 : final String? message;
1052 :
1053 : /// Current step number (for multi-step tasks).
1054 : final int? currentStep;
1055 :
1056 : /// Total number of steps.
1057 : final int? totalSteps;
1058 :
1059 : /// Bytes downloaded so far (download workers only).
1060 : ///
1061 : /// `null` if the worker does not report byte-level progress.
1062 : final int? bytesDownloaded;
1063 :
1064 : /// Total file size in bytes (download workers only).
1065 : ///
1066 : /// `null` if the server did not return a `Content-Length` header.
1067 : final int? totalBytes;
1068 :
1069 : /// Current download/upload speed in bytes per second.
1070 : ///
1071 : /// `null` if speed cannot be computed (e.g. task just started).
1072 : final double? networkSpeed;
1073 :
1074 : /// Estimated time remaining in milliseconds.
1075 : ///
1076 : /// Computed as `(totalBytes - bytesDownloaded) / networkSpeed`.
1077 : /// `null` if either [networkSpeed] or [totalBytes] is unavailable.
1078 : final Duration? timeRemaining;
1079 :
1080 : /// Whether this progress update carries byte-level information.
1081 0 : bool get hasNetworkInfo =>
1082 0 : bytesDownloaded != null && totalBytes != null && networkSpeed != null;
1083 :
1084 : /// Create from platform channel map.
1085 : ///
1086 : /// FIX M5: Null-safe access prevents crash if platform omits a required field.
1087 8 : factory TaskProgress.fromMap(Map<String, dynamic> map) => TaskProgress(
1088 4 : taskId: (map['taskId'] as String?) ?? '',
1089 8 : progress: (map['progress'] as num?)?.toInt() ?? 0,
1090 4 : message: map['message'] as String?,
1091 6 : currentStep: (map['currentStep'] as num?)?.toInt(),
1092 6 : totalSteps: (map['totalSteps'] as num?)?.toInt(),
1093 4 : bytesDownloaded: (map['bytesDownloaded'] as num?)?.toInt(),
1094 4 : totalBytes: (map['totalBytes'] as num?)?.toInt(),
1095 4 : networkSpeed: (map['networkSpeed'] as num? ??
1096 4 : map['networkSpeedBytesPerSecond'] as num?)
1097 1 : ?.toDouble(),
1098 4 : timeRemaining: (map['timeRemainingMs'] != null ||
1099 4 : map['timeRemainingSeconds'] != null)
1100 1 : ? Duration(
1101 1 : milliseconds: (map['timeRemainingMs'] as num? ??
1102 2 : (map['timeRemainingSeconds'] as num? ?? 0) * 1000)
1103 1 : .toInt())
1104 : : null,
1105 : );
1106 :
1107 : /// Convert to map.
1108 4 : Map<String, dynamic> toMap() => {
1109 4 : 'taskId': taskId,
1110 4 : 'progress': progress,
1111 4 : 'message': message,
1112 4 : 'currentStep': currentStep,
1113 4 : 'totalSteps': totalSteps,
1114 2 : if (bytesDownloaded != null) 'bytesDownloaded': bytesDownloaded,
1115 2 : if (totalBytes != null) 'totalBytes': totalBytes,
1116 2 : if (networkSpeed != null) 'networkSpeed': networkSpeed,
1117 2 : if (timeRemaining != null)
1118 0 : 'timeRemainingMs': timeRemaining!.inMilliseconds,
1119 : };
1120 :
1121 2 : @override
1122 : bool operator ==(Object other) =>
1123 : identical(this, other) ||
1124 1 : other is TaskProgress &&
1125 3 : taskId == other.taskId &&
1126 3 : progress == other.progress &&
1127 0 : message == other.message &&
1128 0 : bytesDownloaded == other.bytesDownloaded &&
1129 0 : totalBytes == other.totalBytes &&
1130 0 : networkSpeed == other.networkSpeed &&
1131 0 : timeRemaining == other.timeRemaining;
1132 :
1133 1 : @override
1134 1 : int get hashCode => Object.hash(
1135 7 : taskId, progress, message, bytesDownloaded, totalBytes, networkSpeed, timeRemaining);
1136 :
1137 1 : @override
1138 1 : String toString() => 'TaskProgress('
1139 1 : 'taskId: $taskId, '
1140 1 : 'progress: $progress%, '
1141 1 : 'message: $message, '
1142 2 : 'step: $currentStep/$totalSteps, '
1143 2 : 'bytes: $bytesDownloaded/$totalBytes, '
1144 1 : 'speed: ${networkSpeed != null ? "${(networkSpeed! / 1024).toStringAsFixed(1)} KB/s" : "n/a"})';
1145 : }
1146 :
1147 : /// Represents a critical system-level error on the native side.
1148 : ///
1149 : /// These errors are usually fatal to a task or a queue (e.g., Disk Full).
1150 : @immutable
1151 : class SystemError {
1152 0 : const SystemError({
1153 : required this.code,
1154 : required this.message,
1155 : required this.timestamp,
1156 : });
1157 :
1158 : /// Unique error code (e.g. 'DISK_FULL').
1159 : final String code;
1160 :
1161 : /// Human-readable error description.
1162 : final String message;
1163 :
1164 : /// When the error occurred.
1165 : final DateTime timestamp;
1166 :
1167 0 : factory SystemError.fromMap(Map<String, dynamic> map) => SystemError(
1168 0 : code: map['code'] as String? ?? 'UNKNOWN',
1169 : message:
1170 0 : map['message'] as String? ?? 'An unexpected native error occurred',
1171 0 : timestamp: map['timestamp'] != null
1172 0 : ? DateTime.fromMillisecondsSinceEpoch(
1173 0 : (map['timestamp'] as num).toInt())
1174 0 : : DateTime.now(),
1175 : );
1176 :
1177 0 : @override
1178 0 : String toString() => 'SystemError(code: $code, message: $message)';
1179 : }
|