flutter_form_draft 0.2.0
flutter_form_draft: ^0.2.0 copied to clipboard
Automatic form draft saving, encrypted persistence, restoration, versioning, expiration, and lifecycle recovery for Flutter applications.
flutter_form_draft #
Automatic form draft saving, restoration, versioning, expiration, lifecycle recovery, and optional encryption for Flutter applications.
flutter_form_draft helps prevent data loss when users leave a form early, the app closes, or submission fails. It works with normal Flutter forms and does not require a specific state-management library.
Features #
- Automatic debounced draft saving
- Draft restore workflows (
automatic,askUser,manual,never) - Multiple drafts per form with user isolation
- Draft expiration and cleanup
- Schema migrations for evolving forms
- Pluggable storage backends
- Authenticated AES-256-GCM payload encryption
- File storage with atomic writes and optional gzip compression
- App lifecycle and interval-based saves
- Field adapters for text, boolean, number, date, enum, list, map, custom model, and file-reference values
- Built-in restore dialog, autosave indicator, and navigation protection
- Searchable draft-management screen
- Checksum-based corruption detection
Installation #
dependencies:
flutter_form_draft: ^0.2.0
Quick start #
import 'package:flutter/material.dart';
import 'package:flutter_form_draft/flutter_form_draft.dart';
final draftManager = DraftManager(
storage: SharedPreferencesDraftStorage(),
);
final nameController = TextEditingController();
final emailController = TextEditingController();
final draftController = FormDraftController(
fields: [
TextControllerDraftField(key: 'name', controller: nameController),
TextControllerDraftField(key: 'email', controller: emailController),
],
);
FormDraft<Map<String, dynamic>>(
identity: const DraftIdentity(formId: 'contact_form'),
manager: draftManager,
controller: draftController,
serializer: const MapDraftSerializer(),
config: const DraftConfig(
restoreMode: DraftRestoreMode.automatic,
debounce: Duration(seconds: 1),
),
child: Form(
child: Column(
children: [
TextField(controller: nameController),
TextField(controller: emailController),
],
),
),
);
How automatic restoration works #
DraftRestoreMode.automatic controls what happens
after a saved draft is found. It does not make an in-memory storage backend
persistent.
When FormDraft opens, it:
- Builds the storage key from
formId,draftId,userId, andnamespace. - Reads that key from the configured
DraftStorage. - Checks expiration, checksum, encryption, and schema version.
- Writes the restored values into the registered form controllers.
Automatic restoration therefore requires all of the following:
- The draft finished saving before the old page was disposed.
- The new page uses the same
DraftIdentityvalues. - The selected storage still contains the draft.
- The draft has not expired, been discarded, or been deleted after submission.
Returning to a page #
For a small, non-sensitive form that should survive route changes and app restarts, use persistent storage:
final manager = DraftManager(
storage: SharedPreferencesDraftStorage(),
);
It is safe to call manager.close() when the page is disposed. Closing
SharedPreferencesDraftStorage releases the adapter but does not delete its
stored drafts.
MemoryDraftStorage is intentionally temporary. Its drafts exist only for the
lifetime of that storage instance, and close() clears them. If a page creates
its own memory manager and closes it in dispose(), reopening the page creates
an empty store and there is nothing to restore.
To keep memory drafts while navigating within the current app session, create one manager above the routes, pass the same manager to each page, and close it only when the owning application scope is disposed. Memory drafts still do not survive an app restart.
Saving before navigation #
DraftSaveMode.onChange waits for the configured debounce period. If custom
navigation can happen immediately after typing, flush the pending change first:
Here, draftHandle is the handle supplied by FormDraft.onHandleReady.
await draftHandle.flush();
if (context.mounted) {
Navigator.of(context).pop();
}
DraftPopScope can handle this for back navigation. Its “Save and leave” path
flushes the draft before allowing the route to close.
Storage adapters #
| Adapter | Best for | Platforms |
|---|---|---|
MemoryDraftStorage |
Tests and drafts limited to one storage-instance lifetime | All |
SharedPreferencesDraftStorage |
Small non-sensitive drafts | Mobile, web, desktop |
FileDraftStorage |
Large, nested, offline drafts | Android, iOS, Windows, macOS, Linux |
SharedPreferences persists across route recreation and app restarts, but is not suitable for large payloads or sensitive data.
File storage #
The application chooses the directory, usually with a platform directory package such as path_provider:
final manager = DraftManager(
storage: FileDraftStorage(
directoryPath: applicationSupportDirectory.path,
compress: true,
),
);
FileDraftStorage writes one hashed file per draft using temporary-file replacement. It does not support web because browsers do not expose dart:io. Compression must use the same setting when reopening an existing store.
Encryption #
Encryption is optional and works with every storage adapter. Supply a 32-byte key from platform secure storage or another protected key service:
final manager = DraftManager(
storage: FileDraftStorage(directoryPath: draftsDirectory.path),
cipher: AesDraftCipher(
secretKeyProvider: () async => secureKeyStore.loadDraftKey(),
),
);
AesDraftCipher uses AES-256-GCM with a new random nonce for every write. Form payloads, identity binding, and checksums are authenticated and encrypted. Custom listing metadata remains readable so management screens can work without decrypting form contents; never put secrets in metadata.
Keys are not generated or persisted by this package. Before rotating a key, read and rewrite existing drafts with the old key or delete them. Losing the key makes encrypted drafts unrecoverable.
Lifecycle and periodic saves #
FormDraft automatically attaches a lifecycle observer for onPause and combined modes:
const DraftConfig(
saveMode: DraftSaveMode.combined,
lifecycleSavePolicy: AppLifecycleSavePolicy.pausedDetachedAndHidden,
);
Lifecycle callbacks cannot guarantee enough time for a slow write before the operating system terminates a process. Use on-change or periodic saving as the primary protection for important forms. await handle.flush() remains available for explicit checkpoints.
Periodic saving uses periodicInterval and only writes dirty data:
const DraftConfig(
saveMode: DraftSaveMode.periodic,
periodicInterval: Duration(seconds: 30),
);
Advanced field adapters #
Standard Flutter controllers and notifiers remain usable:
final controller = FormDraftController(
fields: [
EnumDraftField<Priority>(
key: 'priority',
notifier: priorityNotifier,
values: Priority.values,
),
DateDraftField(key: 'dueDate', notifier: dueDateNotifier),
ListDraftField<String>(key: 'tags', notifier: tagsNotifier),
MapDraftField(key: 'address', notifier: addressNotifier),
ModelDraftField<Address>(
key: 'shippingAddress',
notifier: shippingAddressNotifier,
fromJson: Address.fromJson,
toJson: (address) => address.toJson(),
),
],
);
Use DraftFileReference to save durable file metadata rather than binary contents. Temporary picker paths may expire; move files into application-owned storage first. CustomDraftField remains the extension point for signatures, locations, dynamic sections, and other application-specific values.
Restore strategies #
| Mode | Behavior when a valid draft exists |
|---|---|
automatic |
Immediately writes the draft into registered fields |
askUser |
Shows DraftRestoreDialog or calls onRestoreAvailable |
manual |
Leaves restoration to handle.restoreDraft() |
never |
Leaves the stored draft untouched and does not restore it |
The restore mode does not affect how long a draft is stored. Storage lifetime
is determined by the selected DraftStorage and manager ownership.
Multiple drafts #
const identity = DraftIdentity(
formId: 'invoice_form',
draftId: 'invoice_123',
userId: 'user_45',
);
Draft management #
DraftManagementScreen searches and filters metadata without loading form payloads. It can rename, duplicate, delete, and clear expired drafts. Restoration is delegated back to the application because only the form knows its model and serializer:
DraftManagementScreen(
manager: draftManager,
onRestore: (context, metadata) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => EditInvoicePage(identity: metadata.identity),
),
);
},
);
For a custom interface, use listMetadata, updateMetadata, duplicate, delete, and clearExpired directly.
Schema migrations #
class PropertyDraftV1ToV2 extends DraftMigration {
@override
int get fromVersion => 1;
@override
int get toVersion => 2;
@override
Map<String, dynamic> migrate(Map<String, dynamic> oldData) {
return {
...oldData,
'propertyType': oldData['type'],
}..remove('type');
}
}
final manager = DraftManager(
storage: MemoryDraftStorage(),
migrationRegistry: DraftMigrationRegistry([
PropertyDraftV1ToV2(),
]),
);
Example app #
Run the bundled example:
cd example
flutter run
Security considerations #
- Do not store passwords or secrets in drafts unless excluded or encrypted
- SharedPreferences is not secure storage
- Keep encryption keys in platform secure storage; never hardcode production keys
- Custom metadata is intentionally not encrypted and must remain non-sensitive
- Encryption cannot guarantee secure deletion on flash storage or synchronized backups
Roadmap #
0.3.0: Retention policies, advanced diagnostics1.0.0: Stable public API
License #
MIT — see LICENSE.