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
example/lib/main.dart
// example/lib/main.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_offline_sync/riverpod_offline_sync.dart';
import 'screens/chat_screen.dart';
import 'screens/todo_screen.dart';
import 'screens/upload_screen.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()));
}
/// Registers one handler per queue category this example app uses.
///
/// Todos and messages both go through a single `documents` category
/// whose payload carries an `op` field (`set` / `update` / `delete`) —
/// this mirrors how a real app might collapse several Firestore write
/// shapes into one handler rather than registering a separate category
/// per collection. Either approach works; see the README for the
/// alternative (one category per Firestore operation type).
void _registerHandlers() {
OfflineSyncLayer.instance.registerOperationHandler('documents',
(data) async {
final collection = data['collection'] as String;
final docId = data['docId'] as String;
final op = data['op'] as String;
final ref = FirebaseFirestore.instance.collection(collection).doc(docId);
switch (op) {
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: $op');
}
});
OfflineSyncLayer.instance.registerOperationHandler(
QueueCategory.messages.label, (data) async {
final collection = data['collection'] as String;
final docId = data['docId'] as String;
await FirebaseFirestore.instance
.collection(collection)
.doc(docId)
.set(Map<String, dynamic>.from(data['payload'] as Map));
});
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Offline Sync Demo',
theme: authTheme(),
home: const OfflineSyncScope(
// No remoteDataSource is passed here: this example only
// demonstrates offline WRITE queuing (push), which is what
// most apps need most of the time. For real bidirectional
// sync with conflict resolution, pass a FirestoreRemoteDataSource
// — see the README's "Pull-side sync" section.
config: SyncConfig(
syncImmediately: true,
autoSyncOnReconnect: true,
),
child: HomePage(),
),
);
}
}
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pendingCount = ref.watch(pendingItemsCountProvider);
return ConnectivityBanner(
child: OfflineToast(
child: Scaffold(
appBar: AppBar(
title: const Text('riverpod_offline_sync example'),
actions: [
if (pendingCount > 0)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Chip(
label: Text('$pendingCount pending'),
backgroundColor: AuthColors.yellow,
),
),
),
],
),
body: Stack(
children: [
ListView(
padding: const EdgeInsets.all(16),
children: [
_DemoTile(
icon: Icons.check_box_outlined,
title: 'Todos',
subtitle: 'Offline create / update / delete',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TodoScreen()),
),
),
_DemoTile(
icon: Icons.chat_bubble_outline,
title: 'Chat',
subtitle: 'Messages queue when offline',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ChatScreen()),
),
),
_DemoTile(
icon: Icons.upload_file_outlined,
title: 'File Upload',
subtitle: 'Pause / resume / cancel with progress',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UploadScreen()),
),
),
],
),
Positioned(
top: 8,
right: 8,
child: SyncStatusIndicator(),
),
if (kDebugMode)
Positioned(
bottom: 20,
right: 20,
child: FloatingActionButton.small(
heroTag: 'debug-panel-fab',
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const DebugPanel(),
),
child: const Icon(Icons.bug_report),
),
),
],
),
),
),
);
}
}
class _DemoTile extends StatelessWidget {
const _DemoTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}