riverpod_offline_sync 1.0.3 copy "riverpod_offline_sync: ^1.0.3" to clipboard
riverpod_offline_sync: ^1.0.3 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.

pub version license Flutter


Table of Contents #


โœจ 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

Dependencies #

Add the following to your pubspec.yaml:

dependencies:
  riverpod_offline_sync: ^0.1.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,
  );

  // Control the upload
  storageQueue.pauseUpload(idempotencyKey);
  storageQueue.resumeUpload(idempotencyKey);
  storageQueue.cancelUpload(idempotencyKey);

  // Track progress
  storageQueue.progressStream.listen((progress) {
    if (progress.key == idempotencyKey) {
      print('Upload progress: ${progress.percentage}%');
      print('Uploaded: ${progress.bytesUploaded}/${progress.totalBytes}');
    }
  });
}

// 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}');
}

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(),
            },
          );
        },
      ),
    );
  }
}

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<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/
 โ”œโ”€โ”€ queue/
 โ”œโ”€โ”€ 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

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
}

// 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');

StorageQueue #

final storageQueue = StorageQueue();

// Upload file
await storageQueue.uploadFile(
  file: file,
  path: 'uploads/image.jpg',
  idempotencyKey: IdempotencyKey.generate(),
);

// Control upload
storageQueue.pauseUpload('key');
storageQueue.resumeUpload('key');
storageQueue.cancelUpload('key');

// Track progress
storageQueue.progressStream.listen((progress) {
  print('Progress: ${progress.percentage}%');
});

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,
);

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 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');
}

Debug Checklist #

  • โŒ OfflineSyncLayer.instance.isInitialized is true
  • โŒ 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.


๐ŸŽฏ 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
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! ๐Ÿš€