riverpod_offline_sync 2.0.0
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).
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
- What this package actually does
- Installation
- Quick start
- Core concepts
- Riverpod integration
- UI components
- Examples
- Debugging and observability
- Testing
- Known limitations
- Breaking changes from 1.x.x
- API reference
- License
Status #
- Compiles cleanly (
flutter analyze) and the full test suite passes (flutter test) on the versions pinned inpubspec.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 #
- Storage uploads are not resumable across an app-process restart.
If the app is killed mid-upload, the in-flight
UploadTaskreference 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. FirestoreRemoteDataSource.getLocalDatadoes 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 customRemoteDataSourceif you need different semantics.- 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
uidin your owndatapayload if you need this. - 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. FirestoreRemoteDataSourcealways treats Firestore itself as the local cache via its native offline persistence. Implement your ownRemoteDataSourceif you need a separate local store.- 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.codeand rethrow a marker yourRetryStrategytreats as non-retryable) — it is not built in. - Not yet verified on a real device or real Firebase project — see Status.
Breaking changes from 1.x.x #
ConflictResolver.resolve()withConflictStrategy.manualResolvenow throwsManualResolutionRequiredException(carriesconflictingFields,local,remote) instead of a plainException.QueueStats.failedCountis deprecated in favor ofQueueStats.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. OfflineSyncLayerandQueueManagerare no longer hard-coded singletons —OfflineSyncLayer()/QueueManager()are public constructors now..instancestill works the same as before.- Pull sync requires
RemoteDataSource, passed toinitialize(). Default behavior (no-op pull) is unchanged if you don't pass one. OfflineSyncLayer.dispose()no longer always disposes itsQueueManager. If you didn't passqueueManager:to the constructor, it defaults to the sharedQueueManager.instance, anddispose()now leaves that shared instance running. If you did pass your own,dispose()still disposes it by default. Override either way withdisposeQueueManager: true/false.hive_registry.dart,queue_item_adapter.dart, andbackoff_calculator.dartare no longer exported from the package barrel — import them by direct path if you need them.SyncObservergainedonQueueItemOutcome(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 |