FieldRenderer (src/features/forms/components/FieldRenderer.tsx) switches on field.type. The union is FormFieldType in src/features/forms/data/types.ts. Field components live in src/features/forms/components/fields/ unless stated otherwise. For how the screen wires these components, see Forms rendering.

Field definition

Every input field shows label followed by * when required is true, and helpText when present.
The mobile renderer does not read saveToClient, visibleIf, config.accept, config.maxSizeMb, config.measurement, validation.minLength, validation.maxLength or validation.pattern. They are part of the shared schema type only.

Summary

ChoiceField, ConfirmationField, ReadMoreField and TextBlockField are local functions inside FieldRenderer.tsx.

Text and email

Both render the shared TextField with placeholder. validation.multiline === true makes it multiline. email uses the email-address keyboard, everything else the default keyboard. The value is the raw string.

Number, height and body measurement

All three use DecimalField. height and body_measurement add an inline cm unit label (strings.unitCm). number has no unit. The input uses keyboardType="decimal-pad" and inputMode="decimal", and is forced left aligned in every language. Typing goes through helpers in src/features/forms/lib/decimal-input.ts: While typing, the answer is the sanitised string, so "72," is a valid intermediate state. On blur the field commits it. The submitted value is a string such as "72.5", not a number. Validation applies validation.min and validation.max to the parsed number. body_measurement is typed with config.measurement (chest, waist, hips, arm or thigh). The app does not read it. The field renders like height, and what the label says comes from field.label.

Weight and age

MeasureField picks a ruler configuration from the field type and validation: If the fallback is outside the range, clampMid uses the rounded midpoint.

RulerPicker

RulerPicker.tsx is a horizontal scroll ruler with a typed readout above it.
  • Numbers ascend left to right in every language. The track is physicalLtr.
  • Ticks are TICK_SPACING = 12 px apart with snapToInterval. About TARGET_LABELS = 11 ticks carry a label.
  • Only a window of ticks is mounted: TICK_WINDOW = 160 ticks each side, re-centred when the index moves TICK_SHIFT = 40 ticks.
  • The readout updates on scroll. onChange fires on onMomentumScrollEnd and onScrollEndDrag with the snapped value.
  • The readout is a TextInput (decimal or number pad, maxLength 6 with decimals, 3 without). Typing calls onChange with the rounded number on every keystroke and moves the ruler when the value is in range. A typed value outside min and max is still reported, so validation can reject it.
  • Starting a drag dismisses the keyboard.
Until the trainee interacts, the ruler shows the fallback but the answer stays unset. A required weight or age must be touched or typed to count as answered.

Phone

PhoneField builds on the shared phone components in src/components/phone/ and usePhoneCountry.
  • Local mode formats digits as 050-000-0000 (groups of 3, 3, 4, at most 10 digits, maxLength 12).
  • Ticking the non-Israeli checkbox switches to foreign mode, shows a country code button and a country picker sheet, and allows up to 20 characters.
  • The answer is whatever joinPhone(country, national) returns: the national text for the home country, or the international number when a foreign country is selected.
Validation uses normalizeFormPhone, which accepts 0 plus 8 or 9 digits, 972 plus 8 or 9 digits, or a + prefixed number of 7 to 15 digits. A valid value shows a brand coloured check.

Israeli ID

IsraeliIdField draws nine boxes over a hidden TextInput with a number pad. The answer is a digits-only string capped at LENGTH = 9. When nine digits are present and isValidIsraeliId passes, the boxes turn to the brand colour.

Date and birth date

DateField opens a bottom-sheet calendar in a Modal. Helpers are in fields/date-value.ts.
  • The answer is toIso(year, month, day), a YYYY-MM-DD string. parseDateValue reads it back and rejects impossible dates.
  • The closed trigger shows DD/MM/YYYY (formatDisplay).
  • The sheet has a day grid and a 12-year grid. Tapping the title toggles between them.
  • The earliest year is MIN_PICKER_YEAR = 1900.
  • date allows up to MAX_FUTURE_YEARS = 50 years ahead.
  • birth_date stops at the current year and month, and disables future days (isFutureBirthDay). With no value yet it opens on the year grid at January 2000.
  • Picking a day closes the sheet.

Gender, goal and dropdown

All three use ChoiceField.
  • gender renders two Chip pills from a fixed list with values male and female.
  • goal and dropdown render field.options as a vertical list of rows.
  • dropdown with config.multiple === true becomes multi-select. Rows act as checkboxes and the answer is an array of option values. Otherwise the answer is a single option value string.
A single choice cannot be cleared once picked. A multi-select can be emptied, which makes a required field invalid again.

Checkbox confirmation

ConfirmationField splits the copy with confirmationCopy(field) from src/features/forms/lib/confirmation.ts:
  • field.label is the heading above the checkbox.
  • field.content.body is the sentence beside the checkbox. Without a body, the label is used for both.
  • The required marker * is appended to the sentence, not the heading.
  • field.link adds an underlined link under the sentence. normalizeLinkUrl adds https:// when no scheme is present and only allows http and https. The link text is link.label or the raw URL.
The answer is a boolean. A required confirmation must be exactly true.

Rating

RatingField shows config.stars stars, default RATING_STARS_DEFAULT = 5. Tapping star N sets the answer to N. Tapping the current value again sets it to undefined. config.minLabel and config.maxLabel render as captions under the row.

Range slider

RangeField is a custom slider built on the responder system.
  • Bounds come from validation.min and validation.max, defaulting to RANGE_MIN_DEFAULT = 0 and RANGE_MAX_DEFAULT = 10. If max is not greater than min, max becomes min + 1.
  • The track is physically left to right in every language.
  • The answer is Math.round(min + ratio * (max - min)), so always an integer.
  • With no answer the readout shows a dash and the thumb sits at the minimum.

Scroll lock

A vertical scroll view would steal a horizontal drag. The slider prevents that in two ways:
  1. It claims the gesture and refuses to give it up: onStartShouldSetResponder and onMoveShouldSetResponder return true, and onResponderTerminationRequest returns false.
  2. It calls onDragChange(true) on grant and onDragChange(false) on release or terminate. FormScreen stores this in sliderDragging and passes scrollEnabled={!sliderDragging} to Screen.
The slider also calls onDragChange(false) on unmount if a drag was still active, so a page change mid-drag cannot leave the form unscrollable. Movement is computed from pageX deltas against the touch origin, not from locationX, so it stays stable when the finger leaves the track.

Image upload

ImageUploadField opens the photo library with expo-image-picker:
quality: 1 with the Current representation makes the picker copy the file instead of decoding and re-encoding it. scripts/test-shrink-image-guard.cjs asserts both settings. Shrinking happens later in the upload pipeline. The picked asset is passed up as { uri, mimeType, name } with defaults image/jpeg and upload.jpg. The box then shows one of these states: The answer is the fileUrl string returned by the upload. See Forms rendering for the state machine.

Video upload

VideoUploadField shows an action sheet (Alert.alert) with record and library options.
  • Record asks for camera permission, then calls launchCameraAsync with mediaTypes: ['videos'].
  • Library calls launchImageLibraryAsync with mediaTypes: ['videos'].
  • Both use quality: 0.7 (VIDEO_QUALITY).
  • The source is sent as video/mp4 named after field.key with an .mp4 extension.
There is no video preview. The box shows an icon and a status line (prompt, uploading with a progress bar, uploaded, failed, too large). A retry button appears below for failed only. The box is disabled while uploading. The answer is the uploaded fileUrl string.

Food preferences

FoodPreferencesField lets the trainee exclude foods. The catalog is read live from GET /v1/trainee/foods/catalog, so a rename in the food library shows without editing the form.
The answer stores ids only. Logic is in src/features/forms/lib/foodPreferences.ts:
  • foodCatalogGroups(catalog, field) drops categories listed in config.hiddenCategoryIds and categories with no foods.
  • filterFoodCatalogGroups(groups, search) filters foods by a case-insensitive name match.
  • isFoodExcluded is true when the food id is in excludedFoodIds, or its category is excluded and the food is not in includedExceptionIds.
  • toggleFood inside an excluded category flips the food between excluded and exception. Outside one, it adds or removes the id in excludedFoodIds.
  • toggleCategory flips the category and always clears the exceptions for that category’s foods. Foods excluded one by one stay excluded when the category is switched back on.
  • readFoodPreferenceAnswer(value) normalises any stored value into the three de-duplicated id lists.
Each category row shows a category tag followed by its food tags. Excluded tags are filled red, exceptions are outlined green. A summary line shows the number of excluded foods, with a reset action that writes three empty arrays. This field is never invalid, even when required, because nothing excluded is a legitimate answer. With an empty catalog it shows a loading or empty-catalog line instead of the picker.

Display-only blocks

These types produce no answer, are skipped by validation and by the stepper, and are never submitted.

read_more

A bordered card with content.title, content.preview, and a toggle that expands content.body.

coach_message

CoachMessageCard (src/features/forms/components/CoachMessageCard.tsx) shows the studio’s coach avatar from useBrandStore (brand.coachAvatarUrl, or a person icon), content.fromLine, content.title and content.richText. The same card renders the thank-you popup message.

text_block

Renders content.title and content.richText. When content.background is set, the block gets that background colour and padding. Rich text is an array of RichSpan objects rendered by RichTextView. A span has text and optional bold, italic, underline, color, size (sm, md, lg) and align (start, center, end). A newline inside a span starts a new paragraph. Each paragraph takes its alignment from its first span. Sizes map to 12, 14 and 18 px. start and end follow the app direction (see RTL and language).

media_block

MediaBlockField shows one image or one inline video from content.mediaUrl. Helpers are in src/features/forms/lib/mediaBlock.ts.
  • mediaBlockSource uses content.mediaKind when it is image or video. Otherwise it treats URLs ending in mp4, m4v, mov, qt, webm, mkv or m3u8 as video and everything else as image.
  • isEmptyMediaBlock is true when there is no URL. FormScreen leaves such a block out of the page entirely.
  • The box is full width. Its height follows the media aspect ratio, capped at half the screen height (MAX_SCREEN_SHARE = 0.5). Until the ratio is known it reserves a 16:9 box.
  • Video uses expo-video with native controls and does not autoplay. To size portrait clips correctly on native, it probes one thumbnail frame (FRAME_PROBE_SIZE = 160) and falls back to the track size if that fails.
  • The player is paused in a layout-effect cleanup, and when the app goes to background.