Skip to content
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,89 @@ It keeps data synchronized across devices with deterministic conflict resolution
└────────────────────────┘ └────────────────────────┘
````


## Bloc-Like Architecture (New)

The package now supports a Bloc-like mental model for teams that prefer event/state flows with minimal boilerplate.

| Bloc Concept | Package Equivalent |
| --- | --- |
| Event | `SyncEvent<T>` |
| State | `SyncState` |
| Bloc | `SyncController<T>` |
| Repository | `SyncRepository<T>` |
| Data source | `LocalDataSource<T>` + `CloudDataSource<T>` |
| Queue store | `SyncLogStore` |

### Contracts

```dart
abstract class LocalDataSource<T> {
Future<void> insert(T data);
Future<void> update(T data);
Future<void> delete(String id);
Future<T?> getById(String id);
Future<List<T>> getAll();
}

abstract class CloudDataSource<T> {
Future<void> create(T data);
Future<void> update(T data);
Future<void> delete(String id);
Future<T?> fetch(String id);
Future<List<T>> fetchAll();
}
```

### Sync queue model

```dart
enum SyncOperationType { create, update, delete }
enum SyncStatus { pending, syncing, synced, failed }
```

Each local write appends a `SyncLog`, then `StartSync` replays pending logs using latest-first ordering.

### Events and states

```dart
AddData<T>, UpdateData<T>, DeleteData<T>, StartSync<T>, RetryFailed<T>
SyncInitial, SyncInProgress, SyncSuccess, SyncFailure
```

### Recommended wiring

```dart
final repository = SyncRepository<User>(
local: localDataSource,
cloud: cloudDataSource,
logStore: InMemorySyncLogStore(),
idResolver: (user) => user.id,
);

final controller = SyncController<User>(repository: repository);
final engine = OfflineSyncEngine<User>(controller: controller);

await engine.add(user);
await engine.sync();
```

### Architecture diagram

```
UI
OfflineSyncEngine
SyncController
SyncRepository
LocalDataSource CloudDataSource
SyncLogStore
```

## Installation

```bash
Expand Down
13 changes: 13 additions & 0 deletions lib/offline_sync_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,16 @@ export 'src/sync/sync_manager.dart';
// Built-in Implementations (ready to use)
export 'src/implementations/in_memory_database.dart';
export 'src/implementations/in_memory_cloud.dart';


// Bloc-like architecture (vNext)
export 'src/bloc_like/contracts/local_data_source.dart';
export 'src/bloc_like/contracts/cloud_data_source.dart';
export 'src/bloc_like/contracts/sync_log_store.dart';
export 'src/bloc_like/contracts/in_memory_sync_log_store.dart';
export 'src/bloc_like/models/sync_log.dart';
export 'src/bloc_like/events/sync_event.dart';
export 'src/bloc_like/states/sync_state.dart';
export 'src/bloc_like/repository/sync_repository.dart';
export 'src/bloc_like/controller/sync_controller.dart';
export 'src/bloc_like/engine/offline_sync_engine.dart';
7 changes: 7 additions & 0 deletions lib/src/bloc_like/contracts/cloud_data_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
abstract class CloudDataSource<T> {
Future<void> create(T data);
Future<void> update(T data);
Future<void> delete(String id);
Future<T?> fetch(String id);
Future<List<T>> fetchAll();
}
43 changes: 43 additions & 0 deletions lib/src/bloc_like/contracts/in_memory_sync_log_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import '../models/sync_log.dart';
import 'sync_log_store.dart';

class InMemorySyncLogStore implements SyncLogStore {
final List<SyncLog> _logs = [];

@override
Future<void> add(SyncLog log) async {
_logs.add(log);
}

@override
Future<List<SyncLog>> getFailedLogs() async {
return _logs.where((log) => log.status == SyncStatus.failed).toList();
}

@override
Future<List<SyncLog>> getPendingLogs() async {
return _logs.where((log) => log.status == SyncStatus.pending).toList();
}

@override
Future<void> markFailed(String logId) async {
_update(logId, SyncStatus.failed);
}

@override
Future<void> markSynced(String logId) async {
_update(logId, SyncStatus.synced);
}

@override
Future<void> markSyncing(String logId) async {
_update(logId, SyncStatus.syncing);
}

void _update(String logId, SyncStatus status) {
final index = _logs.indexWhere((log) => log.id == logId);
if (index == -1) return;

_logs[index] = _logs[index].copyWith(status: status);
}
}
7 changes: 7 additions & 0 deletions lib/src/bloc_like/contracts/local_data_source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
abstract class LocalDataSource<T> {
Future<void> insert(T data);
Future<void> update(T data);
Future<void> delete(String id);
Future<T?> getById(String id);
Future<List<T>> getAll();
}
10 changes: 10 additions & 0 deletions lib/src/bloc_like/contracts/sync_log_store.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import '../models/sync_log.dart';

abstract class SyncLogStore {
Future<void> add(SyncLog log);
Future<List<SyncLog>> getPendingLogs();
Future<List<SyncLog>> getFailedLogs();
Future<void> markSyncing(String logId);
Future<void> markSynced(String logId);
Future<void> markFailed(String logId);
}
56 changes: 56 additions & 0 deletions lib/src/bloc_like/controller/sync_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'dart:async';

import '../events/sync_event.dart';
import '../repository/sync_repository.dart';
import '../states/sync_state.dart';

class SyncController<T> {
final SyncRepository<T> repository;
final StreamController<SyncState> _states =
StreamController<SyncState>.broadcast();

SyncState _current = const SyncInitial();

SyncController({required this.repository}) {
_states.add(_current);
Comment on lines +9 to +15

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_states is a broadcast stream and the initial SyncInitial is added in the constructor. Any listener that subscribes after construction will not receive that initial state (broadcast streams don’t replay). If consumers are expected to observe the initial state via states, consider emitting the current state in onListen (or using a BehaviorSubject-style controller).

Suggested change
final StreamController<SyncState> _states =
StreamController<SyncState>.broadcast();
SyncState _current = const SyncInitial();
SyncController({required this.repository}) {
_states.add(_current);
late final StreamController<SyncState> _states;
SyncState _current = const SyncInitial();
SyncController({required this.repository}) {
_states = StreamController<SyncState>.broadcast(
onListen: () {
_states.add(_current);
},
);

Copilot uses AI. Check for mistakes.
}

Stream<SyncState> get states => _states.stream;

SyncState get currentState => _current;

Future<void> handle(SyncEvent<T> event) async {
_emit(const SyncInProgress());

try {
if (event is AddData<T>) {
await repository.add(event.data);
} else if (event is UpdateData<T>) {
await repository.update(event.data);
} else if (event is DeleteData<T>) {
await repository.delete(event.id);
} else if (event is StartSync<T>) {
await repository.syncPending();
} else if (event is RetryFailed<T>) {
await repository.retryFailed();
Comment thread
Harsh4114 marked this conversation as resolved.
} else {
throw UnsupportedError(
'Unsupported SyncEvent type: ${event.runtimeType}');
}

_emit(const SyncSuccess());
} catch (e) {
_emit(SyncFailure(e.toString()));
rethrow;

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller emits SyncFailure and then rethrows the exception. The README wiring example uses await engine.sync() without a try/catch and suggests state-driven handling; rethrowing will surface exceptions to callers and can crash apps if unhandled. Consider not rethrowing (state-only error reporting) or explicitly documenting that callers must handle exceptions.

Suggested change
rethrow;

Copilot uses AI. Check for mistakes.
}
}

void _emit(SyncState state) {
_current = state;
_states.add(state);
}

Future<void> dispose() async {
await _states.close();
}
}
26 changes: 26 additions & 0 deletions lib/src/bloc_like/engine/offline_sync_engine.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import '../controller/sync_controller.dart';
import '../events/sync_event.dart';
import '../states/sync_state.dart';

class OfflineSyncEngine<T> {
final SyncController<T> _controller;

OfflineSyncEngine({required SyncController<T> controller})
: _controller = controller;

Stream<SyncState> get states => _controller.states;

SyncState get currentState => _controller.currentState;

Future<void> add(T data) => _controller.handle(AddData<T>(data));

Future<void> update(T data) => _controller.handle(UpdateData<T>(data));

Future<void> delete(String id) => _controller.handle(DeleteData<T>(id));

Future<void> sync() => _controller.handle(const StartSync<T>());

Future<void> retryFailed() => _controller.handle(const RetryFailed<T>());

Future<void> dispose() => _controller.dispose();
}
26 changes: 26 additions & 0 deletions lib/src/bloc_like/events/sync_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
abstract class SyncEvent<T> {
const SyncEvent();
}

class AddData<T> extends SyncEvent<T> {
final T data;
const AddData(this.data);
}

class UpdateData<T> extends SyncEvent<T> {
final T data;
const UpdateData(this.data);
}

class DeleteData<T> extends SyncEvent<T> {
final String id;
const DeleteData(this.id);
}

class StartSync<T> extends SyncEvent<T> {
const StartSync();
}

class RetryFailed<T> extends SyncEvent<T> {
const RetryFailed();
}
35 changes: 35 additions & 0 deletions lib/src/bloc_like/models/sync_log.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
enum SyncOperationType { create, update, delete }

enum SyncStatus { pending, syncing, synced, failed }

class SyncLog {
final String id;
final String entityId;
final SyncOperationType operation;
final DateTime timestamp;
final SyncStatus status;

const SyncLog({
required this.id,
required this.entityId,
required this.operation,
required this.timestamp,
required this.status,
});

SyncLog copyWith({
String? id,
String? entityId,
SyncOperationType? operation,
DateTime? timestamp,
SyncStatus? status,
}) {
return SyncLog(
id: id ?? this.id,
entityId: entityId ?? this.entityId,
operation: operation ?? this.operation,
timestamp: timestamp ?? this.timestamp,
status: status ?? this.status,
);
}
}
Loading