Preference
A durable, local-first Flutter/Dart storage engine for app state and typed
offline entities—currently in alpha. Use Preference like a typed
shared_preferences-style store for settings, then grow into typed collections
for work orders, assets, drafts, and cached API responses without adding a
second local store.
Alpha-phase notice —
0.1.0Preference is ready for hands-on evaluation on native platforms. Its file format and parts of the public API may change before
1.0.0. It is not yet a SQL database, a sync service, or an encrypted store. Test upgrades against a copy of important data. For issues, feedback, or alpha-access support, email frostcraftdev@gmail.com.
Contents
- What is included
- Platform support
- Install
- Open a database
- Use case 1: flat key-value storage
- Use case 2: typed collections
- Custom model serializers
- Indexes and queries
- Reactive updates
- Preference Studio and Flutter VM Service
- How Preference compares with shared_preferences
- Security and data lifecycle
- Current alpha limits
- Roadmap
What is included
- Durable append-only native file storage with recovery of a truncated write tail.
- Typed root key-value reads and writes for
String,int,double, andbool. - Custom serializers for any Dart model.
- Logical collections with stable record IDs.
- Atomic root-key batches; a collection write updates its item and declared indexes in one commit.
- Change streams for keys, collection items, whole collections, and database commits.
- Logical TTL expiry for flat keys.
- Collection lookup indexes for direct unique-style and non-unique lookups.
- Read-only local inspection and Flutter VM Service inspection for debug builds.
- Compaction to reclaim expired and superseded records.
Platform support
The 0.1.0 alpha-phase release supports native Dart VM/file platforms:
| Platform | Status |
|---|---|
| Android 10+ (API 29+) | Supported alpha |
| iOS | Supported alpha |
| macOS, Windows, Linux | Supported alpha |
| Web | Not supported in this alpha |
For Flutter, open the database inside a private, persistent application
directory. path_provider is a common way to obtain one.
Install
dependencies:
preference_flutter: ^0.1.1
path_provider: ^2.1.0 # Flutter apps: recommended for a safe database path
Then run:
flutter pub get
Open a database
Flutter
import 'package:path_provider/path_provider.dart';
import 'package:preference_flutter/preference_flutter.dart';
final directory = await getApplicationSupportDirectory();
final database = await Preference.open(
'${directory.path}/field_notes.pref',
appId: 'com.example.field_notes',
databaseId: 'field_notes',
);
Do not place a database in external/shared storage or a directory the operating system can clear. Close it when the owning service is permanently disposed:
await database.close();
Dart command-line app
import 'dart:io';
import 'package:preference_flutter/preference_flutter.dart';
final database = await Preference.open(
'${Directory.current.path}/field_notes.pref',
);
Use case 1: flat key-value storage
Use flat keys for small application state: settings, feature flags, counters, timestamps represented as strings, and short-lived cached values.
await database.set<String>('settings/theme', 'dark');
await database.set<bool>('settings/biometricsEnabled', true);
await database.set<int>('sync/lastSuccessfulAtMs', 1785524116836);
final theme = await database.get<String>(
'settings/theme',
defaultValue: 'system',
);
get<T>() returns null for a missing key unless a defaultValue is supplied.
Use the same type for every read and write of a key.
Atomic root-key batch
await database.batch((batch) {
batch.set<String>('settings/theme', 'dark');
batch.set<bool>('settings/biometricsEnabled', true);
batch.remove('cache/legacyPayload');
});
The batch becomes visible as one commit.
TTL for a flat key
await database.set<String>(
'cache/assignment-preview',
responseBody,
ttl: const Duration(minutes: 10),
);
After expiry, Preference treats the key as missing. TTL is logical expiry, not
secure deletion; call compact() to reclaim expired and superseded file space.
await database.compact();
Use case 2: typed collections
Use collections for application entities. A work order, asset, draft, or task gets a stable ID and is stored independently from the rest of the collection. Editing one asset does not require rewriting an entire work-order payload.
final assets = database.collection<Asset>('assets');
await assets.set(asset.id, asset);
final asset = await assets.get(assetId);
await assets.remove(assetId);
Collection records are internally namespaced as assets/<id>. Root keys and
different collections cannot collide.
For offline-first backend data, a practical shape is:
workOrders/<workOrderUuid>
assets/<assetUuid>
syncOutbox/<operationUuid>
settings/<settingName>
Keep backend IDs as record IDs. Add model fields such as serverRevision,
updatedAt, and syncState when your app needs conflict handling later.
Custom model serializers
Preference deliberately does not guess how to serialize your models. Register
a PreferenceSerializer<T> when opening the database. A JSON serializer keeps
records understandable in debug inspection tools.
import 'dart:convert';
import 'dart:typed_data';
import 'package:preference_flutter/preference_flutter.dart';
class Asset {
const Asset({required this.id, required this.title, required this.status});
final String id;
final String title;
final String status;
Map<String, Object?> toJson() => {
'id': id,
'title': title,
'status': status,
};
factory Asset.fromJson(Map<String, dynamic> json) => Asset(
id: json['id'] as String,
title: json['title'] as String,
status: json['status'] as String,
);
}
class AssetSerializer implements PreferenceSerializer<Asset> {
@override
Type get type => Asset;
@override
Uint8List encode(Asset asset) =>
Uint8List.fromList(utf8.encode(jsonEncode(asset.toJson())));
@override
Asset decode(Uint8List bytes) =>
Asset.fromJson(jsonDecode(utf8.decode(bytes)) as Map<String, dynamic>);
}
final database = await Preference.open(
databasePath,
serializers: [AssetSerializer()],
);
final assets = database.collection<Asset>('assets');
String, int, double, and bool serializers are built in. Values such as
DateTime, lists, maps, and custom models need an application serializer (or
an explicit string representation such as ISO-8601 JSON).
Indexes and queries
Declare indexes when you need direct lookup by a model field. Non-unique
indexes return all matching records. A unique: true index provides a
single-record lookup; duplicate-value rejection is not yet part of this
alpha, so do not use it as a business-rule constraint yet.
final assets = database.collection<Asset>(
'assets',
schema: CollectionSchema<Asset>(
indexes: [
Index<Asset>(
name: 'status',
selector: (asset) => asset.status,
),
Index<Asset>(
name: 'title',
unique: true,
selector: (asset) => asset.title,
),
],
),
);
final openAssets = await assets.find('status', 'open');
final extinguisher = await assets.findUnique('title', 'Fire extinguisher A');
For ad-hoc in-app filtering, use query. It scans that collection, so declare
an index for repeated field lookups on larger datasets.
final urgentAssets = await assets.query(
(asset) => asset.status == 'open',
limit: 50,
offset: 0,
);
Reactive updates
Watch a single flat key:
final subscription = database.watch<String>('settings/theme').listen((theme) {
print('Theme is now $theme');
});
await database.set<String>('settings/theme', 'dark');
await subscription.cancel();
Watch a single item or a complete collection:
assets.watchItem(assetId).listen((asset) {
print('Asset changed: $asset');
});
assets.watch().listen((allAssets) {
print('There are ${allAssets.length} assets');
});
Observe low-level committed changes for logging or future sync triggers:
database.changes.listen((change) {
print('${change.transactionId}: ${change.type} ${change.key}');
});
Preference Studio and Flutter VM Service
Preference Studio is coming soon. It is currently in development and testing. Its first public release is intentionally read-only: it is built to browse collections, records, JSON, and runtime activity without changing a user's application data. Record editing and other mutations are not supported yet.
When Studio is publicly available, connect it to a debug Flutter application by
copying the Dart VM Service address printed by flutter run. Studio accepts
the HTTP address and its WebSocket form, so no application-side server or npm
process is required. Until Studio is publicly released, application logs and
the database.changes stream remain the most reliable runtime visibility
tools.
For a debug build, enable the inspector after opening the database:
final database = await Preference.open(
databasePath,
serializers: [AssetSerializer()],
appId: 'com.example.field_notes',
databaseId: 'field_notes',
);
database.enableInspector(); // Debug/development builds only.
Run the app with flutter run. Flutter prints a VM Service address similar to:
The Dart VM service is listening on
http://127.0.0.1:5xxxx/2xxxxxxxxxx=/
To connect from Preference Studio, use its WebSocket form:
ws://127.0.0.1:5xxxx/2xxxxxxxxxx=/ws
The port and token are generated per debug run; always use the address Flutter
prints for the current session. Studio also accepts the corresponding http
address and resolves the local debug-service redirect when possible. Do not
enable remote inspection in production builds.
PostgreSQL, MySQL, SQLite/sqflite, and broader source visibility are planned Studio connectors. They are roadmap items, not alpha features.
How Preference compares with shared_preferences
Preference can be used like shared_preferences for simple settings, but it
also supports durable typed collections when your app outgrows flat values.
| Need | shared_preferences style |
Preference |
|---|---|---|
| Theme, flag, small counter | Flat primitive key | database.set<T>(key, value) |
| Read with fallback | Getter with fallback | database.get<T>(key, defaultValue: value) |
| Typed model | Manual JSON around a string | Custom serializer + collection<T>() |
| Stable entity records | Key naming convention | Collection ID + record ID |
| Related root updates | Multiple writes | Atomic database.batch() |
| React to a change | App-managed state | watch, watchItem, collection watch, and changes streams |
| Expiring cache entry | App-managed timestamp | Flat-key ttl |
Choose shared_preferences when only small platform preference values are
needed. Choose Preference when you want those simple keys and a path to
typed local application data without introducing a separate store.
Security and data lifecycle
Preference's alpha file backend is not encrypted at rest. Android/iOS app sandboxing helps protect private application files on ordinary devices, but it is not a replacement for cryptographic database encryption. Do not store passwords, private keys, or long-lived secrets in Preference alone.
Store small secrets and future database-key material in Android Keystore/iOS Keychain through a secure-storage solution. For security questions or responsible disclosure, email frostcraftdev@gmail.com.
Current alpha limits
- Web/IndexedDB storage is intentionally unavailable.
- Encryption at rest and key management are not implemented.
- Cloud sync, conflict resolution, and database migrations are not implemented.
- TTL is available for flat keys only; collection
set()does not yet accept a TTL parameter. unique: trueindexes provide lookup behavior only in this alpha; duplicate values are not rejected yet.- Collection
query()is predicate-based; it is not SQL or full-text search. - Preference Studio is read-only and in active development/testing. Record editing, mutations, and non-Preference connectors are not alpha features.
Roadmap
- Security-reviewed authenticated encryption at rest and platform key storage.
- Collection TTL, migrations, and richer query capabilities.
- Web storage after it meets the native durability gate.
- A public, read-only Preference Studio release, then safe editing/mutations, PostgreSQL/MySQL/SQLite-sqflite visibility, and local-first sync primitives.
Built deliberately for local-first apps
Preference is not a wrapper around shared_preferences, SQLite, or another
database. Its Dart-native storage engine owns the compact .pref binary
format, append-only write log, transaction model, and recovery behavior.
Writes are committed as atomic batches and flushed to the local file before they complete. Each stored batch is integrity-checked, so Preference can safely recover from an interrupted or truncated write tail when the app opens the database again. Compaction later reclaims superseded and expired data.
We chose explicit serializers, explicit database locations, and a small public API over hidden ORM behavior or global state. The goal is simple: keep application data local, durable, understandable, and ready for better tooling over time. The engine is separate from inspection tooling through a protocol layer, which lets Preference Studio, diagnostics, and future sync evolve without changing how an app stores data.
Example
See example/preference_example.dart for a
small runnable field-note application that combines settings, a typed model,
an index, a collection query, and a change stream.
License
See LICENSE.
Libraries
- preference_flutter
- Durable, local-first storage for Flutter and Dart applications.