easy_rest_sync 0.0.2
easy_rest_sync: ^0.0.2 copied to clipboard
An offline-first REST API synchronization package for Flutter with hidden local storage, reactive data, outbox queue, retry handling, cursor-based sync, and conflict management.
๐ easy_rest_sync #
A lightweight, beginner-friendly Flutter package for building offline-first applications that synchronize local data with a server through REST APIs and provide a package-owned reactive streaming system.
Your application communicates only with the clean EasyRestSync API. Local persistence, pending operations, retry handling, cursor tracking, synchronization, reactive lists, single-record streams, sync state, event broadcasting, and REST-based live polling are managed internally.
๐ฆ Pub.dev: https://pub.dev/packages/easy_rest_sync
Your backend must provide compatible push and pull REST endpoints.
โ Summary of Everything Included in This Package #
- โ๏ธ Offline-first local data storage
- โ๏ธ REST API-based push and pull synchronization
- โ๏ธ Automatic local create and update handling
- โ๏ธ Offline delete and server delete synchronization
- โ๏ธ Pending operation queue
- โ๏ธ Automatic or manual synchronization
- โ๏ธ Cursor-based incremental data pulling
- โ๏ธ Batch push and pull support
- โ๏ธ Server-wins and client-wins conflict strategies
- โ๏ธ Failed operation retry support
- โ๏ธ Reactive list streaming with
watchList() - โ๏ธ Single-record streaming with
watchOne() - โ๏ธ Pending-operation count streaming
- โ๏ธ Rich synchronization state streaming
- โ๏ธ Global package event stream
- โ๏ธ REST-based live polling system
- โ๏ธ Start, pause, resume, and stop streaming controls
- โ๏ธ App lifecycle-aware streaming
- โ๏ธ Stream update debouncing
- โ๏ธ Single-record and list queries
- โ๏ธ Local filtering, sorting, and pagination
- โ๏ธ Bearer token provider support
- โ๏ธ Custom request headers
- โ๏ธ Multiple entity support
- โ๏ธ Sync status stream
- โ๏ธ Full Flutter example app
- โ๏ธ Pub.dev-ready formatting
โญ Features #
- ๐ฑ Read and write data without an internet connection
- ๐ Synchronize local changes with REST APIs
- ๐ฅ Pull only new server changes using a cursor
- ๐ค Push pending local operations in batches
- ๐พ Internal local database management
- ๐ต๏ธ No direct local database code required in the application
- โก Reactive
watchList()support - ๐ Watch one record using
watchOne() - ๐ข Watch pending operations using
watchPendingCount() - ๐ Watch detailed sync and streaming state using
watchState() - ๐ข Listen to package-wide events using
events - ๐ Run automatic REST-based live synchronization
- โธ Pause and resume live synchronization
- ๐ฒ Pause streaming automatically when the app is in the background
- ๐ Filter and sort local records
- ๐ Local pagination support
- ๐งฉ Register multiple model types as sync entities
- ๐ Dynamic authentication token support
- ๐งฏ Retry failed operations
- โ๏ธ Configurable conflict handling
- ๐ Synchronization result and status reporting
- ๐งช Complete example application included
๐ How It Works #
Flutter Application
โ
EasyRestSync Public API
โ
Internal Local Storage
โ
Pending Operation Queue
โ
REST Push / Pull API
โ
Server Database
The application always reads from and writes to the package API.
UI โ EasyRestSync โ Local Data
When synchronization runs:
Pending Local Changes โ Push API โ Server
Server Changes โ Pull API โ Local Data โ UI
The package streaming layer works like this:
Local Save / Delete
โ
EasyRestSync Event System
โ
watchList / watchOne / watchState / events
โ
Flutter UI Rebuild
Remote live updates work through configurable REST polling:
Streaming Interval
โ
Push Pending Operations
โ
Pull New Server Changes
โ
Update Local Data
โ
Notify Streams
โ
Flutter UI Rebuild
Because the UI reads local data, previously synchronized records remain available when the device is offline.
โ ๏ธ Important Backend Requirement #
easy_rest_sync is a client-side synchronization engine. It does not automatically create your backend endpoints or database change log.
Your backend must provide:
- A push endpoint for receiving local create, update, and delete operations
- A pull endpoint for returning server-side changes after a cursor
- A unique operation result for every pushed operation
- A server version for conflict detection
- An incremental cursor for pull synchronization
- Authentication and authorization suitable for your application
- Idempotent operation processing using
operationId - Stable cursor ordering for pull responses
The backend can use any database or server technology, including:
- Oracle Database and ORDS
- PostgreSQL
- MySQL
- SQL Server
- MongoDB
- Node.js
- Laravel
- Django
- ASP.NET
- Spring Boot
The built-in live streaming system uses repeated REST synchronization. WebSocket or Server-Sent Events are not required.
๐ฆ Installation #
Add the package to your pubspec.yaml:
dependencies:
easy_rest_sync: ^0.0.2
Then run:
flutter pub get
For local package development:
dependencies:
easy_rest_sync:
path: ../
Import the package:
import 'package:easy_rest_sync/easy_rest_sync.dart';
๐ Basic Initialization #
Create and initialize EasyRestSync before runApp():
late final EasyRestSync easySync;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
pushEndpoint: '/sync/push',
pullEndpoint: '/sync/pull',
scope: 'user_506618',
autoSyncOnWrite: true,
syncOnStart: true,
streamDebounce: const Duration(milliseconds: 50),
streaming: const EasySyncStreamingConfig(
enabled: true,
interval: Duration(seconds: 10),
syncImmediately: true,
pauseWhenAppInBackground: true,
continueAfterError: true,
maxConsecutiveFailures: 0,
),
tokenProvider: () async {
return 'YOUR_ACCESS_TOKEN';
},
conflictStrategy: EasySyncConflictStrategy.serverWins,
),
);
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
await easySync.initialize();
runApp(const MyApp());
}
Register every entity before calling methods such as
save(),get(),getList(),watchList(), orwatchOne().
๐งฑ Create a Model #
Every sync entity needs:
- A unique string ID
- A
toJson()method - A
fromJson()factory
class Product {
final String id;
final String itemCode;
final String itemName;
final double quantity;
const Product({
required this.id,
required this.itemCode,
required this.itemName,
required this.quantity,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id']?.toString() ?? '',
itemCode: json['itemCode']?.toString() ?? '',
itemName: json['itemName']?.toString() ?? '',
quantity:
double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'itemCode': itemCode,
'itemName': itemName,
'quantity': quantity,
};
}
}
๐งฉ Register an Entity #
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
The entity name must match the entity value used by your push and pull APIs.
๐พ Save or Update Data #
The same save() method handles both create and update operations.
final product = Product(
id: '101',
itemCode: '34494',
itemName: 'Plastic Chair',
quantity: 10,
);
await easySync.save<Product>(
entity: 'products',
data: product,
);
The package will:
Save the record locally
โ
Create or update a pending sync operation
โ
Notify reactive streams
โ
Synchronize automatically when enabled
Control synchronization for a specific save:
await easySync.save<Product>(
entity: 'products',
data: product,
syncNow: false,
);
Force immediate synchronization:
await easySync.save<Product>(
entity: 'products',
data: product,
syncNow: true,
);
๐ Get a Single Record #
final product = await easySync.get<Product>(
entity: 'products',
id: '101',
);
if (product != null) {
debugPrint(product.itemName);
}
Deleted or unavailable records return null.
๐ Get All Records #
final products = await easySync.getList<Product>(
entity: 'products',
);
โก Watch a Reactive List #
watchList() emits the current list immediately and emits a new list whenever records for that entity change.
Changes may come from:
- Local
save() - Local
delete() - Remote pull synchronization
- Conflict resolution
- Server acknowledgement
StreamBuilder<List<Product>>(
stream: easySync.watchList<Product>(
entity: 'products',
),
builder: (context, snapshot) {
final products = snapshot.data ?? <Product>[];
if (products.isEmpty) {
return const Center(
child: Text('No product found.'),
);
}
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.itemName),
subtitle: Text(product.itemCode),
trailing: Text(product.quantity.toString()),
);
},
);
},
);
The application does not need to import or access the package's internal database.
๐ Watch a Single Record #
Use watchOne() when only one record needs to update reactively.
StreamBuilder<Product?>(
stream: easySync.watchOne<Product>(
entity: 'products',
id: '101',
),
builder: (context, snapshot) {
final product = snapshot.data;
if (product == null) {
return const Text('Product not found.');
}
return ListTile(
title: Text(product.itemName),
subtitle: Text(product.itemCode),
trailing: Text(product.quantity.toString()),
);
},
);
The stream emits null when the record is deleted.
๐ข Watch Pending Operation Count #
StreamBuilder<int>(
stream: easySync.watchPendingCount(),
builder: (context, snapshot) {
final pending = snapshot.data ?? 0;
return Text('Pending operations: $pending');
},
);
This is useful for:
- Offline indicators
- Unsynchronized data badges
- Sync buttons
- Debugging pending writes
๐ Watch Detailed Sync State #
StreamBuilder<EasySyncState>(
stream: easySync.watchState(),
initialData: easySync.state,
builder: (context, snapshot) {
final state = snapshot.data ?? easySync.state;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Sync status: ${state.status.name}'),
Text('Streaming: ${state.streamingStatus.name}'),
Text('Pending: ${state.pendingOperations}'),
Text('Pushed: ${state.pushedCount}'),
Text('Pulled: ${state.pulledCount}'),
Text('Conflicts: ${state.conflictCount}'),
],
);
},
);
You can also use:
easySync.stateStream
Useful state properties:
easySync.state.isSyncing
easySync.state.isStreaming
easySync.state.lastSuccessfulSync
easySync.state.lastError
๐ข Listen to Global Package Events #
The events stream emits package-wide events.
final subscription = easySync.events.listen((event) {
if (event.isRecordChange) {
debugPrint(
'${event.origin?.name}: '
'${event.entity}/${event.recordId} '
'${event.recordChangeType?.name}',
);
} else {
debugPrint(event.type.name);
}
});
Cancel the subscription when it is no longer needed:
await subscription.cancel();
Available event types include:
EasySyncEventType.initialized
EasySyncEventType.recordChanged
EasySyncEventType.pendingOperationsChanged
EasySyncEventType.syncStarted
EasySyncEventType.syncCompleted
EasySyncEventType.syncFailed
EasySyncEventType.streamingStarted
EasySyncEventType.streamingPaused
EasySyncEventType.streamingResumed
EasySyncEventType.streamingStopped
EasySyncEventType.streamingFailed
Record change types:
EasySyncRecordChangeType.created
EasySyncRecordChangeType.updated
EasySyncRecordChangeType.deleted
Change origins:
EasySyncChangeOrigin.local
EasySyncChangeOrigin.remote
EasySyncChangeOrigin.conflict
EasySyncChangeOrigin.serverAcknowledgement
๐ Filter Local Data #
final availableProducts = await easySync.getList<Product>(
entity: 'products',
where: (product) => product.quantity > 0,
);
Reactive filtering:
final stream = easySync.watchList<Product>(
entity: 'products',
where: (product) => product.itemCode.startsWith('34'),
);
โ๏ธ Sort Local Data #
final products = await easySync.getList<Product>(
entity: 'products',
sort: (a, b) => a.itemName.compareTo(b.itemName),
);
Reactive sorting:
final stream = easySync.watchList<Product>(
entity: 'products',
sort: (a, b) => b.quantity.compareTo(a.quantity),
);
๐ Local Pagination #
Both page and pageSize must be greater than zero.
final firstPage = await easySync.getList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
Reactive pagination:
final stream = easySync.watchList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
Filtering and sorting are applied before local pagination.
๐ Delete Data #
await easySync.delete(
entity: 'products',
id: '101',
);
Delete locally without immediate synchronization:
await easySync.delete(
entity: 'products',
id: '101',
syncNow: false,
);
The package:
Hides the record from local queries
โ
Creates or updates a pending delete operation
โ
Notifies list and record streams
โ
Sends the delete during synchronization
๐ REST-Based Live Streaming #
Easy REST Sync includes a package-owned live streaming system that works with normal REST APIs.
It periodically performs:
Push Pending Local Operations
โ
Pull New Server Changes
โ
Apply Changes Locally
โ
Notify Reactive Streams
This is configurable interval-based synchronization.
It is not a permanent WebSocket connection. It uses the package's standard REST push and pull endpoints.
โ๏ธ Enable Streaming in Configuration #
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
streaming: const EasySyncStreamingConfig(
enabled: true,
interval: Duration(seconds: 10),
syncImmediately: true,
pauseWhenAppInBackground: true,
continueAfterError: true,
maxConsecutiveFailures: 0,
),
)
Streaming configuration options:
| Property | Description |
|---|---|
enabled |
Automatically start streaming during initialization |
interval |
Time between REST synchronization ticks |
syncImmediately |
Synchronize immediately when streaming starts |
pauseWhenAppInBackground |
Pause polling while the app is backgrounded |
continueAfterError |
Continue polling after synchronization errors |
maxConsecutiveFailures |
Stop after repeated failures; 0 means unlimited |
Choose a sensible interval based on:
- Server capacity
- Data freshness requirements
- Mobile data usage
- Battery consumption
- Expected number of active clients
โถ๏ธ Start Streaming Manually #
Use the configuration already supplied to EasyRestSyncConfig:
await easySync.startStreaming();
Or provide a custom streaming configuration:
await easySync.startStreaming(
config: const EasySyncStreamingConfig(
interval: Duration(seconds: 10),
syncImmediately: true,
pauseWhenAppInBackground: true,
continueAfterError: true,
),
);
โธ Pause Streaming #
await easySync.pauseStreaming();
This pauses future streaming ticks. It does not remove local records or pending operations.
โถ๏ธ Resume Streaming #
Resume and synchronize immediately:
await easySync.resumeStreaming(
syncNow: true,
);
Resume without immediate synchronization:
await easySync.resumeStreaming(
syncNow: false,
);
โน Stop Streaming #
await easySync.stopStreaming();
This stops the timer and removes the lifecycle observer used by the streaming system.
๐ฒ Application Lifecycle Streaming #
When enabled:
pauseWhenAppInBackground: true
The package behaves like this:
Application paused, inactive, detached, or hidden
โ
Streaming pauses
Application resumed
โ
Streaming resumes
โ
Immediate synchronization runs
This helps reduce unnecessary network requests while the application is not active.
๐ง Stream Debouncing #
Large pull responses may update many records at once.
Use streamDebounce to combine many rapid changes into fewer UI refreshes:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
streamDebounce: const Duration(milliseconds: 50),
)
Example:
100 Remote Record Changes
โ
50 ms Debounce Window
โ
One List Stream Refresh
A value between 30 and 100 milliseconds is suitable for many applications.
๐ Manual Synchronization #
final result = await easySync.sync();
if (result.isSuccess) {
debugPrint('Pushed: ${result.pushedCount}');
debugPrint('Pulled: ${result.pulledCount}');
debugPrint('Conflicts: ${result.conflictCount}');
debugPrint('Pending: ${result.pendingCount}');
} else {
debugPrint('Sync failed: ${result.message}');
}
Calling sync() while another synchronization is active returns the active synchronization result instead of starting a duplicate process.
๐ Listen to Basic Sync Status #
StreamBuilder<EasySyncStatus>(
stream: easySync.statusStream,
initialData: easySync.status,
builder: (context, snapshot) {
final status =
snapshot.data ?? EasySyncStatus.uninitialized;
return Text(status.name);
},
);
Available statuses:
EasySyncStatus.uninitialized
EasySyncStatus.idle
EasySyncStatus.pushing
EasySyncStatus.pulling
EasySyncStatus.completed
EasySyncStatus.offline
EasySyncStatus.failed
Streaming statuses:
EasySyncStreamingStatus.stopped
EasySyncStreamingStatus.starting
EasySyncStreamingStatus.running
EasySyncStreamingStatus.paused
EasySyncStreamingStatus.failed
๐ค Automatic Sync on Save #
Enable automatic synchronization after local writes:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
autoSyncOnWrite: true,
)
Disable it when you want only manual or streaming-based synchronization:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
autoSyncOnWrite: false,
)
A method-level syncNow value overrides autoSyncOnWrite.
โถ๏ธ Sync on Application Start #
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
syncOnStart: true,
)
When streaming is enabled with syncImmediately: true, streaming performs the initial synchronization.
๐ Authentication Token #
Use tokenProvider to provide the latest access token for every request:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
tokenProvider: () async {
return await authRepository.getAccessToken();
},
)
When a non-empty token is returned, the package sends:
Authorization: Bearer YOUR_ACCESS_TOKEN
Do not hardcode production secrets in the application.
๐งพ Custom Request Headers #
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
headers: const <String, dynamic>{
'X-App-Version': '1.0.0',
'X-Platform': 'mobile',
},
)
The token provider adds Authorization when that header is not already supplied.
๐ค Client ID and Scope #
A client ID identifies the current installation or synchronization client.
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
clientId: 'device-506618-01',
)
When clientId is not provided, the package creates and stores one automatically.
Use scope to isolate data by user, company, organization, branch, tenant, or workspace:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
scope: 'user_506618',
)
The selected scope is included in push and pull requests.
โ๏ธ Conflict Strategy #
Server Wins #
conflictStrategy: EasySyncConflictStrategy.serverWins,
When a conflict occurs, the server version replaces the conflicting local version.
A non-deleted conflict response must include serverData.
Client Wins #
conflictStrategy: EasySyncConflictStrategy.clientWins,
The local operation is rebased using the server version and remains pending for another push attempt.
The server must still validate permissions and business rules. Client-wins does not bypass backend validation.
โป๏ธ Pending and Failed Operations #
Get the current pending operation count:
final count = await easySync.pendingCount();
Reset failed operations to pending:
final retried = await easySync.retryFailed();
debugPrint('$retried failed operations were queued again.');
Operations become failed after reaching maxRetries:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
maxRetries: 5,
)
๐งญ Read the Last Cursor #
final cursor = await easySync.getLastCursor();
debugPrint('Last cursor: $cursor');
Set a custom initial cursor:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
initialCursor: '0',
)
๐ Read the Last Sync Time #
final lastSyncTime = await easySync.getLastSyncTime();
debugPrint('Last sync: $lastSyncTime');
The value is null until a complete synchronization succeeds.
โ๏ธ Complete Configuration Example #
final easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
pushEndpoint: '/sync/push',
pullEndpoint: '/sync/pull',
databaseName: 'my_application_sync',
scope: 'user_506618',
clientId: 'device-506618-01',
batchSize: 100,
maxRetries: 5,
maxPullPages: 100,
initialCursor: '0',
autoSyncOnWrite: true,
syncOnStart: true,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
streamDebounce: const Duration(milliseconds: 50),
headers: const <String, dynamic>{
'X-App-Name': 'Express ERP',
},
tokenProvider: () async {
return await authRepository.getAccessToken();
},
conflictStrategy: EasySyncConflictStrategy.serverWins,
streaming: const EasySyncStreamingConfig(
enabled: true,
interval: Duration(seconds: 10),
syncImmediately: true,
pauseWhenAppInBackground: true,
continueAfterError: true,
maxConsecutiveFailures: 0,
),
),
);
โ๏ธ Push API Contract #
Default endpoint:
POST /sync/push
Content-Type: application/json
Request body:
{
"clientId": "device-506618-01",
"scope": "user_506618",
"operations": [
{
"operationId": "630d23c0-f7bb-42f9-85dd-fac2fac2f404",
"entity": "products",
"recordId": "101",
"type": "create",
"data": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair",
"quantity": 10
},
"baseVersion": 0,
"createdAt": "2026-07-16T04:00:00.000Z"
}
]
}
Supported operation types:
create
update
delete
Each operation should be processed idempotently using operationId.
โ Successful Push Response #
The response must contain a result for every operation:
{
"results": [
{
"operationId": "630d23c0-f7bb-42f9-85dd-fac2fac2f404",
"status": "applied",
"serverVersion": 1,
"serverData": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair",
"quantity": 10
}
}
]
}
Accepted success status values:
applied
success
completed
Successful delete response:
{
"results": [
{
"operationId": "operation-uuid",
"status": "applied",
"serverVersion": 3,
"serverDeleted": true
}
]
}
โ ๏ธ Push Conflict Response #
{
"results": [
{
"operationId": "operation-uuid",
"status": "conflict",
"serverVersion": 5,
"serverData": {
"id": "101",
"itemCode": "34494",
"itemName": "Server Product Name",
"quantity": 20
}
}
]
}
When the server record was deleted:
{
"results": [
{
"operationId": "operation-uuid",
"status": "conflict",
"serverVersion": 5,
"serverDeleted": true
}
]
}
๐ฅ Pull API Contract #
Default endpoint:
GET /sync/pull?cursor=100&limit=100&scope=user_506618&clientId=device-506618-01
Response:
{
"changes": [
{
"cursor": "101",
"entity": "products",
"recordId": "101",
"type": "update",
"serverVersion": 2,
"updatedAt": "2026-07-16T04:10:00.000Z",
"data": {
"id": "101",
"itemCode": "34494",
"itemName": "Plastic Chair Updated",
"quantity": 20
}
}
],
"nextCursor": "101",
"hasMore": false
}
A non-delete change requires a data object.
The live streaming system repeatedly uses this pull endpoint at the configured interval.
๐ Pull Delete Change #
{
"changes": [
{
"cursor": "102",
"entity": "products",
"recordId": "101",
"type": "delete",
"serverVersion": 3,
"updatedAt": "2026-07-16T04:12:00.000Z"
}
],
"nextCursor": "102",
"hasMore": false
}
๐ Multiple Pull Pages #
When more changes are available, return a non-empty change page with hasMore: true:
{
"changes": [
{
"cursor": "200",
"entity": "products",
"recordId": "200",
"type": "update",
"serverVersion": 4,
"data": {
"id": "200",
"itemCode": "50001",
"itemName": "Another Product",
"quantity": 12
}
}
],
"nextCursor": "200",
"hasMore": true
}
The next response must advance nextCursor.
Return hasMore: false on the final page.
The package stops after maxPullPages to protect the application from an endless server loop:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
maxPullPages: 100,
)
๐งช Complete Usage Example #
import 'dart:async';
import 'package:easy_rest_sync/easy_rest_sync.dart';
import 'package:flutter/material.dart';
late final EasyRestSync easySync;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
easySync = EasyRestSync(
config: EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
scope: 'current_user',
autoSyncOnWrite: false,
syncOnStart: false,
streamDebounce: const Duration(milliseconds: 50),
streaming: const EasySyncStreamingConfig(
enabled: true,
interval: Duration(seconds: 10),
syncImmediately: true,
),
tokenProvider: () async => null,
),
);
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
await easySync.initialize();
runApp(const MyApp());
}
class Product {
final String id;
final String name;
final double quantity;
const Product({
required this.id,
required this.name,
required this.quantity,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
quantity:
double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'quantity': quantity,
};
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(
title: const Text('Easy REST Sync'),
actions: <Widget>[
StreamBuilder<EasySyncState>(
stream: easySync.watchState(),
initialData: easySync.state,
builder: (context, snapshot) {
final state = snapshot.data ?? easySync.state;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: Text(
'${state.streamingStatus.name} '
'(${state.pendingOperations})',
),
),
);
},
),
IconButton(
onPressed: () async {
await easySync.sync();
},
icon: const Icon(Icons.sync),
),
],
),
body: StreamBuilder<List<Product>>(
stream: easySync.watchList<Product>(
entity: 'products',
),
builder: (context, snapshot) {
final products = snapshot.data ?? <Product>[];
if (products.isEmpty) {
return const Center(
child: Text('No products found.'),
);
}
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
trailing: Text(product.quantity.toString()),
onLongPress: () async {
await easySync.delete(
entity: 'products',
id: product.id,
);
},
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final product = Product(
id: DateTime.now()
.microsecondsSinceEpoch
.toString(),
name: 'New Product',
quantity: 1,
);
await easySync.save<Product>(
entity: 'products',
data: product,
);
},
child: const Icon(Icons.add),
),
),
);
}
}
โถ๏ธ Example App Run Instructions #
Create an example application from the package root:
flutter create example
Add the local package dependency to example/pubspec.yaml:
dependencies:
flutter:
sdk: flutter
easy_rest_sync:
path: ../
Go to the example folder:
cd example
Then run:
flutter clean
flutter pub get
flutter run
๐งน Dispose Resources #
Call dispose() when the sync instance is no longer needed:
await easySync.dispose();
This:
- Stops the streaming timer
- Removes the application lifecycle observer
- Cancels internal debounce timers
- Closes record and entity stream controllers
- Closes the event stream
- Closes the state stream
- Closes the status stream
- Releases the internally created network client
โ Common Issues #
Entity is not registered #
Error:
Entity "products" is not registered. Call registerEntity() first.
Fix:
easySync.registerEntity<Product>(
EasySyncEntity<Product>(
name: 'products',
idSelector: (product) => product.id,
toJson: (product) => product.toJson(),
fromJson: Product.fromJson,
),
);
Package is not initialized #
Error:
Call EasyRestSync.initialize() before using the package.
Fix:
await easySync.initialize();
Streaming interval is invalid #
The streaming interval must be greater than zero.
Correct:
await easySync.startStreaming(
config: const EasySyncStreamingConfig(
interval: Duration(seconds: 10),
),
);
Streaming does not start automatically #
Check that streaming is enabled:
streaming: const EasySyncStreamingConfig(
enabled: true,
)
Or start it manually:
await easySync.startStreaming();
Streaming pauses in the background #
This is expected when:
pauseWhenAppInBackground: true
Disable lifecycle pausing:
streaming: const EasySyncStreamingConfig(
enabled: true,
pauseWhenAppInBackground: false,
)
Push response does not contain results #
The push API must return:
{
"results": []
}
It must return one result for every submitted operationId.
Pull response does not contain changes #
The pull API must return:
{
"changes": [],
"nextCursor": "0",
"hasMore": false
}
Sync status becomes offline #
The package reports EasySyncStatus.offline for connection errors and network timeouts.
Local reads and writes can continue. Pending operations can be synchronized later.
Failed operations are not being pushed #
Operations that reached maxRetries have the failed state.
Reset them:
await easySync.retryFailed();
await easySync.sync();
Pagination throws an ArgumentError #
Provide both page and pageSize, and ensure both are greater than zero:
await easySync.getList<Product>(
entity: 'products',
page: 1,
pageSize: 20,
);
๐ API Overview #
EasyRestSync(
config: EasyRestSyncConfig(...),
);
easySync.registerEntity<Product>(...);
await easySync.initialize();
await easySync.save<Product>(
entity: 'products',
data: product,
);
await easySync.get<Product>(
entity: 'products',
id: '101',
);
await easySync.getList<Product>(
entity: 'products',
);
easySync.watchList<Product>(
entity: 'products',
);
easySync.watchOne<Product>(
entity: 'products',
id: '101',
);
easySync.watchPendingCount();
easySync.watchState();
easySync.statusStream;
easySync.stateStream;
easySync.events;
await easySync.delete(
entity: 'products',
id: '101',
);
await easySync.startStreaming();
await easySync.pauseStreaming();
await easySync.resumeStreaming(
syncNow: true,
);
await easySync.stopStreaming();
await easySync.sync();
await easySync.pendingCount();
await easySync.retryFailed();
await easySync.getLastCursor();
await easySync.getLastSyncTime();
await easySync.dispose();
๐ Security Best Practices #
- Always use HTTPS in production
- Validate the authenticated user on every push and pull request
- Never trust
scope,entity,recordId, or client-provided data without validation - Verify that the user can access every requested record
- Process
operationIdidempotently to prevent duplicate writes - Use server-side version checks for updates and deletes
- Do not store database credentials or backend secrets in the Flutter app
- Return only data belonging to the authenticated user's scope
- Apply request-size and batch limits on the server
- Log rejected and conflicting operations for debugging
- Rate-limit synchronization endpoints
- Select a safe streaming interval
- Monitor polling traffic and server load
- Avoid returning sensitive information in sync errors
๐ Current Limitations #
- The backend push and pull endpoints must be implemented separately
- REST live streaming is interval-based polling, not WebSocket push
- File and image synchronization are not included
- Field-level merge conflicts are not included
- Custom conflict resolvers are not included
- Local filters and pagination run after loading records for the selected entity
- Background execution after the operating system suspends the application is not guaranteed
- Native background scheduling must be added separately when required
- Indexed local queries are not included in the current version
- Storage migrations and encryption configuration are not yet exposed publicly
๐ฃ Planned Improvements #
- Typed collection API
- Indexed local queries
- Bulk save and delete
- Exponential retry backoff
- Custom REST protocol adapters
- Custom conflict resolvers
- Encryption configuration
- Migration support
- Isolate-based heavy processing
- File and image synchronization
- Server-Sent Events adapter
- WebSocket adapter
- Detailed diagnostics and logging
- Automated backend contract tests
๐ License #
MIT License
See the LICENSE file for details.
๐จโ๐ป Author #
Developed by Nafim Ahmed.
๐ฆ Package: https://pub.dev/packages/easy_rest_sync