Syncly Flutter
Syncly Flutter is a backend-agnostic offline synchronization engine. It stores records locally in SQLite, writes every change to a durable outbox, and only marks a mutation as synchronized after your backend acknowledges it.
Features
- Durable SQLite outbox that survives app restarts.
- Real backend acknowledgement through
SyncTransport. - Version-aware upserts and conflict responses.
- Cursor-based incremental pulls.
- Persisted exponential retry scheduling and error details.
- Offline deletes using tombstones.
- Server-wins, local-wins, last-write-wins, merge, and manual conflicts.
- Automatic sync after connectivity is restored, or manual sync on demand.
Platform support
| Platform | Minimum version | Native package manager |
|---|---|---|
| Android | API 21 | Gradle |
| iOS | 13.0 | Swift Package Manager or CocoaPods |
| macOS | 10.15 | Swift Package Manager or CocoaPods |
The package requires Dart 3.8 and Flutter 3.32 or newer.
Installation
dependencies:
syncly_flutter: ^0.1.0
flutter pub get
Connect a backend
Syncly does not assume a specific REST, Firebase, Supabase, or database schema. Provide a transport that maps mutations to your backend API:
final transport = CallbackSyncTransport(
onPush: (mutation) async {
final response = await myApi.pushChange(
collection: mutation.key,
id: mutation.id,
operation: mutation.operation.name,
data: mutation.data,
baseVersion: mutation.baseVersion,
idempotencyKey: mutation.idempotencyKey,
);
if (response.isConflict) {
return SyncPushResult.conflict(
SyncRemoteRecord(
id: response.record.id,
key: mutation.key,
data: response.record.data,
updatedAt: response.record.updatedAt,
version: response.record.version,
isDeleted: response.record.isDeleted,
),
);
}
return SyncPushResult.accepted(
remoteVersion: response.version,
);
},
onPull: (cursor) async {
final page = await myApi.pullChanges(cursor: cursor);
return SyncPullResult(
records: page.records.map(toSyncRemoteRecord).toList(),
cursor: page.nextCursor,
hasMore: page.hasMore,
);
},
);
Your backend should use idempotencyKey to safely deduplicate repeated writes
and baseVersion for conditional updates. Throw an exception for unavailable
or failed requests; Syncly retains the mutation and schedules a retry.
Initialize
final storage = LocalStorage();
await storage.init();
final manager = SyncManager(
localStorage: storage,
transport: transport,
connectivityMonitor: ConnectivityMonitor(
internetProbe: myApi.isReachable, // Optional backend-specific check.
),
conflictHandler: const ConflictHandler(
strategy: ConflictResolutionStrategy.lastWriteWins,
),
);
Save, update, and delete offline
final task = await manager.save('tasks', {
'id': 'task-123',
'title': 'Prepare release notes',
'completed': false,
});
await manager.save('tasks', {
...task.data,
'completed': true,
});
await manager.delete(task.id);
These calls update SQLite immediately and queue a mutation. Deletion hides the record locally but keeps a tombstone until the backend accepts it.
Synchronize
final report = await manager.syncNow();
print('Pushed: ${report.pushed}');
print('Pulled: ${report.pulled}');
print('Conflicts: ${report.conflicts}');
print('Failures: ${report.failures}');
Concurrent calls share one in-flight run. Individual backend failures are reported and persisted rather than falsely marking records as synchronized.
Listen for detailed lifecycle events:
final subscription = manager.onSyncEvent.listen((event) {
debugPrint('${event.type}: ${event.message}');
});
Read local records
final all = await storage.getAllData();
final unsynced = await storage.getUnsyncedData();
final synced = await storage.getSyncedData();
React to changes caused by local writes, backend acknowledgements, pulls, conflict resolution, and deletion:
final records = storage.watchAllData();
await for (final snapshot in records) {
rebuildUi(snapshot);
}
Conflict handling
final handler = ConflictHandler(
strategy: ConflictResolutionStrategy.manual,
manualConflictResolver: (local, remote) {
return local.copyWith(
data: {...remote.data, ...local.data},
updatedAt: DateTime.now().toUtc(),
);
},
);
Remote versions are opaque strings; they can represent integer revisions, database row versions, ETags, or another backend-specific token.
Migration from 0.0.x
Version 0.1.0 requires the transport argument on SyncManager. Existing
SQLite databases are migrated automatically to schema version 4. Previously
unsynchronized records are added to the durable outbox during migration.
Cleanup
await subscription.cancel();
manager.dispose();
await storage.close();
Current limitations
- Applications must provide a backend transport.
- Background execution must be integrated by the host application.
- Local database encryption is not included.
- Web, Linux, and Windows are not currently supported.
The example application includes a process-local transport demonstrating the complete contract. Replace it with network API calls in a production app.
See the changelog for release details.