offline_first_sync

An intelligent offline-first data management package for Flutter that automatically syncs when online, handles conflicts, and provides visual indicators of sync status.

Features

  • 🔄 Automatic Sync: Intelligent syncing when network becomes available
  • 📱 Offline-First: All operations work offline with local storage
  • Conflict Resolution: Multiple strategies for handling sync conflicts
  • 🎨 Visual Indicators: Beautiful UI components for sync status
  • 🗄️ Local Storage: SQLite-based local persistence
  • 🌐 Network Aware: Automatic network connectivity detection
  • 🔧 Customizable: Flexible sync intervals and strategies

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  offline_first_sync: ^1.0.0

Quick Start

1. Initialize SyncManager

import 'package:offline_first_sync/offline_first_sync.dart';

final syncClient = HttpSyncClient(
  baseUrl: 'https://your-api.com/api',
  headers: {'Authorization': 'Bearer your-token'},
);

final syncManager = SyncManager(
  client: syncClient,
  syncInterval: Duration(minutes: 5),
);

2. Add Sync Items

// Create a new item
await syncManager.addSyncItem(
  entityType: 'todos',
  entityId: 'todo-123',
  data: {'title': 'Buy groceries', 'completed': false},
  action: SyncAction.create,
);

// Update an existing item
await syncManager.addSyncItem(
  entityType: 'todos',
  entityId: 'todo-123',
  data: {'title': 'Buy groceries', 'completed': true},
  action: SyncAction.update,
);

// Delete an item
await syncManager.addSyncItem(
  entityType: 'todos',
  entityId: 'todo-123',
  data: {},
  action: SyncAction.delete,
);

3. Monitor Sync Status

StreamBuilder<SyncStatus>(
  stream: syncManager.statusStream,
  builder: (context, snapshot) {
    final status = snapshot.data;
    return SyncStatusIndicator(
      status: status,
      onTap: () => showSyncDetails(),
    );
  },
)

4. Handle Conflicts

syncManager.conflictsStream.listen((conflicts) {
  for (final conflict in conflicts) {
    // Show conflict resolution UI
    showConflictResolutionDialog(conflict);
  }
});

// Resolve conflict
await syncManager.resolveConflict(
  conflictItem.id,
  ConflictResolutionStrategy.clientWins,
);

Core Classes

SyncManager

The main class that orchestrates offline-first synchronization.

final syncManager = SyncManager(
  client: syncClient,        // Required: Your sync client implementation
  storage: localStorage,     // Optional: Custom storage implementation
  syncInterval: Duration(minutes: 5), // Optional: Auto-sync interval
);

SyncItem

Represents a data change that needs to be synchronized.

final syncItem = SyncItem.create(
  entityType: 'users',
  entityId: 'user-456',
  data: {'name': 'John Doe', 'email': 'john@example.com'},
  action: SyncAction.update,
);

SyncStatus

Provides comprehensive information about the current sync state.

class SyncStatus {
  final bool isOnline;        // Network connectivity status
  final bool isSyncing;       // Whether sync is currently in progress
  final int pendingCount;     // Number of items waiting to sync
  final int failedCount;      // Number of items that failed to sync
  final int conflictCount;    // Number of items with conflicts
  final DateTime? lastSyncTime; // When the last sync completed
  final String? errorMessage;  // Last error message, if any
}

ConflictResolution

Represents the resolution of a sync conflict between local and server data.

class ConflictResolution {
  final String syncItemId;                    // ID of the sync item that had the conflict
  final ConflictResolutionStrategy strategy;  // Strategy used to resolve the conflict
  final Map<String, dynamic>? resolvedData;   // The final resolved data after conflict resolution
  final DateTime resolvedAt;                  // Timestamp when the conflict was resolved
}

Creating a ConflictResolution

final resolution = ConflictResolution(
  syncItemId: 'sync-item-123',
  strategy: ConflictResolutionStrategy.merge,
  resolvedData: {'merged': 'data', 'from': 'both sources'},
  resolvedAt: DateTime.now(),
);

UI Components

SyncStatusIndicator

A compact status indicator showing current sync state:

SyncStatusIndicator(
  status: syncStatus,
  showText: true,              // Show status text alongside icon
  onTap: () => showSyncDetails(), // Callback when tapped
)

SyncFab

A floating action button for manual sync:

SyncFab(
  status: syncStatus,
  onPressed: () => syncManager.sync(),
  tooltip: 'Sync now',
)

Conflict Resolution Strategies

The package provides several built-in strategies for resolving sync conflicts:

ConflictResolutionStrategy.clientWins

Always uses the local (client-side) data, discarding server changes.

await syncManager.resolveConflict(
  conflictItemId,
  ConflictResolutionStrategy.clientWins,
);

ConflictResolutionStrategy.serverWins

Always uses the server data, discarding local changes.

await syncManager.resolveConflict(
  conflictItemId,
  ConflictResolutionStrategy.serverWins,
);

ConflictResolutionStrategy.lastWriteWins

Uses the data with the most recent timestamp.

await syncManager.resolveConflict(
  conflictItemId,
  ConflictResolutionStrategy.lastWriteWins,
);

ConflictResolutionStrategy.merge

Automatically merges both versions using a built-in merge algorithm.

await syncManager.resolveConflict(
  conflictItemId,
  ConflictResolutionStrategy.merge,
);

ConflictResolutionStrategy.manual

Allows custom resolution with user-provided data.

await syncManager.resolveConflict(
  conflictItemId,
  ConflictResolutionStrategy.manual,
  customData: {'manually': 'resolved', 'data': 'here'},
);

Custom Sync Client

Implement your own sync client by extending the SyncClient abstract class:

class CustomSyncClient implements SyncClient {
  @override
  Future<SyncResponse> syncItem(SyncItem item) async {
    // Your custom sync logic here
    try {
      // Perform the sync operation
      final result = await performCustomSync(item);
      
      return SyncResponse(
        success: true,
        data: result,
      );
    } on ConflictException catch (e) {
      return SyncResponse(
        success: false,
        hasConflict: true,
        serverData: e.serverData,
        serverVersion: e.version,
      );
    } catch (e) {
      return SyncResponse(
        success: false,
        error: e.toString(),
      );
    }
  }

  @override
  Future<Map<String, dynamic>?> fetchLatestData(
    String entityType,
    String entityId
  ) async {
    // Fetch latest data from your backend
    return await getDataFromBackend(entityType, entityId);
  }
}

Advanced Usage

Custom Storage

final customStorage = LocalStorage();
final syncManager = SyncManager(
  client: syncClient,
  storage: customStorage,
);

Monitoring Sync Operations

// Listen to sync status changes
syncManager.statusStream.listen((status) {
  print('Sync status: ${status.isOnline ? 'Online' : 'Offline'}');
  print('Pending items: ${status.pendingCount}');
  print('Failed items: ${status.failedCount}');
  print('Conflicts: ${status.conflictCount}');
});

// Listen to conflict events
syncManager.conflictsStream.listen((conflicts) {
  print('Active conflicts: ${conflicts.length}');
  for (final conflict in conflicts) {
    print('Conflict in ${conflict.entityType}:${conflict.entityId}');
  }
});

Retry Failed Items

await syncManager.retryFailed();

Clear Sync Queue

await syncManager.clearSyncQueue();

Manual Sync

await syncManager.sync();

Error Handling

syncManager.statusStream.listen((status) {
  if (status.errorMessage != null) {
    // Handle sync errors
    showErrorDialog(status.errorMessage!);
  }
});

Sync States

Each sync item can be in one of several states:

  • SyncState.pending: Waiting to be synchronized
  • SyncState.syncing: Currently being synchronized
  • SyncState.synced: Successfully synchronized (item removed from queue)
  • SyncState.failed: Synchronization failed (will retry)
  • SyncState.conflict: Conflict detected, requires resolution

Sync Actions

Three types of sync actions are supported:

  • SyncAction.create: Create a new entity on the server
  • SyncAction.update: Update an existing entity on the server
  • SyncAction.delete: Delete an entity from the server

Best Practices

1. Entity Design

Use consistent entity types and IDs across your application:

// Good: Consistent naming
await syncManager.addSyncItem(
  entityType: 'todos',
  entityId: todo.id,
  data: todo.toMap(),
  action: SyncAction.update,
);

2. Data Serialization

Ensure your data is JSON-serializable:

// Good: Simple, serializable data
final data = {
  'title': 'Task title',
  'completed': false,
  'createdAt': DateTime.now().toIso8601String(),
};

3. Conflict Prevention

Design your data model to minimize conflicts:

// Include version or timestamp fields
final data = {
  'title': 'Updated title',
  'lastModified': DateTime.now().toIso8601String(),
  'version': currentVersion + 1,
};

4. Error Monitoring

Always monitor sync status for errors:

StreamBuilder<SyncStatus>(
  stream: syncManager.statusStream,
  builder: (context, snapshot) {
    final status = snapshot.data;
    if (status?.hasIssues == true) {
      return ErrorIndicator(status: status);
    }
    return SyncStatusIndicator(status: status);
  },
)

API Reference

SyncManager Methods

  • addSyncItem(): Add an item to the sync queue
  • sync(): Manually trigger synchronization
  • resolveConflict(): Resolve a conflict with specified strategy
  • retryFailed(): Retry all failed sync items
  • clearSyncQueue(): Clear all pending sync items
  • dispose(): Clean up resources

SyncManager Properties

  • statusStream: Stream of sync status updates
  • conflictsStream: Stream of conflict notifications
  • currentStatus: Current sync status

Example

See the /example folder for a complete todo app implementation using offline_first_sync.

Contributing

Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Libraries

offline_first_sync