Three pieces of 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

A condition lives on 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:
  1. The condition is missing, enabled is false, or sourceFieldId is empty.
  2. No field with that id exists in the schema.
  3. The source field type has no source kind (see the table below).
  4. 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:
  1. empty returns isEmptyAnswer(answer).
  2. filled returns the opposite.
  3. checked returns answer === true.
  4. unchecked returns answer !== true.
  5. For every other operator, an empty answer returns false. A comparison against a blank source hides the dependent field.
  6. The remaining operators are evaluated by kind.
Unlike the outer fail-open rules, a comparison that cannot be computed inside evaluate returns false. That covers an answer or an expected value that does not parse.

Numeric operators

Both sides go through decimalToNumber, 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

  • FormScreen filters the fields of a page with isFieldVisible and computes the page list with visiblePageIndexes. Hidden pages are skipped in both directions and are not counted in the page counter.
  • validateField skips hidden fields. validateAnswers and validatePage skip hidden pages.
  • stripHiddenAnswers removes answers of hidden fields and hidden pages before submit.
All of these receive the merged answers (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:
The key is the assignment id from the route, with no prefix. Reads and writes are synchronous. The file is parsed once into a module-level cache and written back on every change.

API

When a draft is written

  • FormScreen calls saveFormDraft in an effect on every change of answers or page, once the form is hydrated and not yet submitted.
  • closeForm() and the screen’s unmount cleanup call flushFormDrafts().
  • The module registers an AppState listener that flushes whenever the app leaves the active state.

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 version or a malformed shape. The whole file is removed.
  • On load, for any draft older than 30 days or failing the isDraft shape check.
  • Effectively, when the coach publishes a new form version. readFormDraft ignores a draft with a different formVersion, 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 its fileUrl 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.
  • stops is the list of fillable fields on the current page. Display-only blocks are not stops.
  • scrollRef is the ScrollView inside Screen.
  • 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. FormScreen uses 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:
  1. If there is a cursor field and it is on screen, or a scroll is still settling, that field wins.
  2. 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, where FIELD_TOP_GAP is spacing.xl and LINE_SLACK is spacing.xs.
  3. 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):
  1. Returns if the field has no measured box yet.
  2. Cancels any pending settle timer, moves the cursor to the field and republishes the flags.
  3. Computes the target offset: the field top minus FIELD_TOP_GAP, clamped between 0 and the furthest useful offset.
  4. 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.
  5. Otherwise it dismisses the keyboard for input-less fields, starts an animated scrollTo, and focuses the input after SETTLE_MS = 350 milliseconds.
The input is focused only after the scroll animation has had time to finish. While the timer is pending, 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.