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 throughapiClient (see API client).
The id in every path is the form assignment id, not the template id.
field.key. Conditions refer to fields by field.id.
Query hooks
How a form is reached
- Home card.
PendingFormsCard(classic home) lists every item fromuseFormsList()and pushes/forms/:id. It refetches on every tab focus, and renders nothing whenuser.planFrozenis true or the list is empty. The three themed home screens read the same list throughuseHomeModel().pendingFormsand callopenForm(formId). See Home screen. - Launch reminder.
PendingFormPromptis mounted once insrc/app/(app)/_layout.tsx.usePendingFormPromptfilters the list tostatus === 'PENDING', sorts forms with adueAtfirst (soonest first), and opensPendingFormDialogfor the first one. A module-scope flagpromptedThisLaunchlimits it to once per cold start. It waits untiluseStartupPromptsreportssettledand never opens while a workout session is started. Tapping outside or the later button dismisses it. - Push notification.
src/lib/notificationRouting.tshandles a notification whose data hastarget: 'form'. With anassignmentIdit 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 callsreadFormDraft(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 fromschema.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 sharedScreen 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:
valueisanswers[field.key], the raw value and not the merged one.erroris passed only when the field is flagged.inputRefregisters the text input with the stepper.onFocusandonChangecallnavigation.markActive(field.id). Picker-style fields receiveonFocusasonOpenPicker.onBlurmarks the field as visited.onDragChangesetssliderDragging.
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
BackHandlerlistener registered inuseFocusEffectreturnstrueforhardwareBackPress, which swallows the hardware back button. closeForm()callsflushFormDrafts(), thenrouter.back()when possible, otherwiserouter.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.
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 inlib/pageProgress.ts.
missingFields(fields, errors)returns the fields that still fail validation. Optional empty fields never fail.isPageCompleteis 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.
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.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):
- Bumps a per-key run counter. Results from an older run are ignored, so picking a new file while one is uploading is safe.
- Sets the entry to
uploadingwithprogress: 0and an emptyuri, and clears the field answer withsetValue(key, undefined). - 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. - Calls
uploadFile(...)with anonProgresscallback. Progress is rounded to 1 percent steps before it reaches state. - On success sets
doneand storesfileUrlas the answer. - On error sets
tooLargewhenisPayloadTooLarge(error)is true, otherwisefailed.
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.