Offline Sync Kit
Offline Sync Kit is a comprehensive Flutter package designed to solve one of the most challenging aspects of mobile app development: reliable data synchronization between local device storage and remote APIs, especially in environments with intermittent connectivity.
Support me to maintain this plugin continously with a cup of coffee.
Overview
This package provides a robust framework for storing different types of data locally while automatically managing their synchronization with a remote server. Whether your users are filling out forms, creating chat messages, updating records, or changing settings, Offline Sync Kit ensures that their data remains accessible offline and properly synchronized when connectivity is restored.
The Problem Solved
Many mobile applications need to function seamlessly regardless of network conditions. Users expect to continue using apps even when offline, and they expect their changes to persist and eventually synchronize with backend systems without data loss.
Traditional approaches to this problem often involve writing custom synchronization logic for each data type, managing complex conflict resolution, and monitoring network connectivity - all of which require significant development effort and are prone to subtle bugs.
Offline Sync Kit abstracts away these challenges by providing:
- A unified model for defining any type of synchronized data
- Automatic local storage with SQLite-based persistence
- Intelligent upload queuing and retry mechanisms
- Network state monitoring with appropriate action handling
- Configurable synchronization strategies with bidirectional sync capability
- Conflict detection and resolution mechanisms
- Optimized data transfer with delta synchronization
- Enhanced security with optional data encryption
- WebSocket support for real-time data synchronization
Key Architectural Components
The package is built around several key components:
- SyncModel: The base class for all synchronizable data models, featuring standardized fields for tracking synchronization status, timestamps, and error handling
- OfflineSyncManager: The main entry point for the package that orchestrates synchronization operations
- StorageService: Manages local data persistence with transactional support
- SyncEngine: Handles the core synchronization logic with configurable strategies
- NetworkClient: Provides a flexible API client with HTTP and WebSocket implementations
- ConnectivityService: Monitors network state and triggers appropriate actions
- ConflictResolutionHandler: Manages different strategies for resolving data conflicts
- WebSocketConnectionManager: Handles WebSocket connection lifecycle and real-time events
- SyncEventMapper: Customizes event mappings between WebSocket and application events
- WebSocketConfig: Enables customization of WebSocket behavior and messages
Features
-
Core Features:
- Offline data storage and synchronization
- Flexible and extensible structure
- Works with different data types
- Automatic synchronization
- Internet connection monitoring
- Synchronization status tracking
- Exponential backoff for failed requests
- Customizable synchronization policies
-
Advanced Features:
- Delta Synchronization: Only sync changed fields to improve bandwidth efficiency
- Advanced Conflict Resolution: Multiple strategies including server-wins, client-wins, last-update-wins, and custom handlers
- Optional Data Encryption: Secure storage of sensitive information with configurable encryption keys
- Performance Optimizations: Batched synchronization and prioritized sync queue management
- Extended Configuration Options: Flexible sync intervals, batch size settings, and bidirectional sync controls
- Custom Repository Injection: Ability to inject your own repository implementation without forking the package
- Model Factory Access: Built-in access to registered model factories
- Flexible Bidirectional Sync: Enhanced support for fetching and pulling data from the server
- Robust Error Handling: Improved handling of unexpected API responses
- Customizable Synchronization Strategies
- Fine-grained control over delete operations (optimistic or wait-for-remote)
- Configurable data fetching strategies (background sync, remote-first, local with fallback, local-only)
- Customizable save operations (optimistic-local or require-remote)
- Generic result types for improved type safety
- Enhanced query capabilities with flexible parameters
-
WebSocket Features:
- Real-time Synchronization: Immediate data updates via WebSocket connections
- Pub/Sub Channel System: Subscribe to specific data channels for targeted updates
- Automatic Reconnection: Smart reconnection with exponential backoff on connection loss
- Connection State Management: Monitor connection states and react accordingly
- Highly Customizable: Fully customizable message formats, event names, and behavior
- Event Streaming: Track and process synchronization events in real-time
- Compatible with REST APIs: Use alongside or instead of traditional HTTP endpoints
-
REST API Customization
- Type-Safe Request Method: Uses
RestMethod
enum instead of strings for method types - Dynamic URL Parameters: Replace placeholders like
{id}
in URLs with actual values - Request Timeouts: Configure custom timeout durations for specific request types
- Automatic Retry: Set automatic retry count for failed requests
- Response Transformers: Apply custom transformations to API responses
- URL Processing: Smart handling of relative vs absolute URLs
- Enhanced Error Handling: Better timeout and retry handling
- Type-Safe Request Method: Uses
Installation
To add the package to your project, add the following lines to your pubspec.yaml
file:
dependencies:
offline_sync_kit: ^1.5.3
Usage
1. Creating a Data Model
First, derive your synchronizable data model from the SyncModel
class:
import 'package:offline_sync_kit/offline_sync_kit.dart';
class Todo extends SyncModel {
final String title;
final String description;
final bool isCompleted;
final int priority;
// Track changed fields for delta sync
final Set<String> changedFields;
Todo({
super.id,
super.createdAt,
super.updatedAt,
super.isSynced,
super.syncError,
super.syncAttempts,
required this.title,
this.description = '',
this.isCompleted = false,
this.priority = 1,
this.changedFields = const {},
});
@override
String get endpoint => 'todos';
@override
String get modelType => 'todo';
// For Appwrite or custom APIs with specific request requirements
@override
RestRequests? get restRequests => RestRequests(
// Customize GET requests
get: RestRequest(
method: RestMethod.get,
url: 'custom/endpoint/path',
responseDataKey: 'documents', // Extract data from this key in response
),
// Customize POST requests
post: RestRequest(
method: RestMethod.post,
url: 'custom/create/endpoint',
topLevelKey: 'document', // Wrap request body in this key
supplementalTopLevelData: {'collectionId': 'your-collection-id'}, // Add extra data
),
);
@override
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'priority': priority,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'isSynced': isSynced,
'syncError': syncError,
'syncAttempts': syncAttempts,
'changedFields': changedFields.toList(),
};
}
// Support for delta synchronization
Map<String, dynamic> toJsonDelta() {
final Map<String, dynamic> delta = {'id': id};
if (changedFields.contains('title')) delta['title'] = title;
if (changedFields.contains('description')) delta['description'] = description;
if (changedFields.contains('isCompleted')) delta['isCompleted'] = isCompleted;
if (changedFields.contains('priority')) delta['priority'] = priority;
return delta;
}
@override
Todo copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
bool? isSynced,
String? syncError,
int? syncAttempts,
String? title,
String? description,
bool? isCompleted,
int? priority,
Set<String>? changedFields,
}) {
return Todo(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isSynced: isSynced ?? this.isSynced,
syncError: syncError ?? this.syncError,
syncAttempts: syncAttempts ?? this.syncAttempts,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
priority: priority ?? this.priority,
changedFields: changedFields ?? this.changedFields,
);
}
// Helper methods for updating fields
Todo updateTitle(String newTitle) {
final updatedFields = Set<String>.from(changedFields)..add('title');
return copyWith(title: newTitle, changedFields: updatedFields);
}
Todo updateDescription(String newDescription) {
final updatedFields = Set<String>.from(changedFields)..add('description');
return copyWith(description: newDescription, changedFields: updatedFields);
}
Todo updateCompletionStatus(bool completed) {
final updatedFields = Set<String>.from(changedFields)..add('isCompleted');
return copyWith(isCompleted: completed, changedFields: updatedFields);
}
Todo updatePriority(int newPriority) {
final updatedFields = Set<String>.from(changedFields)..add('priority');
return copyWith(priority: newPriority, changedFields: updatedFields);
}
factory Todo.fromJson(Map<String, dynamic> json) {
final changedFieldsList = json['changedFields'] as List<dynamic>?;
final changedFields = changedFieldsList != null
? Set<String>.from(changedFieldsList.map((e) => e as String))
: <String>{};
return Todo(
id: json['id'] as String?,
title: json['title'] as String,
description: json['description'] as String? ?? '',
isCompleted: json['isCompleted'] as bool? ?? false,
priority: json['priority'] as int? ?? 1,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: null,
isSynced: json['isSynced'] as bool? ?? false,
syncError: json['syncError'] as String? ?? '',
syncAttempts: json['syncAttempts'] as int? ?? 0,
changedFields: changedFields,
);
}
}
2. Initializing the Synchronization Manager
Configure the OfflineSyncManager
during your application initialization with advanced options:
import 'package:offline_sync_kit/offline_sync_kit.dart';
import 'package:offline_sync_kit/src/models/conflict_resolution_strategy.dart';
Future<void> initSyncManager() async {
// Specify your API base URL and WebSocket URL
const baseUrl = 'https://api.example.com';
const wsUrl = 'wss://api.example.com/socket';
final storageService = StorageServiceImpl();
await storageService.initialize();
// Register model factory
(storageService as StorageServiceImpl).registerModelDeserializer<Todo>(
'todo',
(json) => Todo.fromJson(json),
);
// Create WebSocket configuration
final webSocketConfig = WebSocketConfig(
serverUrl: wsUrl,
pingInterval: 30,
reconnectDelay: 3000,
maxReconnectAttempts: 5,
);
// Create event mapper for WebSocket events
final eventMapper = SyncEventMapper(
eventNameToTypeMap: {
'item_updated': SyncEventType.modelUpdated,
'item_created': SyncEventType.modelAdded,
'item_deleted': SyncEventType.modelDeleted,
'sync_completed': SyncEventType.syncCompleted,
},
);
// Advanced configuration options
final syncOptions = SyncOptions(
syncInterval: const Duration(minutes: 5),
useDeltaSync: true,
conflictStrategy: ConflictResolutionStrategy.lastUpdateWins,
batchSize: 10,
autoSync: true,
bidirectionalSync: true,
useWebSocket: true, // Enable WebSocket
);
// Create WebSocket network client
final webSocketClient = WebSocketNetworkClient.withConfig(
config: webSocketConfig,
eventMapper: eventMapper,
);
// Enable encryption with a secure key
final encryptionEnabled = true;
final encryptionKey = 'your-secure-encryption-key';
await OfflineSyncManager.initialize(
baseUrl: baseUrl,
storageService: storageService,
syncOptions: syncOptions,
encryptionEnabled: encryptionEnabled,
encryptionKey: encryptionKey,
webSocketClient: webSocketClient, // Pass WebSocket client
);
// Register TodoModel with OfflineSyncManager
OfflineSyncManager.instance.registerModelFactory<Todo>(
'todo',
(json) => Todo.fromJson(json),
);
// Subscribe to WebSocket synchronization events
OfflineSyncManager.instance.syncEventStream.listen((event) {
switch (event.type) {
case SyncEventType.modelUpdated:
print('Model updated via WebSocket: ${event.data}');
break;
case SyncEventType.modelAdded:
print('New model added via WebSocket: ${event.data}');
break;
case SyncEventType.syncCompleted:
print('Synchronization completed');
break;
default:
break;
}
});
// Connect to WebSocket server and subscribe to channels
await OfflineSyncManager.instance.connectWebSocket();
await OfflineSyncManager.instance.subscribeToChannel('todos');
}
3. Managing Data
Adding Data
final newTodo = Todo(
title: 'New task',
description: 'This is an example task',
);
await OfflineSyncManager.instance.saveModel<Todo>(newTodo);
Updating Data with Delta Synchronization
// Only the title will be synchronized with the server
final updatedTodo = todo.updateTitle('Updated title');
// Only the completion status will be synchronized with the server
final completedTodo = todo.updateCompletionStatus(true);
await OfflineSyncManager.instance.updateModel<Todo>(updatedTodo);
Deleting Data
await OfflineSyncManager.instance.deleteModel<Todo>(todo.id, 'todo');
Retrieving Data
// Get a single item
final todo = await OfflineSyncManager.instance.getModel<Todo>(id, 'todo');
// Get all items
final todos = await OfflineSyncManager.instance.getAllModels<Todo>('todo');
Fetching Data from Server
// Fetch items with pagination
final todos = await OfflineSyncManager.instance.fetchItems<Todo>(
'todo',
limit: 20,
offset: 0,
since: lastSyncTime,
);
// Pull and synchronize with local storage
final result = await OfflineSyncManager.instance.pullFromServer<Todo>('todo');
Listening for Real-time Updates
// Listen for real-time model updates via WebSocket
OfflineSyncManager.instance.registerSyncEventCallback((event) {
if (event.type == SyncEventType.modelUpdated &&
event.modelType == 'todo') {
// Process updated Todo model
final updatedTodo = Todo.fromJson(event.data);
print('Todo updated in real-time: ${updatedTodo.title}');
// Update UI or state management
updateTodoInList(updatedTodo);
}
});
// Subscribe to specific data channels
OfflineSyncManager.instance.subscribeToChannel('todos/user123');
// Unsubscribe when no longer needed
OfflineSyncManager.instance.unsubscribeFromChannel('todos/user123');
4. Synchronization
Manual Synchronization
// Synchronize all data
await OfflineSyncManager.instance.syncAll();
// Synchronize a specific model type
await OfflineSyncManager.instance.syncByModelType('todo');
Automatic Synchronization
// Start periodic synchronization
await OfflineSyncManager.instance.startPeriodicSync();
// Stop periodic synchronization
await OfflineSyncManager.instance.stopPeriodicSync();
WebSocket Synchronization
// Check WebSocket connection status
final isConnected = await OfflineSyncManager.instance.isWebSocketConnected;
// Manually reconnect WebSocket if needed
if (!isConnected) {
await OfflineSyncManager.instance.connectWebSocket();
}
// Send a custom message over WebSocket
await OfflineSyncManager.instance.sendWebSocketMessage({
'action': 'refresh_data',
'model_type': 'todo',
'timestamp': DateTime.now().toIso8601String(),
});
// Close WebSocket connection when not needed
await OfflineSyncManager.instance.closeWebSocket();
5. Monitoring Synchronization Status
// Listen to synchronization status
OfflineSyncManager.instance.syncStatusStream.listen((status) {
print('Connection status: ${status.isConnected}');
print('Synchronization process: ${status.isSyncing}');
print('Pending changes: ${status.pendingChanges}');
print('Last synchronization time: ${status.lastSyncTime}');
});
// Get current status
final status = await OfflineSyncManager.instance.currentStatus;
6. Handling Conflicts
// Set up a custom conflict handler
final customConflictResolver = (SyncConflict conflict) {
// Custom logic to resolve conflicts
if (conflict.localVersion.updatedAt.isAfter(conflict.serverVersion.updatedAt)) {
return conflict.localVersion; // Local changes win
} else {
return conflict.serverVersion; // Server changes win
}
};
// Configure with custom conflict resolution
final syncOptions = SyncOptions(
conflictStrategy: ConflictResolutionStrategy.custom,
conflictResolver: customConflictResolver,
);
OfflineSyncManager.instance.updateSyncOptions(syncOptions);
7. Managing Encryption
// Enable encryption
await OfflineSyncManager.instance.enableEncryption('secure-encryption-key');
// Disable encryption (data will be decrypted)
await OfflineSyncManager.instance.disableEncryption();
// Check encryption status
final isEncrypted = OfflineSyncManager.instance.isEncryptionEnabled;
8. Custom Repository Implementation
// Create a custom repository implementation
class MyCustomRepository implements SyncRepository {
final NetworkClient networkClient;
final StorageService storageService;
MyCustomRepository({
required this.networkClient,
required this.storageService,
});
// Implement required methods
// Custom implementation for fetchItems with special handling for your API
@override
Future<List<T>> fetchItems<T extends SyncModel>(
String modelType, {
DateTime? since,
int? limit,
int? offset,
Map<String, dynamic Function(Map<String, dynamic>)>? modelFactories,
}) async {
// Your custom implementation...
}
}
9. Real-time Data Synchronization with WebSockets
// Create a custom SyncEventMapper for translating between WebSocket events and app events
final eventMapper = SyncEventMapper(
// Map WebSocket event names to SyncEventType enum values
eventNameToTypeMap: {
'item_updated': SyncEventType.modelUpdated,
'item_created': SyncEventType.modelAdded,
'item_deleted': SyncEventType.modelDeleted,
'sync_completed': SyncEventType.syncCompleted,
'conflict_detected': SyncEventType.conflictDetected,
},
// Map SyncEventType enum values back to WebSocket event names
typeToEventNameMap: {
SyncEventType.modelUpdated: 'client_update',
SyncEventType.modelAdded: 'client_create',
SyncEventType.modelDeleted: 'client_delete',
},
);
// Create a WebSocketNetworkClient with the event mapper
final webSocketClient = WebSocketNetworkClient.withConfig(
config: WebSocketConfig(
serverUrl: 'wss://api.example.com/socket',
pingInterval: 30,
reconnectDelay: 3000,
),
eventMapper: eventMapper,
);
// Setup notification handling for real-time updates
webSocketClient.eventStream.listen((event) {
if (event.type == SyncEventType.modelUpdated) {
// Extract the model type and ID from the event data
final modelType = event.data['model_type'];
final modelId = event.data['id'];
// Fetch the updated model from the server or use the provided data
if (event.data.containsKey('model_data')) {
// Direct update with provided data
final modelData = event.data['model_data'];
updateLocalModel(modelType, modelId, modelData);
} else {
// Fetch the latest version from the server
OfflineSyncManager.instance.pullModelById(modelType, modelId);
}
}
});
// Connect and authenticate with the WebSocket server
await webSocketClient.connect();
await webSocketClient.authenticate({
'token': 'user-auth-token',
'device_id': 'unique-device-identifier',
});
// Subscribe to specific data channels with filters
webSocketClient.subscribe(
'todos',
parameters: {
'user_id': 'user123',
'since': DateTime.now().subtract(Duration(days: 7)).toIso8601String(),
'status': 'active',
},
);
// Process specific message types with custom handlers
webSocketClient.registerMessageHandler(
'model_batch_update',
(message) {
final updates = message['updates'] as List;
for (final update in updates) {
processModelUpdate(update);
}
return true; // Return true to indicate message was handled
},
);
10. RestRequest Parameters
The RestRequest
class has many features that you can use to adapt to various API structures:
// Simple RestRequest example
final getRequest = RestRequest(
method: RestMethod.get,
url: 'users',
headers: {'X-API-Key': 'my-api-key'},
);
// With more complex configurations
final postRequest = RestRequest(
method: RestMethod.post,
url: 'users',
topLevelKey: 'data', // Wraps data as {'data': ...}
supplementalTopLevelData: {'version': '1.0'}, // Adds extra top-level data
timeoutMillis: 10000, // Request times out after 10 seconds
retryCount: 3, // Retries 3 more times if it fails
);
// Example with URL parameters
final putRequest = RestRequest(
method: RestMethod.put,
url: 'users/{userId}/profile',
urlParameters: {'userId': '123'}, // Processed as /users/123/profile
);
// Using with response transformer
final getWithTransformer = RestRequest(
method: RestMethod.get,
url: 'items',
responseDataKey: 'results', // Extracts 'results' field from JSON response
responseTransformer: (data) {
// Apply custom transformation logic
if (data is List) {
return data.where((item) => item['active'] == true).toList();
}
return data;
},
);
Customizing REST Requests in SyncModel
The following example shows a model that configures custom settings for different HTTP requests:
class TodoModel extends SyncModel {
final String title;
final bool isCompleted;
TodoModel({
super.id,
required this.title,
this.isCompleted = false,
});
@override
String get modelType => 'todo';
@override
String get endpoint => 'todos';
// Custom configuration for each HTTP method
@override
RestRequests? get restRequests => RestRequests(
// Configuration for GET requests
get: RestRequest(
method: RestMethod.get,
url: 'api/todos',
responseDataKey: 'items', // Extracts 'items' field from API response
),
// Configuration for POST requests
post: RestRequest(
method: RestMethod.post,
url: 'api/todos',
topLevelKey: 'todo', // Wraps data as {"todo": {...}}
retryCount: 2, // Retry for important create operations
),
// Configuration for PUT requests
put: RestRequest(
method: RestMethod.put,
url: 'api/todos/{id}', // Dynamic URL parameter
urlParameters: {'id': id}, // Replaces ID in URL
),
// Configuration for DELETE requests
delete: RestRequest(
method: RestMethod.delete,
url: 'api/todos/{id}',
urlParameters: {'id': id},
),
);
// Other required methods...
}
8. Advanced Retrieval with Custom Endpoints
For more complex API integrations like Appwrite, Supabase or custom backends that require specific request formats:
// Define a custom REST request configuration for this specific query
final restConfig = RestRequest(
method: RestMethod.get,
url: 'custom/endpoint/path',
responseDataKey: 'documents', // Extract data from this key in the response
supplementalTopLevelData: { // Add extra data to the request
'collectionId': 'todos-collection',
'databaseId': 'main-database'
},
);
// Use the advanced getModelsWithQuery with custom policies and configuration
final todos = await OfflineSyncManager.instance.getModelsWithQuery<Todo>(
'todo',
query: Query.where([
WhereCondition.exact('isCompleted', false),
]),
forceLocalSyncFromRemote: true, // Force refresh from the remote API
policy: FetchStrategy.remoteFirst, // Use remote data first, fallback to local
restConfig: restConfig, // Apply custom request configuration
);
This approach is particularly useful when integrating with services like Appwrite, Firebase, or any API that requires specific request structures.
Example App
To run the example app that comes with the package:
cd example
flutter run
License
This package is distributed under the MIT license.