riverpod_offline_sync 1.0.5
riverpod_offline_sync: ^1.0.5 copied to clipboard
Production-ready offline-first sync engine for Flutter super apps with Riverpod integration
๐ฆ riverpod_offline_sync #
A production-ready offline-first sync engine for Flutter with Riverpod state management, queue orchestration, conflict resolution, Firebase integration, retry handling, connectivity monitoring, and built-in offline UI components.
Table of Contents #
- Features
- What's New in v1.0.0
- Dependencies
- Quick Start with Firebase
- Complete Firebase Integration
- New in v1.0.0
- Complete Service Class Example
- Complete Todo App Example
- Architecture
- Core Concepts
- Providers
- Methods
- UI Components
- Configuration
- Metrics & Analytics
- Retry System
- Connectivity Monitoring
- Utilities
- Examples
- Debugging
- Troubleshooting
- FAQ
- License
โจ Features #
- ๐ Bi-directional sync (push + pull)
- ๐ฆ Persistent offline queue (Hive)
- โก Concurrent queue processing
- ๐ Smart retry with exponential backoff
- ๐ Connectivity-aware synchronization
- ๐ถ WiFi-only sync mode
- ๐ง Conflict resolution strategies
- ๐งพ Idempotency protection
- ๐ Sync metrics & analytics
- ๐ Debug inspection tools
- ๐จ Built-in UI widgets
- ๐ฅ Full Firebase integrations (Firestore, Storage, Auth)
- ๐งฉ Riverpod integration
- ๐ฑ Offline-first architecture
- ๐ Queue survives app restarts
- ๐ฏ Upload pause / resume / cancel
- ๐ Real-time sync progress streams
- ๐ Production-ready sync orchestration
๐ What's New in v1.0.0 #
- ๐ Queue Statistics API โ Get detailed queue insights (pending/failed/retrying counts, age, breakdowns)
- ๐๏ธ Sync Observer System โ Listen to sync events for analytics and monitoring
- ๐ฏ OfflineSyncScope Widget โ Simplified initialization with loading states
- ๐ง Improved Queue Trimming โ Smart priority-based queue management
- ๐ UUID-based IDs โ Better collision prevention for queue items
- ๐ Enhanced Progress Tracking โ Percentage-based upload progress
- ๐ Better Connectivity Handling โ Safe disposal and improved WiFi detection
- ๐ก๏ธ Conflict Detector Ignored Fields โ Skip timestamp fields in conflict detection
Dependencies #
Add the following to your pubspec.yaml:
dependencies:
riverpod_offline_sync: ^1.0.0
flutter_riverpod: ^2.5.0
firebase_core: ^2.24.0
cloud_firestore: ^4.17.0
firebase_storage: ^11.6.0
firebase_auth: ^4.17.0
hive_flutter: ^1.1.0
connectivity_plus: ^5.0.0
synchronized: ^3.1.0
http: ^1.1.0
Then run:
flutter pub get
Quick Start with Firebase #
1. Complete Setup in main() #
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase
await Firebase.initializeApp();
// Enable Firestore native offline persistence
await FirebaseFirestore.instance.settings =
const Settings(persistenceEnabled: true);
// Initialize offline sync layer
await OfflineSyncLayer.instance.initialize(
config: const SyncConfig(
autoSyncOnReconnect: true,
syncImmediately: true,
maxConcurrentOperations: 3,
enableMetrics: true,
enableDebugLogging: false,
),
);
// Register ALL operation handlers
_registerHandlers();
runApp(const ProviderScope(child: MyApp()));
}
2. Register All Operation Handlers #
void _registerHandlers() {
final sync = OfflineSyncLayer.instance;
// โ
CREATE / SET document handler
sync.registerOperationHandler('firestore_set', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.set(Map<String, dynamic>.from(data['payload']));
});
// โ
UPDATE document handler
sync.registerOperationHandler('firestore_update', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.update(Map<String, dynamic>.from(data['payload']));
});
// โ
DELETE document handler
sync.registerOperationHandler('firestore_delete', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.delete();
});
// โ
Batch write handler
sync.registerOperationHandler('firestore_batch', (data) async {
final batch = FirebaseFirestore.instance.batch();
final writes = List<Map<String, dynamic>>.from(data['writes']);
for (final write in writes) {
final docRef = FirebaseFirestore.instance
.collection(write['collection'])
.doc(write['docId']);
switch (write['type']) {
case 'set':
batch.set(docRef, write['data']);
break;
case 'update':
batch.update(docRef, write['data']);
break;
case 'delete':
batch.delete(docRef);
break;
}
}
await batch.commit();
});
// โ
Custom REST API handler
sync.registerOperationHandler('api_request', (data) async {
final response = await http.post(
Uri.parse(data['url']),
body: data['body'],
headers: data['headers'],
);
if (response.statusCode != 200) {
throw Exception('API request failed: ${response.statusCode}');
}
return response.body;
});
// โ
Analytics handler (low priority)
sync.registerOperationHandler('analytics', (data) async {
await AnalyticsService.trackEvent(
data['event_name'],
properties: data['properties'],
);
});
}
3. Wrap Your App #
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Offline-First App',
home: ConnectivityBanner(
child: OfflineToast(
child: HomePage(),
),
),
);
}
}
4. Submit Offline Operations #
// โ
CREATE document (works offline)
Future<void> createUser(String userId, String name, String email) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_set',
priority: QueuePriority.high.value,
idempotencyKey: 'set_user_${userId}_${DateTime.now().millisecondsSinceEpoch}',
data: {
'collection': 'users',
'docId': userId,
'payload': {
'name': name,
'email': email,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
},
},
);
}
// โ
UPDATE document (works offline)
Future<void> updateUser(String userId, Map<String, dynamic> updates) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_update',
priority: QueuePriority.normal.value,
idempotencyKey: IdempotencyKey.generate(),
data: {
'collection': 'users',
'docId': userId,
'payload': {
...updates,
'updatedAt': DateTime.now().toIso8601String(),
},
},
);
}
// โ
DELETE document (works offline)
Future<void> deleteUser(String userId) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_delete',
priority: QueuePriority.high.value,
idempotencyKey: 'delete_user_${userId}_${DateTime.now().millisecondsSinceEpoch}',
data: {
'collection': 'users',
'docId': userId,
},
);
}
Complete Firebase Integration #
Firestore Operations #
class OfflineFirestoreService {
static Future<void> submitOperation({
required String category,
required Map<String, dynamic> data,
QueuePriority priority = QueuePriority.normal,
String? customIdempotencyKey,
}) async {
await OfflineSyncLayer.instance.submitOperation(
category: category,
priority: priority.value,
idempotencyKey: customIdempotencyKey ?? IdempotencyKey.generate(),
data: data,
);
}
static Future<void> setDocument({
required String collection,
required String docId,
required Map<String, dynamic> data,
}) async {
await submitOperation(
category: 'firestore_set',
priority: QueuePriority.high,
data: {'collection': collection, 'docId': docId, 'payload': data},
);
}
static Future<void> updateDocument({
required String collection,
required String docId,
required Map<String, dynamic> updates,
}) async {
await submitOperation(
category: 'firestore_update',
priority: QueuePriority.normal,
data: {'collection': collection, 'docId': docId, 'payload': updates},
);
}
static Future<void> deleteDocument({
required String collection,
required String docId,
}) async {
await submitOperation(
category: 'firestore_delete',
priority: QueuePriority.high,
data: {'collection': collection, 'docId': docId},
);
}
static Future<void> batchWrite({
required List<Map<String, dynamic>> operations,
}) async {
await submitOperation(
category: 'firestore_batch',
priority: QueuePriority.high,
data: {'writes': operations},
);
}
}
// Usage
await OfflineFirestoreService.setDocument(
collection: 'products',
docId: 'prod_001',
data: {'name': 'Laptop', 'price': 999},
);
await OfflineFirestoreService.updateDocument(
collection: 'products',
docId: 'prod_001',
updates: {'price': 899},
);
await OfflineFirestoreService.batchWrite(
operations: [
{'type': 'update', 'collection': 'products', 'docId': 'prod_001', 'data': {'stock': 5}},
{'type': 'set', 'collection': 'orders', 'docId': 'order_001', 'data': {'productId': 'prod_001'}},
],
);
Firebase Storage Uploads #
final storageQueue = StorageQueue();
// Upload a file with full queue support
Future<void> uploadUserPhoto(File file, String userId) async {
final idempotencyKey = IdempotencyKey.generate();
await storageQueue.uploadFile(
file: file,
path: 'uploads/$userId/photo.jpg',
idempotencyKey: idempotencyKey,
onProgress: (percentage) {
print('Upload progress: ${(percentage * 100).toStringAsFixed(1)}%');
},
);
// Control the upload
storageQueue.pauseUpload(idempotencyKey);
storageQueue.resumeUpload(idempotencyKey);
storageQueue.cancelUpload(idempotencyKey);
}
// Upload multiple files
Future<void> uploadMultipleFiles(List<File> files, String userId) async {
for (final file in files) {
await storageQueue.uploadFile(
file: file,
path: 'uploads/$userId/${DateTime.now().millisecondsSinceEpoch}.jpg',
idempotencyKey: IdempotencyKey.generate(),
);
}
}
Firebase Auth Persistence #
final auth = AuthPersistence();
// Sign in (works offline - stores credentials)
await auth.signInWithEmail('email@example.com', 'password');
// Check auth state
final user = auth.currentUser;
if (user != null) {
print('Signed in as: ${user.email}');
}
// Set persistence type
await auth.setPersistence(AuthPersistenceType.local); // Keep logged in
await auth.setPersistence(AuthPersistenceType.session); // Until app closes
await auth.setPersistence(AuthPersistenceType.none); // Never persist
Real-time Firestore + Offline Queue #
// Provider for real-time Firestore data
final usersProvider = StreamProvider.autoDispose<List<User>>((ref) {
return FirebaseFirestore.instance
.collection('users')
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => User.fromFirestore(doc))
.toList());
});
class UserListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(usersProvider);
final pendingCount = ref.watch(pendingItemsCountProvider).valueOrNull ?? 0;
final isSyncing = ref.watch(isSyncingProvider);
return Scaffold(
appBar: AppBar(
title: Text('Users'),
actions: [
if (pendingCount > 0)
Stack(
children: [
Icon(Icons.sync),
Positioned(
right: 0,
top: 0,
child: Container(
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(minWidth: 16, minHeight: 16),
child: Text(
'$pendingCount',
style: TextStyle(color: Colors.white, fontSize: 10),
textAlign: TextAlign.center,
),
),
),
],
),
],
),
body: Stack(
children: [
usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(users[index].name),
subtitle: Text(users[index].email),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () async {
await OfflineFirestoreService.deleteDocument(
collection: 'users',
docId: users[index].id,
);
},
),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
if (isSyncing)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
],
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
await OfflineFirestoreService.setDocument(
collection: 'users',
docId: 'user_${DateTime.now().millisecondsSinceEpoch}',
data: {
'name': 'New User',
'email': 'new@example.com',
'createdAt': DateTime.now().toIso8601String(),
},
);
},
),
);
}
}
New in v1.0.0 #
Queue Statistics API #
// Get comprehensive queue statistics
final stats = await queueManager.getQueueStats();
print('Pending: ${stats.pendingCount}');
print('Failed: ${stats.failedCount}');
print('Retrying: ${stats.retryingCount}');
print('Oldest item age: ${stats.oldestItemAge.inMinutes} minutes');
print('Category breakdown: ${stats.categoryBreakdown}');
print('Priority breakdown: ${stats.priorityBreakdown}');
// Use with providers
final statsProvider = FutureProvider<QueueStats>((ref) async {
final manager = ref.watch(queueManagerProvider);
return await manager.getQueueStats();
});
Sync Observer System #
class MySyncObserver implements SyncObserver {
@override
void onSyncStarted() {
print('Sync started at ${DateTime.now()}');
}
@override
void onSyncCompleted() {
print('Sync completed successfully');
}
@override
void onSyncFailed(Object error) {
print('Sync failed: $error');
// Send to analytics
Analytics.track('sync_failed', {'error': error.toString()});
}
@override
void onProgressChanged(int current, int total) {
print('Progress: $current/$total');
// Update UI progress bar
}
@override
void onItemProcessed(String itemId, bool success) {
print('Item $itemId processed: ${success ? "success" : "failed"}');
}
}
// Register observer
final observer = MySyncObserver();
OfflineSyncLayer.instance.addObserver(observer);
// Don't forget to remove when done
OfflineSyncLayer.instance.removeObserver(observer);
// Analytics observer example
class AnalyticsSyncObserver implements SyncObserver {
@override
void onSyncStarted() {
Analytics.trackEvent('sync_started');
}
@override
void onSyncCompleted() {
Analytics.trackEvent('sync_completed');
}
@override
void onSyncFailed(Object error) {
Analytics.trackEvent('sync_failed', properties: {
'error': error.toString(),
});
}
@override
void onProgressChanged(int current, int total) {
final percentage = (current / total * 100).toStringAsFixed(1);
Analytics.trackEvent('sync_progress', properties: {
'percentage': percentage,
'current': current,
'total': total,
});
}
}
OfflineSyncScope Widget #
void main() {
runApp(
ProviderScope(
child: OfflineSyncScope(
config: SyncConfig.defaultConfig(),
loadingWidget: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Initializing offline sync...'),
],
),
),
),
onInitialized: () {
print('Offline sync ready!');
// Register handlers after initialization
_registerHandlers();
},
child: MyApp(),
),
),
);
}
Complete Service Class Example #
class OfflineSyncService {
static Future<void> initialize() async {
await Firebase.initializeApp();
await FirebaseFirestore.instance.settings =
const Settings(persistenceEnabled: true);
await OfflineSyncLayer.instance.initialize(
config: SyncConfig.defaultConfig(),
);
_registerHandlers();
}
static void _registerHandlers() {
final sync = OfflineSyncLayer.instance;
sync.registerOperationHandler('firestore_set', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.set(data['payload']);
});
sync.registerOperationHandler('firestore_update', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.update(data['payload']);
});
sync.registerOperationHandler('firestore_delete', (data) async {
await FirebaseFirestore.instance
.collection(data['collection'])
.doc(data['docId'])
.delete();
});
}
static Future<void> create(String collection, String id, Map<String, dynamic> data) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_set',
priority: QueuePriority.high.value,
idempotencyKey: IdempotencyKey.generate(),
data: {'collection': collection, 'docId': id, 'payload': data},
);
}
static Future<void> update(String collection, String id, Map<String, dynamic> updates) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_update',
priority: QueuePriority.normal.value,
idempotencyKey: IdempotencyKey.generate(),
data: {'collection': collection, 'docId': id, 'payload': updates},
);
}
static Future<void> delete(String collection, String id) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_delete',
priority: QueuePriority.high.value,
idempotencyKey: IdempotencyKey.generate(),
data: {'collection': collection, 'docId': id},
);
}
static Future<void> forceSync() async => OfflineSyncLayer.instance.sync();
static Future<int> getPendingCount() async {
final pending = await OfflineSyncLayer.instance.getPendingOperations();
return pending.length;
}
static Future<QueueStats> getQueueStats() async {
return await OfflineSyncLayer.instance.queueManager.getQueueStats();
}
static Future<void> clearQueue() async => OfflineSyncLayer.instance.clearQueue();
static Future<void> retryFailed(String id) async =>
OfflineSyncLayer.instance.retryFailedOperation(id);
}
Complete Todo App Example #
Todo Model #
class Todo {
final String id;
final String title;
final bool completed;
final DateTime createdAt;
final DateTime? updatedAt;
Todo({
required this.id,
required this.title,
this.completed = false,
required this.createdAt,
this.updatedAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'completed': completed,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
factory Todo.fromJson(Map<String, dynamic> json) => Todo(
id: json['id'],
title: json['title'],
completed: json['completed'] ?? false,
createdAt: DateTime.parse(json['createdAt']),
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
);
factory Todo.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
return Todo(
id: doc.id,
title: data['title'],
completed: data['completed'],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: data['updatedAt'] != null
? (data['updatedAt'] as Timestamp).toDate()
: null,
);
}
Todo copyWith({String? title, bool? completed}) => Todo(
id: id,
title: title ?? this.title,
completed: completed ?? this.completed,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
Todo Service #
class TodoService {
static Future<void> addTodo(Todo todo) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_set',
priority: QueuePriority.high.value,
idempotencyKey: 'todo_${todo.id}_${DateTime.now().millisecondsSinceEpoch}',
data: {
'collection': 'todos',
'docId': todo.id,
'payload': todo.toJson(),
},
);
}
static Future<void> updateTodo(Todo todo) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_update',
priority: QueuePriority.normal.value,
idempotencyKey: IdempotencyKey.generate(),
data: {
'collection': 'todos',
'docId': todo.id,
'payload': {
'completed': todo.completed,
'updatedAt': DateTime.now().toIso8601String(),
},
},
);
}
static Future<void> deleteTodo(String todoId) async {
await OfflineSyncLayer.instance.submitOperation(
category: 'firestore_delete',
priority: QueuePriority.high.value,
idempotencyKey: 'delete_todo_${todoId}_${DateTime.now().millisecondsSinceEpoch}',
data: {
'collection': 'todos',
'docId': todoId,
},
);
}
static Stream<List<Todo>> streamTodos() {
return FirebaseFirestore.instance
.collection('todos')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => Todo.fromFirestore(doc))
.toList());
}
static Future<void> forceSync() async => OfflineSyncLayer.instance.sync();
}
Todo List Page #
final todoListProvider = StreamProvider.autoDispose<List<Todo>>((ref) {
return TodoService.streamTodos();
});
class TodoListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final todosAsync = ref.watch(todoListProvider);
final pendingCount = ref.watch(pendingItemsCountProvider).valueOrNull ?? 0;
final isSyncing = ref.watch(isSyncingProvider);
return Scaffold(
appBar: AppBar(
title: Text('Offline Todo'),
actions: [
if (pendingCount > 0 || isSyncing)
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Row(
children: [
if (isSyncing)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
if (pendingCount > 0)
Padding(
padding: EdgeInsets.only(left: 4),
child: Text('$pendingCount'),
),
],
),
),
),
IconButton(
icon: Icon(Icons.sync),
onPressed: () async {
await TodoService.forceSync();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Syncing...')),
);
},
),
],
),
body: Stack(
children: [
todosAsync.when(
data: (todos) {
if (todos.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No todos yet', style: TextStyle(color: Colors.grey)),
SizedBox(height: 8),
Text('Add your first todo!', style: TextStyle(fontSize: 12)),
],
),
);
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return CheckboxListTile(
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
value: todo.completed,
onChanged: (completed) async {
await TodoService.updateTodo(todo.copyWith(completed: completed));
},
secondary: IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () async => TodoService.deleteTodo(todo.id),
),
);
},
);
},
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
SizedBox(height: 16),
Text('Error loading todos: $err'),
SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(todoListProvider),
child: Text('Retry'),
),
],
),
),
),
if (isSyncing)
Positioned(
top: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(),
),
],
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
final newTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: 'New Task ${DateTime.now().hour}:${DateTime.now().minute}',
completed: false,
createdAt: DateTime.now(),
);
await TodoService.addTodo(newTodo);
},
),
);
}
}
๐๏ธ Architecture #
UI Widgets
โ
Riverpod Providers (Real-time data)
โ
OfflineSyncLayer (Queue management)
โ
QueueManager (Orchestration)
โ
Retry / Conflict / Connectivity Systems
โ
Persistence Layer (Hive for queue + Firestore for data)
โ
Backend APIs / Firebase
๐ Package Structure #
lib/
โโโ core/
โ โโโ sync_layer.dart
โ โโโ sync_config.dart
โ โโโ sync_metrics.dart
โ โโโ sync_progress.dart
โ โโโ sync_state_machine.dart
โ โโโ sync_observer.dart (NEW in v1.0.0)
โ โโโ offline_sync_scope.dart (NEW in v1.0.0)
โโโ queue/
โ โโโ queue_manager.dart
โ โโโ queue_item.dart
โ โโโ queue_priority.dart
โ โโโ queue_category.dart
โ โโโ retry_strategy.dart
โ โโโ hive_registry.dart
โ โโโ queue_item_adapter.dart
โ โโโ queue_stats.dart (NEW in v1.0.0)
โโโ connectivity/
โโโ conflict/
โโโ firebase/
โ โโโ firestore_service.dart
โ โโโ storage_queue.dart
โ โโโ auth_persistence.dart
โโโ providers/
โโโ ui/
โโโ mixins/
โโโ utils/
โโโ riverpod_offline_sync.dart
Core Concepts #
Queue Priorities #
| Priority | Value | Use Cases |
|---|---|---|
| critical | 0 | Payments, KYC |
| high | 1 | Orders, Messages |
| normal | 2 | Profile updates |
| low | 3 | Analytics |
| background | 4 | Cache refresh |
Queue Categories #
enum QueueCategory {
uploads,
orders,
messages,
payments,
sync,
analytics,
media,
documents,
background,
}
Sync Strategies #
| Strategy | Description |
|---|---|
| auto | Automatic synchronization |
| manual | Manual user-triggered sync |
| background | Silent sync |
| pushOnly | Push local changes only |
| pullOnly | Pull remote changes only |
Conflict Resolution #
ConflictStrategy.serverWins
ConflictStrategy.clientWins
ConflictStrategy.merge
ConflictStrategy.lastWriteWins
ConflictStrategy.manualResolve
Example:
final resolver = ConflictResolver();
final resolved = await resolver.resolve(
local: localData,
remote: remoteData,
strategy: ConflictStrategy.merge,
);
Features:
- Deep merge support
- Nested map resolution
- List merging
- Duplicate removal
- Timestamp-based conflict handling
- Ignored fields support (NEW in v1.0.0)
Providers #
Sync Providers #
final offlineSyncLayerProvider
final syncStateProvider
final syncProgressProvider
final syncMetricsProvider
final isSyncingProvider
final syncStatusTextProvider
Queue Providers #
final queueManagerProvider
final pendingItemsProvider
final pendingItemsCountProvider
final queueBreakdownProvider
Connectivity Providers #
final connectivityMonitorProvider
final connectivityStatusProvider
final isConnectedProvider
Methods #
OfflineSyncLayer #
// Initialize
await OfflineSyncLayer.instance.initialize();
// Submit operation
await OfflineSyncLayer.instance.submitOperation(
category: 'orders',
priority: 1,
idempotencyKey: IdempotencyKey.generate(),
data: {'key': 'value'},
);
// Manual sync
await OfflineSyncLayer.instance.sync();
// Clear queue
await OfflineSyncLayer.instance.clearQueue();
// Get pending operations
final pending = await OfflineSyncLayer.instance.getPendingOperations();
// Retry failed operation
await OfflineSyncLayer.instance.retryFailedOperation('item_id');
// Check initialization
if (OfflineSyncLayer.instance.isInitialized) {
// Safe to use
}
// Observer management (NEW in v1.0.0)
OfflineSyncLayer.instance.addObserver(myObserver);
OfflineSyncLayer.instance.removeObserver(myObserver);
// Dispose
await OfflineSyncLayer.instance.dispose();
QueueManager #
final manager = QueueManager();
await manager.initialize();
await manager.enqueue(
category: 'orders',
priority: 1,
data: {},
);
await manager.processQueue(maxConcurrent: 3);
await manager.retryFailed('item_id');
// Get queue statistics (NEW in v1.0.0)
final stats = await manager.getQueueStats();
print('Failed items: ${stats.failedCount}');
print('Retrying items: ${stats.retryingCount}');
StorageQueue #
final storageQueue = StorageQueue();
// Upload file with progress (NEW in v1.0.0)
await storageQueue.uploadFile(
file: file,
path: 'uploads/image.jpg',
idempotencyKey: IdempotencyKey.generate(),
onProgress: (percentage) {
print('Progress: ${(percentage * 100).toStringAsFixed(1)}%');
},
);
// Control upload
storageQueue.pauseUpload('key');
storageQueue.resumeUpload('key');
storageQueue.cancelUpload('key');
UI Components #
ConnectivityBanner #
ConnectivityBanner(
child: HomePage(),
)
SyncStatusIndicator #
SyncStatusIndicator(
showAsFloatingAction: true,
)
SyncProgressBar #
SyncProgressBar(
showDetails: true,
)
OfflineToast #
OfflineToast(
child: HomePage(),
)
DebugPanel #
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const DebugPanel(),
);
Features: Queue inspection, sync metrics, connectivity status, retry operations, queue breakdown, manual sync controls.
Configuration #
const config = SyncConfig(
autoSyncOnReconnect: true,
syncImmediately: true,
autoSyncInterval: Duration(minutes: 15),
maxRetries: 5,
initialRetryDelay: Duration(seconds: 2),
maxConcurrentOperations: 3,
enableMetrics: true,
enableDebugLogging: false,
syncOnWiFiOnly: false,
maxQueueSize: 1000,
conflictStrategy: ConflictStrategy.merge, // NEW in v1.0.0
);
Predefined Configurations:
SyncConfig.defaultConfig() // Balanced
SyncConfig.aggressive() // Fast sync, more battery
SyncConfig.batteryFriendly() // Less frequent sync
SyncConfig.wifiOnly() // Only sync on WiFi
Metrics & Analytics #
final metrics = OfflineSyncLayer.instance.metrics;
print('Success rate: ${metrics.successRatePercentage}%');
print('Total syncs: ${metrics.totalSyncs}');
print('Failed syncs: ${metrics.failedSyncs}');
print('Avg duration: ${metrics.averageSyncDuration}');
Retry System #
final delay = BackoffCalculator.calculateNextRetry(attempt: 3);
// Returns: Duration(seconds: 8) for attempt 3 with base delay 2
Features: Exponential backoff, delayed retry scheduling, retry tracking, configurable retry count.
Connectivity Monitoring #
final connected = OfflineSyncLayer.instance.connectivityMonitor.isConnected;
final isWifi = await OfflineSyncLayer.instance.connectivityMonitor.isWifiConnected;
// Listen to changes
OfflineSyncLayer.instance.connectivityMonitor.onConnectivityChanged.listen((status) {
print('Connectivity: $status');
});
Utilities #
Idempotency Keys #
// Auto-generate
final key = IdempotencyKey.generate();
// Custom key
final customKey = 'user_${userId}_action_${timestamp}';
Logger #
OfflineLogger.isEnabled = true;
OfflineLogger.info('Sync started');
OfflineLogger.error('Sync failed: $error');
OfflineLogger.debug('Queue processed: ${items.length} items');
Backoff Calculator #
final delay = BackoffCalculator.calculateNextRetry(3);
// Exponential backoff: 2^3 = 8 seconds
Examples #
Offline Orders #
await OfflineSyncLayer.instance.submitOperation(
category: QueueCategory.orders.label,
priority: QueuePriority.high.value,
idempotencyKey: IdempotencyKey.generate(),
data: {
'product': 'Laptop',
'quantity': 1,
'price': 999.99,
'userId': currentUser.id,
},
);
Chat Messages #
await OfflineSyncLayer.instance.submitOperation(
category: QueueCategory.messages.label,
priority: QueuePriority.high.value,
idempotencyKey: IdempotencyKey.generate(),
data: {
'message': 'Hello!',
'recipientId': 'user456',
'timestamp': DateTime.now().toIso8601String(),
},
);
Analytics Tracking #
await OfflineSyncLayer.instance.submitOperation(
category: 'analytics',
priority: QueuePriority.low.value,
idempotencyKey: IdempotencyKey.generate(),
data: {
'event_name': 'button_click',
'properties': {
'button': 'add_todo',
'screen': 'home',
},
'timestamp': DateTime.now().toIso8601String(),
},
);
Debugging #
Enable Debug Logging #
OfflineLogger.isEnabled = true;
Show Debug Panel #
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) => const DebugPanel(),
);
Manual Inspection #
// Get pending operations
final operations = await OfflineSyncLayer.instance.getPendingOperations();
print('Pending: ${operations.length}');
// Get queue statistics (NEW in v1.0.0)
final stats = await OfflineSyncLayer.instance.queueManager.getQueueStats();
print('Failed: ${stats.failedCount}');
print('Retrying: ${stats.retryingCount}');
// Get metrics
final metrics = OfflineSyncLayer.instance.metrics;
print('Success rate: ${metrics.successRatePercentage}%');
// Check connectivity
final isConnected = OfflineSyncLayer.instance.connectivityMonitor.isConnected;
print('Connected: $isConnected');
// Check initialization
final isInitialized = OfflineSyncLayer.instance.isInitialized;
print('Initialized: $isInitialized');
Troubleshooting #
โ HiveError: A Hive box with name 'offline_queue' already exists
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
await OfflineSyncLayer.instance.initialize();
runApp(const ProviderScope(child: MyApp()));
}
โ Hive has not been initialized
await Hive.initFlutter();
await OfflineSyncLayer.instance.initialize();
โ No Firebase App '[DEFAULT]' has been created
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // Initialize first
await OfflineSyncLayer.instance.initialize();
runApp(const ProviderScope(child: MyApp()));
}
โ Firestore persistence enabled too late
await Firebase.initializeApp();
await FirebaseFirestore.instance.settings =
const Settings(persistenceEnabled: true); // Before sync layer
await OfflineSyncLayer.instance.initialize();
โ Queue Stuck Processing
// 1. Force sync
await OfflineSyncLayer.instance.sync();
// 2. Verify handler is registered
OfflineSyncLayer.instance.registerOperationHandler('your_category', ...);
// 3. Check WiFi-only mode
if (syncConfig.syncOnWiFiOnly) {
final isWifi = await OfflineSyncLayer.instance.connectivityMonitor.isWifiConnected;
if (!isWifi) print('WiFi-only mode active');
}
// 4. Retry manually
await OfflineSyncLayer.instance.retryFailedOperation('item_id');
โ Duplicate Operations
await OfflineSyncLayer.instance.submitOperation(
category: 'orders',
priority: 1,
data: orderData,
idempotencyKey: 'unique_order_${orderData['id']}', // Custom key
);
โ OfflineSyncLayer not initialized
if (OfflineSyncLayer.instance.isInitialized) {
await OfflineSyncLayer.instance.submitOperation(...);
} else {
print('Sync layer not ready yet');
}
โ Queue Statistics Not Updating (NEW in v1.0.0)
// Force refresh stats
final stats = await OfflineSyncLayer.instance.queueManager.getQueueStats();
// Or invalidate provider
ref.invalidate(pendingItemsCountProvider);
โ Observer Not Receiving Events (NEW in v1.0.0)
// Ensure observer is added after initialization
await OfflineSyncLayer.instance.initialize();
OfflineSyncLayer.instance.addObserver(myObserver);
Debug Checklist #
- โ
OfflineSyncLayer.instance.isInitializedistrue - โ Handlers registered for all categories
- โ Firebase initialized before sync layer
- โ Firestore persistence enabled before sync layer
- โ Connectivity monitor working
- โ Queue has items
- โ No console errors
- โ Hive initialized
- โ Unique idempotency keys used
- โ WiFi-only mode not blocking (if enabled)
FAQ #
Q: Does it work offline? A: Yes. Queue operations persist locally using Hive and sync automatically when online.
Q: What happens if the app restarts? A: Queue data persists using Hive and resumes automatically.
Q: Does it support Firebase? A: Yes. Full Firestore, Storage, and Auth integrations are included.
Q: Can I use custom backends? A: Yes. Works with REST APIs, GraphQL, or any backend.
Q: How are duplicates prevented? A: Idempotency keys prevent duplicate operations.
Q: Does it support large uploads? A: Yes. Includes pause, resume, cancel, and progress tracking.
Q: Is it Riverpod-only? A: Core sync system works independently, but Riverpod integration is included.
Q: Do I need Firestore persistence enabled?
A: Recommended for a complete offline-first experience. It works alongside riverpod_offline_sync.
Q: How does this compare to Firebase's offline?
A: Firebase handles query caching; riverpod_offline_sync handles operation queuing with retries, conflict resolution, and full control.
Q: Can I use both together?
A: Yes! Use Firestore native offline for reads and riverpod_offline_sync for critical writes needing control.
Q: What's new in v1.0.0? A: Queue statistics API, sync observer system, OfflineSyncScope widget, improved queue trimming, UUID-based IDs, enhanced progress tracking, and better connectivity handling.
๐ฏ Handler Registration Checklist #
Required handlers to register in main():
- โ
firestore_setโ For create/set operations - โ
firestore_updateโ For update operations - โ
firestore_deleteโ For delete operations - โ
firestore_batchโ For batch writes (optional) - โ
api_requestโ For custom API endpoints - โ
analyticsโ For analytics tracking
No handler needed for:
- โ
Firebase Storage uploads (uses
StorageQueue) - โ Firestore real-time listeners (native)
๐ Package Comparison #
| Feature | Firebase Only | With riverpod_offline_sync |
|---|---|---|
| Offline reads | โ Yes (cached queries) | โ Yes (via Firestore) |
| Offline writes | โ Basic queue | โ Full queue control |
| Pause/Resume operations | โ No | โ Yes |
| Conflict resolution | โ Last write wins | โ Multiple strategies |
| Retry logic | โ Basic | โ Customizable |
| Queue inspection | โ No | โ Debug panel + metrics |
| Queue Statistics | โ No | โ Yes (v1.0.0) |
| Sync Observers | โ No | โ Yes (v1.0.0) |
| Idempotency | โ Manual | โ Built-in |
| Progress tracking | โ No | โ Real-time streams |
| Upload control | โ No | โ Pause/Resume/Cancel |
License #
MIT License โ see LICENSE file for details.
โค๏ธ Built For Offline-First Flutter Apps #
Reliable synchronization for super apps, delivery apps, chat apps, POS systems, CRM apps, warehouse systems, social apps, field-service apps, and media upload apps.
Perfect for Firebase offline-first development! ๐