Platform Dart License Version

๐Ÿ“ฆ unified_local_data

A universal, engine-agnostic local database abstraction layer for Flutter.
Swap between Hive, Isar, or any future engine โ€” without changing a single line of your app logic.


Table of Contents


Overview

unified_local_data is a production-grade Dart package that provides a single, unified interface for all local database operations in Flutter apps. It decouples your application logic from any specific database engine using the Strategy Pattern, enabling you to switch between Hive CE and Isar Community โ€” or plug in any future engine โ€” with zero refactoring.

The package offers two data access paths:

  • Generic Path โ€” Engine-agnostic put/get/putObject API using JSON serialization. Fully portable across all engines.
  • Native Path โ€” Direct access to engine-native features like Hive TypeAdapter and Isar @collection with full index, binary speed, and native query support.

Why unified_local_data?

Pain Point Solution
Locked into one database across all projects Engine-agnostic interface โ€” pick per project
Migrating databases requires rewriting data layer Swap via a single config change
Inconsistent API between Hive and Isar One unified API for all engines
No reactive support out of the box Built-in Stream watching for keys and collections
Complex objects need boilerplate per engine Generic DataModelAdapter<T> handles serialization universally
Querying differs wildly between engines Unified QueryBuilder<T> with filter, sort, limit, offset
Adding a new engine means rewriting everything Open/Closed Principle โ€” add engines without touching core
Can't use Hive TypeAdapters or Isar @collection natively NativeCollectionAccessor<T> gives full engine-native power

Architecture

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                     APPLICATION LAYER                        โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚   Generic Path          โ”‚   Native Path                     โ”‚
โ”‚   (Portable)            โ”‚   (Full Engine Power)             โ”‚
โ”‚                         โ”‚                                    โ”‚
โ”‚   LocalDataSource       โ”‚   NativeCollectionAccessor<T>     โ”‚
โ”‚   put/get/putObject     โ”‚   Hive: full TypeAdapter          โ”‚
โ”‚   via JSON              โ”‚   Isar: full @collection          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
             โ”‚                           โ”‚
             โ–ผ                           โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              LocalDataSourceFactory.create()                  โ”‚
โ”‚          (Singleton ยท Strategy Selection ยท Init)             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
             โ”‚                              โ”‚
             โ–ผ                              โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚     HiveDataSource         โ”‚  โ”‚     IsarDataSource            โ”‚
โ”‚     implements             โ”‚  โ”‚     implements                โ”‚
โ”‚     LocalDataSource        โ”‚  โ”‚     LocalDataSource           โ”‚
โ”‚                            โ”‚  โ”‚                               โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚  โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚ Generic: Box<dynamic>โ”‚  โ”‚  โ”‚  โ”‚ Generic: IsarKvEntry    โ”‚  โ”‚
โ”‚  โ”‚ Native: Box<T>       โ”‚  โ”‚  โ”‚  โ”‚ Native: IsarCollection  โ”‚  โ”‚
โ”‚  โ”‚ + TypeAdapter        โ”‚  โ”‚  โ”‚  โ”‚ + @collection           โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚  โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
             โ”‚                              โ”‚
             โ–ผ                              โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                    FUTURE ENGINE SLOT                         โ”‚
โ”‚         DriftDataSource / SqfliteDataSource / etc.           โ”‚
โ”‚         implements LocalDataSource                           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Supported Engines

Engine Use Case Package
Hive CE Fast key-value storage, lightweight apps, caching hive_ce
Isar Community Complex queries, relational data, large datasets isar_community
Your Engine Extend anytime โ€” zero changes to core See Extending

Installation

Add the following to your pubspec.yaml:

dependencies:
  unified_local_data:
    path: packages/unified_local_data  # or your git/pub reference

The package bundles both Hive CE and Isar Community internally. You do not need to add them separately.


Quick Start

Initialize with Hive

import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  final db = await LocalDataSourceFactory.create(
    LocalDataSourceConfig.hive(
      directory: dir.path,
      preloadBoxes: ['settings', 'cache'],
    ),
  );

  await db.put<String>('settings', 'language', 'en');
  final language = await db.get<String>('settings', 'language');
}

Initialize with Isar

import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  final db = await LocalDataSourceFactory.create(
    LocalDataSourceConfig.isar(
      directory: dir.path,
      schemas: [],
      maxSizeMiB: 512,
      inspector: true,
    ),
  );

  await db.put<int>('analytics', 'launch_count', 42);
  final count = await db.get<int>('analytics', 'launch_count');
}

That's it. The rest of your code uses the same LocalDataSource API regardless of which engine is active.


Core API Reference

All methods are available on the LocalDataSource interface returned by LocalDataSourceFactory.create().

Primitives

Store and retrieve simple Dart types (String, int, double, bool, List, Map).

await db.put<String>('settings', 'theme', 'dark');

final theme = await db.get<String>('settings', 'theme');

await db.delete('settings', 'theme');

await db.deleteAll('settings', ['theme', 'locale']);

await db.clear('settings');

await db.drop(collection: 'settings');

await db.drop();
Method Signature Description
put<T> Future<void> put<T>(String collection, String key, T value) Stores a value by key
get<T> Future<T?> get<T>(String collection, String key) Retrieves a value by key
delete Future<void> delete(String collection, String key) Removes a single key
deleteAll Future<void> deleteAll(String collection, List<String> keys) Removes several keys in one call
clear Future<void> clear(String collection) Removes all entries in a collection
drop Future<void> drop({String? collection}) Deletes a collection from disk, or the whole database when collection is omitted
dispose Future<void> dispose() Closes the database and releases all resources

clear vs drop: clear empties a collection but keeps it available for reuse. drop removes the collection itself โ€” on Hive it deletes the box file from disk, on Isar it deletes every entry belonging to that collection. Calling drop() with no argument wipes the entire database.


Complex Objects (Generic Path)

Use DataModelAdapter<T> to serialize/deserialize any custom Dart object. This path stores objects as JSON strings, making it fully portable across all engines.

Tip: If your model class already implements toJson/fromJson, you can optionally have it implement the DataModel<T> interface as a marker contract:

abstract interface class DataModel<T> {
  JsonMap toJson();
  T fromJson(JsonMap json);
}

The adapter (DataModelAdapter<T>) is what the API actually uses; DataModel<T> is an optional structural contract you can adopt for consistency.

Step 1 โ€” Define your model:

class User {
  final String id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as String,
        name: json['name'] as String,
        email: json['email'] as String,
      );

  Map<String, dynamic> toJson() => {'id': id, 'name': name, 'email': email};
}

Step 2 โ€” Create an adapter:

final userAdapter = DataModelAdapter<User>(
  fromJson: User.fromJson,
  toJson: (user) => user.toJson(),
);

Step 3 โ€” Store and retrieve:

final user = User(id: '1', name: 'Yasin', email: 'yasin@example.com');

await db.putObject<User>('users', 'user_1', user, userAdapter);

final retrieved = await db.getObject<User>('users', 'user_1', userAdapter);

final allUsers = await db.getAllObjects<User>('users', userAdapter);
Method Signature Description
putObject<T> Future<void> putObject<T>(String collection, String key, T value, DataModelAdapter<T> adapter) Stores a complex object
getObject<T> Future<T?> getObject<T>(String collection, String key, DataModelAdapter<T> adapter) Retrieves a complex object
getAllObjects<T> Future<List<T>> getAllObjects<T>(String collection, DataModelAdapter<T> adapter) Retrieves all objects in a collection

Batch Operations

Write multiple entries in a single call for better performance.

await db.putAll<String>('settings', {
  'theme': 'dark',
  'language': 'en',
  'font_size': '16',
});

await db.putAllObjects<User>('users', {
  'user_1': User(id: '1', name: 'Yasin', email: 'yasin@example.com'),
  'user_2': User(id: '2', name: 'Ali', email: 'ali@example.com'),
}, userAdapter);
Method Signature Description
putAll<T> Future<void> putAll<T>(String collection, Map<String, T> entries) Batch stores primitives
putAllObjects<T> Future<void> putAllObjects<T>(String collection, Map<String, T> entries, DataModelAdapter<T> adapter) Batch stores complex objects

Collection Utilities

final keys = await db.getAllKeys('users');

final exists = await db.containsKey('users', 'user_1');

final total = await db.count('users');

final allValues = await db.getAll<String>('settings');
Method Signature Description
getAll<T> Future<List<T>> getAll<T>(String collection) All raw values in a collection
getAllKeys Future<List<String>> getAllKeys(String collection) All keys in a collection
containsKey Future<bool> containsKey(String collection, String key) Checks key existence
count Future<int> count(String collection) Entry count in a collection

Reactive Streams

Subscribe to real-time changes on keys, collections, or object collections.

db.watch<String>('settings', 'theme').listen((value) {
  print('Theme changed to: $value');
});

db.watchObject<User>('users', 'user_1', userAdapter).listen((user) {
  print('User changed: ${user?.name}');
});

db.watchAll<String>('settings').listen((values) {
  print('Settings updated: $values');
});

db.watchAllObjects<User>('users', userAdapter).listen((users) {
  print('Users updated: ${users.length}');
});
Method Signature Description
watch<T> Stream<T?> watch<T>(String collection, String key) Watches a single key for changes
watchObject<T> Stream<T?> watchObject<T>(String collection, String key, DataModelAdapter<T> adapter) Watches a single key as a typed object; emits null when the key is absent
watchAll<T> Stream<List<T>> watchAll<T>(String collection) Watches entire collection
watchAllObjects<T> Stream<List<T>> watchAllObjects<T>(String collection, DataModelAdapter<T> adapter) Watches collection as typed objects

All streams emit the current value immediately upon subscription, then emit on every subsequent change.


Query Builder

Build fluent, chainable queries with filtering, sorting, pagination, and reactive watching.

final activeUsers = await db
    .query<User>('users', userAdapter)
    .where((user) => user.name.startsWith('Y'))
    .sortBy((a, b) => a.name.compareTo(b.name))
    .limit(20)
    .offset(0)
    .findAll();

final firstMatch = await db
    .query<User>('users', userAdapter)
    .where((user) => user.email.contains('@example.com'))
    .findFirst();

final total = await db
    .query<User>('users', userAdapter)
    .where((user) => user.id.isNotEmpty)
    .count();

db.query<User>('users', userAdapter)
    .where((user) => user.name.contains('Yasin'))
    .sortBy((a, b) => a.name.compareTo(b.name))
    .limit(10)
    .watch()
    .listen((users) {
  print('Matching users: ${users.length}');
});
Method Signature Description
query<T> QueryBuilder<T> query<T>(String collection, DataModelAdapter<T> adapter) Creates a query builder
.where() QueryBuilder<T> where(bool Function(T) clause) Adds a filter predicate
.sortBy() QueryBuilder<T> sortBy(int Function(T, T) comparator) Sets sort order
.limit() QueryBuilder<T> limit(int count) Limits result count
.offset() QueryBuilder<T> offset(int start) Skips first N results
.findAll() Future<List<T>> findAll() Executes and returns all matches
.findFirst() Future<T?> findFirst() Executes and returns first match
.count() Future<int> count() Returns number of matches
.deleteAll() Future<bool> deleteAll() Deletes all matched objects (native engines only; throws on generic in-memory path)
.watch() Stream<List<T>> watch() Watches query results reactively

Transactions

Wrap multiple operations in a transaction for atomicity.

await db.transaction(() async {
  await db.put<String>('settings', 'theme', 'dark');
  await db.put<String>('settings', 'language', 'en');
  await db.putObject<User>('users', 'user_1', newUser, userAdapter);
});

Hive: Operations are executed sequentially (Hive has no native transaction support).

Isar: Operations are wrapped in a native writeTxn for true ACID compliance.


Native Engine Access

Understanding the Two Paths

The package provides two distinct ways to store complex objects:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        DECISION FLOWCHART                            โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚                                                                      โ”‚
โ”‚  Need to swap engines freely across projects?                        โ”‚
โ”‚  โ”œโ”€ YES โ†’ Use Generic Path                                          โ”‚
โ”‚  โ”‚        db.putObject / db.getObject                                โ”‚
โ”‚  โ”‚        Uses DataModelAdapter<T> + JSON serialization              โ”‚
โ”‚  โ”‚        โœ… Works on ALL engines without changes                    โ”‚
โ”‚  โ”‚        โš ๏ธ No engine-native indexes or binary speed                โ”‚
โ”‚  โ”‚                                                                   โ”‚
โ”‚  โ””โ”€ NO โ†’ Engine is fixed for this project?                           โ”‚
โ”‚     โ”œโ”€ YES โ†’ Use Native Path                                         โ”‚
โ”‚     โ”‚        db.native<T>('collection')                              โ”‚
โ”‚     โ”‚        โœ… Full Hive TypeAdapter support                        โ”‚
โ”‚     โ”‚        โœ… Full Isar @collection + indexes                     โ”‚
โ”‚     โ”‚        โœ… Binary speed, zero JSON overhead                    โ”‚
โ”‚     โ”‚        โš ๏ธ Models are engine-specific                           โ”‚
โ”‚     โ”‚                                                                โ”‚
โ”‚     โ””โ”€ Need Isar's full .filter().sortBy() generated DSL?           โ”‚
โ”‚        โ””โ”€ Cast to IsarDataSource, use rawInstance                    โ”‚
โ”‚           โœ… Complete Isar native query power                       โ”‚
โ”‚           โŒ Completely tied to Isar                                โ”‚
โ”‚                                                                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Path Portability Performance Query Power Best For
Generic (putObject) โœ… Any engine Good In-memory filter Settings, cache, portable data
Native (db.native<T>()) โš ๏ธ Engine-specific models Best In-memory filter Domain entities with TypeAdapter / @collection
Raw (rawInstance) โŒ Single engine only Best Full native DSL Complex Isar queries with composite indexes

NativeCollectionAccessor API

The NativeCollectionAccessor<T> interface provides a unified API for working with engine-native models:

Method Signature Description
put Future<void> put(T item) Stores a single native object
putAll Future<void> putAll(List<T> items) Batch stores native objects
getById Future<T?> getById(dynamic id) Retrieves by ID
getAll Future<List<T>> getAll() Retrieves all objects
deleteById Future<void> deleteById(dynamic id) Deletes by ID
deleteAll Future<void> deleteAll() Clears all objects
count Future<int> count() Returns object count
watchAll Stream<List<T>> watchAll() Watches all objects reactively
watchById Stream<T?> watchById(dynamic id) Watches a single object reactively
query QueryBuilder<T> query() Creates a chainable query builder

Hive โ€” Native TypeAdapter Flow

Use Hive's native TypeAdapter for maximum binary serialization speed.

Step 1 โ€” Define your Hive model:

import 'package:hive_ce/hive_ce.dart';

part 'user_entity.g.dart';

@HiveType(typeId: 1)
class UserEntity extends HiveObject {
  @HiveField(0)
  final String id;

  @HiveField(1)
  final String name;

  @HiveField(2)
  final String email;

  @HiveField(3)
  final DateTime createdAt;

  UserEntity({
    required this.id,
    required this.name,
    required this.email,
    required this.createdAt,
  });
}

Step 2 โ€” Run code generation:

dart run build_runner build --delete-conflicting-outputs

Step 3 โ€” Register adapter, open typed box, and use:

import 'package:hive_ce/hive_ce.dart';
import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';

import 'models/user_entity.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  Hive.registerAdapter(UserEntityAdapter());

  final db = await LocalDataSourceFactory.create(
    LocalDataSourceConfig.hive(directory: dir.path),
  );

  final hiveDb = db as HiveDataSource;
  await hiveDb.openTypedBox<UserEntity>('users');

  final users = db.native<UserEntity>('users');

  await users.put(UserEntity(
    id: '1',
    name: 'Yasin',
    email: 'yasin@example.com',
    createdAt: DateTime.now(),
  ));

  await users.putAll([
    UserEntity(
      id: '2',
      name: 'Ali',
      email: 'ali@example.com',
      createdAt: DateTime.now(),
    ),
  ]);

  // NOTE: HiveNativeAccessor.putAll() uses box.addAll() โ€” items are appended
  // with auto-incremented integer keys. For keyed upsert, call put() individually.

  final allUsers = await users.getAll();

  final yasin = await users.getById('1');

  users.watchAll().listen((userList) {
    print('Users changed: ${userList.length}');
  });

  final filtered = await users
      .query()
      .where((u) => u.name.startsWith('Y'))
      .sortBy((a, b) => a.createdAt.compareTo(b.createdAt))
      .limit(10)
      .findAll();
}

Isar โ€” Native @collection Flow

Use Isar's native @collection annotation for full index and schema support.

Step 1 โ€” Define your Isar model:

import 'package:isar_community/isar_community.dart';

part 'product_entity.g.dart';

@collection
class ProductEntity {
  Id id = Isar.autoIncrement;

  @Index()
  late String title;

  late double price;

  @Index(composite: [CompositeIndex('price')])
  late String category;

  late DateTime createdAt;
}

@embedded
class Address {
  late String street;
  late String city;
  late String zipCode;
}

Step 2 โ€” Run code generation:

dart run build_runner build --delete-conflicting-outputs

Step 3 โ€” Pass schemas at init and use:

import 'package:unified_local_data/local_data_source.dart';
import 'package:path_provider/path_provider.dart';

import 'models/product_entity.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();

  final db = await LocalDataSourceFactory.create(
    LocalDataSourceConfig.isar(
      directory: dir.path,
      schemas: [ProductEntitySchema],
    ),
  );

  final products = db.native<ProductEntity>('products');

  await products.put(
    ProductEntity()
      ..title = 'Flutter Widget'
      ..price = 29.99
      ..category = 'Development'
      ..createdAt = DateTime.now(),
  );

  await products.putAll([
    ProductEntity()
      ..title = 'Dart Package'
      ..price = 9.99
      ..category = 'Development'
      ..createdAt = DateTime.now(),
    ProductEntity()
      ..title = 'UI Kit'
      ..price = 49.99
      ..category = 'Design'
      ..createdAt = DateTime.now(),
  ]);

  final allProducts = await products.getAll();

  final product = await products.getById(1);

  products.watchAll().listen((productList) {
    print('Products changed: ${productList.length}');
  });

  final expensive = await products
      .query()
      .where((p) => p.price > 20.0)
      .sortBy((a, b) => b.price.compareTo(a.price))
      .limit(5)
      .findAll();
}

Isar โ€” Raw Instance Access

When you need Isar's full native query DSL โ€” generated .filter(), .sortBy(), composite index queries โ€” access the raw Isar instance directly:

import 'package:isar_community/isar_community.dart';
import 'package:unified_local_data/local_data_source.dart';

import 'models/product_entity.dart';

Future<void> advancedIsarQuery(LocalDataSource db) async {
  final isarDb = db as IsarDataSource;
  final isar = isarDb.rawInstance;

  final results = await isar.productEntitys
      .filter()
      .categoryEqualTo('Development')
      .and()
      .priceGreaterThan(10.0)
      .sortByPriceDesc()
      .limit(20)
      .findAll();

  isar.productEntitys
      .filter()
      .priceGreaterThan(50)
      .watch(fireImmediately: true)
      .listen((premiumProducts) {
    print('Premium products: ${premiumProducts.length}');
  });
}

โš ๏ธ Warning: Using rawInstance ties your code directly to Isar. This should be limited to repository implementations where you have consciously chosen Isar as the engine.


When to Use Which Path

Scenario Recommended Path Why
App settings, feature flags, simple cache Generic put/get Simple key-value, no need for native features
User profile, auth tokens Generic putObject Portable across engines, easy to serialize
Large product catalog with search Native db.native<T>() Benefits from Isar indexes and binary speed
Chat messages with complex queries Native + Raw Need Isar composite indexes and full-text search
Offline-first app with sync Native db.native<T>() Need full TypeAdapter/collection for performance
Shared package used across many projects Generic path only Maximum portability, no engine lock-in
Prototype / MVP Generic path Ship fast, optimize later

Swapping Engines

The entire point of this package โ€” swap your database with one config change:

// Using Hive
final db = await LocalDataSourceFactory.create(
  LocalDataSourceConfig.hive(directory: dir.path),
);

// Switch to Isar โ€” no other code changes needed (for Generic Path)
final db = await LocalDataSourceFactory.create(
  LocalDataSourceConfig.isar(directory: dir.path, schemas: []),
);

Swap at runtime:

// reset() calls dispose() on the current instance, then clears it
await LocalDataSourceFactory.reset();

await LocalDataSourceFactory.create(
  LocalDataSourceConfig.isar(directory: dir.path, schemas: []),
);

final db = LocalDataSourceFactory.instance;

You can also dispose a specific instance directly:

await db.dispose(); // closes the DB and frees all resources

Note: create() automatically disposes any existing instance before creating the new one, so you don't need to call reset() first when simply switching configs.

Code using the Generic Path (put/get/putObject) swaps seamlessly. Code using the Native Path (db.native<T>()) requires engine-specific models and may need adjustment when switching engines.


Extending with a New Engine

Adding a new database engine (e.g., Drift, Sqflite) requires zero changes to the core abstraction. This is the Open/Closed Principle in action.

Step 1 โ€” Add the engine enum value:

enum DatabaseEngine { hive, isar, drift }

Step 2 โ€” Create engine-specific config:

final class DriftDataSourceConfig extends LocalDataSourceConfig {
  final String databaseName;

  const DriftDataSourceConfig({
    required super.directory,
    required this.databaseName,
  }) : super(engine: DatabaseEngine.drift);
}

Step 3 โ€” Implement the interface:

final class DriftDataSource implements LocalDataSource {
  final DriftDataSourceConfig config;

  DriftDataSource({required this.config});

  @override
  Future<void> init() async { /* Drift initialization */ }

  @override
  Future<void> put<T>(String collection, String key, T value) async { /* ... */ }

  @override
  NativeCollectionAccessor<T> native<T>(String collection) { /* ... */ }

  // ... implement all other methods
}

Step 4 โ€” Register in the factory (one line):

final LocalDataSource dataSource = switch (config) {
  HiveDataSourceConfig() => HiveDataSource(config: config),
  IsarDataSourceConfig() => IsarDataSource(config: config),
  DriftDataSourceConfig() => DriftDataSource(config: config),
};

That's it. No existing code changes. Pure Open/Closed Principle.


Error Handling

The package uses a sealed exception hierarchy for exhaustive, compile-time checked error handling:

try {
  final value = await db.get<String>('settings', 'theme');
} on DataSourceNotInitializedException {
  // Database not initialized โ€” call init() first
} on DataSourceReadException catch (e) {
  print('Read error: ${e.message}, cause: ${e.cause}');
} on DataSourceWriteException catch (e) {
  print('Write error: ${e.message}, cause: ${e.cause}');
} on DataNotFoundException catch (e) {
  // Requested data does not exist
} on UnsupportedOperationException catch (e) {
  // Operation not supported by current engine
} on LocalDataSourceException catch (e) {
  // Catch-all for any data source error
}
Exception When
DataSourceNotInitializedException init() was not called before usage
DataSourceReadException A read operation fails (corruption, cast error, etc.)
DataSourceWriteException A write operation fails (disk full, permission, etc.)
DataNotFoundException Explicitly requested data does not exist
UnsupportedOperationException Operation not available on current engine

Stream Extensions

The package includes utility extensions for working with reactive streams:

import 'package:unified_local_data/local_data_source.dart';

db.watchAll<String>('settings')
    .debounceTime(Duration(milliseconds: 300))
    .listen((values) {
  print('Debounced update: $values');
});

db.watch<String>('settings', 'theme')
    .distinctUntilChanged()
    .listen((value) {
  print('Theme actually changed to: $value');
});
Extension Description
.debounceTime(Duration) Waits for a pause in emissions before forwarding
.distinctUntilChanged() Filters out consecutive duplicate values

Design Patterns

Pattern Where Purpose
Strategy LocalDataSource interface + engine implementations Swap database engine without changing consumer code
Factory LocalDataSourceFactory Unified creation with sealed config type matching
Adapter DataModelAdapter<T> + NativeCollectionAccessor<T> Bridge between domain models and storage
Repository Each engine implementation Encapsulates all data access behind a single contract
Singleton LocalDataSourceFactory._instance Single database instance across the app
Builder QueryBuilder<T> chain Fluent query construction with deferred execution
Sealed Class Exceptions hierarchy Exhaustive compile-time checked exception types
Abstract Class LocalDataSourceConfig Shared config base with engine-specific subclasses

Folder Structure

local_data_source/
โ”œโ”€โ”€ pubspec.yaml
โ”œโ”€โ”€ lib/
โ”‚   โ”œโ”€โ”€ local_data_source.dart                     # Public barrel export
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ core/
โ”‚       โ”‚   โ”œโ”€โ”€ local_data_source.dart             # Base abstract interface
โ”‚       โ”‚   โ”œโ”€โ”€ local_data_source_config.dart      # Unified configuration (abstract)
โ”‚       โ”‚   โ”œโ”€โ”€ local_data_source_factory.dart     # Factory entry point
โ”‚       โ”‚   โ”œโ”€โ”€ data_model.dart                    # DataModel<T> interface + DataModelAdapter<T>
โ”‚       โ”‚   โ”œโ”€โ”€ native_collection_accessor.dart    # Native engine access interface
โ”‚       โ”‚   โ”œโ”€โ”€ query_builder.dart                 # Abstract + in-memory query builder
โ”‚       โ”‚   โ”œโ”€โ”€ exceptions.dart                    # Sealed exception hierarchy
โ”‚       โ”‚   โ””โ”€โ”€ typedefs.dart                      # Shared type aliases
โ”‚       โ”œโ”€โ”€ engines/
โ”‚       โ”‚   โ”œโ”€โ”€ hive/
โ”‚       โ”‚   โ”‚   โ”œโ”€โ”€ hive_data_source.dart          # Hive implementation
โ”‚       โ”‚   โ”‚   โ”œโ”€โ”€ hive_native_accessor.dart      # Hive TypeAdapter accessor
โ”‚       โ”‚   โ”‚   โ””โ”€โ”€ hive_config.dart               # Hive-specific configuration
โ”‚       โ”‚   โ””โ”€โ”€ isar/
โ”‚       โ”‚       โ”œโ”€โ”€ isar_data_source.dart          # Isar implementation
โ”‚       โ”‚       โ”œโ”€โ”€ isar_native_accessor.dart      # Isar @collection accessor
โ”‚       โ”‚       โ”œโ”€โ”€ isar_config.dart               # Isar-specific configuration
โ”‚       โ”‚       โ”œโ”€โ”€ isar_kv_entry.dart             # Internal @collection for KV ops
โ”‚       โ”‚       โ””โ”€โ”€ isar_kv_entry.g.dart           # Generated Isar schema (build_runner)
โ”‚       โ””โ”€โ”€ extensions/
โ”‚           โ””โ”€โ”€ stream_extensions.dart             # Reactive stream utilities
โ””โ”€โ”€ test/
    โ””โ”€โ”€ local_data_source_test.dart

FAQ

Can I use this in a pure Dart project (no Flutter)?

Yes, but you will need to provide the directory path manually instead of using path_provider. The core interface and Hive engine work in pure Dart. Isar also supports pure Dart with manual library setup.

Do I need to register Hive TypeAdapters manually?

Generic Path: No. Objects are serialized to JSON via DataModelAdapter<T>, avoiding the need for Hive TypeAdapter registration.

Native Path: Yes. Register your TypeAdapters before opening typed boxes:

Hive.registerAdapter(UserEntityAdapter());
final hiveDb = db as HiveDataSource;
await hiveDb.openTypedBox<UserEntity>('users');
Do I need to define Isar @collection schemas?

Generic Path: No. The package uses an internal IsarKvEntry collection for key-value storage.

Native Path: Yes. Define your @collection classes, run build_runner, and pass schemas at initialization:

LocalDataSourceConfig.isar(
  directory: dir.path,
  schemas: [ProductEntitySchema, OrderEntitySchema],
)
Can I mix Generic and Native paths in the same project?

Absolutely. This is a common and recommended pattern:

// Generic: app settings (portable)
await db.put<String>('settings', 'theme', 'dark');

// Native: domain entities (engine-optimized)
final products = db.native<ProductEntity>('products');
await products.put(myProduct);
Can I access the raw Hive/Isar instance?

Yes. Both engine implementations expose their raw instance for advanced use cases:

// Isar
final isarDb = db as IsarDataSource;
final isar = isarDb.rawInstance;

// Hive โ€” access typed boxes directly
final hiveDb = db as HiveDataSource;
final box = await hiveDb.openTypedBox<UserEntity>('users');

This is intentionally not part of the LocalDataSource interface to maintain abstraction.

Is this thread-safe?

Yes. Both Hive and Isar handle concurrent access internally. The package uses async/await throughout and avoids shared mutable state beyond the singleton instance.

What happens if I call methods before init()?

A DataSourceNotInitializedException is thrown immediately. Always call LocalDataSourceFactory.create() before accessing the database.

How do I encrypt my data?

With Hive, pass an encryption config:

LocalDataSourceConfig.hive(
  directory: dir.path,
  encryption: HiveEncryptionConfig(key: mySecureKey),
);

Isar Community does not support encryption natively. You can encrypt at the application layer using the DataModelAdapter<T> serialization step.

When should I call dispose()?

Call dispose() when your app is shutting down or when you want to explicitly release database resources. Both engine implementations close all open boxes/databases and clear internal caches.

// Dispose directly on the instance
await db.dispose();

// Or via the factory (also clears the singleton)
await LocalDataSourceFactory.reset();

LocalDataSourceFactory.create() automatically calls dispose() on any existing instance before opening the new one, so manual disposal is only needed on app exit or in tests.

How do I handle schema migrations?

Generic Path: Since objects are stored as JSON, schema evolution is handled by your model's fromJson factory. Add default values for new fields and handle missing keys gracefully.

Native Path (Hive): Hive TypeAdapters handle field additions via @HiveField indices. New fields with higher indices are backward-compatible.

Native Path (Isar): Isar handles schema migrations automatically for non-breaking changes (adding fields, indexes). For breaking changes, consult the Isar migration guide.

What if my engine doesn't support a specific operation?

The engine implementation should throw UnsupportedOperationException for operations it cannot fulfill. The sealed exception hierarchy allows you to handle this gracefully:

try {
  await db.transaction(() async { /* ... */ });
} on UnsupportedOperationException {
  // Fallback: execute operations without transaction
}

License

MIT License

Copyright (c) 2026

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Built with โค๏ธ for the Flutter community
One interface. Any engine. Zero rewrites.
Generic when you need portability. Native when you need power.

Libraries

unified_local_data