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
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 sharedTextField 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 useDecimalField. 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 = 12px apart withsnapToInterval. AboutTARGET_LABELS = 11ticks carry a label. - Only a window of ticks is mounted:
TICK_WINDOW = 160ticks each side, re-centred when the index movesTICK_SHIFT = 40ticks. - The readout updates on scroll.
onChangefires ononMomentumScrollEndandonScrollEndDragwith the snapped value. - The readout is a
TextInput(decimal or number pad,maxLength6 with decimals, 3 without). Typing callsonChangewith the rounded number on every keystroke and moves the ruler when the value is in range. A typed value outsideminandmaxis still reported, so validation can reject it. - Starting a drag dismisses the keyboard.
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,maxLength12). - 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.
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), aYYYY-MM-DDstring.parseDateValuereads 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. dateallows up toMAX_FUTURE_YEARS = 50years ahead.birth_datestops 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 useChoiceField.
genderrenders twoChippills from a fixed list with valuesmaleandfemale.goalanddropdownrenderfield.optionsas a vertical list of rows.dropdownwithconfig.multiple === truebecomes multi-select. Rows act as checkboxes and the answer is an array of option values. Otherwise the answer is a single option value string.
Checkbox confirmation
ConfirmationField splits the copy with confirmationCopy(field) from src/features/forms/lib/confirmation.ts:
field.labelis the heading above the checkbox.field.content.bodyis 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.linkadds an underlined link under the sentence.normalizeLinkUrladdshttps://when no scheme is present and only allowshttpandhttps. The link text islink.labelor the raw URL.
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.minandvalidation.max, defaulting toRANGE_MIN_DEFAULT = 0andRANGE_MAX_DEFAULT = 10. If max is not greater than min, max becomesmin + 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:- It claims the gesture and refuses to give it up:
onStartShouldSetResponderandonMoveShouldSetResponderreturntrue, andonResponderTerminationRequestreturnsfalse. - It calls
onDragChange(true)on grant andonDragChange(false)on release or terminate.FormScreenstores this insliderDraggingand passesscrollEnabled={!sliderDragging}toScreen.
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
launchCameraAsyncwithmediaTypes: ['videos']. - Library calls
launchImageLibraryAsyncwithmediaTypes: ['videos']. - Both use
quality: 0.7(VIDEO_QUALITY). - The source is sent as
video/mp4named afterfield.keywith an.mp4extension.
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.
src/features/forms/lib/foodPreferences.ts:
foodCatalogGroups(catalog, field)drops categories listed inconfig.hiddenCategoryIdsand categories with no foods.filterFoodCatalogGroups(groups, search)filters foods by a case-insensitive name match.isFoodExcludedis true when the food id is inexcludedFoodIds, or its category is excluded and the food is not inincludedExceptionIds.toggleFoodinside an excluded category flips the food between excluded and exception. Outside one, it adds or removes the id inexcludedFoodIds.toggleCategoryflips 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.
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 withcontent.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
Renderscontent.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.
mediaBlockSourceusescontent.mediaKindwhen it isimageorvideo. Otherwise it treats URLs ending inmp4,m4v,mov,qt,webm,mkvorm3u8as video and everything else as image.isEmptyMediaBlockis true when there is no URL.FormScreenleaves 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-videowith 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.