The forms feature lives in src/features/forms/. One screen, FormScreen, renders any form schema the backend sends. Field kinds are covered in Form field types. Visibility rules, drafts and the footer stepper are covered in Conditions, drafts and the field stepper.

Files

Endpoints

All calls go through apiClient (see API client). The id in every path is the form assignment id, not the template id.
Answers are keyed by field.key. Conditions refer to fields by field.id.

Query hooks

How a form is reached

  • Home card. PendingFormsCard (classic home) lists every item from useFormsList() and pushes /forms/:id. It refetches on every tab focus, and renders nothing when user.planFrozen is true or the list is empty. The three themed home screens read the same list through useHomeModel().pendingForms and call openForm(formId). See Home screen.
  • Launch reminder. PendingFormPrompt is mounted once in src/app/(app)/_layout.tsx. usePendingFormPrompt filters the list to status === 'PENDING', sorts forms with a dueAt first (soonest first), and opens PendingFormDialog for the first one. A module-scope flag promptedThisLaunch limits it to once per cold start. It waits until useStartupPrompts reports settled and never opens while a workout session is started. Tapping outside or the later button dismisses it.
  • Push notification. src/lib/notificationRouting.ts handles a notification whose data has target: 'form'. With an assignmentId it pushes /forms/[id], otherwise it pushes /home. See Notifications.

FormScreen

FormScreen reads the id route param, loads the form with useFormDetail, and keeps this local state:

Hydration

On the first render with data, the screen calls readFormDraft(assignmentId, data.formVersion) and seeds answers and page from the draft, or from empty values. This is an in-render state reset guarded by hydratedFor, not an effect.

Merged answers

buildMerged() produces the object used for every visibility and validation decision. For each field that is not display-only it takes answers[field.key] ?? field.defaultValue. Display-only types are read_more, coach_message, text_block and media_block (DISPLAY_ONLY_TYPES in lib/validate.ts).

Pages

The page list starts from schema.settings.pages. If fields reference a higher page index than the list covers, placeholder pages with ids p1, p2 and so on are appended. visiblePageIndexes(pages, fields, merged) then drops pages hidden by a page condition. The header shows a page counter only when more than one page is visible. With a single page it shows template.description instead. The title is settings.displayName (trimmed) or template.name. The fields rendered on a page are those where field.page equals the active page, isEmptyMediaBlock(field) is false, and isFieldVisible(field, fields, merged) is true. The fillable subset (not display-only) is what the stepper and the completeness check work on.

Layout

The screen uses the shared Screen component with scroll, a footer, scrollViewRef, scrollResetKey={activePage} and scrollEnabled={!sliderDragging}. Keyboard behaviour for footer screens is described in Keyboard and Screen. The background is derived from the home skin: stepAway(backdrop, colors.surface, 0.25), where backdrop is colors.background for the classic theme or SKIN_BASE_COLOR[skin][scheme] otherwise. Each field is wrapped in FieldAttention, which reports its layout to the stepper and draws a red outline when the field is flagged.

FieldRenderer

FieldRenderer is a chain of if (field.type === ...) branches. Its props:
FormScreen wires them like this:
  • value is answers[field.key], the raw value and not the merged one.
  • error is passed only when the field is flagged.
  • inputRef registers the text input with the stepper.
  • onFocus and onChange call navigation.markActive(field.id). Picker-style fields receive onFocus as onOpenPicker.
  • onBlur marks the field as visited.
  • onDragChange sets sliderDragging.
Any type without its own branch falls through to a plain TextField. That covers text and email.

Locked exit

The trainee can only leave a form through the close button in the top bar.
  • The route sets gestureEnabled: false, so the iOS back swipe is off.
  • On Android, a BackHandler listener registered in useFocusEffect returns true for hardwareBackPress, which swallows the hardware back button.
  • closeForm() calls flushFormDrafts(), then router.back() when possible, otherwise router.replace('/home').
  • The thank-you dialog has no backdrop dismissal. Its only button and Android back both call closeForm, guarded by a ref so a double tap cannot pop two screens.
Leaving never discards answers. The draft is saved on every change, so the form reopens where it was left.

Validation

lib/validate.ts validates on the client. validateField skips display-only and hidden fields, then applies these rules: validatePage(schema, answers, page) validates one page. validateAnswers(schema, answers) validates every visible page.
validation.minLength, validation.maxLength and validation.pattern exist on the type but validate.ts does not check them. A server-side rejection surfaces through the 400 handling described below.

Blocking on unanswered fields

The primary footer button never submits or advances an incomplete page. The helpers are in lib/pageProgress.ts.
  • missingFields(fields, errors) returns the fields that still fail validation. Optional empty fields never fail.
  • isPageComplete is true when nothing is missing.
  • flaggedFields(fields, errors, visited, isUploading) returns missing fields the trainee has already left, excluding any field whose upload is still running. Only flagged fields get the red outline and an inline error.
  • nextMissingField(fields, errors, currentIndex) returns the first missing field after the cursor, or the first missing field on the page.
When goNext() runs on an incomplete page it marks the cursor field as visited, scrolls to the next missing field with navigation.goToField, and announces the count through AccessibilityInfo.announceForAccessibility. Nothing else happens. While at least one field is flagged, the footer shows UnansweredFieldsNotice with the count and a button that jumps to the first flagged field. nextButtonLabel decides the button text. On an incomplete page it is always the generic next label. On a complete page it is the page ctaLabel if set, otherwise settings.submitLabel (or the default submit string) on the last page and the next label elsewhere.

Submit flow

On the last visible page with a complete page, goNext() does the following:
1

Commit decimals

commitDecimalAnswers(fields, buildMerged()) normalises any half-typed decimal string in number, height and body_measurement fields.
2

Validate the whole form

validateAnswers runs over every visible page, because editing an earlier page can invalidate a later one. If an earlier page has errors, the screen jumps to the first such page, pre-marks its missing fields as visited, and stores the first missing field id in pendingJump. That field scrolls into view from its own onLayout, since it has no layout until the page renders.
3

Strip hidden answers

stripHiddenAnswers(schema, answers) drops display-only fields, fields hidden by a field condition, and fields on pages hidden by a page condition.
4

Post

submit.mutate(...) posts { answers }. On success the screen records submittedFor, calls clearFormDraft(assignmentId), dismisses the keyboard and opens the thank-you dialog.
On failure an alert is shown. When the error is an ApiError with httpStatus === 400, the server message is shown as is, because it names the rejected field. Any other failure shows the generic formSubmitFailed string. thankYouRichText(settings, fallback) picks the popup text: settings.thankYou.richText when any span has non-blank text, then the legacy settings.successMessage, then the app default. The footer button is disabled and shows the uploading label while an upload is running for a field on the current page, or for any field when on the last page. goNext() and goPrev() also return early while the stepper is settling a scroll.

Upload fields

useFormUploads(setValue) owns upload state for image_upload and video_upload fields. It returns { uploads, startUpload, retryUpload }, where uploads maps a field key to:
startUpload(key, source):
  1. Bumps a per-key run counter. Results from an older run are ignored, so picking a new file while one is uploading is safe.
  2. Sets the entry to uploading with progress: 0 and an empty uri, and clears the field answer with setValue(key, undefined).
  3. Awaits prepareImageForUpload(source). If it produced a new file, that local uri becomes the preview. If the source came back unchanged, the preview uri stays empty.
  4. Calls uploadFile(...) with an onProgress callback. Progress is rounded to 1 percent steps before it reaches state.
  5. On success sets done and stores fileUrl as the answer.
  6. On error sets tooLarge when isPayloadTooLarge(error) is true, otherwise failed.
retryUpload(key) restarts the upload from the stored prepared source. The retry button is only offered for failed. A tooLarge upload can only be replaced with a different file. The shared pipeline (shrinking, retry policy, the 413 case) is documented in Tracking and uploads.