Skip to content

feat: offline translations + logic corrections cumulative (closes #42 #43 #47 #49 #52 #53 #64 #72)#76

Open
ankitDhwani wants to merge 32 commits into
dhwani-ris:developfrom
ankitDhwani:feat/sdk-offline-phase2
Open

feat: offline translations + logic corrections cumulative (closes #42 #43 #47 #49 #52 #53 #64 #72)#76
ankitDhwani wants to merge 32 commits into
dhwani-ris:developfrom
ankitDhwani:feat/sdk-offline-phase2

Conversation

@ankitDhwani

Copy link
Copy Markdown
Contributor

Summary

Cumulative merge from the ankitDhwani fork incorporating upstream om/logic_correction (PR #74 by @om-dhwani-ris) plus additional offline-sync hardening, issue fixes, and the offline translations feature.


Issues Closed

From upstream PR #74 (om/logic_correction)

From local branches


New Feature: Offline Translations

Frappe backends already expose ~840 Hindi / ~1030 Gujarati keys via mobile_auth.get_translations. This feature persists them to a dedicated translations_cache.db SQLite KV store so form field labels and select options render in the correct language from the first frame, even offline.

What changed

File Change
lib/src/database/daos/translation_dao.dart New — standalone KV SQLite cache (bulkUpsert, readAll, in-memory variant for tests)
lib/src/services/translation_service.dart loadFromCache (SQLite, <5 ms, no network), refreshAsync (fire-and-forget fetch+persist), onChanged stream, dispose()
lib/src/sdk/frappe_sdk.dart Boot wiring: fire-and-forget refreshAsync replaces blocking fetch; loadFromCache in restore-session path; dispose() propagated
lib/src/ui/widgets/fields/select_field.dart _getRawOptions()/_getOptions() split — translated labels displayed, English keys stored (matches Frappe web behaviour)

Behaviour

  • First install (cold, offline): form renders in English immediately; silently switches to correct language when background fetch completes (~200 ms after login)
  • Every subsequent open: correct language from first frame (SQLite pre-warm)
  • Language switch: instant from cache; background refresh keeps it current
  • Zero impact on login sync time: blocking fetch removed, replaced with fire-and-forget

Other Fixes (also in PR #74)

  • FieldNormalizer extracted from FrappeFormBuilder into a pure headless normalizer
  • print() replaced with sdkLog across all services and UI
  • Example app updated to use SDK getters instead of rebuilding services manually
  • Shared helpers extracted to reduce duplication across field/screen/row/util code
  • Initial-sync offset pagination, fast-fail boot probes, and schema gap fixes

Test Plan

  • flutter test — 1408/1408 passing
  • Verify translations render in Hindi/Gujarati on a connected device after login
  • Verify form select dropdowns display translated option labels but submit English keys to server
  • Verify first-frame translation on app restart (SQLite cache pre-warm, no network required)
  • Verify sync modes work: full, pushOnly, pullOnly
  • Verify mid-sync kill + resume completes cleanly (cursor checkpoint S3: Persist initial-sync cursor position to SQLite to enable resumable pull after app termination #64)

omprakashk22 and others added 30 commits May 14, 2026 13:14
… code

Consolidate repeated patterns into single sources of truth so future
changes are made in one place instead of 10-20.

New helpers:
- utils/date_helpers.dart — parseDateTime, parseTime, formatDurationSeconds
  shared by date/datetime/duration fields and form_builder patch normaliser
- utils/frappe_json_utils.dart — parseBool: canonical 0/1/'true'/bool
  coercion for every model fromJson
- utils/sql_row_utils.dart — retryCountFrom, lastAttemptAtFrom,
  utcMillisFrom, parseEnumByName for sqflite row factories
- ui/widgets/fields/field_helpers.dart — requiredValidator,
  baseFieldDecoration, fieldErrorText shared by every Form*Field
- ui/widgets/screen_helpers.dart — showStatusSnackBar + showConfirmDialog
  replace ad-hoc SnackBar/Dialog construction across screens

Schema/query additions:
- system_columns: systemSyncMetadataColumnNames + linkCompanionColumnDDL
  consumed by PayloadAssembler, PayloadSerializer, parent/child schema
  builders and runtime migration so strip rules cannot diverge
- table_name: stripDocsPrefix inverse of normalizeDoctypeTableName
- query_result: QueryResult.ofRows derives hasMore + returnedCount for
  online/offline parity in UnifiedResolver

No behaviour change. flutter analyze + dart format clean across the package.
… UX polish

- DoctypeService.list: guard non-List Frappe message shape → treat as empty page
- RestHelper.get/getPublic: expose per-call timeout and maxRetries overrides so
  boot/splash callers can fast-fail instead of burning the default 30s × 3-retry budget
- PermissionService.syncFromApi: accept timeout param; remove silent catch so
  callers own error handling
- MobileHomeScreen: suppress full-screen spinner on background syncComplete$ reloads —
  only show it on first paint when the list is empty
- FieldStyle: default showLabel/showDescription to false
- Tests: Cursor.start round-trip, initial-sync limit_start advance vs incremental
  modified-filter path, stall guard cursor pre-seeding
- FrappeSDK._runUpgradeClosurePull: call ensureSchemaForClosure before the
  closure pull so per-doctype mirror tables exist (closes the "no such
  table" hole that previously required SNF's runSnfPostSdkSync to re-pull)
- FrappeSDK._initialMetaAndDataSync: bracket the boot pull with
  isInitialSync=true so host UI subscribed to state$ can render a syncing
  indicator; reset on every exit path
- FrappeSDK: surface boot failures via SyncStateNotifier.lastError
  (code: 'network'|'permissions'), expose retryInitialMetaAndDataSync(),
  clear lastError on logout
- parent/child schema DDL: CREATE TABLE/INDEX IF NOT EXISTS so SNF's
  belt-and-suspenders ensureSchemaForClosure and the SDK's eager pull
  can re-enter safely after a partial failure
- PullEngine: optional SchemaReconcilerFn wired to
  OfflineRepository.reconcileParentTableForMeta so ALTER ADD COLUMN runs
  before applying a page (closes the meta-cache race)
- PullEngine stall guard now scoped to incremental (complete=true);
  initial-sync uses limit_start offset which always advances
- PushEngine: optional PayloadTransformerFn between assembly and HTTP
  dispatch (e.g. host auto-submit on sync); exception-safe
- FrappeFormBuilder: onValidationFailed callback so parents can stop
  loading indicators when submit is rejected
- FrappeFormBuilder: ??=-inject linkOptionService / linkFieldCoordinator
  on host-supplied customFieldFactory so Link pickers aren't half-configured
- SyncStateNotifier: recordLastError / clearLastError bypass copyWith
  (cannot express "set to null")
- SDK barrel: export PayloadTransformerFn + ChildTableFormBuilder
- Tests: PayloadTransformerFn smoke; onValidationFailed fires on invalid
  submit and not on valid
…logic_correction

Conflict resolution favoured HEAD (om/logic_correction): kept the
`stripDocsPrefix` helper, the `tableNameFor` docblock, `_assertInitialized`,
`_buildPullableDoctypes`, the `payloadTransformer` parameter wiring, and the
`closure_result.dart` import. The incoming `3cbb9e6` ("Port of
om/logic_correction fixes to om/offline_improvement") was an earlier
snapshot of the same work, so HEAD's refactored versions are the canonical
copy. Also picked up the `.gitignore` trailing-newline strip from the
incoming branch.
# Conflicts:
#	lib/src/database/app_database.dart
#	lib/src/database/daos/outbox_dao.dart
#	lib/src/screens/mobile_home_screen.dart
#	lib/src/ui/document_list_screen.dart
#	lib/src/ui/widgets/form_builder.dart
Combines the offline-push correctness fixes with logic_correction's
payload-transformer / DDL-helper refactors. Conflict resolutions:

- push_engine.dart / sync_engine_builder.dart: kept BOTH logic's
  PayloadTransformerFn (field + re-export) AND offline's deadlock retry
  (DeadlockError, isDeadlockApiException, _withJitter, _dispatchUnits).
- system_columns.dart: linkCompanionColumnDDL now quotes the column
  ("name__is_local") — keeps logic's single-source helper while honoring
  offline's "quote identifiers in DDL" fix (parent/child schema use it).
- offline_repository.dart: kept offline's per-iteration try/catch +
  _reconcileParentTableSchema healing, using logic's _executeDDL helper.
- form_builder.dart (from prior develop merge): submit path keeps the
  hidden-field strip; _buildCompleteFormData retained for dirty detection.

Full suite green (1304 tests); analyzer clean.
…cripts/)

These were swept in by `git add -A` during the merges; they are local
developer tooling, not part of the package. Untracked and gitignored.
…mable pull, pre-flight manifest

- dhwani-ris#43: PullApply matches a pulled parent by mobile_uuid when server_name is
  not yet stamped locally, preventing duplicate rows during an in-flight
  push INSERT; reconcile (no false conflict) when local server_name is NULL.
  Extend the hasActivePushFor defer to SyncService pull paths (parity with
  PullEngine) via SyncStatus.deferredActivePush.
- dhwani-ris#47: SyncMode {full, pushOnly, pullOnly} on SyncController.syncNow
  (default full = existing behavior).
- dhwani-ris#64: checkpoint the PullEngine cursor (complete:false) after every applied
  page so an app-kill mid initial-sync resumes from the last page, not page 0.
- dhwani-ris#49: /sync_details pre-flight client (DoctypeService.getSyncDetails) + pure
  doctypesToSkip; wired into _runUpgradeClosurePull to skip unchanged
  INCREMENTAL doctypes, with graceful full-pull fallback on any failure.

Note: sdk_log.dart and the sdkLog lines in sync_controller/sync_service are
pre-existing refactor bits that ride along because these files are shared;
the rest of that print->sdkLog refactor is intentionally left uncommitted.
Route stray print() diagnostics through sdkLog (a no-op in release builds)
and drop the `// ignore: avoid_print` suppressions, importing sdk_log where
needed. No behavioral change.
Resolve conflicts in auth.dart, mobile_home_screen.dart, frappe_sdk.dart,
and sync_service.dart:
- Adopt sdkLog over debugPrint/print per the offline-sync logging sweep.
- Keep both screen_helpers and sdk_log imports in mobile_home_screen.
- forTesting ctor: keep httpClient param + payloadTransformer init.
- Closure pull: use the dhwani-ris#49 sync_details 'allowed' (skip-unchanged) set;
  fix List/Set mismatch since the refactor made _buildPullableDoctypes
  return a List (allowed = pullable.toSet(); allowed.difference(skip)).
- sync_service: keep refactored _loadMetaFromDao, preserve the new
  pullOneInternalForTest helper, drop vestigial parse-catch tails.

flutter analyze: clean. flutter test: 1333 passed.
…x types & terminal-error pause

dhwani-ris#42 (A3): equal-jitter exponential backoff for GET retries — adds
retryBackoffDelay() ([base/2, base], attempt-clamped, seedable) and wires both
retry sites; de-synchronises simultaneous client reconnects. +5 tests.

dhwani-ris#53 (A5): export OutboxRow/OutboxState/OutboxOperation/ErrorCode on the public
barrel so consumers drop raw-SQL/implementation_imports access. Add
ErrorCode.isTerminal (exhaustive, no default → new codes fail the build) and
OutboxRow.isTerminal. New OutboxState.paused: a terminal server rejection
(HTTP 417 validate-hook, 403, mandatory, link) is parked via OutboxDao.markPaused
instead of markFailed — the push drain reads only `pending`, so it never loops;
a corrected re-save collapses it back to pending (resume). UI (sync_error_banner,
document_list badge) renders the paused state. +13 tests.

dhwani-ris#52 (B1): integrated via merge of fix/label-overflow-ticker-crash
(TickerProviderStateMixin + mapEquals guard + label Expanded wrap, +3 tests).

Full suite: 1353 passing. flutter analyze clean (lib/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The field displayed number-only but emitted +91-stored; form_builder's self
patchValue echoed the stored value back into the same name-bound field, whose
onChanged re-prefixed it (+9191...), never reaching a fixed point → stack
overflow on the first keystroke (broke every form with a contact_number).

Fix: decouple the visible controller (number-only, owned by a private stateful
_PhoneNumberInput) from the FormBuilder value (+91 stored) via FormBuilderField,
reconciling external patches with an echo guard; make toStored idempotent as
defense-in-depth. Validation + +91 save contract + prefill preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FrappeSDK.dispose() closes the shared notifier, but an in-flight
PushEngine/PullEngine/SyncController (all wired to the same instance by
SyncEngineBuilder) can resume from an await and write notifier.value
afterwards, hitting StreamController.add on a closed controller and
throwing StateError during logout/shutdown. dispose() only drained
_pendingDrain, not controller-driven syncs.

Guard the setter with an isClosed check so a late write from any writer
is a safe no-op. Update the notifier test to the new no-throw contract
and add a regression test for the teardown race.
Move the per-fieldtype normalization switch and the multiselect helper
out of the FrappeFormBuilder widget into a pure, headless FieldNormalizer
so the rules can be unit-tested directly. Behavior unchanged; the widget
delegates at both call sites. Adds 22 characterization tests covering
every branch.
_handleLoginSuccess and build() re-instantiated MetaService,
OfflineRepository, SyncService and a hand-rolled UnifiedResolver that
diverged from the SDK's own wiring. Those blocks only ran when
initialize() failed, but build() returns the error screen first, so they
were unreachable. Remove them; rely on sdk.meta/repository/sync/
linkOptions with a "not initialized" guard.
…ations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hanged stream

- Add injectDao/dao/loadFromCache/refreshAsync/setCurrentLangForTesting
- setLocale is now cache-first (SQLite) + unconditional background refresh
- Add onChanged broadcast stream that emits after cache is populated
- FrappeClient field made nullable (Dart 3 sound null safety; production unaffected)
- Update pre-existing setLocale test to reflect new fire-and-forget contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cking translation fetch

- Add import for TranslationDao in frappe_sdk.dart
- Both construction sites (_doInitialize and forTesting factory) now inject a TranslationDao instance via ..injectDao()
- Replace blocking await _translationService?.loadTranslations('en') with fire-and-forget refreshAsync() — removes 1 network round-trip from the boot critical path
- Add setLocale() call after restoreFromDb() to warm the SQLite translation cache from a persisted SessionUser language preference (currently inert; becomes effective when language persistence lands in Task 5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…down

Ensures TranslationDao and the onChanged StreamController are properly closed
when the SDK disposes, preventing resource leaks. The dispose() method is called
from FrappeSDK.dispose() during the teardown phase alongside other services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…glish stored keys

Splits _getOptions() into _getRawOptions() (English keys, used for all
value/equality/onChanged paths) and _getOptions() (translated display labels,
used only as Text children in DropdownMenuItem and FormBuilderFieldOption).
Fixes all four correctness sites: item display, multi-select matching,
single-select re-hydration, and single-option auto-select.

Adds 5 tests covering translation rendering, no-translate passthrough,
item value invariant, stored-value passthrough, and auto-select key emission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sertion, trim options

- Extend _field() helper with allowMultiple param (no copyWith needed on DocField)
- Add 3 multi-select tests: display labels translated, tap stores English key, single-option auto-select emits English key
- Replace vacuous Test 4 (isNull) with assertions that verify item values are English keys and item children show translated labels
- Fix _getRawOptions() to trim-then-filter (consistent with _valueToList)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ry DAO in forTesting

- Add isClosed guards to both _changedController.add(null) call sites in
  TranslationService (loadFromCache and _doRefresh) so a dispose() during
  an in-flight background refresh never throws a StateError.
- Switch FrappeSDK.forTesting to TranslationDao.forTesting() (in-memory)
  so tests never touch the filesystem.
- Add two dispose-safety tests covering no-StateError and no-emission-
  after-dispose scenarios.
@ankitDhwani ankitDhwani changed the base branch from om/offline_improvement to develop June 9, 2026 07:07
…e hook, ARB priority, full UI coverage

- lib/src/utils/translate.dart: FrappeTranslations static registry + tr() global helper
  exported from frappe_mobile_sdk.dart; bridges SDK widgets to host-app ARB lookup
  (renamed from __ — Dart treats leading-underscore identifiers as library-private)
- TranslationService.translateDelegate: nullable hook for host app to intercept
  translation queries; ARB lookup wins over SQLite cache when result differs from source
- TranslationService.translateLocal: renamed inner method for SQLite-only lookup
- All SDK screens and field widgets migrated to tr(): form_screen, login_screen,
  document_list_screen, sync_status_screen, base_field, field_helpers, select_field,
  link_field, numeric_field, phone_field, searchable_select, table_multi_select,
  form_builder, sync_error_banner, sync_status_bar, app_guard, dialogs
- doc/TRANSLATIONS.md: complete consumer guide covering architecture, ARB priority,
  SQLite fallback, locale lifecycle, reactive rebuild path, and testing patterns
- Lint: remove redundant flutter/services.dart imports (data_field, text_field),
  add braces in form_screen if-statement, rename _service() test helper to makeService()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment