src/features/forms/ sit under FormScreen and are easy to break without noticing: the conditions engine, the draft store and the field stepper. For the screen itself see Forms rendering.
Conditions engine
src/features/forms/lib/conditions.ts decides whether a field or a page is shown. It is the mobile copy of the rules the web form builder and the backend also implement, so a change to an operator has to be made in all three repos. This page documents the mobile copy only. The engine fails open: a condition it cannot resolve is treated as met, and the field or page stays visible.
Shape
FormFieldDef.condition (field visibility) or on FormPageMeta.condition (page visibility). sourceFieldId is the id of another field. The answer is looked up by that field’s key.
Entry points
isFieldVisible in lib/validate.ts is a thin wrapper around isConditionMet(field.condition, ...).
Fail-open rules
isConditionMet returns true (visible) without evaluating when any of these holds:
- The condition is missing,
enabledis false, orsourceFieldIdis empty. - No field with that id exists in the schema.
- The source field type has no source kind (see the table below).
- The operator is not allowed for that source kind.
Source kinds and allowed operators
Each source field type maps to a kind, and each kind accepts a fixed operator list.food_preferences and the display-only types have no kind. A condition that points at one of them always passes.
Evaluation order
evaluate(condition, source, answers) reads answers[source.key] and runs these checks in order:
emptyreturnsisEmptyAnswer(answer).filledreturns the opposite.checkedreturnsanswer === true.uncheckedreturnsanswer !== true.- For every other operator, an empty answer returns
false. A comparison against a blank source hides the dependent field. - The remaining operators are evaluated by kind.
evaluate returns false. That covers an answer or an expected value that does not parse.
Numeric operators
Both sides go throughdecimalToNumber, so "72,5" and 72.5 compare equal.
Date operators
toDayValue turns a value into a UTC midnight timestamp. It accepts a finite number, a string starting with YYYY-MM-DD, a D/M/YYYY style string with /, . or - separators, or anything Date.parse accepts.
ageInYears subtracts the birth year from the current year, then subtracts one more if this year’s birthday is still ahead. For the age operators value is parsed as a number.
Choice operators
choiceEquals compares as strings. When the answer is an array (a multi-select dropdown), eq is true if any entry equals the expected value, and ne is true only if none does.
Where conditions are applied
FormScreenfilters the fields of a page withisFieldVisibleand computes the page list withvisiblePageIndexes. Hidden pages are skipped in both directions and are not counted in the page counter.validateFieldskips hidden fields.validateAnswersandvalidatePageskip hidden pages.stripHiddenAnswersremoves answers of hidden fields and hidden pages before submit.
answers[key] ?? field.defaultValue), so a default value can satisfy a condition before the trainee touches the source field.
Drafts
src/features/forms/lib/draftStore.ts keeps unfinished answers on the device. Nothing is sent to the server until submit.
Storage
One file holds every draft:
API
When a draft is written
FormScreencallssaveFormDraftin an effect on every change ofanswersorpage, once the form is hydrated and not yet submitted.closeForm()and the screen’s unmount cleanup callflushFormDrafts().- The module registers an
AppStatelistener that flushes whenever the app leaves theactivestate.
When a draft is dropped
- After a successful submit, through
clearFormDraft. - When the answers are empty and the trainee is on the first page.
- On load, when the stored file has a different
versionor a malformed shape. The whole file is removed. - On load, for any draft older than 30 days or failing the
isDraftshape check. - Effectively, when the coach publishes a new form version.
readFormDraftignores a draft with a differentformVersion, and the next save overwrites it.
Nothing else in the app references the draft file. Signing out does not clear it. Drafts are keyed by assignment id only, so they stay on the device until one of the rules above removes them.
Preview mode
In the web preview embedded in the coach dashboard (IS_PREVIEW from src/lib/preview.ts), every draft function is a no-op and readFormDraft returns null.
What a draft does not hold
Upload progress is not part of a draft. A finished upload is, because itsfileUrl is the field answer. An upload interrupted by closing the form starts again from the picker. The visited map is not saved either, so a reopened form shows no red outlines until the trainee moves through fields again.
Field stepper
src/features/forms/hooks/useFieldNavigation.ts drives the two footer buttons. Previous steps back one field at a time. Next, on an incomplete page, jumps to the next missing field. The hook tracks where every field sits in the scroll view so both can scroll to an exact position.
stopsis the list of fillable fields on the current page. Display-only blocks are not stops.scrollRefis theScrollViewinsideScreen.onLeaveField(id)fires whenever the cursor moves off a field: another field is focused, a jump happens, or Previous is pressed. It does not fire when a page change clears the cursor.FormScreenuses it to mark the field as visited.
Layout bookkeeping
All positions are kept in refs, not state, so scrolling does not re-render the form.
A field’s absolute top is
content.y + bodyTop + box.y. The furthest useful scroll offset is content.y + content.height - viewport, floored at 0.
Current field
currentIndex(stops, refs) answers which field the trainee is on:
- If there is a cursor field and it is on screen, or a scroll is still settling, that field wins.
- Otherwise it is the last field whose top is at or above a line just below the top of the viewport:
offset + FIELD_TOP_GAP + LINE_SLACK, whereFIELD_TOP_GAPisspacing.xlandLINE_SLACKisspacing.xs. - If no field qualifies the index is
-1.
atFirstField is the only piece of state the hook exposes. It is true when there are no stops or the index is 0 or lower. It is recomputed on scroll, on every layout callback, when the stop list changes, and on keyboardDidShow and keyboardDidHide.
FormScreen shows the Previous button when the trainee is past the first visible page or atFirstField is false.
Moving to a field
goToField(field):
- Returns if the field has no measured box yet.
- Cancels any pending settle timer, moves the cursor to the field and republishes the flags.
- Computes the target offset: the field top minus
FIELD_TOP_GAP, clamped between 0 and the furthest useful offset. - If the target is within 1 px of the current offset, it focuses the field’s input straight away, or dismisses the keyboard when the field has no input.
- Otherwise it dismisses the keyboard for input-less fields, starts an animated
scrollTo, and focuses the input afterSETTLE_MS = 350milliseconds.
isSettling() returns true, and FormScreen ignores presses on both footer buttons.
Other methods
Page changes
FormScreen runs a layout effect on activePage that calls resetScroll() and then scrolls to the top again on the next animation frame. Screen also receives scrollResetKey={activePage}, which remounts the scroll view. Field boxes are cleared because the new page has different fields.
When a full-form validation fails on an earlier page, the target field has no box until that page renders. FormScreen stores the field id in a pendingJump ref, and the field’s own onLayout calls goToField on the next animation frame.