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

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

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