All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Major release: offline-first foundation, server-driven offline-mode toggle, and a new query/sync surface. Upgrades from 1.x use a single transactional migration from database schema v2 to v3.
Offline foundation
OfflineModevalue object (enabled,isPersisted) bound to a session, plusOfflineModeNotifier. The mode is persisted insdk_metaand re-resolved on each launch.OfflineRepository— primary write path for local edits; manages per-doctype tables and child rows viaLocalWriter.OfflineTransitionServicewith sealed-state stream (TransitionIdle,TransitionDraining,TransitionDrainFailed,TransitionWipingTables,TransitionCompleted) plusrunDrainAndWipe(),retry(),forceExit(). Drives the offline → online transition: drains pending records, then drops local data tables.OfflineTransitionScreen— full-screen transition UI withPopScopeguard; drain progress, drain failure, retry, and force-exit flows.OfflineTransitionGuard— wraps a child widget and overlays the transition screen for as long as the SDK's transition stream is non-idle. Recommended integration point for consumer apps.AtomicWipe— deletes and rebuilds the SQLite database atomically; used during logout-wipe.FrappeSDK.offlineTransitiongetter andrunOfflineTransitionIfPending()for explicit foreground orchestration.
Query / read path
UnifiedResolver— single offline-first read path used by Link pickers, list screens, andfetch_from. DB-first; background API refresh when online.FilterParser+ParsedQuery— translate Frappe-style filter lists into parameter-bound SQLite queries; pure functions, no I/O.QueryResult+RowOrigin— read results carry per-row provenance (local edit vs server) for filter chips and observability.LinkDecorator+TargetMetaResolver— display companions for Link / Dynamic Link values.FrappeTimespan+TimespanRange— Frappe-style timespan keywords resolved to absolute ISO ranges offline.
Link fields
LinkFilterBuildercallback — runtime filter builder for link-option fetch, keyed on the target doctype. Replaces staticlinkFiltersJSON when dynamic field/row dependencies are needed.LinkOptionService— offline-first Link picker; routes throughUnifiedResolverwith DB-first reads and background refresh.LinkFieldCoordinator— dependency-aware sequencing and progress tracking for cascading Link fields.
Sync engine
- Cursor-based pull.
SyncServiceuses(modified, name)cursors withDoctypePullPhase(initial/resume/incremental), look-ahead pagination, and resume-on-crash. Final-page lookahead persistscomplete: trueand transitions toincremental. - Tier-ordered push.
PushEnginedrains the outbox viaTierComputer-grouped dispatch — tier 0 has no inter-pending dependencies; tier k depends only on tiers< k. Concurrent dispatch within a tier; stable ordercreatedAt asc, id asc. - L1 / L2 / L3 idempotency on INSERT. L1 uses
autoname=field:mobile_uuid; L2 uses a consumer-supplied dedup hook; L3 GETs bymobile_uuidto detect prior successful POSTs before retry. pullSyncMany— batch pull for multiple doctypes through a bounded worker pool.getPullPhase/getPullPhases— query pull phase per doctype for UX gating (blocking screen vs background indicator).- UUID rewrite on push.
UuidRewriterrewrites Link fields containingmobile_uuidvalues to theirserver_namebefore push, using<field>__is_localcompanion columns. - Three-key child identity match on pull-apply (
server_name → mobile_uuid → position); preserves UUIDs across re-pulls and avoids orphaning Link references. SyncController— public imperative surface:syncNow,pause/resume,retry,retryAll,resolveConflict,previewDeleteCascade,acceptDeleteCascade. Observablestate$stream.SyncState,DoctypeSyncState,QueueSummary,SyncErrorSummary,SyncStateNotifier— composable sync-state snapshot for UI widgets; per-doctype progress, queue counts, last error.RetryPriority— reorders outbox rows for "Retry all" so user-visible errors retry first.
Error capture
- Side-channel capture of terminal push failures. When a push drain hits a permanent server rejection (
4XX/ terminal401/5XX), the SDK records the exact wire request, the raw server response, and the session user's identity and roles — enough for a developer to reconstruct a replayable request with the same payload and permissions. Capture is automatic at the engine'ssend()boundary and rethrows the original error unchanged; the host app wires nothing up. ErrorLogCollector— aggregates failures per drain by a stable client-computedsignature, accumulates an occurrence count, and keeps a rolling window of the most recent 5 example payloads. Emits aMobileErrorRecord.MobileErrorPoster— best-effort POST of the aggregated record to the server'smobile_sync.report_errorendpoint after each drain (PushEngine.onDrainComplete); failures to report are dropped silently so capture never affects sync.- Wire metadata on exceptions.
FrappeExceptionnow carriesrequestUrl,requestMethod, requestbody, rawresponseBody, andtraceId;rest_helperstamps these on any>= 400response (and the403/404paths no longer drop the raw body). SessionUserServiceis created and restored before the sync engine is built so captured records carry the real reporting user and roles rather than a guest/empty identity.
Sync UI
SyncStatusBar,SyncProgressScreen,SyncErrorsScreen— status bar, blocking bootstrap-pull screen, errors list with per-row Retry / View error / Open actions and a header Retry-all.DocumentListFilterChip(withDocumentListFilter/DocumentListFilterCounts) — MaterialSegmentedButtonchip for tri-state filtering (all / unsynced / errors) with live counts.showDeleteCascadePrompt(withDeleteCascadeAction) — shown when DELETE fails withLinkExistsError; lets the user delete-all, fix-manually, or cancel.showLogoutGuardDialog(withLogoutGuardAction) — soft-gate dialog when Logout is tapped with unsynced rows.showForceLogoutConfirm— hard-gate dialog requiring "LOGOUT" text entry before destructive logout.
Session
SessionUservalue object plusSessionUserService— owns the in-memory session user and publishes changes via stream.- All login paths (username/password, OTP, API key, OAuth) now populate
SessionUserautomatically.sdk.sessionUserandsdk.sessionUser$are available immediately after login. - Persisted to
sdk_meta.session_user_jsonso restore-session paths rehydrate without an extra round-trip.
Server-driven offline-mode toggle (companion server feature)
- New
offline_enabledCheck field on the server-sideMobile Configurationdoctype controls whether the SDK runs as an offline-first client or a thin online client. Default is off (online). - Companion server release:
frappe-mobile-control1.x withoffline_enabledsurfaced on every authenticated login response. SdkMetaDao— read/write helpers for the persisted offline-mode flag onsdk_meta.
Other
ClosureBuilder— parallel BFS, level-by-level meta fetching with bounded concurrency (default 4 workers); reduces closure build time on large schemas.DoctypeService.bulkGetWithChildren— single POST to themobile_sync.get_docs_with_childrenendpoint for a chunk of names. CompanionDoctypeService.listFullDocspaginates names fromget_list, splits them into 200-name chunks (matching the server-sideMAX_BATCH), and on404falls back to per-nameGETrequests with bounded concurrency (slice size 20) for older deployments.DoctypeService.listaccepts an optionalorFiltersparameter (additive; passes Frappe'sor_filtersquery param through).RestHelper— error messages distinguish "connection refused" from "no internet".FormScreenoffline-first save — checks connectivity before save; treatsserverId == nulldocs as INSERT when going back online.ApiTracer— debug-mode tracing utility for API calls.extractErrorMessage/toUserFriendlyMessage— shared helpers for error-string normalization across the SDK.LinkFieldPickerModeenum (inlineanddialogmodes) to support alternative full-modal lookup dialogs for Link and Table MultiSelect fields, configurable globally viaFrappeFormStyle.SearchableSelectDialogwidget rendering a modal dialog with a dedicated search filter, checkboxes for multi-select, and checkmarks for single-select.FrappeFormBuilder.cascadeProgrammaticChanges(defaultfalse) — when enabled, a value written programmatically by anonFieldChangepatch (e.g. a computed value, or a value the handler fetched from a linked document and returned as a patch) re-fires that field's own change pipeline, so dependent fields recompute. Mirrors Frappe Desk'sfrm.set_value→ trigger behaviour. Loop-safe via value-equality plus a depth cap; the synchronouspatchValueecho is suppressed so each handler fires once (no double-run for typed fields whose representationFieldNormalizerchanges). Covers handler-returned patches only: the SDK's nativeDocField.fetchFrompatches use a separate internal path and do not cascade (seedoc/COMPUTED_FIELD_CASCADE.md, "Known limitation"). Cascade re-fires now passChangeSource.reactiontoonFieldChange(the originating user edit staysChangeSource.user), so a handler can gate one-shot side effects onsource == ChangeSource.user; with the flag off every invocation remainsChangeSource.user.
Translation pipeline (PR #76)
TranslationDao— KV store for offline translation persistence (schema:(lang, src, tgt)). Thekvtable lives insideAppDatabase(the main app database), not in a separate file.TranslationDaois constructed with an injectedDatabasehandle;close()is a deliberate no-op (AppDatabase manages the connection lifecycle). Cleared viaTranslationService.clearAll()orAppDatabase.clearAllData().TranslationDao.forTesting()opens an isolated in-memory DB. Methods:bulkUpsert,readAll,deleteAll.TranslationService— offline-capable translation service withloadFromCache(SQLite, <5 ms),refreshAsync/refreshAllAsync(fire-and-forget network),translateDelegatehook for host-app ARB priority lookup,onChangedbroadcast stream,clearAll, anddispose().TranslationService.fetchEnabledLanguages()— queriesfrappe.client.get_liston theLanguagedoctype to discover all enabled language codes.TranslationService.refreshAllAsync()— callsfetchEnabledLanguages()then_doRefresh(lang)for every enabled language; fires once per login. Ensures the offline cache covers all languages the deployment supports.TranslationService.clearAll()— clears in-memory cache, resets_currentLang = 'en', and callsTranslationDao.deleteAll(). Wires intoFrappeSDK.logout(clearDatabase: true).FrappeTranslationsstatic registry +sdkTr()top-level helper — exported fromfrappe_mobile_sdk.dart. Host apps callFrappeTranslations.setDelegate(sdk.translations.translate)once (e.g. in a Riverpod listener) to routesdkTr()through the liveTranslationService.TranslationService.translateDelegate— nullableString Function(String, [List<Object>?])?field onTranslationService. When set, the delegate is called first; if it returns a value different from the source, that value is used (ARB wins). Otherwise falls through totranslateLocal(SQLite). Lets host apps inject their compiled ARB lookup without the SDK knowing about Flutter's localization layer.doc/TRANSLATIONS.md— consumer guide covering architecture,sdkTrusage, host app integration, ARB lookup, SQLite fallback, locale lifecycle, reactive rebuild path, dispose safety, and whysdkTrnottr.doc/PHONE-FIELD.md— documents the PhoneField storage contract,toStored/numberFromStoredinvariants, the echo-guard that prevents OOM, regression history, and integration checklist.doc/OUTBOX-STATE-MACHINE.md— documents all 7 outbox states, state transitions, supersede pass logic,isOwnInsertRoundtripghost-success data-loss scenario and fix,recordSaveduplicate-INSERT prevention, and error-surfacing invariants.
-
Single read path. All list reads route through
UnifiedResolver. DB-first with background refresh on connectivity; Link decoration viaLinkDecorator. -
SearchableSelectupdated to support launchingSearchableSelectDialogwhenpickerModeisLinkFieldPickerMode.dialog. -
pullSyncguards child doctypes. Doctypes withistable=1are skipped at the entrypoint —frappe.client.get_listdoes not permit listing them, and children arrive embedded in parent pulls. -
Form-level cascade clears. When a Link field changes, the SDK auto-clears dependent Link fields whose
linkFilterscontaineval:doc.{fieldname}references. ConsumerFieldChangeHandlercallbacks should add value-derivation only — the form owns cascade cleanup. -
Local UUID resolution. Values matching the v4-UUID shape resolve from
docs__*only; the SDK never callsgetByName(...)for UUID-shaped values, since server names never match the UUID pattern. -
Conflict surfaces. When a pulled row is newer than a local dirty/failed row,
PullApplysetssync_status = 'conflict'. Resolve viaSyncController.resolveConflict()with two actions:pullAndOverwriteLocal(apply server snapshot) orkeepLocalAndRetry(requeue; runsThreeWayMergeagainst the pre-edit base). -
Mobile-UUID round-trip. After a successful first push,
OfflineRepository.reconcileServerSaveattaches the server'sserver_nameto the local row keyed bymobile_uuid, cancels pending outbox rows for the pair, and applies the full server snapshot so server-derived columns (defaults, formulas) land in the mirror. -
OfflineRepositoryconstructor —databaseis positional;localWriter,offlineMode,offlineModeNotifier,client, andmetaFetcherare named. When the effective offline mode is off,create/updateDocumentData/deleteDocumentroute toFrappeClientdirectly;getDirtyDocumentsreturns empty. -
OfflineRepository.createDocument— preserves an existingmobile_uuidfrom the payload rather than always generating a fresh one. -
OfflineRepository.getRowFromPerDoctypeTable— added forfetch_fromoffline resolution. -
UnifiedResolver— translates theparentfilter column toparent_uuidfor child-table queries. All constructor parameters are named:db,metaDao,isOnline,backgroundFetch,metaResolverare required;offlineMode,offlineModeNotifier,clientare optional. When the effective offline mode is off,resolve()short-circuits to a REST passthrough (no DB read, noLinkDecorator, no background-refresh dedupe). -
LinkOptionServiceconstructor — now takesUnifiedResolverand a meta-resolver instead ofFrappeClient. -
SyncService—client,offlineRepository,databaseare positional;getMobileUuid,offlineMode,offlineModeNotifier,pushRunnerare named.pushRunneris the production push driver (FrappeSDKwires it toPushEngine.runOnce);pushSyncreturnsSyncResult.empty()and logs a warning if it's unset. Every public method (pushSync,pullSync,pullSyncMany,syncDoctype,getSyncStats) returnsSyncResult.empty()(or zeros) when the effective offline mode is off. Adds theSyncResult.empty()factory. -
FrappeSDK.initialize()— reads the persisted offline-mode flag, resolves the session-bound mode via_resolveBootMode, and gates closure pull in_initialMetaAndDataSyncaccordingly.autoRestoreAndSyncdefaults tofalse; whentrue, restores session and runs post-login bootstrap. -
FrappeSDK.forTesting— accepts anofflineModeparameter (default: offline) so existing tests continue to exercise the offline path.
DocumentDao— deleted, no replacement. All single-bag CRUD is replaced byOfflineRepository+UnifiedResolver.- Legacy
documentstable — dropped during the v2 → v3 migration. The single-bag JSON store is replaced by per-doctypedocs__<doctype>tables. Drop is safe because1.xdevices push before persisting, so there are no unsynced rows indocumentsat upgrade time.
AppDatabase._versionbumped from2to4.sdk_meta.schema_versionis written in lockstep by both_onCreateand all upgrade paths.- Migration step
_migrateV2ToV3runs within one transaction: (1) safely add v3 + v4 column extensions todoctype_metavia wrappedALTER TABLE ADD COLUMN(catches "duplicate column name"); (2) idempotently create system tables (outbox,pending_attachments,sdk_meta) withCREATE TABLE IF NOT EXISTS; (3) drop the legacydocumentstable and its indexes; (4) upsert the singletonsdk_metarow withschema_version = 3. - Migration step
_migrateV3ToV4(new — runs for all existing v3 installs): creates thekvtable (offline translation cache,PRIMARY KEY (lang, src)) insideAppDatabaseusingCREATE TABLE IF NOT EXISTS; updatessdk_meta.schema_version = 4. Fresh installs also receive thekvtable viasystemTablesDDL(). - Upgrade paths: v2→v4 runs both steps; v3→v4 runs only
_migrateV3ToV4; fresh installs skip both. All three paths produce identical final schema. - Storage layers:
- Per-doctype mirror —
docs__<doctype>tables withmobile_uuidPK,server_name,sync_status,sync_op,push_base_payload, and field columns. Children carryparent_uuid. Tables are lazily created on first pull viaOfflineRepository.ensureSchemaForClosure. - System —
sdk_meta(singleton row trackingschema_version, bootstrap state, offline mode, session user);outbox(operation log indexed by state + created_at);pending_attachments(file upload queue with retry);kv(offline translation cache).
- Per-doctype mirror —
- Fresh installs, v2→v4 upgrades, and v3→v4 upgrades all end in identical schema state (
schema_version = 4).
FrappeFormBuilder— aonFieldChangehandler patching the same field that is changing with a rewritten value (e.g. normalising'hi'→'HI') crashed with an unbounded synchronous recursion (StackOverflowError) through the text field's controller notifications; independent ofcascadeProgrammaticChanges. The self-key's widget patch is now deferred one frame (form data still updates immediately). Regression-tested for both flag states.system_tables.dart— allCREATE TABLEstatements useIF NOT EXISTS;sdk_metaseed usesINSERT OR IGNOREfor migration idempotency.pull_apply.dart— conflict flag now only fires when the servermodifiedtimestamp is strictly after the localmodified(previously flagged any dirty row unconditionally).SyncController.pause()/resume()—syncNownow checks theisPausedflag before running.BaseField— wrap field labels inExpandedand useCrossAxisAlignment.startto prevent horizontalRenderFlexoverflows when field labels contain long text (Issue #52).FrappeFormBuilder— replaceSingleTickerProviderStateMixinwithTickerProviderStateMixinto prevent "Multiple Tickers" crashes, and utilizemapEqualsdeep comparison forinitialDataindidUpdateWidgetto avoid unnecessary controller teardowns and state resets (Issue #72).
Translation pipeline (feat/sdk-offline-phase2 — PR #76)
TranslationService.loadTranslations— continues to callmobile_auth.get_translations(thefrappe-mobile-controlcustom method).frappe.client.get_list('Translation')was evaluated but not adopted: it returns only user-created custom translations and misses the compiled.potranslations that supply field labels. Thefrappe-mobile-controlbackend is still required.TranslationService.refreshAllAsync— fires once per login to pull translations for all enabled languages, not just the current user's language. Ensures the offline SQLite cache is fully populated for every language the deployment supports, so offline sessions have no missing translations regardless of locale.TranslationService.clearAll(+TranslationDao.deleteAll) — called fromFrappeSDK.logout(clearDatabase: true). Clears the in-memory translation cache and wipes thekvtable insideAppDatabaseon logout so a different user logging into the same device never sees stale translations.TranslationService._disposedflag — guardsloadFromCacheand_doRefreshagainst writing to a closedStreamControlleror DAO whendispose()fires during an in-flight network call.TranslationDaoconstructor — accepts an injectedDatabasehandle, eliminating any lazy-open pattern. No_open()method exists; the TOCTOU race cannot occur.sdkTrglobal helper — renamed fromtrto avoid naming collisions witheasy_localizationand GetX in host apps. Host apps should define a localtr(String s) => FrappeTranslations.translate(s);wrapper instead of importingsdkTrdirectly.
Outbox state machine (feat/sdk-offline-phase2 — PR #76)
PushEngine._drainOncesupersede pass — extended to includepausedrows alongsidefailed. A stale paused row from a prior terminal rejection is now cleaned up when a newerpendingrow covers the same(doctype, mobile_uuid, operation)tuple, preventing phantom errors in the UI.SyncController._allActionableRows+OfflineRepository.getSyncErrorsForDoc—pausedrows are now included in all error-surfacing queries. They appear in SyncErrorsScreen, document badges, and the per-document form banner.retryAllstill excludespaused(bulk-re-queuing a terminal rejection is incorrect; re-save is the resume path).OutboxDao.recordSave— when a paused + pending INSERT pair co-existed for the same document,firstWherereset one row and returned, leaving the second row alive. Both rows then dispatched, producing two INSERTs for the same document. Fixed by deleting all remaining collapsable same-operation rows after resetting the survivor.PullApply.isOwnInsertRoundtrip— when a ghost-success INSERT leftserver_name NULLand the user re-edited before write-back, the dirty-conflict guard was bypassed and local edits were silently overwritten on the next pull (data loss). Fixed by checking the outbox for any non-done, non-in_flight rows for the matchedmobile_uuidbefore accepting the round-trip flag; if owed work exists, the flag is cleared and the normal conflict guard runs.
PhoneField (feat/sdk-offline-phase2 — PR #76)
PhoneField.toStored— regression introduced in0a33fbecallednumberFromStored()(the inverse operation — strips+91) instead of prepending the dial code. Every phone entered or edited on device was stored as bare digits with no country code. Fixed by reverting to the correct form:return '$_defaultDialCode$digits'. Test expectations restored to+919876543210. The echo-guard invariant (numberFromStored(toStored(digits)) == digits) is unaffected — no OOM regression.
Misc (feat/sdk-offline-phase2 — PR #76)
FrappeSDK.onFfiInitFailure— optionalvoid Function(Object, StackTrace)?constructor parameter. Called when SQLite FFI initialisation fails before the MethodChannel fallback activates. Wire to your crash reporter to capture non-fatal FFI errors without blocking SDK startup. Example:onFfiInitFailure: (e, st) => FirebaseCrashlytics.instance.recordError(e, st, fatal: false).FrappeSDKpage-size constructor parameters —pullPageSize(default 500),syncServicePageSize(default 1000),listChildDocsPageSize(default 1000),listFullDocsPageSize(default 1000),listDefaultPageSize(default 20). Tune for your server's response-time profile; higher values reduce round-trips at the cost of larger per-request payloads.FrappeAppGuard.allowDeferringUpdates— default wasfalsebut documented astrue. The deferrable-update branch in_checkAppStatusnever assigneddeferrableUpdate = truebecause theelsebranch was missing. Both defects fixed.debugPrint→sdkLogmigration completed acrosstranslation_service.dart,doctype_service.dart,login_screen.dart,sync_status_screen.dart,form_screen.dart,document_list_screen.dart, andapp_guard.dart.sdkLogis a no-op in release builds;debugPrintwas not.doc/FIELD_TYPES.md—SearchableSelect/SearchableSelectDialogusage examples marked as internal (not exported from the barrel).
- All new constructor parameters are optional with sensible defaults; existing call sites continue to compile and run.
- Offline mode is off by default. To run as an offline-first client, deploy the
frappe-mobile-controlserver release that flipsoffline_enabled = trueonMobile Configurationbefore upgrading the SDK on devices. An offline deployment that does not flip the flag will see clients persistfalseon first login and drain + wipe local data on the next launch. - Token refresh does not refresh the offline-mode flag. Long-lived sessions stay in their previous mode until the user re-authenticates (password / OTP / OAuth / API key).
- Existing tests that use
FrappeSDK.forTestingcontinue to default to offline. To exercise the online-only path in tests, passofflineMode: const OfflineMode(enabled: false, isPersisted: true). - Downgrade is not supported.
sqflitedoes not provide a downgrade hook; users cannot downgrade through the app stores.
1.1.0 (2026-04-17)
- auth: disable social login and auto-discovery (4382822)
- review: move docs to doc/FIELD_TYPES.md, remove pubspec.lock from tracking (72f7c74)
- add optional field change handler and improve form data handling (9012b3e)
- auth: implement social login support with OAuth integration (e123df7)
- fields: add SearchableSelect, TableMultiSelect, Geolocation field widgets and fix form data handling (06e8a32)
1.0.0 - 2026-04-01
- Initial stable release of Frappe Mobile SDK for Flutter (
frappe_mobile_sdk). - Frappe API client — authentication (password, OAuth, API key, mobile OTP), CRUD, file upload, custom methods, and query-style access via
FrappeClient. - Stateless mobile auth —
mobile_auth.loginintegration with token persistence, session restore, and automatic token refresh. - Dynamic forms — metadata-driven rendering from Frappe DocTypes (
FrappeFormBuilder, list and document screens). - Offline-first data layer — SQLite storage, offline repository, and bi-directional sync with conflict handling.
- App guard —
FrappeAppGuardfor server-driven app status, versioning, and force-update flows (requires Frappe Mobile Control on the server). - Translations — load dictionaries from the server and apply to labels in forms and lists.
- Workflows — workflow state and transitions on forms when configured on the DocType.
- Example app under
example/and in-repo docs underdoc/.