From d442d9a8fbedecca146bd2cc4fc1a30ef2fd2358 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 14:48:38 +0200 Subject: [PATCH 1/9] feat(formulus): add support for i18n --- AGENTS.md | 1 + desktop/src/lib/buildFormPreviewInit.ts | 7 +- desktop/src/lib/uiLocale.ts | 73 + desktop/src/pages/FormPreviewPage.tsx | 40 + docs/custom-question-types.md | 26 + formulus-formplayer/AGENTS.md | 14 + formulus-formplayer/README.md | 5 + formulus-formplayer/src/App.tsx | 254 +- .../src/components/DraftSelector.tsx | 14 +- .../src/components/FormLayout.tsx | 6 +- .../src/i18n/FormplayerLocaleContext.tsx | 4 + .../src/i18n/applyFormUiTranslations.test.ts | 101 + .../src/i18n/applyFormUiTranslations.ts | 253 ++ formulus-formplayer/src/i18n/createOdeI18n.ts | 101 + formulus-formplayer/src/i18n/localeUtils.ts | 46 + formulus-formplayer/src/i18n/useOdeT.ts | 13 + formulus-formplayer/src/locales/en.json | 53 + formulus-formplayer/src/locales/fr.json | 53 + formulus-formplayer/src/locales/pt.json | 53 + .../CustomQuestionTypeAdapter.test.tsx | 111 + .../renderers/CustomQuestionTypeAdapter.tsx | 10 + .../src/renderers/FinalizeRenderer.tsx | 13 +- .../src/renderers/SwipeLayoutRenderer.tsx | 25 +- .../src/types/CustomQuestionTypeContract.ts | 5 + .../src/types/FormulusInterfaceDefinition.ts | 2 +- .../src/utils/formObservationData.test.ts | 1 + .../src/utils/formObservationData.ts | 2 + formulus/.prettierignore | 2 +- formulus/AGENTS.md | 7 + formulus/App.tsx | 41 +- .../assets/webview/FormulusInjectionScript.js | 3385 ++++++++++------- formulus/assets/webview/formulus-api.js | 433 ++- formulus/eslint.config.js | 1 + formulus/package.json | 3 + formulus/pnpm-lock.yaml | 79 + formulus/src/components/FormplayerModal.tsx | 25 +- formulus/src/i18n/index.ts | 47 + formulus/src/lib/locale.test.ts | 71 + formulus/src/lib/locale.ts | 94 + formulus/src/locales/en.json | 34 + formulus/src/locales/fr.json | 34 + formulus/src/locales/pt.json | 34 + formulus/src/screens/SettingsScreen.tsx | 102 +- .../src/services/LocaleSettingsService.ts | 82 + formulus/src/types/AppConfig.ts | 5 + .../webview/FormulusInterfaceDefinition.ts | 2 +- 46 files changed, 3937 insertions(+), 1830 deletions(-) create mode 100644 desktop/src/lib/uiLocale.ts create mode 100644 formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx create mode 100644 formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts create mode 100644 formulus-formplayer/src/i18n/applyFormUiTranslations.ts create mode 100644 formulus-formplayer/src/i18n/createOdeI18n.ts create mode 100644 formulus-formplayer/src/i18n/localeUtils.ts create mode 100644 formulus-formplayer/src/i18n/useOdeT.ts create mode 100644 formulus-formplayer/src/locales/en.json create mode 100644 formulus-formplayer/src/locales/fr.json create mode 100644 formulus-formplayer/src/locales/pt.json create mode 100644 formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx create mode 100644 formulus/src/i18n/index.ts create mode 100644 formulus/src/lib/locale.test.ts create mode 100644 formulus/src/lib/locale.ts create mode 100644 formulus/src/locales/en.json create mode 100644 formulus/src/locales/fr.json create mode 100644 formulus/src/locales/pt.json create mode 100644 formulus/src/services/LocaleSettingsService.ts diff --git a/AGENTS.md b/AGENTS.md index a0f08d97a..de65e9c99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ git push origin v1.1.1-alpha.3 - **Custom app bridge (v1.1.0+):** `persistObservation` (headless write), `sync`, `getConnectivityStatus`, `getCurrentDataRevisionCount`, and `openFormplayer` options `skipFinalize` / `skipDraftSelection` — contract in [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts); run `pnpm run sync-interface` in formplayer after changes. - **Sub-observations:** Each nested Formplayer session validates its own schema; `skipFinalize` only skips the Finalize page. Custom validators are per-session — see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) (docs site). - **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS). +- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [form translations guide](https://opendataensemble.org/docs/guides/form-translations). --- diff --git a/desktop/src/lib/buildFormPreviewInit.ts b/desktop/src/lib/buildFormPreviewInit.ts index f7fc8c38a..330f4cc2a 100644 --- a/desktop/src/lib/buildFormPreviewInit.ts +++ b/desktop/src/lib/buildFormPreviewInit.ts @@ -1,5 +1,6 @@ import type { FormInitData } from './formplayerHost'; import { sanitizePortableAttachmentsInFormData } from './sanitizeFormSavedData'; +import { resolveDesktopUiLocale } from './uiLocale'; /** Infer SQLite observation id from embedded saved row data (matches Workbench navigate-from-custom-app). */ export function inferObservationIdFromSavedData( @@ -31,10 +32,14 @@ export function buildFormPreviewInit(args: { extensions?: FormInitData['extensions']; customQuestionTypes?: FormInitData['customQuestionTypes']; }): FormInitData { + const locale = + typeof args.params.locale === 'string' + ? resolveDesktopUiLocale(args.params.locale) + : resolveDesktopUiLocale(); const init: FormInitData = { formType: args.formType, observationId: args.observationId ?? null, - params: args.params, + params: { ...args.params, locale }, savedData: sanitizePortableAttachmentsInFormData(args.savedData), formSchema: args.formSchema, uiSchema: args.uiSchema, diff --git a/desktop/src/lib/uiLocale.ts b/desktop/src/lib/uiLocale.ts new file mode 100644 index 000000000..d8b1eb041 --- /dev/null +++ b/desktop/src/lib/uiLocale.ts @@ -0,0 +1,73 @@ +/** Desktop UI locale — mirrors Formulus Settings key and resolution rules. */ + +const STORAGE_KEY = '@ode/uiLocale'; +const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const; +export type DesktopUiLocale = (typeof ODE_UI_LOCALES)[number]; +export type UiLocalePreference = 'auto' | DesktopUiLocale; + +function normalizeLocaleTag(tag: string): string { + return tag.trim().replace(/_/g, '-').toLowerCase(); +} + +function localeLookupCandidates(tag: string): string[] { + const normalized = normalizeLocaleTag(tag); + if (!normalized) return ['en']; + const parts = normalized.split('-'); + const candidates: string[] = [normalized]; + if (parts.length > 1) candidates.push(parts[0]!); + return candidates; +} + +function matchOdeCatalogLocale(tag: string): DesktopUiLocale | null { + for (const candidate of localeLookupCandidates(tag)) { + if ((ODE_UI_LOCALES as readonly string[]).includes(candidate)) { + return candidate as DesktopUiLocale; + } + } + return null; +} + +export function getDesktopLocalePreference(): UiLocalePreference { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if ( + stored === 'auto' || + stored === 'en' || + stored === 'pt' || + stored === 'fr' + ) { + return stored; + } + } catch { + // ignore + } + return 'auto'; +} + +export function setDesktopLocalePreference(preference: UiLocalePreference): void { + localStorage.setItem(STORAGE_KEY, preference); +} + +export function resolveDesktopUiLocale( + sessionOverride?: string | null, + bundleDefaultLocale?: string | null, +): DesktopUiLocale { + if (sessionOverride) { + const matched = matchOdeCatalogLocale(sessionOverride); + if (matched) return matched; + } + + const preference = getDesktopLocalePreference(); + if (preference !== 'auto') return preference; + + const device = navigator.language || 'en'; + const fromDevice = matchOdeCatalogLocale(device); + if (fromDevice) return fromDevice; + + if (bundleDefaultLocale) { + const fromBundle = matchOdeCatalogLocale(bundleDefaultLocale); + if (fromBundle) return fromBundle; + } + + return 'en'; +} diff --git a/desktop/src/pages/FormPreviewPage.tsx b/desktop/src/pages/FormPreviewPage.tsx index 317745108..01f577c9a 100644 --- a/desktop/src/pages/FormPreviewPage.tsx +++ b/desktop/src/pages/FormPreviewPage.tsx @@ -28,6 +28,11 @@ import { dropPendingSubObservationOpen, registerPendingSubObservationOpen, } from '../lib/formPreviewSubObservationBridge'; +import { + getDesktopLocalePreference, + setDesktopLocalePreference, + type UiLocalePreference, +} from '../lib/uiLocale'; const DEFAULT_JSON = '{}'; @@ -68,6 +73,8 @@ export function FormPreviewPage() { const [previewObservationId, setPreviewObservationId] = useState< string | null >(null); + const [uiLocalePreference, setUiLocalePreference] = + useState(() => getDesktopLocalePreference()); const [formInitData, setFormInitData] = useState(null); @@ -577,6 +584,39 @@ export function FormPreviewPage() { {formOptions} + + {listError ? (

{listError}

) : forms.length === 0 && !listLoading ? ( diff --git a/docs/custom-question-types.md b/docs/custom-question-types.md index 054c1f66b..d5181c820 100644 --- a/docs/custom-question-types.md +++ b/docs/custom-question-types.md @@ -50,6 +50,9 @@ interface CustomQuestionTypeProps { // 3. Schema parameters. Any non-standard JSON Schema keys are passed here. config: Record; + // 3b. Display strings from ui.json Control.options (after locale preprocess). + options?: Record; + // 4. Validation state for styling error cases validation: { error: boolean; @@ -117,6 +120,29 @@ module.exports = { --- +## Internationalization + +Custom question types use the **same** `ui.json` `translations` pattern as built-in controls. Put user-visible strings in `label`, `description`, and `Control.options` — not hardcoded in `renderer.js`: + +```json +{ + "type": "Control", + "scope": "#/properties/rating", + "label": "Rate this", + "options": { "hint": "Tap a star" }, + "translations": { + "pt": { + "label": "Avalie", + "options": { "hint": "Toque numa estrela" } + } + } +} +``` + +The renderer reads `props.options.hint`. Behavioral settings (`maxStars`, filters) stay in `schema.json` as `config`. See [Form translations](https://opendataensemble.org/docs/guides/form-translations). + +--- + ## Using it in your Form Once your custom question type is defined, use it in your form's `schema.json`. diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index ce0790119..045f526c3 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -58,6 +58,20 @@ This file gives AI assistants and developers enough context to work effectively 3. **Custom question types**: Loaded from a **manifest** (source strings) from the RN app, evaluated in a sandbox with `React` and `MaterialUI` on `window`. They use **format** in the schema (e.g. `"format": "signature"`), not only `type`. Contract: `src/types/CustomQuestionTypeContract.ts`. 4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens. 5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview. + +## Internationalization (i18n) + +Two **independent** layers: + +| Layer | Owner | Mechanism | +| ---------- | ----------- | ----------------------------------------------------------------------------------------- | +| ODE chrome | ODE | `src/locales/{en,pt,fr}.json`, `createOdeI18n()`, JsonForms `i18n` prop, `useOdeT()` hook | +| Form copy | Form author | Optional `translations` on `ui.json`; `applyFormUiTranslations()` at init in `App.tsx` | + +- Host passes `params.locale` (reserved bridge key, not observation data). +- Custom question types receive localized `options` from `ui.json` via `CustomQuestionTypeAdapter`. +- Docs: [form translations](https://opendataensemble.org/docs/guides/form-translations). + 6. **Numeric inputs** (`type: integer` / `type: number`): Built-in control is **`NumberStepperRenderer`** (`src/renderers/NumberStepperRenderer.tsx`) backed by **`useNumericDraftInput`** (`src/hooks/useNumericDraftInput.ts`). Policy: **draft text while focused** (`type="text"` + `inputMode` + `enterKeyHint` from `FormContext`); observation JSON stores **JSON numbers only** (parse on commit, never strings); **never clamp** to `minimum`/`maximum` while typing—surface AJV errors instead. Custom numeric question types must follow the same contract (see `CustomQuestionTypeContract.ts`). ## Adding or changing behavior diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index d25e8e7e8..04293ba41 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -31,6 +31,11 @@ If you run `pnpm install` only in formulus-formplayer, the tokens package’s `p Use `pnpm run build:copy` to build the project and copy the bundle into the Formulus app (Android + iOS) and ODE Desktop (`desktop/public/formplayer_dist/`). +## Internationalization + +- **ODE chrome** — `src/locales/{en,pt,fr}.json`, wired via `createOdeI18n()` and JsonForms `i18n`. Host sets `params.locale`. +- **Form copy** — optional `translations` objects on `ui.json`; merged once at init by `applyFormUiTranslations()`. See [form translations](https://opendataensemble.org/docs/guides/form-translations). + ## Javascript interface The javascript interface made available to the custom app is as follows: diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 7ea7fdb27..685597567 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -46,6 +46,11 @@ import { applyStickyDefaults, } from './utils/stickyFieldHelpers'; import { stickyService } from './services/StickyService'; +import { applyFormUiTranslations } from './i18n/applyFormUiTranslations'; +import { createOdeI18n } from './i18n/createOdeI18n'; +import { odeT } from './i18n/createOdeI18n'; +import { resolveFormplayerLocale, type OdeUiLocale } from './i18n/localeUtils'; +import { FormplayerLocaleContext } from './i18n/FormplayerLocaleContext'; import SwipeLayoutRenderer, { swipeLayoutTester, @@ -391,6 +396,9 @@ function App() { const [validationMode, setValidationMode] = useState< 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' >('ValidateAndShow'); + const [uiLocale, setUiLocale] = useState('en'); + + const odeI18n = useMemo(() => createOdeI18n(uiLocale), [uiLocale]); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -430,6 +438,11 @@ function App() { setFormInitData(initData); + const resolvedLocale = resolveFormplayerLocale( + (params as Record | null)?.locale, + ); + setUiLocale(resolvedLocale); + // Debug: log schema details, especially x-dynamicEnum usage try { const properties = (formSchema as any)?.properties || {}; @@ -576,21 +589,21 @@ function App() { setSchema({} as FormSchema); // Set to empty schema or handle as per requirements // First ensure SwipeLayout root, then process to ensure Finalize element is present const swipeLayoutUISchema = ensureSwipeLayoutRoot(null); - const processedUISchema = processUISchemaWithFinalize( - swipeLayoutUISchema, - skipFinalize, + const withLocale = applyFormUiTranslations( + processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), + resolvedLocale, ); - setUISchema(processedUISchema); + setUISchema(withLocale); } else { setSchema(formSchema as FormSchema); const swipeLayoutUISchema = ensureSwipeLayoutRoot( uiSchema as FormUISchema, ); - const processedUISchema = processUISchemaWithFinalize( - swipeLayoutUISchema, - skipFinalize, + const withLocale = applyFormUiTranslations( + processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), + resolvedLocale, ); - setUISchema(processedUISchema); + setUISchema(withLocale); } const formSchemaTyped = formSchema as FormSchema | null; @@ -1264,13 +1277,15 @@ function App() { if (showDraftSelector && pendingFormInit) { return ( - + + + ); } @@ -1288,10 +1303,14 @@ function App() { }}> - Loading form... + {odeT(uiLocale, 'form.loading', 'Loading form...')} - Waiting for data from Formulus... + {odeT( + uiLocale, + 'form.waitingForData', + 'Waiting for data from Formulus...', + )} ); @@ -1316,7 +1335,7 @@ function App() { variant="h6" color="error" sx={{ mb: 2, textAlign: 'center' }}> - Error Loading Form + {odeT(uiLocale, 'form.errorLoading', 'Error Loading Form')} - Loading form... + {odeT(uiLocale, 'form.loading', 'Loading form...')} ); @@ -1361,110 +1380,117 @@ function App() { return ( - -
- {/* Main app content - 60% width in development mode */} + +
- - {loadError ? ( - - - Error Loading Form - - - {loadError} - - - ) : ( - <> - - - - {/* Success Snackbar */} - setShowFinalizeMessage(false)}> - setShowFinalizeMessage(false)} - severity="info"> - Form submitted successfully! - - - {/* Error Snackbar for submit failures */} - setSubmitError(null)}> - setSubmitError(null)} - severity="error"> - {submitError} - - - - )} - -
- - {/* Development testbed - 40% width in development mode (lazy-loaded, not in production bundle) */} - {DevTestbedLazy && ( + {/* Main app content - 60% width in development mode */}
- - - + {loadError ? ( + + + Error Loading Form + + + {loadError} + + + ) : ( + <> + + + + {/* Success Snackbar */} + setShowFinalizeMessage(false)}> + setShowFinalizeMessage(false)} + severity="info"> + {odeT( + uiLocale, + 'form.submitSuccess', + 'Form submitted successfully!', + )} + + + {/* Error Snackbar for submit failures */} + setSubmitError(null)}> + setSubmitError(null)} + severity="error"> + {submitError} + + + + )}
- )} -
-
+ + {/* Development testbed - 40% width in development mode (lazy-loaded, not in production bundle) */} + {DevTestbedLazy && ( +
+ + + + + +
+ )} + + +
); } diff --git a/formulus-formplayer/src/components/DraftSelector.tsx b/formulus-formplayer/src/components/DraftSelector.tsx index 454da4e59..57ca7d1a4 100644 --- a/formulus-formplayer/src/components/DraftSelector.tsx +++ b/formulus-formplayer/src/components/DraftSelector.tsx @@ -21,6 +21,7 @@ import { import { Button } from '@ode/components/react-web'; import { Delete as DeleteIcon } from '@mui/icons-material'; import { draftService, DraftSummary } from '../services/DraftService'; +import { useOdeT } from '../i18n/useOdeT'; interface DraftSelectorProps { /** The form type to show drafts for */ @@ -50,6 +51,7 @@ export const DraftSelector: React.FC = ({ fullScreen = false, }) => { const theme = useTheme(); + const t = useOdeT(); const [drafts, setDrafts] = useState([]); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [draftToDelete, setDraftToDelete] = useState(null); @@ -153,7 +155,7 @@ export const DraftSelector: React.FC = ({ variant="neutral" size="small" onPress={() => onResumeDraft(draft.id)}> - Resume + {t('draft.resume', 'Resume')} handleDeleteDraft(draft.id)} @@ -207,7 +209,7 @@ export const DraftSelector: React.FC = ({ mb: 0.5, textAlign: 'left', }}> - Resume Draft or Start New + {t('draft.title', 'Resume Draft or Start New')}
= ({ size="medium" onPress={onStartNew} style={{ minWidth: 180 }}> - Start New Form + {t('draft.startNew', 'Start New Form')} @@ -260,7 +262,7 @@ export const DraftSelector: React.FC = ({ setDeleteConfirmOpen(false)}> - Delete Draft + {t('draft.delete', 'Delete Draft')} Are you sure you want to delete this draft? This action cannot @@ -271,10 +273,10 @@ export const DraftSelector: React.FC = ({ diff --git a/formulus-formplayer/src/components/FormLayout.tsx b/formulus-formplayer/src/components/FormLayout.tsx index 1c0d18f78..8647d7e62 100644 --- a/formulus-formplayer/src/components/FormLayout.tsx +++ b/formulus-formplayer/src/components/FormLayout.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, useCallback } from 'react'; import { Box, Paper } from '@mui/material'; import { Button } from '@ode/components/react-web'; import { useKeyboardScrollClamp } from '../hooks/useKeyboardScrollClamp'; +import { useOdeT } from '../i18n/useOdeT'; /** Keeps a submit control in the DOM so mobile keyboards can trigger the primary action (Go / Send / →). */ const visuallyHiddenSubmitStyle: React.CSSProperties = { @@ -104,6 +105,7 @@ const FormLayout: React.FC = ({ scrollRefMerge, scrollHandlers, }) => { + const t = useOdeT(); const scrollRef = useKeyboardScrollClamp(); const mergedScrollRef = useCallback( @@ -193,7 +195,7 @@ const FormLayout: React.FC = ({ disabled={previousButton.disabled} size="medium" style={{ width: '100%', maxWidth: '100%' }}> - {previousButton.label || 'Previous'} + {previousButton.label || t('nav.previous', 'Previous')} )} @@ -213,7 +215,7 @@ const FormLayout: React.FC = ({ size="medium" className="formplayer-solid-primary" style={{ width: '100%', maxWidth: '100%' }}> - {nextButton.label || 'Next'} + {nextButton.label || t('nav.next', 'Next')} )} diff --git a/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx b/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx new file mode 100644 index 000000000..412757f0a --- /dev/null +++ b/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import type { OdeUiLocale } from './localeUtils'; + +export const FormplayerLocaleContext = createContext('en'); diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts new file mode 100644 index 000000000..68ac18a64 --- /dev/null +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { applyFormUiTranslations } from './applyFormUiTranslations'; + +describe('applyFormUiTranslations', () => { + it('returns same reference when no translations', () => { + const ui = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/a', label: 'A' }], + }; + expect(applyFormUiTranslations(ui, 'fr')).toBe(ui); + }); + + it('merges partial control label', () => { + const ui = { + type: 'Control', + scope: '#/properties/name', + label: 'Name', + translations: { fr: { label: 'Nom' } }, + }; + const out = applyFormUiTranslations(ui, 'fr') as typeof ui; + expect(out.label).toBe('Nom'); + expect((out as { translations?: unknown }).translations).toBeUndefined(); + }); + + it('falls back to default for missing locale', () => { + const ui = { + type: 'Control', + scope: '#/properties/name', + label: 'Name', + translations: { fr: { label: 'Nom' } }, + }; + const out = applyFormUiTranslations(ui, 'de') as typeof ui; + expect(out.label).toBe('Name'); + }); + + it('merges SwipeLayout options', () => { + const ui = { + type: 'SwipeLayout', + options: { headerTitle: 'Census', nextButtonLabel: 'Next' }, + translations: { + pt: { + options: { headerTitle: 'Censo', nextButtonLabel: 'Seguinte' }, + }, + }, + elements: [], + }; + const out = applyFormUiTranslations(ui, 'pt') as { + options: { headerTitle: string; nextButtonLabel: string }; + }; + expect(out.options.headerTitle).toBe('Censo'); + expect(out.options.nextButtonLabel).toBe('Seguinte'); + }); + + it('merges oneOf by const', () => { + const ui = { + type: 'Control', + scope: '#/properties/q', + options: { + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + translations: { + fr: { + options: { + oneOf: [{ const: 'yes', title: 'Oui' }], + }, + }, + }, + }; + const out = applyFormUiTranslations(ui, 'fr') as { + options: { oneOf: { const: string; title: string }[] }; + }; + expect(out.options.oneOf[0]?.title).toBe('Oui'); + expect(out.options.oneOf[1]?.title).toBe('No'); + }); + + it('handles pt-BR via pt fallback', () => { + const ui = { + type: 'Label', + text: 'Hello', + translations: { pt: { text: 'Olá' } }, + }; + const out = applyFormUiTranslations(ui, 'pt-BR') as { text: string }; + expect(out.text).toBe('Olá'); + }); + + it('perf smoke: large form with few translations', () => { + const elements = Array.from({ length: 200 }, (_, i) => ({ + type: 'Control', + scope: `#/properties/f${i}`, + label: `Field ${i}`, + ...(i < 20 ? { translations: { fr: { label: `Champ ${i}` } } } : {}), + })); + const ui = { type: 'VerticalLayout', elements }; + const start = performance.now(); + applyFormUiTranslations(ui, 'fr'); + expect(performance.now() - start).toBeLessThan(50); + }); +}); diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts new file mode 100644 index 000000000..cde5d386a --- /dev/null +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts @@ -0,0 +1,253 @@ +import type { OdeUiLocale } from './localeUtils'; +import { localeLookupCandidates } from './localeUtils'; + +type UiSchemaNode = Record; + +const TRANSLATABLE_TOP_KEYS = new Set([ + 'label', + 'description', + 'text', + 'headerTitle', + 'nextButtonLabel', + 'finalizeButtonLabel', + 'addButtonLabel', +]); + +/** Keys that live under options on SwipeLayout / Control. */ +const SWIPE_OPTION_KEYS = new Set([ + 'headerTitle', + 'nextButtonLabel', + 'finalizeButtonLabel', +]); + +function hasTranslationsSubtree(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) { + return node.some(hasTranslationsSubtree); + } + const obj = node as UiSchemaNode; + if (obj.translations && typeof obj.translations === 'object') return true; + if (Array.isArray(obj.elements)) { + return obj.elements.some(hasTranslationsSubtree); + } + if (obj.options && typeof obj.options === 'object') { + if (Array.isArray((obj.options as UiSchemaNode).columns)) { + if ( + ((obj.options as UiSchemaNode).columns as unknown[]).some( + hasTranslationsSubtree, + ) + ) { + return true; + } + } + } + return false; +} + +function pickLocaleBlock( + translations: Record, + locale: OdeUiLocale, +): Record | null { + for (const candidate of localeLookupCandidates(locale)) { + const block = translations[candidate]; + if (block && typeof block === 'object' && !Array.isArray(block)) { + return block as Record; + } + } + return null; +} + +function mergeOptionsArraysByConst( + base: unknown[], + patch: unknown[], +): unknown[] { + const result = base.map(item => + item && typeof item === 'object' ? { ...(item as object) } : item, + ); + for (const patchItem of patch) { + if (!patchItem || typeof patchItem !== 'object') continue; + const patchObj = patchItem as Record; + const patchConst = patchObj.const; + let matched = false; + if (patchConst !== undefined) { + for (let i = 0; i < result.length; i++) { + const baseItem = result[i]; + if ( + baseItem && + typeof baseItem === 'object' && + (baseItem as Record).const === patchConst + ) { + result[i] = { ...(baseItem as object), ...patchObj }; + matched = true; + break; + } + } + } + if (!matched) { + result.push(patchItem); + } + } + return result; +} + +function deepMergeOptions( + base: Record, + patch: Record, +): Record { + const out: Record = { ...base }; + for (const [key, patchVal] of Object.entries(patch)) { + const baseVal = out[key]; + if ( + key === 'oneOf' || + key === 'columns' || + (Array.isArray(patchVal) && Array.isArray(baseVal)) + ) { + out[key] = mergeOptionsArraysByConst( + Array.isArray(baseVal) ? baseVal : [], + Array.isArray(patchVal) ? patchVal : [], + ); + } else if ( + patchVal && + typeof patchVal === 'object' && + !Array.isArray(patchVal) && + baseVal && + typeof baseVal === 'object' && + !Array.isArray(baseVal) + ) { + out[key] = deepMergeOptions( + baseVal as Record, + patchVal as Record, + ); + } else { + out[key] = patchVal; + } + } + return out; +} + +function applyBlockToNode( + node: UiSchemaNode, + block: Record, +): UiSchemaNode { + const out: UiSchemaNode = { ...node }; + const optionsBase = + out.options && + typeof out.options === 'object' && + !Array.isArray(out.options) + ? { ...(out.options as Record) } + : {}; + + for (const [key, val] of Object.entries(block)) { + if ( + key === 'options' && + val && + typeof val === 'object' && + !Array.isArray(val) + ) { + out.options = deepMergeOptions( + optionsBase, + val as Record, + ); + continue; + } + if (SWIPE_OPTION_KEYS.has(key)) { + out.options = { + ...((out.options as Record) ?? {}), + [key]: val, + }; + continue; + } + if (TRANSLATABLE_TOP_KEYS.has(key)) { + out[key] = val; + } + } + + return out; +} + +function processNode(node: unknown, locale: OdeUiLocale): unknown { + if (!node || typeof node !== 'object') return node; + if (Array.isArray(node)) { + let changed = false; + const next = node.map(child => { + const processed = processNode(child, locale); + if (processed !== child) changed = true; + return processed; + }); + return changed ? next : node; + } + + const obj = node as UiSchemaNode; + let result: UiSchemaNode = obj; + let changed = false; + + const translations = obj.translations; + if (translations && typeof translations === 'object') { + const block = pickLocaleBlock( + translations as Record, + locale, + ); + if (block) { + result = applyBlockToNode(result, block); + changed = true; + } + } + + if (Array.isArray(obj.elements)) { + let elementsChanged = false; + const newElements = obj.elements.map(el => { + const processed = processNode(el, locale); + if (processed !== el) elementsChanged = true; + return processed; + }); + if (elementsChanged) { + result = changed + ? { ...result, elements: newElements } + : { ...obj, elements: newElements }; + changed = true; + } + } + + if ( + result.options && + typeof result.options === 'object' && + !Array.isArray(result.options) + ) { + const opts = result.options as Record; + if (Array.isArray(opts.columns)) { + let colsChanged = false; + const newCols = opts.columns.map(col => { + const processed = processNode(col, locale); + if (processed !== col) colsChanged = true; + return processed; + }); + if (colsChanged) { + result = { + ...result, + options: { ...opts, columns: newCols }, + }; + changed = true; + } + } + } + + if (changed && result.translations) { + const { translations: _removed, ...without } = result; + return without; + } + + return changed ? result : node; +} + +/** + * Apply embedded ui.json translations for the active locale (once at form init). + * Returns the same reference when no translations exist in the tree. + */ +export function applyFormUiTranslations( + uischema: T, + locale: OdeUiLocale, +): T { + if (!uischema || typeof uischema !== 'object') return uischema; + if (!hasTranslationsSubtree(uischema)) return uischema; + return processNode(uischema, locale) as T; +} diff --git a/formulus-formplayer/src/i18n/createOdeI18n.ts b/formulus-formplayer/src/i18n/createOdeI18n.ts new file mode 100644 index 000000000..e115c3247 --- /dev/null +++ b/formulus-formplayer/src/i18n/createOdeI18n.ts @@ -0,0 +1,101 @@ +import type { ErrorObject } from 'ajv'; +import type { JsonFormsI18nState, Translator } from '@jsonforms/core'; +import en from '../locales/en.json'; +import pt from '../locales/pt.json'; +import fr from '../locales/fr.json'; +import type { OdeUiLocale } from './localeUtils'; + +type Catalog = Record; + +const CATALOGS: Record = { + en: en as Catalog, + pt: pt as Catalog, + fr: fr as Catalog, +}; + +/** Simple {{key}} interpolation for catalog strings. */ +export function interpolate( + template: string, + vars?: Record, +): string { + if (!vars) return template; + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + const val = vars[key]; + return val !== undefined ? String(val) : `{{${key}}}`; + }); +} + +export function getOdeCatalog(locale: OdeUiLocale): Catalog { + return CATALOGS[locale] ?? CATALOGS.en; +} + +export function odeT( + locale: OdeUiLocale, + key: string, + defaultMessage?: string, + vars?: Record, +): string { + const catalog = getOdeCatalog(locale); + const raw = catalog[key] ?? defaultMessage ?? key; + return interpolate(raw, vars); +} + +/** + * JsonForms-compatible i18n state for ODE-owned chrome and validation messages. + */ +export function createOdeI18n(locale: OdeUiLocale): JsonFormsI18nState { + const catalog = getOdeCatalog(locale); + + const translate: Translator = ( + key: string, + defaultMessage?: string, + _context?: unknown, + ) => { + if (defaultMessage === undefined) { + return catalog[key] ?? undefined; + } + return catalog[key] ?? defaultMessage; + }; + + const translateError = ( + error: ErrorObject, + t: typeof translate, + _uischema?: unknown, + ): string => { + const keyword = error.keyword; + const limit = + (error.params as { limit?: number })?.limit ?? + (error.params as { minimum?: number })?.minimum ?? + (error.params as { maximum?: number })?.maximum; + + const vars: Record = {}; + if (limit !== undefined) vars.limit = limit; + + const ajvMessage = + typeof error.message === 'string' ? error.message : undefined; + + if (keyword) { + const fieldKey = `${String(error.instancePath).replace(/^\//, '').replace(/\//g, '.')}.error.${keyword}`; + const fieldMsg = t(fieldKey, undefined); + if (fieldMsg) return fieldMsg; + + const globalKey = `error.${keyword}`; + const globalMsg = catalog[globalKey]; + if (globalMsg) return interpolate(globalMsg, vars); + } + + if (ajvMessage) { + const byMessage = + catalog[`error.${ajvMessage}`] ?? t(ajvMessage, ajvMessage); + if (byMessage) return interpolate(byMessage, vars); + } + + return ajvMessage ?? 'Validation error'; + }; + + return { + locale, + translate, + translateError, + }; +} diff --git a/formulus-formplayer/src/i18n/localeUtils.ts b/formulus-formplayer/src/i18n/localeUtils.ts new file mode 100644 index 000000000..ab4fc6c4d --- /dev/null +++ b/formulus-formplayer/src/i18n/localeUtils.ts @@ -0,0 +1,46 @@ +/** + * Locale utilities (mirrored from formulus/src/lib/locale.ts for Formplayer). + */ + +export const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const; +export type OdeUiLocale = (typeof ODE_UI_LOCALES)[number]; + +export function normalizeLocaleTag(tag: string): string { + return tag.trim().replace(/_/g, '-').toLowerCase(); +} + +export function localeLookupCandidates(tag: string): string[] { + const normalized = normalizeLocaleTag(tag); + if (!normalized) return ['en']; + const parts = normalized.split('-'); + const candidates: string[] = [normalized]; + if (parts.length > 1) { + candidates.push(parts[0]!); + } + return candidates; +} + +export function matchOdeCatalogLocale( + tag: string, + supported: readonly string[] = ODE_UI_LOCALES, +): OdeUiLocale | null { + for (const candidate of localeLookupCandidates(tag)) { + if ((supported as readonly string[]).includes(candidate)) { + return candidate as OdeUiLocale; + } + } + return null; +} + +/** Resolve locale from bridge params; falls back to en. */ +export function resolveFormplayerLocale( + paramsLocale: unknown, + fallback = 'en', +): OdeUiLocale { + if (typeof paramsLocale === 'string' && paramsLocale.trim()) { + const matched = matchOdeCatalogLocale(paramsLocale); + if (matched) return matched; + } + const fromFallback = matchOdeCatalogLocale(fallback); + return fromFallback ?? 'en'; +} diff --git a/formulus-formplayer/src/i18n/useOdeT.ts b/formulus-formplayer/src/i18n/useOdeT.ts new file mode 100644 index 000000000..1c732a679 --- /dev/null +++ b/formulus-formplayer/src/i18n/useOdeT.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { odeT } from './createOdeI18n'; +import { FormplayerLocaleContext } from './FormplayerLocaleContext'; + +/** Translate ODE-owned chrome strings using the active Formplayer locale. */ +export function useOdeT() { + const locale = useContext(FormplayerLocaleContext); + return ( + key: string, + defaultMessage?: string, + vars?: Record, + ) => odeT(locale, key, defaultMessage, vars); +} diff --git a/formulus-formplayer/src/locales/en.json b/formulus-formplayer/src/locales/en.json new file mode 100644 index 000000000..1f0f6f4cb --- /dev/null +++ b/formulus-formplayer/src/locales/en.json @@ -0,0 +1,53 @@ +{ + "form.loading": "Loading form...", + "form.waitingForData": "Waiting for data from Formulus...", + "form.submitSuccess": "Form submitted successfully!", + "form.errorLoading": "Error Loading Form", + "form.errorInitializing": "Error initializing form", + "nav.previous": "Previous", + "nav.next": "Next", + "nav.done": "Done", + "nav.finalize": "Finalize", + "nav.previousScreen": "Previous screen", + "nav.nextScreen": "Next screen", + "draft.title": "Resume Draft or Start New", + "draft.resume": "Resume Draft", + "draft.startNew": "Start New Form", + "draft.delete": "Delete Draft", + "draft.cancel": "Cancel", + "draft.savedAt": "Saved {{date}}", + "finalize.title": "Finalize", + "finalize.summary": "FORM SUMMARY", + "finalize.notProvided": "Not provided", + "finalize.allValid": "All validations passed! Review your answers and submit.", + "validation.missingRequiredFields": "Missing required fields", + "validation.missingRequiredField": "Missing required field", + "validation.missingRequiredFieldsNamed": "Missing required field(s): {{names}}", + "validation.fieldsNeedAttention": "{{count}} fields need attention: {{summary}} Tap Done to review.", + "validation.stayHere": "Stay here", + "validation.goBack": "Go back", + "subObservation.add": "+ Add observation", + "subObservation.none": "No observations", + "subObservation.adding": "Adding…", + "media.photo": "Photo", + "media.photoTap": "Tap to capture photo", + "media.audio": "Audio", + "media.video": "Video", + "media.gps": "GPS Location", + "media.qrCode": "QR Code", + "media.approximateDate": "Approximate Date", + "media.takePhoto": "Take photo", + "media.recording": "Recording...", + "cqt.errorTitle": "Custom Question Type Error", + "cqt.errorBody": "The custom question type \"{{format}}\" encountered an error and could not be rendered.", + "cqt.errorDetails": "Error Details (click to expand)", + "cqt.errorContinue": "The form will continue to function, but this field cannot be edited.", + "error.required": "This field is required", + "error.is a required property": "This field is required", + "error.minLength": "Must be at least {{limit}} characters", + "error.maxLength": "Must be at most {{limit}} characters", + "error.minimum": "Must be at least {{limit}}", + "error.maximum": "Must be at most {{limit}}", + "error.pattern": "Invalid format", + "error.type": "Invalid value" +} diff --git a/formulus-formplayer/src/locales/fr.json b/formulus-formplayer/src/locales/fr.json new file mode 100644 index 000000000..ea34dd087 --- /dev/null +++ b/formulus-formplayer/src/locales/fr.json @@ -0,0 +1,53 @@ +{ + "form.loading": "Chargement du formulaire...", + "form.waitingForData": "En attente des données de Formulus...", + "form.submitSuccess": "Formulaire envoyé avec succès !", + "form.errorLoading": "Erreur de chargement du formulaire", + "form.errorInitializing": "Erreur d'initialisation du formulaire", + "nav.previous": "Précédent", + "nav.next": "Suivant", + "nav.done": "Terminer", + "nav.finalize": "Finaliser", + "nav.previousScreen": "Écran précédent", + "nav.nextScreen": "Écran suivant", + "draft.title": "Reprendre le brouillon ou recommencer", + "draft.resume": "Reprendre le brouillon", + "draft.startNew": "Nouveau formulaire", + "draft.delete": "Supprimer le brouillon", + "draft.cancel": "Annuler", + "draft.savedAt": "Enregistré {{date}}", + "finalize.title": "Finaliser", + "finalize.summary": "RÉSUMÉ DU FORMULAIRE", + "finalize.notProvided": "Non renseigné", + "finalize.allValid": "Toutes les validations sont passées ! Vérifiez vos réponses et soumettez.", + "validation.missingRequiredFields": "Champs obligatoires manquants", + "validation.missingRequiredField": "Champ obligatoire manquant", + "validation.missingRequiredFieldsNamed": "Champ(s) obligatoire(s) manquant(s) : {{names}}", + "validation.fieldsNeedAttention": "{{count}} champs nécessitent une attention : {{summary}} Appuyez sur Terminer pour vérifier.", + "validation.stayHere": "Rester ici", + "validation.goBack": "Retour", + "subObservation.add": "+ Ajouter une observation", + "subObservation.none": "Aucune observation", + "subObservation.adding": "Ajout…", + "media.photo": "Photo", + "media.photoTap": "Appuyez pour prendre une photo", + "media.audio": "Audio", + "media.video": "Vidéo", + "media.gps": "Position GPS", + "media.qrCode": "Code QR", + "media.approximateDate": "Date approximative", + "media.takePhoto": "Prendre une photo", + "media.recording": "Enregistrement...", + "cqt.errorTitle": "Erreur du type de question personnalisé", + "cqt.errorBody": "Le type de question personnalisé « {{format}} » a rencontré une erreur et n'a pas pu être affiché.", + "cqt.errorDetails": "Détails de l'erreur (cliquer pour développer)", + "cqt.errorContinue": "Le formulaire continuera de fonctionner, mais ce champ ne peut pas être modifié.", + "error.required": "Ce champ est obligatoire", + "error.is a required property": "Ce champ est obligatoire", + "error.minLength": "Doit contenir au moins {{limit}} caractères", + "error.maxLength": "Doit contenir au plus {{limit}} caractères", + "error.minimum": "Doit être au moins {{limit}}", + "error.maximum": "Doit être au plus {{limit}}", + "error.pattern": "Format invalide", + "error.type": "Valeur invalide" +} diff --git a/formulus-formplayer/src/locales/pt.json b/formulus-formplayer/src/locales/pt.json new file mode 100644 index 000000000..db57a4f44 --- /dev/null +++ b/formulus-formplayer/src/locales/pt.json @@ -0,0 +1,53 @@ +{ + "form.loading": "A carregar formulário...", + "form.waitingForData": "A aguardar dados do Formulus...", + "form.submitSuccess": "Formulário enviado com sucesso!", + "form.errorLoading": "Erro ao carregar formulário", + "form.errorInitializing": "Erro ao inicializar formulário", + "nav.previous": "Anterior", + "nav.next": "Seguinte", + "nav.done": "Concluir", + "nav.finalize": "Finalizar", + "nav.previousScreen": "Ecrã anterior", + "nav.nextScreen": "Ecrã seguinte", + "draft.title": "Retomar rascunho ou começar de novo", + "draft.resume": "Retomar rascunho", + "draft.startNew": "Novo formulário", + "draft.delete": "Eliminar rascunho", + "draft.cancel": "Cancelar", + "draft.savedAt": "Guardado {{date}}", + "finalize.title": "Finalizar", + "finalize.summary": "RESUMO DO FORMULÁRIO", + "finalize.notProvided": "Não fornecido", + "finalize.allValid": "Todas as validações passaram! Reveja as respostas e submeta.", + "validation.missingRequiredFields": "Campos obrigatórios em falta", + "validation.missingRequiredField": "Campo obrigatório em falta", + "validation.missingRequiredFieldsNamed": "Campo(s) obrigatório(s) em falta: {{names}}", + "validation.fieldsNeedAttention": "{{count}} campos precisam de atenção: {{summary}} Toque em Concluir para rever.", + "validation.stayHere": "Ficar aqui", + "validation.goBack": "Voltar", + "subObservation.add": "+ Adicionar observação", + "subObservation.none": "Sem observações", + "subObservation.adding": "A adicionar…", + "media.photo": "Fotografia", + "media.photoTap": "Toque para capturar fotografia", + "media.audio": "Áudio", + "media.video": "Vídeo", + "media.gps": "Localização GPS", + "media.qrCode": "Código QR", + "media.approximateDate": "Data aproximada", + "media.takePhoto": "Tirar fotografia", + "media.recording": "A gravar...", + "cqt.errorTitle": "Erro no tipo de pergunta personalizado", + "cqt.errorBody": "O tipo de pergunta personalizado \"{{format}}\" encontrou um erro e não pôde ser apresentado.", + "cqt.errorDetails": "Detalhes do erro (clique para expandir)", + "cqt.errorContinue": "O formulário continuará a funcionar, mas este campo não pode ser editado.", + "error.required": "Este campo é obrigatório", + "error.is a required property": "Este campo é obrigatório", + "error.minLength": "Deve ter pelo menos {{limit}} caracteres", + "error.maxLength": "Deve ter no máximo {{limit}} caracteres", + "error.minimum": "Deve ser pelo menos {{limit}}", + "error.maximum": "Deve ser no máximo {{limit}}", + "error.pattern": "Formato inválido", + "error.type": "Valor inválido" +} diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx new file mode 100644 index 000000000..a44000135 --- /dev/null +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import { registerCustomQuestionTypes } from '../services/CustomQuestionTypeRegistry'; +import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; +import { applyFormUiTranslations } from '../i18n/applyFormUiTranslations'; + +const FORMAT = 'confidence-rating-test'; +const ajv = new Ajv({ allErrors: true, strict: false }); + +let capturedProps: CustomQuestionTypeProps | null = null; + +function SpyCqt(props: CustomQuestionTypeProps) { + capturedProps = props; + return
; +} + +const schema: JsonSchema7 = { + type: 'object', + properties: { + confidence: { + type: 'number', + title: 'Confidence', + format: FORMAT, + }, + }, +}; + +const rawUischema: UISchemaElement = { + type: 'Control', + scope: '#/properties/confidence', + options: { + lowLabel: 'Not at all', + highLabel: 'Completely', + }, + translations: { + pt: { + options: { + lowLabel: 'Nada', + highLabel: 'Completamente', + }, + }, + }, +}; + +function renderWithUischema(uischema: UISchemaElement) { + const renderers = registerCustomQuestionTypes(new Map([[FORMAT, SpyCqt]])); + + render( + + + {}} + /> + + , + ); +} + +afterEach(() => { + capturedProps = null; + cleanup(); +}); + +describe('CustomQuestionTypeAdapter', () => { + it('passes preprocessed ui.json options to the custom component', () => { + const uischema = applyFormUiTranslations( + rawUischema, + 'pt', + ) as UISchemaElement; + + renderWithUischema(uischema); + + expect(screen.getByTestId('spy-cqt')).toBeTruthy(); + expect(capturedProps?.options).toEqual({ + lowLabel: 'Nada', + highLabel: 'Completamente', + }); + }); + + it('passes default options when locale has no translation block', () => { + const uischema = applyFormUiTranslations( + rawUischema, + 'de', + ) as UISchemaElement; + + renderWithUischema(uischema); + + expect(capturedProps?.options).toEqual({ + lowLabel: 'Not at all', + highLabel: 'Completely', + }); + }); +}); diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index a0772f331..90204e5b1 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -120,6 +120,7 @@ export function createCustomQuestionTypeRenderer( handleChange, path, schema, + uischema, errors, enabled, label, @@ -214,6 +215,15 @@ export function createCustomQuestionTypeRenderer( const customProps: CustomQuestionTypeProps = { value: data, config, + options: + uischema && + typeof uischema === 'object' && + 'options' in uischema && + uischema.options && + typeof uischema.options === 'object' && + !Array.isArray(uischema.options) + ? (uischema.options as Record) + : undefined, onChange: (newValue: unknown) => handleChange(path, newValue), validation: { error: Boolean(hasErrors), diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index 6b5d5d2cc..8bcc46543 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -9,6 +9,7 @@ import { ErrorObject } from 'ajv'; import { useFormContext } from '../App'; import EditIcon from '@mui/icons-material/Edit'; import { displayAdate } from '../utils/adateUtils'; +import { useOdeT } from '../i18n/useOdeT'; interface SummaryItem { label: string; @@ -21,6 +22,7 @@ interface SummaryItem { const FinalizeRenderer = ({ data }: ControlProps) => { const { core } = useJsonForms(); + const t = useOdeT(); const errors = core?.errors || []; const { formInitData } = useFormContext(); const fullSchema = core?.schema; @@ -40,7 +42,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { // Helper function to format field value based on type const formatFieldValue = (value: any, fieldSchema: any): string => { if (value === null || value === undefined || value === '') { - return 'Not provided'; + return t('finalize.notProvided', 'Not provided'); } // Handle special formats @@ -340,7 +342,10 @@ const FinalizeRenderer = ({ data }: ControlProps) => { color="success.main" gutterBottom sx={{ textAlign: 'center' }}> - All validations passed! You can now finalize your submission. + {t( + 'finalize.allValid', + 'All validations passed! You can now finalize your submission.', + )} )} @@ -352,7 +357,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { disabled={Boolean(hasErrors)} className="formplayer-solid-primary" style={{ width: '100%' }}> - Finalize + {t('nav.finalize', 'Finalize')} @@ -371,7 +376,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { variant="h5" gutterBottom sx={{ fontWeight: 700, textAlign: 'center' }}> - FORM SUMMARY + {t('finalize.summary', 'FORM SUMMARY')} (''); const { core, config } = useJsonForms(); + const t = useOdeT(); const parentFormContext = useFormContext(); const { formInitData } = parentFormContext; @@ -369,9 +371,13 @@ const SwipeLayoutRenderer = ({ const missingFields = getMissingRequiredFieldsOnPage(); if (missingFields.length > 0) { - const message = `Missing required ${ - missingFields.length === 1 ? 'field' : 'fields' - }: ${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`; + const message = t( + 'validation.missingRequiredFieldsNamed', + `Missing required field(s): ${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`, + { + names: `${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`, + }, + ); setPendingNavigation(newPage); setSnackbarMessage(message); @@ -491,7 +497,7 @@ const SwipeLayoutRenderer = ({ primaryKeyboardEnterKeyHint( isOnFinalizePage, nextVisiblePage !== null ? nextButtonLabelOption : undefined, - finalizeButtonLabelOption ?? 'Finalize', + finalizeButtonLabelOption ?? t('nav.finalize', 'Finalize'), ), [ isOnFinalizePage, @@ -642,7 +648,7 @@ const SwipeLayoutRenderer = ({ ? { onClick: trySubmitForm, disabled: isNavigating || !formInitData, - label: finalizeButtonLabelOption ?? 'Done', + label: finalizeButtonLabelOption ?? t('nav.done', 'Done'), } : nextVisiblePage !== null ? { @@ -711,7 +717,10 @@ const SwipeLayoutRenderer = ({ - Missing required fields + {t( + 'validation.missingRequiredFields', + 'Missing required fields', + )} - Stay here + {t('validation.stayHere', 'Stay here')} diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index 438eec16a..13a2e8f4e 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -26,6 +26,11 @@ export interface CustomQuestionTypeProps { */ config: Record; + /** + * Display-oriented settings from ui.json Control.options (after locale preprocess). + */ + options?: Record; + /** * Callback to update the field value. Call with a JSON number/boolean/string/etc. — * never a display string for numeric schema types. diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 3be0c2205..5005614bd 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -41,7 +41,7 @@ export interface ExtensionMetadata { * Data passed to the Formulus app when a form is initialized * @property {string} formType - The form type (e.g. 'form1') * @property {string | null} observationId - The observation ID (generated by the database on first form submission). NULL if this is a new form. - * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors` (and any future keys added to the formplayer allowlist)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus `locale` so polluted rows are cleaned on re-save. + * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (active UI language), `context` (session context)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale`. * @property {Record} savedData - Previously saved form data (for editing) * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) diff --git a/formulus-formplayer/src/utils/formObservationData.test.ts b/formulus-formplayer/src/utils/formObservationData.test.ts index 9f0f78297..0e97b3e1e 100644 --- a/formulus-formplayer/src/utils/formObservationData.test.ts +++ b/formulus-formplayer/src/utils/formObservationData.test.ts @@ -25,6 +25,7 @@ describe('initialFormDataFromParams', () => { theme: 'dark', darkMode: true, themeColors: { primary: '#000' }, + locale: 'pt', species: 'oak', }; expect(initialFormDataFromParams(params)).toEqual({ species: 'oak' }); diff --git a/formulus-formplayer/src/utils/formObservationData.ts b/formulus-formplayer/src/utils/formObservationData.ts index 70fdb2cd0..c7ee38543 100644 --- a/formulus-formplayer/src/utils/formObservationData.ts +++ b/formulus-formplayer/src/utils/formObservationData.ts @@ -9,6 +9,8 @@ export const FORMPARAMS_NON_DATA_KEYS = new Set([ 'theme', 'darkMode', 'themeColors', + // UI locale from host — not observation data (distinct from optional schema `locale` field). + 'locale', // Reserved read-only session context channel (see App init): a custom app may // pass `params.context` with session info (device role, selected cluster, ...) // that must never be persisted as observation data. diff --git a/formulus/.prettierignore b/formulus/.prettierignore index f83ff68d4..a7245668b 100644 --- a/formulus/.prettierignore +++ b/formulus/.prettierignore @@ -1,6 +1,6 @@ node_modules +third_party android ios coverage - diff --git a/formulus/AGENTS.md b/formulus/AGENTS.md index f6fee055c..5511c9670 100644 --- a/formulus/AGENTS.md +++ b/formulus/AGENTS.md @@ -33,6 +33,13 @@ - **Custom apps** are HTML/JS/CSS bundles loaded from Synkronus; they receive the **Formulus** injected API (see interface definition). Authors do not need this monorepo — public docs and [custom_app](https://github.com/OpenDataEnsemble/custom_app) describe usage. - **Formplayer** is a sibling package; after changing `FormulusInterfaceDefinition.ts`, run **`pnpm run sync-interface`** (or build) in **formulus-formplayer** so its copy stays aligned. +## UI language (i18n) + +- **Settings → Language** (`SettingsScreen`): Auto / `en` / `pt` / `fr`; stored in AsyncStorage (`@ode/uiLocale`) via [`LocaleSettingsService`](src/services/LocaleSettingsService.ts). +- Resolution: [`src/lib/locale.ts`](src/lib/locale.ts) — preference → device → `app.config.json` `defaultLocale` → `en`. +- Shell strings: `react-i18next` + [`src/locales/`](src/locales/). +- Formplayer receives resolved locale as `params.locale` from [`FormplayerModal`](src/components/FormplayerModal.tsx). + --- ## Changing the bridge diff --git a/formulus/App.tsx b/formulus/App.tsx index 58fdafba2..b944f94f5 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -4,8 +4,9 @@ import { DefaultTheme, DarkTheme, } from '@react-navigation/native'; -import { StatusBar, Alert } from 'react-native'; +import { StatusBar, Alert, View, ActivityIndicator } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { I18nextProvider } from 'react-i18next'; import 'react-native-url-polyfill/auto'; import { FormService } from './src/services/FormService'; import { @@ -25,6 +26,7 @@ import SignatureCaptureModal from './src/components/SignatureCaptureModal'; import MainAppNavigator from './src/navigation/MainAppNavigator'; import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; import { FormSpec } from './src/services'; +import { initFormulusI18n, i18n } from './src/i18n'; /** * Inner component that consumes the AppTheme context to build a dynamic @@ -315,16 +317,35 @@ function AppInner(): React.JSX.Element { * custom app's brand colors are available to all native UI elements. */ function App(): React.JSX.Element { + const [i18nReady, setI18nReady] = useState(false); + + useEffect(() => { + void initFormulusI18n().then(() => setI18nReady(true)); + }, []); + + if (!i18nReady) { + return ( + + + + + + ); + } + return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 5c071dd1e..278d8e244 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -2,37 +2,50 @@ // Do not edit directly - this file will be overwritten // Last generated: 2026-06-19T16:58:11.324Z -(function() { +(function () { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); + return ( + globalThis.formulus || + (typeof window !== 'undefined' ? window.formulus : undefined) + ); } function isFormulusAvailable() { const api = getFormulus(); - return api && typeof api === 'object' && typeof api.getVersion === 'function'; + return ( + api && typeof api === 'object' && typeof api.getVersion === 'function' + ); } // Idempotent guard to avoid double-initialization when scripts are reinjected - if ((globalThis).__formulusBridgeInitialized) { + if (globalThis.__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); + console.debug( + 'Formulus bridge already initialized and functional. Skipping duplicate injection.', + ); return; } else { - console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); + console.warn( + 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', + ); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug('Formulus interface already exists and is functional. Skipping injection.'); + console.debug( + 'Formulus interface already exists and is functional. Skipping injection.', + ); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); + console.warn( + 'Formulus interface exists but appears non-functional. Re-injecting...', + ); } // Helper function to handle callbacks @@ -48,7 +61,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -61,1625 +74,2087 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { + if ( + data.type === 'callback' && + data.callbackId && + callbacks[data.callbackId] + ) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - - if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { + + if ( + data.type === 'onFormulusReady' && + globalThis.formulusCallbacks?.onFormulusReady + ) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); + console.error( + 'Global handleMessage: Error processing message:', + e, + 'Raw event.data:', + event.data, + ); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + // getVersion: => Promise + getVersion: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getVersion callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getVersion_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getVersion_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getVersion' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getVersion', messageId, - - })); - - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAvailableForms_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAvailableForms' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAvailableForms', messageId, - - })); - - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise - openFormplayer: function(formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise + openFormplayer: function (formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'openFormplayer callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'openFormplayer_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'openFormplayer_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'openFormplayer' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options - })); - - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function(formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function (formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservations callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservations_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservations_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getObservations' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted - })); - - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise - getObservationsByQuery: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + includeDeleted: includeDeleted, + }), + ); + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise + getObservationsByQuery: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options - })); - - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function(formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function (formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'submitObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'submitObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'submitObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'submitObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData - })); - - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function(observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + formType: formType, + finalData: finalData, + }), + ); + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function (observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'updateObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'updateObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'updateObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'updateObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData - })); - - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + finalData: finalData, + }), + ); + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestCamera callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestCamera_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestCamera_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestCamera' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // getCachedLocation: fieldId: string => Promise - getCachedLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // getCachedLocation: fieldId: string => Promise + getCachedLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCachedLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCachedLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCachedLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getCachedLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getCachedLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCachedLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCachedLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise - allocateSequence: function(scopeKey, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise + allocateSequence: function (scopeKey, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'allocateSequence_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('allocateSequence callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'allocateSequence_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'allocateSequence' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'allocateSequence', messageId, - scopeKey: scopeKey, - options: options - })); - - }); - }, - - // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> - watchLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + scopeKey: scopeKey, + options: options, + }), + ); + }); + }, + + // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'watchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'watchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('watchLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'watchLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'watchLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'watchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'watchLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // stopWatchLocation: fieldId: string => Promise - stopWatchLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // stopWatchLocation: fieldId: string => Promise + stopWatchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'stopWatchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'stopWatchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('stopWatchLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'stopWatchLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'stopWatchLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'stopWatchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'stopWatchLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestFile callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestFile_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestFile_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestFile' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId - })); - - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function(fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function (fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'launchIntent callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'launchIntent_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'launchIntent_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'launchIntent' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec - })); - - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function(fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + intentSpec: intentSpec, + }), + ); + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function (fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'callSubform callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'callSubform_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'callSubform_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'callSubform' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options - })); - - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestAudio callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestAudio_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestAudio_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestAudio' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestVideo: fieldId: string => Promise - requestVideo: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestVideo: fieldId: string => Promise + requestVideo: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestVideo callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestVideo_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestVideo callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestVideo_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestVideo' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestVideo' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestVideo', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestQrcode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestQrcode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestQrcode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestBiometric_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestBiometric_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestBiometric' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestConnectivityStatus', messageId, - - })); - - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestSyncStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestSyncStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestSyncStatus', messageId, - - })); - - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function(fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'runLocalModel_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'runLocalModel_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'runLocalModel' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input - })); - - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentUser_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCurrentUser' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentUser', messageId, - - })); - - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getThemeMode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getThemeMode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getThemeMode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getThemeMode', messageId, - - })); - - }); - }, - - // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise - getAttachmentUri: function(fileName) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName - })); - - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentsUri', messageId, - - })); - - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCustomAppUri', messageId, - - })); - - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getFormSpecsUri', messageId, - - })); - - }); - }, - - // persistObservation: input: PersistObservationInput => Promise - persistObservation: function(input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // persistObservation: input: PersistObservationInput => Promise + persistObservation: function (input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'persistObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'persistObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('persistObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'persistObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'persistObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'persistObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'persistObservation', messageId, - input: input - })); - - }); - }, - - // sync: options: { includeAttachments?: boolean; } => Promise - sync: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // sync: options: { includeAttachments?: boolean; } => Promise + sync: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'sync callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if (data.type === 'sync_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('sync callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'sync_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'sync' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'sync' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'sync', messageId, - options: options - })); - - }); - }, - - // getConnectivityStatus: => Promise - getConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getConnectivityStatus: => Promise + getConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getConnectivityStatus', messageId, - - })); - - }); - }, - - // getCurrentDataRevisionCount: => Promise - getCurrentDataRevisionCount: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCurrentDataRevisionCount: => Promise + getCurrentDataRevisionCount: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentDataRevisionCount_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getCurrentDataRevisionCount_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getCurrentDataRevisionCount' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCurrentDataRevisionCount' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentDataRevisionCount', messageId, - - })); - - }); - }, + }), + ); + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - (globalThis).__formulusBridgeInitialized = true; + globalThis.__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now() - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now(), + }), + ); } } globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'onFormulusReady' - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'onFormulusReady', + }), + ); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } - })(); diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index 2aee71918..f4d47b754 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -1,16 +1,16 @@ /** * Formulus API Interface (JavaScript Version) - * + * * This file provides type information and documentation for the Formulus API * that's available in the WebView context as `globalThis.formulus`. - * + * * This file is auto-generated from FormulusInterfaceDefinition.ts * Last generated: 2026-06-19T16:58:11.756Z - * + * * @example * // In your JavaScript file: * /// - * + * * // Now you'll get autocompletion and type hints in IDEs that support JSDoc * globalThis.formulus.getVersion().then(version => { * console.log('Formulus version:', version); @@ -29,268 +29,267 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus bridge API (the interface contract - * version, e.g. for {@link isCompatibleVersion} checks). - * @returns {Promise} The API version (semver) - */ - getVersion: function() {}, + * Get the current version of the Formulus bridge API (the interface contract + * version, e.g. for {@link isCompatibleVersion} checks). + * @returns {Promise} The API version (semver) + */ + getVersion: function () {}, /** - * Get a list of available forms - * @returns {Promise} Array of form information objects - */ - getAvailableForms: function() {}, - - /** - * Open Formplayer with the specified form - * Reserved keys are not treated as observation data: - * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and - * `context` — a read-only **session context** object (device role, selected cluster, - * ...) that Formplayer never persists and exposes to extensions as - * `window.formulusSessionContext`. Draft bypass is not a param key — use - * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). - * observation instead of creating a new one - * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization. - * @param {Object} savedData - Previously saved form data (for editing) - * @returns {Promise} Promise that resolves when the form is completed/closed with result details - */ - openFormplayer: function(formType, params, savedData, options) {}, + * Get a list of available forms + * @returns {Promise} Array of form information objects + */ + getAvailableForms: function () {}, /** - * Get observations for a specific form - * @param {string} formType - The identifier of the formtype - * @returns {Promise} Array of form observations - */ - getObservations: function(formType, isDraft, includeDeleted) {}, + * Open Formplayer with the specified form + * Reserved keys are not treated as observation data: + * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and + * `context` — a read-only **session context** object (device role, selected cluster, + * ...) that Formplayer never persists and exposes to extensions as + * `window.formulusSessionContext`. Draft bypass is not a param key — use + * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). + * observation instead of creating a new one + * @param {string} formType - The identifier of the formtype to open + * @param {Object} params - Additional parameters for form initialization. + * @param {Object} savedData - Previously saved form data (for editing) + * @returns {Promise} Promise that resolves when the form is completed/closed with result details + */ + openFormplayer: function (formType, params, savedData, options) {}, /** - * Get observations with optional WHERE clause filtering (for dynamic choice lists). - * Supports format: data.field = 'value' AND data.other = 'value' - * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * @returns {Promise} Array of filtered observations - */ - getObservationsByQuery: function(options) {}, + * Get observations for a specific form + * @param {string} formType - The identifier of the formtype + * @returns {Promise} Array of form observations + */ + getObservations: function (formType, isDraft, includeDeleted) {}, /** - * Submit a completed form - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to submit - * @returns {Promise} The observationId of the submitted form - */ - submitObservation: function(formType, finalData) {}, + * Get observations with optional WHERE clause filtering (for dynamic choice lists). + * Supports format: data.field = 'value' AND data.other = 'value' + * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. + * @returns {Promise} Array of filtered observations + */ + getObservationsByQuery: function (options) {}, /** - * Update an existing form - * @param {string} observationId - The identifier of the observation - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to update - * @returns {Promise} The observationId of the updated form - */ - updateObservation: function(observationId, formType, finalData) {}, + * Submit a completed form + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to submit + * @returns {Promise} The observationId of the submitted form + */ + submitObservation: function (formType, finalData) {}, /** - * Request camera access for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation - */ - requestCamera: function(fieldId) {}, + * Update an existing form + * @param {string} observationId - The identifier of the observation + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to update + * @returns {Promise} The observationId of the updated form + */ + updateObservation: function (observationId, formType, finalData) {}, /** - * Request location for a field (captures into the form GPS field). - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with location result or rejects on error - */ - requestLocation: function(fieldId) {}, + * Request camera access for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation + */ + requestCamera: function (fieldId) {}, /** - * Return the best cached device fix (if any) without forcing a new GPS read. - */ - getCachedLocation: function(fieldId) {}, + * Request location for a field (captures into the form GPS field). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with location result or rejects on error + */ + requestLocation: function (fieldId) {}, /** - * Allocate the next monotonic integer in a device-local scope. - * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. - */ - allocateSequence: function(scopeKey, options) {}, + * Return the best cached device fix (if any) without forcing a new GPS read. + */ + getCachedLocation: function (fieldId) {}, /** - * Subscribe to location updates while the custom app WebView is active. - * Updates arrive as `locationWatchUpdate` window messages. - */ - watchLocation: function(fieldId) {}, + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + */ + allocateSequence: function (scopeKey, options) {}, /** - * Stop a prior `watchLocation` subscription for `fieldId`. - */ - stopWatchLocation: function(fieldId) {}, + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + */ + watchLocation: function (fieldId) {}, /** - * Request file selection for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation - */ - requestFile: function(fieldId) {}, + * Stop a prior `watchLocation` subscription for `fieldId`. + */ + stopWatchLocation: function (fieldId) {}, /** - * Launch an external intent - * @param {string} fieldId - The ID of the field - * @param {Object} intentSpec - The intent specification - * @returns {Promise} - */ - launchIntent: function(fieldId, intentSpec) {}, + * Request file selection for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation + */ + requestFile: function (fieldId) {}, /** - * Call a subform - * @param {string} fieldId - The ID of the field - * @param {string} formType - The ID of the subform - * @param {Object} options - Additional options for the subform - * @returns {Promise} - */ - callSubform: function(fieldId, formType, options) {}, + * Launch an external intent + * @param {string} fieldId - The ID of the field + * @param {Object} intentSpec - The intent specification + * @returns {Promise} + */ + launchIntent: function (fieldId, intentSpec) {}, /** - * Request audio recording for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation - */ - requestAudio: function(fieldId) {}, + * Call a subform + * @param {string} fieldId - The ID of the field + * @param {string} formType - The ID of the subform + * @param {Object} options - Additional options for the subform + * @returns {Promise} + */ + callSubform: function (fieldId, formType, options) {}, /** - * Request video recording for a field (camera / picker — host-defined). - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation - */ - requestVideo: function(fieldId) {}, + * Request audio recording for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation + */ + requestAudio: function (fieldId) {}, /** - * Request QR code scanning for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation - */ - requestQrcode: function(fieldId) {}, + * Request video recording for a field (camera / picker — host-defined). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo: function (fieldId) {}, /** - * Request biometric authentication - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestBiometric: function(fieldId) {}, + * Request QR code scanning for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation + */ + requestQrcode: function (fieldId) {}, /** - * Request the current connectivity status - * @returns {Promise} - */ - requestConnectivityStatus: function() {}, + * Request biometric authentication + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestBiometric: function (fieldId) {}, /** - * Request the current sync status - * @returns {Promise} - */ - requestSyncStatus: function() {}, + * Request the current connectivity status + * @returns {Promise} + */ + requestConnectivityStatus: function () {}, /** - * Run a local ML model - * @param {string} fieldId - The ID of the field - * @param {string} modelId - The ID of the model to run - * @param {Object} input - The input data for the model - * @returns {Promise} - */ - runLocalModel: function(fieldId, modelId, input) {}, + * Request the current sync status + * @returns {Promise} + */ + requestSyncStatus: function () {}, /** - * Get information about the currently authenticated user. - * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} - */ - getCurrentUser: function() {}, + * Run a local ML model + * @param {string} fieldId - The ID of the field + * @param {string} modelId - The ID of the model to run + * @param {Object} input - The input data for the model + * @returns {Promise} + */ + runLocalModel: function (fieldId, modelId, input) {}, /** - * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. - */ - getThemeMode: function() {}, - - /** - * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). - * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: - * 1. `attachments/draft/` — unsaved capture (formplayer preview) - * 2. `attachments/synced/` — canonical committed / downloaded copy - * 3. `attachments/pending/` — queued for upload (fallback only) - * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. - * Path segments and ".." are rejected. - * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). - * @returns {Promise} Display URL, or `null` if none - */ - getAttachmentUri: function(fileName) {}, - - /** - * Base `file://` URL for the canonical attachments directory (trailing slash). - * Returns the `synced/` subfolder — only committed/downloaded files are - * iterable from here. Drafts and the upload queue are excluded by design so - * custom apps can safely list this directory. - * **Breaking change (v2 layout):** this used to return the `attachments/` - * parent directory, which mixed committed files with `draft/` and - * `pending_upload/` subfolders. Custom apps that iterate this URL will now - * see only fully-committed attachments. - * @returns {Promise} e.g. `file:///.../attachments/synced/` - */ - getAttachmentsUri: function() {}, + * Get information about the currently authenticated user. + * When no one is logged in, resolves with `{ username: '' }` (does not reject). + * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} + */ + getCurrentUser: function () {}, /** - * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * @returns {Promise} App directory URL for extensions, question_types, etc. - */ - getCustomAppUri: function() {}, - + * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. + * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. + */ + getThemeMode: function () {}, + /** - * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). - * Some bundles also use files under the custom app `forms/` subdirectory. - * @returns {Promise} Forms directory URL - */ - getFormSpecsUri: function() {}, - - /** - * Persist an observation **without opening Formplayer** (headless write). - * Intended for custom apps that create or update records programmatically. - * Uses the same persistence path as a Formplayer submit, including promotion - * of any referenced draft attachments. Provide `observationId` to update an - * existing row; omit it to create a new one. - * @since 1.3.0 - * @param {PersistObservationInput} input - The observation to persist - * @returns {Promise} The stored observation id and data - */ - persistObservation: function(input) {}, - - /** - * Trigger a synchronization with Synkronus (pull + push). - * Resolves when the sync completes; rejects if a sync is already in progress - * or the sync fails. Attachments are excluded by default for speed. - * @since 1.3.0 - * @returns {Promise} The final data version after sync - */ - sync: function(options) {}, - - /** - * Probe connectivity to the configured Synkronus server (`GET /health`). - * Never rejects for an offline device — returns `{ online: false }` so callers - * can branch on connectivity without try/catch. Suitable for the - * "verify subject details when online, fall back when offline" pattern. - * @since 1.3.0 - * @returns {Promise} The current connectivity status - */ - getConnectivityStatus: function() {}, - - /** - * Read the device's last-known Synkronus data revision (`current_version` - * from the most recent successful sync). Reflects server-stream alignment - * only — not unsynced local edits. Use with periodic polling or after - * {@link sync} to detect when another device has pushed changes. - * @since 1.4.0 - * @returns {Promise} Non-negative revision count (0 if never synced) - */ - getCurrentDataRevisionCount: function() {}, - + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). + * @returns {Promise} Display URL, or `null` if none + */ + getAttachmentUri: function (fileName) {}, + + /** + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. + * @returns {Promise} e.g. `file:///.../attachments/synced/` + */ + getAttachmentsUri: function () {}, + + /** + * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). + * @returns {Promise} App directory URL for extensions, question_types, etc. + */ + getCustomAppUri: function () {}, + + /** + * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). + * Some bundles also use files under the custom app `forms/` subdirectory. + * @returns {Promise} Forms directory URL + */ + getFormSpecsUri: function () {}, + + /** + * Persist an observation **without opening Formplayer** (headless write). + * Intended for custom apps that create or update records programmatically. + * Uses the same persistence path as a Formplayer submit, including promotion + * of any referenced draft attachments. Provide `observationId` to update an + * existing row; omit it to create a new one. + * @since 1.3.0 + * @param {PersistObservationInput} input - The observation to persist + * @returns {Promise} The stored observation id and data + */ + persistObservation: function (input) {}, + + /** + * Trigger a synchronization with Synkronus (pull + push). + * Resolves when the sync completes; rejects if a sync is already in progress + * or the sync fails. Attachments are excluded by default for speed. + * @since 1.3.0 + * @returns {Promise} The final data version after sync + */ + sync: function (options) {}, + + /** + * Probe connectivity to the configured Synkronus server (`GET /health`). + * Never rejects for an offline device — returns `{ online: false }` so callers + * can branch on connectivity without try/catch. Suitable for the + * "verify subject details when online, fall back when offline" pattern. + * @since 1.3.0 + * @returns {Promise} The current connectivity status + */ + getConnectivityStatus: function () {}, + + /** + * Read the device's last-known Synkronus data revision (`current_version` + * from the most recent successful sync). Reflects server-stream alignment + * only — not unsynced local edits. Use with periodic polling or after + * {@link sync} to detect when another device has pushed changes. + * @since 1.4.0 + * @returns {Promise} Non-negative revision count (0 if never synced) + */ + getCurrentDataRevisionCount: function () {}, }; // Make the API available globally in browser environments diff --git a/formulus/eslint.config.js b/formulus/eslint.config.js index fa44ecf3f..d124fa420 100644 --- a/formulus/eslint.config.js +++ b/formulus/eslint.config.js @@ -11,6 +11,7 @@ export default tseslint.config( { ignores: [ '**/node_modules/**', + 'third_party/**', '**/android/**', '**/ios/**', '**/coverage/**', diff --git a/formulus/package.json b/formulus/package.json index 8eb6fced0..b2349a1ea 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -41,8 +41,10 @@ "@react-navigation/native": "^7.1.26", "@react-navigation/stack": "^7.6.13", "@testing-library/react-native": "^13.3.3", + "i18next": "^26.3.4", "lucide-react-native": "^0.577.0", "react": "19.2.4", + "react-i18next": "^17.0.8", "react-native": "0.83.1", "react-native-camera-kit-no-google": "^17.0.1", "react-native-device-info": "^15.0.1", @@ -50,6 +52,7 @@ "react-native-gesture-handler": "^2.30.0", "react-native-image-picker": "^8.2.1", "react-native-keychain": "^10.0.0", + "react-native-localize": "^3.7.0", "react-native-nitro-modules": "^0.35.4", "react-native-nitro-sound": "^0.2.10", "react-native-permissions": "^5.4.4", diff --git a/formulus/pnpm-lock.yaml b/formulus/pnpm-lock.yaml index 775c9ec9b..3dfdbf483 100644 --- a/formulus/pnpm-lock.yaml +++ b/formulus/pnpm-lock.yaml @@ -66,12 +66,18 @@ importers: '@testing-library/react-native': specifier: ^13.3.3 version: 13.3.3(jest@30.4.2(@types/node@24.12.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.2.4(react@19.2.4))(react@19.2.4) + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@5.9.3) lucide-react-native: specifier: ^0.577.0 version: 0.577.0(react-native-svg@15.15.5(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.4(typescript@5.9.3))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-native: specifier: 0.83.1 version: 0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4) @@ -93,6 +99,9 @@ importers: react-native-keychain: specifier: ^10.0.0 version: 10.0.0 + react-native-localize: + specifier: ^3.7.0 + version: 3.7.0(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react-native-nitro-modules: specifier: ^0.35.4 version: 0.35.7(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -3362,6 +3371,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3382,6 +3394,14 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -4381,6 +4401,22 @@ packages: peerDependencies: react: '>=17.0.0' + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4430,6 +4466,19 @@ packages: resolution: {integrity: sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw==} engines: {node: '>=16'} + react-native-localize@3.7.0: + resolution: {integrity: sha512-6Ohx+zZzycC6zhNVBGM/u1U+O6Ww29YIFseeyXqsKcO/pTfjLcdE40IUJF4SVVwrdh00IMJwy90HjLGUaeqK7Q==} + peerDependencies: + '@expo/config-plugins': '*' + react: '*' + react-native: '*' + react-native-macos: '*' + peerDependenciesMeta: + '@expo/config-plugins': + optional: true + react-native-macos: + optional: true + react-native-nitro-modules@0.35.7: resolution: {integrity: sha512-3EiU27EmnxTlD3pAXR2OBWeDg7+9tdjKrNQOPX08rFX5hJPNIT/h1i2PjvTUieOypCGTEi9nW/SNBtK3F+4HYg==} peerDependencies: @@ -5072,6 +5121,10 @@ packages: vlq@1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -9120,6 +9173,10 @@ snapshots: html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -9151,6 +9208,10 @@ snapshots: human-signals@2.1.0: {} + i18next@26.3.4(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -10419,6 +10480,17 @@ snapshots: dependencies: react: 19.2.4 + react-i18next@17.0.8(i18next@26.3.4(typescript@5.9.3))(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.4(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-native: 0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -10458,6 +10530,11 @@ snapshots: react-native-keychain@10.0.0: {} + react-native-localize@3.7.0(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-native: 0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4) + react-native-nitro-modules@0.35.7(react-native@0.83.1(@babel/core@7.29.7)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -11193,6 +11270,8 @@ snapshots: vlq@1.0.1: {} + void-elements@3.1.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index b92689549..5d6c6bdb8 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -46,6 +46,8 @@ import { useAppTheme } from '../contexts/AppThemeContext'; import { useConfirmModal } from '../contexts/ConfirmModalContext'; import { geolocationService } from '../services/GeolocationService'; import { persistObservationWithAttachments } from '../services/attachmentStorage'; +import { localeSettingsService } from '../services/LocaleSettingsService'; +import { useTranslation } from 'react-i18next'; interface FormplayerModalProps { visible: boolean; @@ -76,6 +78,7 @@ const FormplayerModal = forwardRef( const webViewRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const { showConfirm } = useConfirmModal(); + const { t } = useTranslation(); // Theme colors & resolved mode from AppThemeContext. const { themeColors, resolvedMode } = useAppTheme(); @@ -177,15 +180,14 @@ const FormplayerModal = forwardRef( } showConfirm({ - title: 'Close form?', - message: - 'This will close the current form. Any changes made will not be saved, but will be available as a draft next time you open the form.', + title: t('formplayer.closeTitle'), + message: t('formplayer.closeMessage'), buttons: [ - { text: 'Cancel', variant: 'tertiary', onPress: () => {} }, - { text: 'Close form', variant: 'danger', onPress: performClose }, + { text: t('common.cancel'), variant: 'tertiary', onPress: () => {} }, + { text: t('common.close'), variant: 'danger', onPress: performClose }, ], }); - }, [isClosing, isSubmitting, performClose, showConfirm]); + }, [isClosing, isSubmitting, performClose, showConfirm, t]); // Removed closeFormplayer event listener - now using direct promise-based submission handling @@ -261,12 +263,17 @@ const FormplayerModal = forwardRef( // that form UI elements (buttons, inputs, headers) match the branding. const isDark = resolvedMode === 'dark'; + const sessionLocale = + params && typeof params.locale === 'string' ? params.locale : null; + const resolvedLocale = + await localeSettingsService.resolveActiveLocale(sessionLocale); + const formParams = { - locale: 'en', theme: 'default', darkMode: isDark, themeColors, // ← custom app palette forwarded to Formplayer ...params, + locale: resolvedLocale, }; // Load extensions for this form @@ -726,7 +733,9 @@ const FormplayerModal = forwardRef( size="large" color={colors.semantic.info.ios} /> - Saving form data... + + {t('formplayer.saving')} + )} diff --git a/formulus/src/i18n/index.ts b/formulus/src/i18n/index.ts new file mode 100644 index 000000000..82c248854 --- /dev/null +++ b/formulus/src/i18n/index.ts @@ -0,0 +1,47 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from '../locales/en.json'; +import pt from '../locales/pt.json'; +import fr from '../locales/fr.json'; +import { localeSettingsService } from '../services/LocaleSettingsService'; + +const resources = { + en: { translation: en }, + pt: { translation: pt }, + fr: { translation: fr }, +}; + +let initPromise: Promise | null = null; + +export async function initFormulusI18n(): Promise { + if (initPromise) return initPromise; + + initPromise = (async () => { + await localeSettingsService.load(); + const locale = await localeSettingsService.resolveActiveLocale(); + + if (!i18n.isInitialized) { + await i18n.use(initReactI18next).init({ + resources, + lng: locale, + fallbackLng: 'en', + interpolation: { escapeValue: false }, + compatibilityJSON: 'v4', + }); + } else { + await i18n.changeLanguage(locale); + } + + return i18n; + })(); + + return initPromise; +} + +/** Re-apply locale after Settings change. */ +export async function syncFormulusI18nLanguage(): Promise { + const locale = await localeSettingsService.resolveActiveLocale(); + await i18n.changeLanguage(locale); +} + +export { i18n }; diff --git a/formulus/src/lib/locale.test.ts b/formulus/src/lib/locale.test.ts new file mode 100644 index 000000000..5c6308080 --- /dev/null +++ b/formulus/src/lib/locale.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + localeLookupCandidates, + matchOdeCatalogLocale, + normalizeLocaleTag, + resolveActiveLocale, +} from './locale'; + +describe('locale', () => { + it('normalizes locale tags', () => { + expect(normalizeLocaleTag('pt_BR')).toBe('pt-br'); + expect(normalizeLocaleTag(' EN ')).toBe('en'); + }); + + it('builds lookup candidates', () => { + expect(localeLookupCandidates('pt-BR')).toEqual(['pt-br', 'pt']); + expect(localeLookupCandidates('en')).toEqual(['en']); + }); + + it('matches ODE catalog locales', () => { + expect(matchOdeCatalogLocale('pt-BR')).toBe('pt'); + expect(matchOdeCatalogLocale('de')).toBeNull(); + }); + + it('resolveActiveLocale respects explicit preference', () => { + expect( + resolveActiveLocale({ + preference: 'fr', + deviceLocale: 'en-US', + }), + ).toBe('fr'); + }); + + it('resolveActiveLocale uses device when auto', () => { + expect( + resolveActiveLocale({ + preference: 'auto', + deviceLocale: 'pt-PT', + }), + ).toBe('pt'); + }); + + it('resolveActiveLocale falls back to bundle default', () => { + expect( + resolveActiveLocale({ + preference: 'auto', + deviceLocale: 'de-DE', + bundleDefaultLocale: 'fr', + }), + ).toBe('fr'); + }); + + it('resolveActiveLocale session override wins', () => { + expect( + resolveActiveLocale({ + preference: 'en', + deviceLocale: 'en-US', + sessionOverride: 'pt', + }), + ).toBe('pt'); + }); + + it('resolveActiveLocale defaults to en', () => { + expect( + resolveActiveLocale({ + preference: 'auto', + deviceLocale: 'de-DE', + }), + ).toBe('en'); + }); +}); diff --git a/formulus/src/lib/locale.ts b/formulus/src/lib/locale.ts new file mode 100644 index 000000000..68ef33bc8 --- /dev/null +++ b/formulus/src/lib/locale.ts @@ -0,0 +1,94 @@ +/** + * UI locale resolution for Formulus and Formplayer bridge. + * ODE ships en, pt, fr catalogs; other tags fall back through normalization. + */ + +export const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const; +export type OdeUiLocale = (typeof ODE_UI_LOCALES)[number]; + +/** User preference stored in Settings (auto = follow device). */ +export type UiLocalePreference = 'auto' | OdeUiLocale; + +export const UI_LOCALE_PREFERENCE_OPTIONS: { + value: UiLocalePreference; + labelKey: string; +}[] = [ + { value: 'auto', labelKey: 'settings.language.auto' }, + { value: 'en', labelKey: 'settings.language.en' }, + { value: 'pt', labelKey: 'settings.language.pt' }, + { value: 'fr', labelKey: 'settings.language.fr' }, +]; + +/** Normalize BCP-47 tag for catalog lookup (lowercase, hyphen). */ +export function normalizeLocaleTag(tag: string): string { + return tag.trim().replace(/_/g, '-').toLowerCase(); +} + +/** Candidate tags to try for a locale (e.g. pt-BR → pt-br, pt). */ +export function localeLookupCandidates(tag: string): string[] { + const normalized = normalizeLocaleTag(tag); + if (!normalized) return ['en']; + const parts = normalized.split('-'); + const candidates: string[] = [normalized]; + if (parts.length > 1) { + candidates.push(parts[0]!); + } + return candidates; +} + +export function isOdeUiLocale(tag: string): tag is OdeUiLocale { + return (ODE_UI_LOCALES as readonly string[]).includes(tag); +} + +/** + * Map a device or bundle tag to the nearest ODE catalog locale, or null if none match. + */ +export function matchOdeCatalogLocale( + tag: string, + supported: readonly string[] = ODE_UI_LOCALES, +): OdeUiLocale | null { + for (const candidate of localeLookupCandidates(tag)) { + if ((supported as readonly string[]).includes(candidate)) { + return candidate as OdeUiLocale; + } + } + return null; +} + +export interface ResolveActiveLocaleInput { + preference: UiLocalePreference; + deviceLocale: string; + bundleDefaultLocale?: string | null; + /** Optional per-session override from openFormplayer params. */ + sessionOverride?: string | null; +} + +/** + * Resolve the active UI locale for Formplayer and shell i18n. + * Precedence: session override → explicit preference → device (auto) → bundle default → en. + */ +export function resolveActiveLocale( + input: ResolveActiveLocaleInput, +): OdeUiLocale { + const { preference, deviceLocale, bundleDefaultLocale, sessionOverride } = + input; + + if (sessionOverride) { + const matched = matchOdeCatalogLocale(sessionOverride); + if (matched) return matched; + } + + if (preference !== 'auto' && isOdeUiLocale(preference)) { + return preference; + } + + const fromDevice = matchOdeCatalogLocale(deviceLocale); + if (fromDevice) return fromDevice; + + if (bundleDefaultLocale) { + const fromBundle = matchOdeCatalogLocale(bundleDefaultLocale); + if (fromBundle) return fromBundle; + } + + return 'en'; +} diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json new file mode 100644 index 000000000..3e59656e9 --- /dev/null +++ b/formulus/src/locales/en.json @@ -0,0 +1,34 @@ +{ + "settings.language.auto": "Auto (device)", + "settings.language.en": "English", + "settings.language.pt": "Português", + "settings.language.fr": "Français", + "settings.language.label": "Language:", + "settings.theme.label": "Theme:", + "settings.title": "Settings", + "settings.appSettings": "App Settings", + "settings.login": "Login", + "settings.loggingIn": "Logging in...", + "settings.serverUrl": "Server URL", + "settings.username": "Username", + "settings.password": "Password", + "settings.updated": "Settings updated successfully", + "tabs.sync": "Sync", + "tabs.forms": "Forms", + "tabs.settings": "Settings", + "tabs.home": "Home", + "sync.title": "Sync", + "sync.syncing": "Syncing…", + "sync.completed": "Sync completed", + "sync.failed": "Sync failed", + "formplayer.closeTitle": "Close form?", + "formplayer.closeMessage": "Unsaved changes may be lost.", + "formplayer.saving": "Saving form data...", + "common.cancel": "Cancel", + "common.close": "Close", + "common.ok": "OK", + "common.about": "About", + "common.help": "Help & Support", + "common.logout": "Logout", + "common.login": "Login" +} diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json new file mode 100644 index 000000000..6351df4bd --- /dev/null +++ b/formulus/src/locales/fr.json @@ -0,0 +1,34 @@ +{ + "settings.language.auto": "Auto (appareil)", + "settings.language.en": "English", + "settings.language.pt": "Português", + "settings.language.fr": "Français", + "settings.language.label": "Langue :", + "settings.theme.label": "Thème :", + "settings.title": "Paramètres", + "settings.appSettings": "Paramètres de l'application", + "settings.login": "Connexion", + "settings.loggingIn": "Connexion...", + "settings.serverUrl": "URL du serveur", + "settings.username": "Nom d'utilisateur", + "settings.password": "Mot de passe", + "settings.updated": "Paramètres mis à jour avec succès", + "tabs.sync": "Synchroniser", + "tabs.forms": "Formulaires", + "tabs.settings": "Paramètres", + "tabs.home": "Accueil", + "sync.title": "Synchroniser", + "sync.syncing": "Synchronisation…", + "sync.completed": "Synchronisation terminée", + "sync.failed": "Échec de la synchronisation", + "formplayer.closeTitle": "Fermer le formulaire ?", + "formplayer.closeMessage": "Les modifications non enregistrées peuvent être perdues.", + "formplayer.saving": "Enregistrement des données du formulaire...", + "common.cancel": "Annuler", + "common.close": "Fermer", + "common.ok": "OK", + "common.about": "À propos", + "common.help": "Aide et support", + "common.logout": "Déconnexion", + "common.login": "Connexion" +} diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json new file mode 100644 index 000000000..7e5c005a2 --- /dev/null +++ b/formulus/src/locales/pt.json @@ -0,0 +1,34 @@ +{ + "settings.language.auto": "Automático (dispositivo)", + "settings.language.en": "English", + "settings.language.pt": "Português", + "settings.language.fr": "Français", + "settings.language.label": "Idioma:", + "settings.theme.label": "Tema:", + "settings.title": "Definições", + "settings.appSettings": "Definições da aplicação", + "settings.login": "Entrar", + "settings.loggingIn": "A entrar...", + "settings.serverUrl": "URL do servidor", + "settings.username": "Nome de utilizador", + "settings.password": "Palavra-passe", + "settings.updated": "Definições atualizadas com sucesso", + "tabs.sync": "Sincronizar", + "tabs.forms": "Formulários", + "tabs.settings": "Definições", + "tabs.home": "Início", + "sync.title": "Sincronizar", + "sync.syncing": "A sincronizar…", + "sync.completed": "Sincronização concluída", + "sync.failed": "Falha na sincronização", + "formplayer.closeTitle": "Fechar formulário?", + "formplayer.closeMessage": "Alterações não guardadas podem perder-se.", + "formplayer.saving": "A guardar dados do formulário...", + "common.cancel": "Cancelar", + "common.close": "Fechar", + "common.ok": "OK", + "common.about": "Acerca de", + "common.help": "Ajuda e suporte", + "common.logout": "Terminar sessão", + "common.login": "Entrar" +} diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index d82e6ec50..7ceb1e347 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -58,7 +58,14 @@ import { import { Button } from '../components/common'; import { useScreenShellStyle } from '../hooks/useScreenShellStyle'; import Logo from '../../assets/images/logo.png'; -import { Moon, Monitor, Sun } from 'lucide-react-native'; +import { Moon, Monitor, Sun, Languages } from 'lucide-react-native'; +import { useTranslation } from 'react-i18next'; +import { localeSettingsService } from '../services/LocaleSettingsService'; +import { + UI_LOCALE_PREFERENCE_OPTIONS, + type UiLocalePreference, +} from '../lib/locale'; +import { syncFormulusI18nLanguage } from '../i18n'; const HTTP_TRANSPORT_TOAST = 'HTTP is allowed, but we recommend HTTPS so traffic is encrypted (https://).'; @@ -69,6 +76,7 @@ type SettingsScreenNavigationProp = BottomTabNavigationProp< >; const SettingsScreen = () => { + const { t } = useTranslation(); const navigation = useNavigation(); const { themeColors, themeMode, setThemeMode, resolvedMode } = useAppTheme(); const shellStyle = useScreenShellStyle(); @@ -91,6 +99,8 @@ const SettingsScreen = () => { const [isLoggingIn, setIsLoggingIn] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false); const [version, setVersion] = useState(''); + const [uiLocalePreference, setUiLocalePreference] = + useState('auto'); const mountedRef = useRef(true); useEffect(() => { @@ -111,6 +121,30 @@ const SettingsScreen = () => { load(); }, []); + useEffect(() => { + void localeSettingsService.load().then(() => { + if (mountedRef.current) { + setUiLocalePreference(localeSettingsService.getPreference()); + } + }); + }, []); + + const handleLocalePreference = useCallback( + async (preference: UiLocalePreference) => { + setUiLocalePreference(preference); + await localeSettingsService.setPreference(preference); + await syncFormulusI18nLanguage(); + }, + [], + ); + + const localeOptionLabels: Record = { + auto: t('settings.language.auto'), + en: t('settings.language.en'), + pt: t('settings.language.pt'), + fr: t('settings.language.fr'), + }; + useEffect(() => { let cancelled = false; @@ -534,7 +568,7 @@ const SettingsScreen = () => { styles.appSettingsLabel, { color: themeColors.onSurface }, ]}> - Theme: + {t('settings.theme.label')} @@ -586,6 +620,54 @@ const SettingsScreen = () => { + + + + + {t('settings.language.label')} + + + + {UI_LOCALE_PREFERENCE_OPTIONS.map(opt => ( + void handleLocalePreference(opt.value)} + accessibilityRole="button" + accessibilityLabel={`Language: ${localeOptionLabels[opt.value]}`}> + + {localeOptionLabels[opt.value]} + + + ))} + + + {!!version && ( @@ -708,6 +790,22 @@ const styles = StyleSheet.create({ themeIconButton: { padding: odeSpacing.xs, }, + languageOptionsRow: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + gap: odeSpacing.xs, + justifyContent: 'flex-end', + }, + languageChip: { + borderWidth: 1, + borderRadius: 16, + paddingHorizontal: odeSpacing.sm, + paddingVertical: odeSpacing.xxs, + }, + languageChipText: { + fontSize: odeTypography.bodySm, + }, inputContainer: { marginBottom: 1, }, diff --git a/formulus/src/services/LocaleSettingsService.ts b/formulus/src/services/LocaleSettingsService.ts new file mode 100644 index 000000000..25751e8fd --- /dev/null +++ b/formulus/src/services/LocaleSettingsService.ts @@ -0,0 +1,82 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as RNLocalize from 'react-native-localize'; +import { + resolveActiveLocale, + type OdeUiLocale, + type UiLocalePreference, +} from '../lib/locale'; +import { AppConfigService } from './AppConfigService'; + +const STORAGE_KEY = '@ode/uiLocale'; + +export class LocaleSettingsService { + private static instance: LocaleSettingsService | null = null; + + private preference: UiLocalePreference = 'auto'; + private loaded = false; + + static getInstance(): LocaleSettingsService { + if (!LocaleSettingsService.instance) { + LocaleSettingsService.instance = new LocaleSettingsService(); + } + return LocaleSettingsService.instance; + } + + async load(): Promise { + if (this.loaded) return; + try { + const stored = await AsyncStorage.getItem(STORAGE_KEY); + if ( + stored === 'auto' || + stored === 'en' || + stored === 'pt' || + stored === 'fr' + ) { + this.preference = stored; + } + } catch (err) { + console.warn('[LocaleSettingsService] Failed to load preference:', err); + } + this.loaded = true; + } + + getPreference(): UiLocalePreference { + return this.preference; + } + + async setPreference(preference: UiLocalePreference): Promise { + this.preference = preference; + this.loaded = true; + await AsyncStorage.setItem(STORAGE_KEY, preference); + } + + getDeviceLocale(): string { + try { + const locales = RNLocalize.getLocales(); + if (locales.length > 0 && locales[0]?.languageTag) { + return locales[0].languageTag; + } + } catch { + // ignore + } + return 'en'; + } + + /** + * Resolved catalog locale for Formplayer bridge and i18next. + */ + async resolveActiveLocale( + sessionOverride?: string | null, + ): Promise { + await this.load(); + const appConfig = await AppConfigService.getInstance().getConfig(); + return resolveActiveLocale({ + preference: this.preference, + deviceLocale: this.getDeviceLocale(), + bundleDefaultLocale: appConfig?.defaultLocale ?? null, + sessionOverride, + }); + } +} + +export const localeSettingsService = LocaleSettingsService.getInstance(); diff --git a/formulus/src/types/AppConfig.ts b/formulus/src/types/AppConfig.ts index c0ea5358c..d8f4c09b6 100644 --- a/formulus/src/types/AppConfig.ts +++ b/formulus/src/types/AppConfig.ts @@ -117,6 +117,11 @@ export interface AppConfig { navigation?: NavigationConfig; /** Local index definitions for observation queries */ observationIndexes?: ObservationIndexDef[]; + /** + * Default UI locale when device language is not in ODE catalogs (en, pt, fr). + * Used when Formulus Settings language is Auto. + */ + defaultLocale?: string; } // Keep this alias exported so app-config consumers can strongly type diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 3be0c2205..5005614bd 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -41,7 +41,7 @@ export interface ExtensionMetadata { * Data passed to the Formulus app when a form is initialized * @property {string} formType - The form type (e.g. 'form1') * @property {string | null} observationId - The observation ID (generated by the database on first form submission). NULL if this is a new form. - * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors` (and any future keys added to the formplayer allowlist)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus `locale` so polluted rows are cleaned on re-save. + * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (active UI language), `context` (session context)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale`. * @property {Record} savedData - Previously saved form data (for editing) * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) From eaafe361352a1e7d2fd59a37fe02281b7ecbae73 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Tue, 30 Jun 2026 15:49:20 +0200 Subject: [PATCH 2/9] chore: fix tests --- formulus-formplayer/src/App.tsx | 69 ++++++++++-- .../src/components/DraftSelector.tsx | 35 ++++-- .../src/i18n/applyFormUiTranslations.ts | 10 +- formulus-formplayer/src/i18n/createOdeI18n.ts | 18 +++- formulus-formplayer/src/locales/en.json | 50 ++++++++- formulus-formplayer/src/locales/fr.json | 50 ++++++++- formulus-formplayer/src/locales/pt.json | 50 ++++++++- .../CustomQuestionTypeAdapter.test.tsx | 25 +++-- .../renderers/CustomQuestionTypeAdapter.tsx | 43 ++++++-- .../src/renderers/FinalizeRenderer.tsx | 100 ++++++++++-------- .../src/renderers/SwipeLayoutRenderer.tsx | 11 +- .../src/utils/errorPageNavigation.ts | 23 +++- formulus-formplayer/tsconfig.json | 7 +- 13 files changed, 387 insertions(+), 104 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 685597567..e366d888d 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -584,7 +584,11 @@ function App() { 'formSchema was not provided. Form rendering might fail or be incomplete.', ); setLoadError( - 'Form schema is missing. Form rendering might fail or be incomplete.', + odeT( + resolvedLocale, + 'form.schemaMissing', + 'Form schema is missing. Form rendering might fail or be incomplete.', + ), ); setSchema({} as FormSchema); // Set to empty schema or handle as per requirements // First ensure SwipeLayout root, then process to ensure Finalize element is present @@ -706,7 +710,12 @@ function App() { error instanceof Error ? error.message : 'Unknown error during form initialization'; - setLoadError(`Error initializing form: ${errorMessage}`); + const errorLocale = resolveFormplayerLocale( + (initData.params as Record | null)?.locale, + ); + setLoadError( + `${odeT(errorLocale, 'form.errorInitializing', 'Error initializing form')}: ${errorMessage}`, + ); setIsLoading(false); isLoadingRef.current = false; } @@ -734,7 +743,13 @@ function App() { console.error( 'formType is crucial and was not provided in onFormInit. Cannot proceed.', ); - setLoadError('Form ID is missing. Cannot initialize form.'); + setLoadError( + odeT( + resolveFormplayerLocale(initData.params?.locale), + 'form.missingFormId', + 'Form ID is missing. Cannot initialize form.', + ), + ); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage @@ -794,7 +809,9 @@ function App() { error instanceof Error ? error.message : 'Unknown error during form initialization'; - setLoadError(`Error processing form data: ${errorMessage}`); + setLoadError( + `${odeT(resolveFormplayerLocale(initData.params?.locale), 'form.errorProcessing', 'Error processing form data')}: ${errorMessage}`, + ); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage @@ -868,7 +885,11 @@ function App() { 'Setting error message because mock is not active or not in development', ); setLoadError( - 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', + odeT( + uiLocale, + 'form.noNativeHost', + 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', + ), ); setIsLoading(false); isLoadingRef.current = false; @@ -897,7 +918,11 @@ function App() { '[Formplayer] onFormInit timeout: Form failed to initialize after extended wait.', ); setLoadError( - 'Failed to initialize form: No data received from native host. Please try again.', + odeT( + uiLocale, + 'form.initTimeout', + 'Failed to initialize form: No data received from native host. Please try again.', + ), ); setIsLoading(false); isLoadingRef.current = false; @@ -928,7 +953,7 @@ function App() { console.log('Unregistered window.onFormInit handler.'); } }; - }, [handleFormInitByNative]); // Dependency: re-run if handleFormInitByNative changes + }, [handleFormInitByNative, uiLocale]); // Attachment handling is now fully encapsulated within individual components // using the Promise-based media/action APIs exposed by Formulus. @@ -1145,7 +1170,11 @@ function App() { '[App.tsx] Cannot finalize form: formInitData is missing', ); setSubmitError( - 'Cannot submit form because initialization data is missing.', + odeT( + uiLocale, + 'form.submitMissingInit', + 'Cannot submit form because initialization data is missing.', + ), ); return; } @@ -1162,7 +1191,11 @@ function App() { if (finalizeValidatorErrors.length > 0) { setCustomValidatorErrors(finalizeValidatorErrors); setSubmitError( - 'Cannot submit form until custom validation errors are resolved.', + odeT( + uiLocale, + 'form.submitValidatorErrors', + 'Cannot submit form until custom validation errors are resolved.', + ), ); return; } @@ -1201,7 +1234,13 @@ function App() { }) .catch(error => { console.error('[App.tsx] Error submitting form:', error); - setSubmitError('Failed to submit form. Please try again.'); + setSubmitError( + odeT( + uiLocale, + 'form.submitFailed', + 'Failed to submit form. Please try again.', + ), + ); }); }; @@ -1240,7 +1279,15 @@ function App() { handleRevalidate as EventListener, ); }; - }, [formInitData, draftSessionKey, uischema, schema, ajv, refreshFormData]); + }, [ + formInitData, + draftSessionKey, + uischema, + schema, + ajv, + refreshFormData, + uiLocale, + ]); // Create dynamic theme based on dark mode preference and custom app colors. // When a custom app provides themeColors, they override the default palette diff --git a/formulus-formplayer/src/components/DraftSelector.tsx b/formulus-formplayer/src/components/DraftSelector.tsx index 57ca7d1a4..aed60c4ee 100644 --- a/formulus-formplayer/src/components/DraftSelector.tsx +++ b/formulus-formplayer/src/components/DraftSelector.tsx @@ -66,12 +66,19 @@ export const DraftSelector: React.FC = ({ const oldDraftCount = draftService.getOldDraftCount(); if (oldDraftCount > 0) { setCleanupMessage( - `${oldDraftCount} draft${ - oldDraftCount === 1 ? '' : 's' - } older than 7 days will be automatically removed.`, + oldDraftCount === 1 + ? t( + 'draft.cleanupOldOne', + '1 draft older than 7 days will be automatically removed.', + ) + : t( + 'draft.cleanupOldMany', + '{{count}} drafts older than 7 days will be automatically removed.', + { count: oldDraftCount }, + ), ); } - }, [formType, formVersion]); + }, [formType, formVersion, t]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -113,7 +120,9 @@ export const DraftSelector: React.FC = ({ - Recent drafts ({drafts.length}) + {t('draft.recentDrafts', 'Recent drafts ({{count}})', { + count: drafts.length, + })} = ({ color="text.primary" noWrap sx={{ textAlign: 'left' }}> - Draft saved {formatDate(draft.updatedAt)} + {t('draft.savedAtLine', 'Draft saved {{date}}', { + date: formatDate(draft.updatedAt), + })} = ({ ) : ( - No recent drafts found for this form. + {t('draft.none', 'No recent drafts found for this form.')} ); @@ -215,7 +226,7 @@ export const DraftSelector: React.FC = ({ variant="body2" color="text.secondary" sx={{ textAlign: 'left' }}> - Form: {formType} + {t('draft.formLabel', 'Form: {{formType}}', { formType })} {formVersion && ( )} @@ -240,7 +251,7 @@ export const DraftSelector: React.FC = ({ gutterBottom color="text.primary" sx={{ fontWeight: 600, textAlign: 'left' }}> - New Form + {t('draft.newFormSection', 'New Form')}
diff --git a/formulus/assets/webview/placeholder_app.html b/formulus/assets/webview/placeholder_app.html index 52454cc61..a96d89fac 100644 --- a/formulus/assets/webview/placeholder_app.html +++ b/formulus/assets/webview/placeholder_app.html @@ -5,8 +5,8 @@