๐ easy_rest_sync
A lightweight, beginner-friendly Flutter package for building offline-first applications that synchronize local data with a server through REST APIs.
This package is designed to make REST-based data synchronization simple. Your application communicates only with the clean EasyRestSync API, while local persistence, pending operations, retry handling, cursor tracking, and synchronization are managed internally.
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 stream for Flutter UI
- โ๏ธ 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 - ๐ 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
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
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
๐ฆ Installation
Add the package to your pubspec.yaml:
dependencies:
easy_rest_sync: ^0.0.1
Then run:
flutter pub get
For local package development:
dependencies:
easy_rest_sync:
path: ../
๐ Basic Initialization
Import the package:
import 'package:easy_rest_sync/easy_rest_sync.dart';
import 'package:flutter/material.dart';
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,
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(), orwatchList().
๐งฑ 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
โ
Synchronize automatically when enabled
To control synchronization for a specific save:
await easySync.save<Product>(
entity: 'products',
data: product,
syncNow: false,
);
๐ Get a Single Record
final product = await easySync.get<Product>(
entity: 'products',
id: '101',
);
if (product != null) {
debugPrint(product.itemName);
}
๐ Get All Records
final products = await easySync.getList<Product>(
entity: 'products',
);
โก Watch a Reactive List
watchList() emits a new list whenever local records for that entity change.
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.
๐ 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',
);
To delete locally without starting immediate synchronization:
await easySync.delete(
entity: 'products',
id: '101',
syncNow: false,
);
The package hides deleted records from get() and getList() while keeping the required delete operation pending until it is accepted by the server.
๐ 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 same active synchronization result instead of starting a duplicate process.
๐ Listen to Sync Status
StreamBuilder<EasySyncStatus>(
stream: easySync.statusStream,
initialData: easySync.status,
builder: (context, snapshot) {
final status = snapshot.data ?? EasySyncStatus.idle;
return Text(status.name);
},
);
Available statuses:
EasySyncStatus.uninitialized
EasySyncStatus.idle
EasySyncStatus.pushing
EasySyncStatus.pulling
EasySyncStatus.completed
EasySyncStatus.offline
EasySyncStatus.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 synchronization:
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
autoSyncOnWrite: false,
)
A method-level syncNow value overrides autoSyncOnWrite for that operation.
โถ๏ธ Sync on Application Start
EasyRestSyncConfig(
baseUrl: 'https://your-domain.com/api',
syncOnStart: true,
)
When enabled, synchronization is scheduled after package initialization.
๐ 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 automatically 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 will add Authorization when that header is not already provided.
๐ค Client ID and Scope
A client ID identifies the current installation or sync 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, for example by user, company, organization, 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 conflict push response must include serverData unless the server record was deleted.
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),
headers: const <String, dynamic>{
'X-App-Name': 'Express ERP',
},
tokenProvider: () async {
return await authRepository.getAccessToken();
},
conflictStrategy: EasySyncConflictStrategy.serverWins,
),
);
โ๏ธ 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
For a successful delete, the server may return:
{
"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.
๐ 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 '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,
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>[
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>[];
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 closes the sync status stream and 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();
Push response does not contain results
The push API must return:
{
"results": []
}
It must also 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 still continue, and 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',
);
await easySync.delete(
entity: 'products',
id: '101',
);
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
๐ Current Limitations
- The backend push and pull endpoints must be implemented separately
- File and image synchronization are not included
- Field-level merge conflicts are not included
- Local filters and pagination run after loading records for the selected entity
- Background scheduling must be added by the application when required
- Custom conflict resolvers are not included in the current version
๐ License
MIT License
๐จโ๐ป Author
Developed by Nafim Ahmed.