riverpod_offline_sync 2.0.0 copy "riverpod_offline_sync: ^2.0.0" to clipboard
riverpod_offline_sync: ^2.0.0 copied to clipboard

Production-ready offline-first sync engine for Flutter super apps with Riverpod integration

riverpod_offline_sync #

An offline-first sync engine for Flutter + Riverpod apps, built on Firebase (Firestore, Storage, Auth).

License: MIT Flutter

This package has not yet been run against a real device or a real Firebase project — see Status before you rely on it in production.

Table of contents #

Status #

  • Compiles cleanly (flutter analyze) and the full test suite passes (flutter test) on the versions pinned in pubspec.yaml.
  • Not yet verified: real Firebase project behavior, real-device behavior (app kills mid-upload, real airplane-mode toggling, iOS background execution limits), and interaction with your actual Firestore security rules.
  • Treat this as "ready for your own testing," not "ready to hand to end users." See Known limitations for the specific gaps.

What this package actually does #

Push (writes). Queue Firestore/Storage/custom operations locally (Hive-backed), process them in priority order with idempotency-key deduplication, retry failed ones with exponential backoff and jitter, and move permanently-failed ones to a dead-letter state instead of silently deleting them.

Pull (remote changes). Optional. Supply a RemoteDataSource (a ready-made Firestore-backed one is included) to get polling for remote changes plus conflict resolution against your local cache. If you don't supply one, pull sync is a documented no-op — push still works normally.

Storage uploads. Pause, resume, and cancel with progress, independent of the operation queue (uploads have a richer lifecycle than a generic queued write).

Riverpod providers. Sync state, progress, queue stats, and connectivity, plus a handful of UI widgets (ConnectivityBanner, SyncStatusIndicator, DebugPanel, and others).

Installation #

dependencies:
  riverpod_offline_sync:
    path: ../riverpod_offline_sync # or your internal package registry

This package isn't published to a public git host or pub.flutter-io.cn — use a local path: dependency, or point it at wherever your team hosts internal packages.

Run flutter pub get, then regenerate the Hive adapter if you change anything in lib/src/queue/queue_item.dart:

flutter pub run build_runner build --delete-conflicting-outputs

A generated queue_item.g.dart is checked in, so this step is only needed after modifying QueueItem's fields.

Quick start #

1. Initialize Firebase and register handlers #

// main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp();

  // Must be set before any other Firestore call in the app's lifetime.
  FirebaseFirestore.instance.settings =
      const Settings(persistenceEnabled: true);

  _registerHandlers();

  runApp(const ProviderScope(child: MyApp()));
}

void _registerHandlers() {
  OfflineSyncLayer.instance.registerOperationHandler('documents',
      (data) async {
    final ref = FirebaseFirestore.instance
        .collection(data['collection'] as String)
        .doc(data['docId'] as String);

    switch (data['op'] as String) {
      case 'set':
        await ref.set(Map<String, dynamic>.from(data['payload'] as Map));
        break;
      case 'update':
        await ref.update(Map<String, dynamic>.from(data['payload'] as Map));
        break;
      case 'delete':
        await ref.delete();
        break;
      default:
        throw ArgumentError('Unknown op: ${data['op']}');
    }
  });
}

Note the break after every case: Dart's classic switch statement does not allow implicit fallthrough for a non-empty case body, so omitting these is a compile error.

2. Wrap your app root #

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const OfflineSyncScope(
        config: SyncConfig(
          syncImmediately: true,
          autoSyncOnReconnect: true,
        ),
        child: HomePage(),
      ),
    );
  }
}

OfflineSyncScope calls OfflineSyncLayer.instance.initialize() for you and shows a loading state until it completes. If you need more control (e.g. injecting a test double, or initializing before runApp), call OfflineSyncLayer.instance.initialize(...) directly instead and skip this widget.

3. Submit operations #

await ref.read(offlineSyncLayerProvider).submitOperation(
  category: 'documents',
  priority: QueuePriority.normal.value,
  idempotencyKey: 'todo_set_$todoId',
  data: {
    'op': 'set',
    'collection': 'todos',
    'docId': todoId,
    'payload': {'title': 'Buy cement', 'completed': false},
  },
);

idempotencyKey is optional — omit it and the package generates one for you — but supplying your own (as above) is what lets a retried call collapse into a single write instead of duplicating it.

Core concepts #

Queue manager #

QueueManager persists operations to Hive, processes them in priority order, retries failures with backoff, and moves permanently-failed items to a dead-letter state.

final queueManager = QueueManager(
  retryStrategy: const RetryStrategy(
    maxRetries: 5,
    initialDelay: Duration(seconds: 2),
    jitterFraction: 0.2,
  ),
  boxName: 'my_queue',
);

await queueManager.initialize(maxQueueSize: 1000);

OfflineSyncLayer.instance already owns a QueueManager internally — you only construct your own for tests or when you need more than one independent queue.

Operation handlers #

Handlers process operations for a given category. Register them once during app startup:

OfflineSyncLayer.instance.registerOperationHandler('messages', (data) async {
  await FirebaseFirestore.instance
      .collection('messages')
      .doc(data['id'] as String)
      .set(Map<String, dynamic>.from(data['payload'] as Map));
});

Retry strategy #

final conservative = RetryStrategy.conservative(); // fewer retries, longer delays
final aggressive = RetryStrategy.aggressive();      // more retries, shorter delays
final noRetry = RetryStrategy.noRetry();

final custom = RetryStrategy(
  maxRetries: 10,
  initialDelay: const Duration(seconds: 1),
  maxDelay: const Duration(minutes: 5),
  jitterFraction: 0.3,
  shouldRetryOnTimeout: true,
  shouldRetryOnServerError: true,
  shouldRetryOnNetworkError: true,
);

Handlers that don't supply an HTTP status code (most Firestore/Storage calls) fall back to retrying on any error — see the dartdoc on RetryStrategy for the exact rules, since the 4xx/5xx branch only applies when a status code is actually available.

Conflict resolution #

final resolver = ConflictResolver();

try {
  final resolved = await resolver.resolve(
    local: localData,
    remote: remoteData,
    strategy: ConflictStrategy.lastWriteWins,
    localTimestamp: DateTime.tryParse(localData['updatedAt'] as String? ?? ''),
    remoteTimestamp: DateTime.tryParse(remoteData['updatedAt'] as String? ?? ''),
  );
} on ManualResolutionRequiredException catch (e) {
  showMergeDialog(
    local: e.local,
    remote: e.remote,
    conflictingFields: e.conflictingFields,
  );
}
Strategy Behavior
serverWins Always use remote data
clientWins Always use local data
lastWriteWins Use the newer version (falls back to remote if either timestamp is missing)
merge Recursive field-level merge; remote wins on scalar conflicts
manualResolve Throws ManualResolutionRequiredException for your own UI to handle

Pull sync #

Bidirectional sync requires a RemoteDataSource:

await OfflineSyncLayer.instance.initialize(
  remoteDataSource: FirestoreRemoteDataSource(
    collections: ['todos', 'messages'],
  ),
);

Custom source:

class MyRemoteDataSource implements RemoteDataSource {
  @override
  Future<List<RemoteChange>> fetchChangesSince(DateTime since) async {
    final changes = await myApi.getChanges(since);
    return changes
        .map((c) => RemoteChange(
              collection: c.collection,
              id: c.id,
              data: c.data,
            ))
        .toList();
  }

  @override
  Future<Map<String, dynamic>?> getLocalData(String collection, String id) {
    return myLocalDb.get('$collection/$id');
  }

  @override
  Future<void> applyRemoteData(
      String collection, String id, Map<String, dynamic> data) {
    return myLocalDb.put('$collection/$id', data);
  }
}

Riverpod integration #

Provider Type Description
offlineSyncLayerProvider Provider<OfflineSyncLayer> The layer instance (override in tests)
syncStateProvider StreamProvider<SyncStateType> idle / syncing / completed / failed
syncMachineStateProvider StreamProvider<SyncMachineState> Detailed sync phase
isSyncingProvider Provider<bool> Reactive is-syncing flag
syncProgressProvider StreamProvider<SyncProgress?> Current push/pull progress
syncMetricsProvider Provider<SyncMetrics> Success/failure counts, last error, timing
pendingItemsProvider StreamProvider<List<QueueItem>> Live queue contents
pendingItemsCountProvider Provider<int> Count of pending items
queueBreakdownProvider Provider<Map<String, int>> Pending items by category
queueStatsProvider FutureProvider<QueueStats> Full pending/dead-letter/retrying stats
isConnectedProvider Provider<bool> Reactive connectivity flag
connectivityStatusProvider StreamProvider<bool> Raw connectivity stream
class SyncStatusWidget extends ConsumerWidget {
  const SyncStatusWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isSyncing = ref.watch(isSyncingProvider);
    final pendingCount = ref.watch(pendingItemsCountProvider);
    final progressAsync = ref.watch(syncProgressProvider);

    if (isSyncing) {
      return progressAsync.when(
        data: (p) => Text('Syncing: ${p?.progressText ?? '...'}'),
        loading: () => const CircularProgressIndicator(),
        error: (e, _) => Text('Error: $e'),
      );
    }

    if (pendingCount > 0) {
      return Text('$pendingCount operations pending');
    }

    return const Text('All synced');
  }
}

Overriding providers for tests #

await tester.pumpWidget(
  ProviderScope(
    overrides: [
      offlineSyncLayerProvider.overrideWithValue(fakeLayer),
    ],
    child: const MyApp(),
  ),
);

UI components #

// Offline banner with a manual retry button
ConnectivityBanner(
  onManualRetry: () => debugPrint('Manual retry triggered'),
  child: MyPageContent(),
)

// Pending-count indicator
const SyncStatusIndicator(showAsFloatingAction: true)

// Detailed progress
const SyncProgressBar(showDetails: true)

// Toast messages on sync events
OfflineToast(child: MyApp())

// Full inspection panel — renders nothing in release builds
ElevatedButton(
  onPressed: () => showModalBottomSheet(
    context: context,
    builder: (_) => const DebugPanel(),
  ),
  child: const Text('Open debug panel'),
)

ConnectivityBanner lays out as a Column with an Expanded child, so it needs bounded height from its parent — using it as Scaffold.body (as above) satisfies that.

Examples #

Todo list with offline create, update, delete #

Uses ChangeNotifier/ChangeNotifierProvider for local optimistic state. flutter_riverpod 3.x dropped its re-export of package:state_notifier, so StateNotifier/StateNotifierProvider are not available without adding that package separately — if you already depend on it directly, or use Riverpod's @riverpod code generator with Notifier, either of those works too.

class TodoListNotifier extends ChangeNotifier {
  List<Todo> _todos = [];
  List<Todo> get todos => List.unmodifiable(_todos);

  void add(Todo todo) {
    _todos = [todo, ..._todos];
    notifyListeners();
  }

  void remove(String id) {
    _todos = _todos.where((t) => t.id != id).toList();
    notifyListeners();
  }
}

final todoListProvider = ChangeNotifierProvider<TodoListNotifier>((ref) {
  return TodoListNotifier();
});

// In a widget:
Future<void> addTodo(WidgetRef ref, Todo todo) async {
  ref.read(todoListProvider).add(todo); // optimistic local update

  await ref.read(offlineSyncLayerProvider).submitOperation(
    category: 'documents',
    priority: QueuePriority.normal.value,
    idempotencyKey: 'todo_set_${todo.id}',
    data: {
      'op': 'set',
      'collection': 'todos',
      'docId': todo.id,
      'payload': todo.toJson(),
    },
  );
}

ChangeNotifierProvider<T> exposes the notifier itself from both ref.watch and ref.read — there is no separate .notifier accessor the way StateNotifierProvider has one.

Chat with message queuing #

class ChatScreen extends ConsumerWidget {
  const ChatScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final messages = ref.watch(chatProvider).messages;
    final isOffline = !ref.watch(isConnectedProvider);

    return Scaffold(
      body: Column(
        children: [
          if (isOffline)
            Container(
              color: Colors.red,
              padding: const EdgeInsets.all(8),
              child: const Text('Offline — messages will send once reconnected'),
            ),
          Expanded(child: MessageList(messages: messages)),
          _MessageInput(),
        ],
      ),
    );
  }
}

Future<void> sendMessage(WidgetRef ref, String text) async {
  await ref.read(offlineSyncLayerProvider).submitOperation(
    category: 'messages',
    priority: QueuePriority.high.value,
    idempotencyKey: 'msg_${DateTime.now().millisecondsSinceEpoch}',
    data: {
      'op': 'set',
      'collection': 'messages',
      'docId': DateTime.now().microsecondsSinceEpoch.toString(),
      'payload': {'text': text, 'userId': currentUserId},
    },
  );
}

File upload with pause, resume, cancel #

class UploadScreen extends StatefulWidget {
  const UploadScreen({super.key});

  @override
  State<UploadScreen> createState() => _UploadScreenState();
}

class _UploadScreenState extends State<UploadScreen> {
  final _storageQueue = StorageQueue();
  double _progress = 0;
  String? _currentKey;

  Future<void> _uploadFile() async {
    final result = await FilePicker.platform.pickFiles();
    if (result == null || result.files.single.path == null) return;

    final key = IdempotencyKey.generate();
    setState(() => _currentKey = key);

    try {
      final url = await _storageQueue.uploadFile(
        file: File(result.files.single.path!),
        path: 'uploads/${result.files.single.name}',
        idempotencyKey: key,
        onProgress: (progress) => setState(() => _progress = progress),
      );
      debugPrint('Uploaded: $url');
    } catch (e) {
      debugPrint('Upload failed: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          LinearProgressIndicator(value: _progress),
          Row(
            children: [
              ElevatedButton(onPressed: _uploadFile, child: const Text('Upload')),
              if (_currentKey != null) ...[
                IconButton(
                  onPressed: () => _storageQueue.pauseUpload(_currentKey!),
                  icon: const Icon(Icons.pause),
                ),
                IconButton(
                  onPressed: () => _storageQueue.resumeUpload(_currentKey!),
                  icon: const Icon(Icons.play_arrow),
                ),
                IconButton(
                  onPressed: () => _storageQueue.cancelUpload(_currentKey!),
                  icon: const Icon(Icons.cancel),
                ),
              ],
            ],
          ),
        ],
      ),
    );
  }
}

Note the upload isn't resumable across a full app-process kill — see Known limitations.

Debugging and observability #

// Logging
OfflineLogger.level = LogLevel.debug; // or LogLevel.none to silence
// SyncConfig(enableDebugLogging: true) sets this automatically during initialize()

// Sync metrics
final metrics = ref.watch(syncMetricsProvider);
print('Total syncs: ${metrics.totalSyncs}');
print('Success rate: ${metrics.successRatePercentage}');

// Queue stats — go through .queueManager, OfflineSyncLayer has no
// getQueueStats() of its own
final stats = await OfflineSyncLayer.instance.queueManager.getQueueStats();
print('Pending: ${stats.pendingCount}');
print('Dead-letter: ${stats.deadLetterCount}');
print('Retrying: ${stats.retryingCount}');

// Reacting to permanently-failed operations
class MyObserver extends SyncObserver {
  @override
  void onQueueItemOutcome(ItemOutcome outcome) {
    if (outcome.isDeadLetter) {
      // prompt the user to retry (OfflineSyncLayer.retryFailedOperation)
      // or discard (QueueManager.clearDeadLetterItems)
    }
  }
}

OfflineSyncLayer.instance.addObserver(MyObserver());

retrying and deadLetter are reported separately on purpose: a retrying failure needs no user action, but a deadLetter one usually does.

DebugPanel renders nothing in release builds (checks kReleaseMode internally), so it's safe to leave a button that opens it in production code.

Testing #

OfflineSyncLayer and QueueManager are plain constructible classes, not hard singletons, so you can inject fakes:

class FakeConnectivityMonitor implements ConnectivityMonitor {
  bool connected = true;
  final _controller = StreamController<bool>.broadcast();

  @override
  Future<void> initialize() async {}

  @override
  bool get isInitialized => true;

  @override
  bool get isConnected => connected;

  @override
  Future<bool> get isWifiConnected async => connected;

  @override
  Stream<bool> get onConnectivityChanged => _controller.stream;

  void emit(bool value) {
    connected = value;
    _controller.add(value);
  }

  @override
  void dispose() => _controller.close();
}

Implementing ConnectivityMonitor means providing every one of its public members — initialize, isInitialized, isConnected, isWifiConnected, onConnectivityChanged, and dispose — leaving any one out is a compile error. Use a broadcast StreamController for onConnectivityChanged, not Stream.value(...), since a single-value stream closes after its first emission and can't model a connectivity change happening later in a test.

test('QueueManager processes operations', () async {
  final queue = QueueManager(boxName: 'test_queue');
  await queue.initialize(maxQueueSize: 10);

  var processed = false;
  queue.registerHandler('test', (data) async {
    processed = true;
  });

  await queue.enqueue(
    category: 'test',
    priority: 0,
    data: {'key': 'value'},
    idempotencyKey: 'test_1',
  );

  await queue.processQueue();
  expect(processed, true);
});

test('SyncObserver receives queue item outcomes', () async {
  final layer = OfflineSyncLayer(
    queueManager: QueueManager(boxName: 'observer_test_queue'),
    connectivityMonitor: FakeConnectivityMonitor(),
  );
  await layer.initialize(config: const SyncConfig(syncImmediately: false));
  layer.registerOperationHandler('test', (data) async {});

  var outcomeReceived = false;
  layer.addObserver(_RecordingObserver(() => outcomeReceived = true));

  await layer.submitOperation(category: 'test', priority: 0, data: const {});
  await layer.sync(strategy: SyncStrategyType.pushOnly);

  expect(outcomeReceived, true);
  await layer.dispose();
});

class _RecordingObserver extends SyncObserver {
  _RecordingObserver(this.onOutcome);
  final void Function() onOutcome;

  @override
  void onQueueItemOutcome(ItemOutcome outcome) => onOutcome();
}

SyncObserver is an abstract class you extends and override methods on — it has no constructor that takes callbacks directly.

Testing with SyncAwareMixin #

class _TestWidgetState extends State<_TestWidget> with SyncAwareMixin {
  @override
  OfflineSyncLayer resolveSyncLayer() => widget.injectedLayer;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => submitOffline(
        category: 'test',
        priority: 0,
        data: const {'key': 'value'},
      ),
      child: const Text('Submit'),
    );
  }
}

Override resolveSyncLayer() (default falls back to OfflineSyncLayer.instance) to point the mixin at an injected layer in tests. If your test uses testWidgets and the layer performs real Hive I/O, wrap those calls in tester.runAsync()testWidgets runs its callback inside a FakeAsync zone that doesn't reliably resolve real disk I/O, and runAsync is flutter_test's documented escape hatch for exactly that. See test/sync_aware_mixin_test.dart in this package for a complete example, including why touching OfflineSyncLayer.instance itself (even just for a negative identity comparison) can hang a widget test — it constructs a real ConnectivityMonitor backed by a platform channel with no native implementation under the headless test harness.

Known limitations #

  1. Storage uploads are not resumable across an app-process restart. If the app is killed mid-upload, the in-flight UploadTask reference is lost; a re-queued upload for the same file starts fresh. This is a Firebase Storage SDK constraint, not something this package works around.
  2. FirestoreRemoteDataSource.getLocalData does not catch Firestore errors. If a document isn't cached and the device is offline, it throws rather than treating that as "no local copy." Pass a custom RemoteDataSource if you need different semantics.
  3. No per-user queue isolation. If a device is shared between multiple signed-in users, operations queued while one user was signed in remain in the same Hive box after another signs in. Tag operations by uid in your own data payload if you need this.
  4. Queue trimming has one fixed strategy: drop the lowest-priority, oldest items first, down to half of maxQueueSize, when the queue is full. Not configurable today.
  5. FirestoreRemoteDataSource always treats Firestore itself as the local cache via its native offline persistence. Implement your own RemoteDataSource if you need a separate local store.
  6. A write rejected by your Firestore security rules is treated like any other failure — it retries with backoff and eventually moves to dead-letter, the same as a network timeout would. If you want permission errors to fail immediately without burning through retries, that distinction needs to be added to your own operation handler (inspect the thrown FirebaseException.code and rethrow a marker your RetryStrategy treats as non-retryable) — it is not built in.
  7. Not yet verified on a real device or real Firebase project — see Status.

Breaking changes from 1.x.x #

  • ConflictResolver.resolve() with ConflictStrategy.manualResolve now throws ManualResolutionRequiredException (carries conflictingFields, local, remote) instead of a plain Exception.
  • QueueStats.failedCount is deprecated in favor of QueueStats.deadLetterCount — the old getter still works.
  • Items that permanently exhaust retries move to a dead-letter state instead of being deleted. Call QueueManager.clearDeadLetterItems() to restore the old delete-on-failure behavior explicitly.
  • OfflineSyncLayer and QueueManager are no longer hard-coded singletons — OfflineSyncLayer() / QueueManager() are public constructors now. .instance still works the same as before.
  • Pull sync requires RemoteDataSource, passed to initialize(). Default behavior (no-op pull) is unchanged if you don't pass one.
  • OfflineSyncLayer.dispose() no longer always disposes its QueueManager. If you didn't pass queueManager: to the constructor, it defaults to the shared QueueManager.instance, and dispose() now leaves that shared instance running. If you did pass your own, dispose() still disposes it by default. Override either way with disposeQueueManager: true / false.
  • hive_registry.dart, queue_item_adapter.dart, and backoff_calculator.dart are no longer exported from the package barrel — import them by direct path if you need them.
  • SyncObserver gained onQueueItemOutcome(ItemOutcome outcome) (default empty body — non-breaking for existing subclasses).

API reference #

Core classes #

Class Description
OfflineSyncLayer Main sync orchestrator
QueueManager Persistent operation queue
QueueItem A single queued operation
SyncConfig Configuration for sync behavior
SyncMetrics Sync success/failure tracking
SyncStateMachine Sync phase management
ConflictResolver Resolves data conflicts
RetryStrategy Configures retry behavior
ConnectivityMonitor Network connectivity monitoring
RemoteDataSource Interface for pull-sync data access
FirestoreRemoteDataSource Firestore-backed RemoteDataSource

Enums #

Enum Values
QueuePriority critical, high, normal, low, background
QueueItemStatus pending, retrying, deadLetter
ItemOutcomeType success, retrying, deadLetter
SyncStateType idle, syncing, completed, failed
SyncMachineState idle, checking, pulling, pushing, completing, failed, cancelled
SyncStrategyType auto, manual, background, pushOnly, pullOnly
ConflictStrategy serverWins, clientWins, merge, lastWriteWins, manualResolve

UI widgets #

Widget Description
ConnectivityBanner Offline status banner with optional retry
SyncStatusIndicator Pending-count indicator
SyncProgressBar Detailed progress bar
OfflineToast Toast notifications for sync events
DebugPanel Full debug inspection panel (release-mode safe)
OfflineSyncScope Initialization wrapper with loading/error states

License #

MIT — see LICENSE.

iOS Permissions for StorageQueue #

Yes, if your app uses StorageQueue to upload files (photos, videos, documents, etc.), you need to add the appropriate iOS permissions in ios/Runner/Info.plist.


Permissions Required Based on File Source #

File Source Required Permission Key Description
Photo Library NSPhotoLibraryUsageDescription Access to photos/videos from the device library
Camera NSCameraUsageDescription Taking photos/videos directly
Files/Documents UISupportsDocumentBrowser or LSSupportsOpeningDocumentsInPlace Access to files via document picker
Microphone NSMicrophoneUsageDescription For video recordings with audio

Info.plist Configuration #

<!-- ios/Runner/Info.plist -->

<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to upload images and videos</string>

<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos for upload</string>

<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for video uploads</string>

<key>UISupportsDocumentBrowser</key>
<true/>

<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

<!-- For Firebase Storage uploads, you may also need: -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Complete Info.plist Example #

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- Existing keys... -->
    
    <!-- MARK: - Permissions for File Uploads -->
    
    <!-- Photo Library -->
    <key>NSPhotoLibraryUsageDescription</key>
    <string>This app needs access to your photo library to upload images to the cloud</string>
    
    <!-- Camera -->
    <key>NSCameraUsageDescription</key>
    <string>This app needs camera access to take photos for upload</string>
    
    <!-- Microphone (for video) -->
    <key>NSMicrophoneUsageDescription</key>
    <string>This app needs microphone access for video uploads</string>
    
    <!-- File System Access -->
    <key>UISupportsDocumentBrowser</key>
    <true/>
    
    <key>LSSupportsOpeningDocumentsInPlace</key>
    <true/>
    
    <!-- Network Security (if using Firebase Storage) -->
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
    
    <!-- Firebase configuration (if not already present) -->
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>REVERSED_CLIENT_ID</string>
            </array>
        </dict>
    </array>
</dict>
</plist>

Using FilePicker with StorageQueue #

import 'package:file_picker/file_picker.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';

class UploadService {
  final StorageQueue storageQueue = StorageQueue();

  Future<String?> uploadPhoto() async {
    // Pick image from gallery (requires NSPhotoLibraryUsageDescription)
    final result = await FilePicker.platform.pickFiles(
      type: FileType.image,
      allowMultiple: false,
    );

    if (result == null || result.files.isEmpty) return null;

    final file = File(result.files.first.path!);
    final key = IdempotencyKey.generate();

    try {
      final url = await storageQueue.uploadFile(
        file: file,
        path: 'uploads/photos/${DateTime.now().millisecondsSinceEpoch}_${result.files.first.name}',
        idempotencyKey: key,
        onProgress: (progress) {
          print('Upload progress: ${(progress * 100).toStringAsFixed(1)}%');
        },
      );
      return url;
    } catch (e) {
      print('Upload failed: $e');
      return null;
    }
  }

  Future<String?> uploadVideo() async {
    // Pick video (requires NSPhotoLibraryUsageDescription)
    final result = await FilePicker.platform.pickFiles(
      type: FileType.video,
      allowMultiple: false,
    );

    if (result == null || result.files.isEmpty) return null;

    final file = File(result.files.first.path!);
    final key = IdempotencyKey.generate();

    try {
      final url = await storageQueue.uploadFile(
        file: file,
        path: 'uploads/videos/${DateTime.now().millisecondsSinceEpoch}_${result.files.first.name}',
        idempotencyKey: key,
        onProgress: (progress) {
          print('Upload progress: ${(progress * 100).toStringAsFixed(1)}%');
        },
      );
      return url;
    } catch (e) {
      print('Upload failed: $e');
      return null;
    }
  }

  Future<String?> uploadDocument() async {
    // Pick document (requires UISupportsDocumentBrowser)
    final result = await FilePicker.platform.pickFiles(
      type: FileType.any,
      allowMultiple: false,
    );

    if (result == null || result.files.isEmpty) return null;

    final file = File(result.files.first.path!);
    final key = IdempotencyKey.generate();

    try {
      final url = await storageQueue.uploadFile(
        file: file,
        path: 'uploads/documents/${DateTime.now().millisecondsSinceEpoch}_${result.files.first.name}',
        idempotencyKey: key,
        onProgress: (progress) {
          print('Upload progress: ${(progress * 100).toStringAsFixed(1)}%');
        },
      );
      return url;
    } catch (e) {
      print('Upload failed: $e');
      return null;
    }
  }
}

Camera Capture with StorageQueue #

import 'package:image_picker/image_picker.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';

class CameraUploadService {
  final StorageQueue storageQueue = StorageQueue();
  final ImagePicker _picker = ImagePicker();

  Future<String?> takePhotoAndUpload() async {
    // Requires NSCameraUsageDescription
    final XFile? photo = await _picker.pickImage(
      source: ImageSource.camera,
      maxWidth: 1920,
      maxHeight: 1080,
      imageQuality: 85,
    );

    if (photo == null) return null;

    final file = File(photo.path);
    final key = IdempotencyKey.generate();

    try {
      final url = await storageQueue.uploadFile(
        file: file,
        path: 'uploads/camera/${DateTime.now().millisecondsSinceEpoch}.jpg',
        idempotencyKey: key,
        onProgress: (progress) {
          print('Upload progress: ${(progress * 100).toStringAsFixed(1)}%');
        },
      );
      return url;
    } catch (e) {
      print('Upload failed: $e');
      return null;
    }
  }

  Future<String?> takeVideoAndUpload() async {
    // Requires NSCameraUsageDescription + NSMicrophoneUsageDescription
    final XFile? video = await _picker.pickVideo(
      source: ImageSource.camera,
      maxDuration: const Duration(seconds: 30),
    );

    if (video == null) return null;

    final file = File(video.path);
    final key = IdempotencyKey.generate();

    try {
      final url = await storageQueue.uploadFile(
        file: file,
        path: 'uploads/videos/${DateTime.now().millisecondsSinceEpoch}.mp4',
        idempotencyKey: key,
        onProgress: (progress) {
          print('Upload progress: ${(progress * 100).toStringAsFixed(1)}%');
        },
      );
      return url;
    } catch (e) {
      print('Upload failed: $e');
      return null;
    }
  }
}

Adding to the README.md #

You should add this section to the README:

## 📱 iOS Permissions

If your app uses `StorageQueue` to upload files (photos, videos, documents), add these permissions to `ios/Runner/Info.plist`:

```xml
<!-- Photo Library Access -->
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photo library to upload images</string>

<!-- Camera Access -->
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to take photos for upload</string>

<!-- Microphone Access (for video) -->
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for video uploads</string>

<!-- File System Access -->
<key>UISupportsDocumentBrowser</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

Which permissions to add based on your feature: #

Feature Required Permission
Upload photos from gallery NSPhotoLibraryUsageDescription
Take photo with camera NSCameraUsageDescription
Upload videos from gallery NSPhotoLibraryUsageDescription
Record video with camera NSCameraUsageDescription + NSMicrophoneUsageDescription
Upload any file from device UISupportsDocumentBrowser
Upload files from iCloud LSSupportsOpeningDocumentsInPlace

Example Usage #

final storageQueue = StorageQueue();

// Upload a photo from gallery
final photo = await FilePicker.platform.pickFiles(type: FileType.image);
if (photo != null) {
  final url = await storageQueue.uploadFile(
    file: File(photo.files.first.path!),
    path: 'uploads/${photo.files.first.name}',
    idempotencyKey: IdempotencyKey.generate(),
    onProgress: (p) => setState(() => _progress = p),
  );
}

---

## Summary

| Platform | Permission Required | When |
|----------|-------------------|------|
| **iOS** | `NSPhotoLibraryUsageDescription` | When picking from photo library |
| **iOS** | `NSCameraUsageDescription` | When taking photos/videos |
| **iOS** | `NSMicrophoneUsageDescription` | When recording videos with audio |
| **iOS** | `UISupportsDocumentBrowser` | When picking files from device |
| **iOS** | `LSSupportsOpeningDocumentsInPlace` | When opening files in place |
| **Android** | `READ_EXTERNAL_STORAGE` | When reading files (Android 12 and below) |
| **Android** | `CAMERA` | When using camera |
| **Android** | `RECORD_AUDIO` | When recording video with audio |