๐Ÿ”„ 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(), or watchList().


๐Ÿงฑ 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 operationId idempotently 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.

Libraries

easy_rest_sync