easy_rest_sync 0.0.1 copy "easy_rest_sync: ^0.0.1" to clipboard
easy_rest_sync: ^0.0.1 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.

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.

1
likes
140
points
28
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

dio, flutter, hive_ce_flutter, uuid

More

Packages that depend on easy_rest_sync