๐ 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