Skip to content

Mobile Exercise Management Tab

Overview

The mobile exercise management tab gives therapists full exercise library management directly from the mobile app. Previously, exercise library management was only available on the web (OktaPT-FE/pages/doctor/exercise-library.tsx). The mobile app had a basic exercise search in the personalized-video flow, but no full library browsing, filtering, creating, or editing.

This feature adds a dedicated Exercises tab for doctors with: - Full exercise library browsing with search, tag filtering (AND/OR mode), and segment control - Exercise detail view with video playback (Cloudflare Stream, Kinescope, YouTube) - Create/edit screens with device camera and photo library integration via expo-image-picker - Favorite toggling with haptic feedback - Feature-flagged behind therapistExerciseManagement tenant feature flag

Gating: Only visible to doctors whose tenant has therapistExerciseManagement enabled. Uses existing TenantFeaturesContext + hasFeature() pattern. Cross-ref feature-flags.md.


Tab Registration

Tab Bar Config

File: Okta-Mobile/components/navigation/tabBarConfig.ts

The exercises tab is registered at index 2 (between Messages and Manage):

{
  routeName: "exercises",
  iconFilled: "fitness",
  iconOutline: "fitness-outline",
  color: "#06B6D4",       // Cyan
  colorLight: "rgba(6,182,212,0.15)",
}
Property Value
Icon fitness / fitness-outline (Ionicons)
Accent Color Cyan #06B6D4
Pill Tint rgba(6,182,212,0.15)

Cross-ref mobile-navigation-design.md for tab bar visual design.

Tab Visibility

File: Okta-Mobile/app/(tabs)/_layout.tsx

<Tabs.Screen
  name="exercises"
  options={{
    href: isDoctor && hasFeature("therapistExerciseManagement") ? undefined : null,
    title: t("tabTitles.exercises"),
  }}
/>
  • href: null hides the tab entirely (patients, tenants without the feature)
  • href: undefined shows the tab (doctors with the feature enabled)
  • Requires both useTenantFeatures() and useAuth() context

Exercise List Screen

File: Okta-Mobile/app/(tabs)/exercises.tsx

The main tab screen is a scrollable list with search, filtering, and segmentation.

Layout

+------------------------------------------+
|  Exercise Library (header, 30px bold)     |
+------------------------------------------+
|  [Search bar] [Filter badge button]       |
|  [All] [My Private] [Favorites]           |
+------------------------------------------+
|  ExerciseCard                             |
|  ExerciseCard                             |
|  ExerciseCard                             |
|  ...                                      |
+------------------------------------------+
|                              [+ FAB]      |
+------------------------------------------+

Features

Feature Implementation
Search TextInput with debounced client-side filtering via filterExercises()
Filter Badge button showing active filter count, opens TagFilterSheet
Segments 3 toggleable pills: All Exercises, My Private, Favorites
List FlatList with keyboardShouldPersistTaps="handled", keyboardDismissMode="on-drag"
Pull-to-refresh RefreshControl triggers refetch()
Create FAB "+" button positioned above tab bar, navigates to create screen
Bottom padding useTabBarBottomPadding() for floating tab bar clearance

States

State Display
Loading LoadingState component (skeleton)
Content FlatList of ExerciseCard items
Empty Guidance message ("No exercises found")
Error ErrorState component with retry button

Client-Side Filtering

File: Okta-Mobile/lib/exerciseFilters.ts

filterExercises(exercises, filters, userId)
Dimension Logic
Name Case-insensitive includes
Tags (AND mode) All selected tags must be present on the exercise
Tags (OR mode) At least one selected tag must be present
Segment: All No filter
Segment: My Private !isPublic && createdById === userId
Segment: Favorites isFavorite === true

Exercise Card

File: Okta-Mobile/components/exercises/ExerciseCard.tsx

Each exercise renders as a card with thumbnail, metadata, tags, and a favorite toggle.

Content

Element Details
Thumbnail 80x80px rounded, via getExerciseThumbnailUrl(exercise)
Play overlay Shown if hasVideoSource(exercise) is true
Name 16px weight 600, truncated to 1 line
Category 12px, capitalized (categoryId.replace(/_/g, " "))
Creator 13px muted, "by FirstName LastName" or "System"
Tags Up to 2 TagChip components + "+N more" indicator
Favorite star Pressable, haptic feedback via hapticLight()

Thumbnail Priority

Uses getExerciseThumbnailUrl() from lib/exerciseUtils.ts:

  1. Personalized video (personalizedVideo.cloudflareStreamId)
  2. Kinescope (kinescopeVideoId) → https://kinescope.io/{id}/poster.jpg
  3. Cloudflare Stream (cloudflareStreamId) → https://customer-j8qsy7zjqvqbtuqp.cloudflarestream.com/{id}/thumbnails/thumbnail.jpg?time=1s
  4. YouTube (extracted from videoUrl) → https://img.youtube.com/vi/{id}/hqdefault.jpg
  5. Direct photo (photoUrl)
  6. Placeholder (barbell icon)

Cross-ref exercise-media-rendering.md for full thumbnail/video rendering details.

Press Interaction

  • Card press: navigates to /(doctor)/(exercises)/detail?exerciseId=...
  • Press animation via createPressHandlers() (scale spring from lib/designSystem.ts)
  • Favorite star: hapticLight() on press

Tag Chip

File: Okta-Mobile/components/exercises/TagChip.tsx

Colored pill component used throughout the exercise management screens.

Prop Type Description
name string Tag display name
color string \| null Hex color (fallback: #6B7280)
type TagType Determines emoji prefix
size "sm" \| "md" Pill size variant
onRemove () => void Shows X button when provided
onPress () => void Makes chip tappable
selected boolean Higher opacity background/border

Type Emoji Mapping

TagType Emoji
REGION pin
MUSCLE_GROUP muscle
MOVEMENT_PATTERN runner
EQUIPMENT weight lifter
OBJECTIVE target
SPECIALTY hospital
POSITION yoga
DIFFICULTY_CONTEXT lightning
CUSTOM label

Color Handling

Tag color is nullable in the database (String? in Prisma). The component uses a fallback:

const DEFAULT_TAG_COLOR = "#6B7280";
const safeColor = color || DEFAULT_TAG_COLOR;
  • Default state: background at 15% opacity (+ "26"), border at 30% opacity (+ "4D")
  • Selected state: background at 20% opacity (+ "33"), border at 50% opacity (+ "80")

Exercise List Header

File: Okta-Mobile/components/exercises/ExerciseListHeader.tsx

Combined header widget containing search, filter, and segment control.

  • Left icon (search), center TextInput, right clear button
  • Placeholder: "Search exercises..."
  • Debounced filtering applied to exercise list

Filter Button

  • Shows badge with count of selected tags
  • Tapping opens TagFilterSheet

Segment Control

  • 3 pills: All Exercises | My Private | Favorites
  • Styled like the login toggle: gray container, active pill gets brand color background + white text
  • Segment definitions use as const for proper NestedKeyOf<TranslationKeys> type inference

Tag Filter Bottom Sheet

File: Okta-Mobile/components/exercises/TagFilterSheet.tsx

Shared modal bottom sheet used for both: - filtering the exercise library by tags - selecting tags while creating/editing an exercise

Layout

+------------------------------------------+
|  (backdrop - tap to dismiss)              |
|                                           |
|  +--------------------------------------+ |
|  | Filter by Tags            [X close]  | |
|  |                                      | |
|  | [Match All] [Match Any]              | |  <- filter mode only
|  |                                      | |
|  | [Search tags...]                     | |
|  |                                      | |
|  | > Region (2 selected)                | |
|  |   [Tag] [Tag] [Tag] [Tag]           | |
|  |                                      | |
|  | > Movement Pattern                   | |
|  |   [Tag] [Tag] [Tag]                 | |
|  |                                      | |
|  | [Clear All]         [Apply]          | |
|  +--------------------------------------+ |
+------------------------------------------+

Features

Feature Implementation
Backdrop rgba(0,0,0,0.5), tap-to-dismiss
Container White bottom sheet, 24px top border radius, fixed height with scrollable body
Mode toggle AND ("Match All") / OR ("Match Any"), shown only for exercise-library filtering
Tag search Filters tag name and key within sections
Sections Grouped by TagType, each collapsible
Section headers Type name + emoji + selected count badge
Tag chips Toggleable TagChip components in a flex wrap grid
States Explicit loading, error + retry, and empty states
Bottom bar "Clear All" (ghost) + "Apply" (primary)

Default Expanded Sections

REGION, MOVEMENT_PATTERN, EQUIPMENT, POSITION — other sections start collapsed.

Group Ordering

Tags are rendered in this stable order: 1. REGION 2. MOVEMENT_PATTERN 3. EQUIPMENT 4. POSITION 5. OBJECTIVE 6. SPECIALTY 7. DIFFICULTY_CONTEXT 8. MUSCLE_GROUP 9. CUSTOM

Tags within each section are sorted alphabetically by display name.

Search Behavior

  • If search is empty, default expanded sections are preserved
  • If search is non-empty, all matching sections auto-expand so results are immediately visible
  • Used in create/edit screens with showModeSelector={false} to hide filter-only controls

Hermes Engine Compatibility

The component avoids JavaScript features that are unreliable in React Native's Hermes engine: - Uses TagType[] array instead of Set<TagType> for expandedSections state - Uses array-safe iteration/grouping helpers - Uses Array.isArray(tags) ? tags : [] safety guard before iteration


Exercise Detail Screen

File: Okta-Mobile/app/(doctor)/(exercises)/detail.tsx

Full-screen exercise detail view, navigated to from ExerciseCard.

Layout

  • Header: Back button
  • Video section: Renders based on available video source
  • Cloudflare Stream: WebView iframe (https://customer-j8qsy7zjqvqbtuqp.cloudflarestream.com/{id}/iframe)
  • Kinescope: wrapped web-app player via ${EXPO_PUBLIC_WEBVIEW_BASE_URL}/embed/k/{id}
  • Photo only: Image component
  • Exercise name: 22px bold
  • Difficulty badge: Color-coded
  • Description: Full text
  • Tags section: TagChip components
  • Creator info: "by FirstName LastName"
  • Action buttons:
  • Favorite toggle (star icon)
  • Edit (pencil, only if createdById === userId) → navigate to edit screen
  • Delete (trash, only if createdById === userId) → confirmation alert → soft-delete

Data Fetching

  • Fetches base exercise via exerciseService.getExercise(id)
  • Fetches associated tags separately via exerciseService.getExerciseTags(id, currentLocale)
  • Merges both responses into local screen state before rendering
  • Refetches on screen focus via useFocusEffect(...), so returning from edit shows updated fields immediately
  • Media view is keyed off the current media fields so updated video/photo sources refresh immediately after edit

Create/Edit Screens

Route Group

File: Okta-Mobile/app/(doctor)/(exercises)/_layout.tsx

<Stack screenOptions={{ headerShown: false }}>
  <Stack.Screen name="detail" />
  <Stack.Screen name="create" />
  <Stack.Screen name="edit" />
</Stack>

Registered in Okta-Mobile/app/(doctor)/_layout.tsx as <Stack.Screen name="(exercises)" />.

Exercise Form

File: Okta-Mobile/components/exercises/ExerciseForm.tsx

Shared form component for both create and edit modes.

Field Type Required Notes
Name TextInput (56px) Yes Focused state border highlight
Description TextInput (multiline, 4 rows) No Free-text instructions
Category Picker/modal Yes Values from ExerciseCategory enum (12 options)
Difficulty Picker/modal No Values from DifficultyLevel enum (10 options)
Public Switch toggle No Controls exercise visibility
Tags Tag picker No Opens TagFilterSheet in selection mode
Video Media picker No Record or choose from library
Photo Media picker No Take photo or choose from library

Media Picker

File: Okta-Mobile/components/exercises/MediaPicker.tsx

Uses expo-image-picker for system camera UI (like iMessage action sheet experience).

pickVideo(t, onResult)  // For video capture/selection
pickPhoto(t, onResult)  // For photo capture/selection
Platform Action Sheet
iOS ActionSheetIOS.showActionSheetWithOptions()
Android Alert.alert() with button options
Option API Call
Record Video ImagePicker.launchCameraAsync({ mediaTypes: ['videos'] })
Choose from Library ImagePicker.launchImageLibraryAsync({ mediaTypes: ['videos'] })
Take Photo ImagePicker.launchCameraAsync({ mediaTypes: ['images'] })
Choose from Library ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'] })

Permission requests handled via ImagePicker.requestCameraPermissionsAsync() and ImagePicker.requestMediaLibraryPermissionsAsync().

Type workaround: The t function has a strict NestedKeyOf<TranslationKeys> type. MediaPicker functions use TranslateFn = (key: any, params?: Record<string, any>) => string to avoid type conflicts when passing t across module boundaries.

Create Screen

File: Okta-Mobile/app/(doctor)/(exercises)/create.tsx

Submit flow: 1. Create exercise via POST /v2/exercises (name, description, categoryId, isPublic, etc.) 2. If video file selected: get upload URL → upload to S3 → confirm with webhook token 3. If photo file selected: get upload URL → upload to S3 4. Save tags via PUT /v2/exercises/exercise/:exerciseId/tags 5. Show SuccessToast, navigate back, trigger refetch

Upload errors are non-blocking — exercise creation still succeeds even if media upload fails.

Edit Screen

File: Okta-Mobile/app/(doctor)/(exercises)/edit.tsx

  • Receives exerciseId param
  • Fetches current exercise data via exerciseService.getExercise(id) plus exerciseService.getExerciseTags(id, currentLocale)
  • Pre-fills ExerciseForm fields
  • Submit: PATCH /exercises/:id + upload new media + update tags
  • Can delete existing media via "Remove" buttons → DELETE /exercises/:id/media/:type

Types

File: Okta-Mobile/types/exercise.ts

Interfaces

Type Purpose
Exercise Full exercise object with all fields
Tag Tag with id, key, type, color (nullable), name, description
ExerciseFilters Filter state: name, tags, tagMode (all/any), segment (all/myPrivate/favorites)
CreateExerciseData POST body for creating exercises
UpdateExerciseData PATCH body for updating exercises

Enums

Enum Values
ExerciseCategory 12 values: UPPER_BODY, LOWER_BODY, CORE, CARDIO, STRETCHING, BALANCE, MOBILITY, REHABILITATION, MIDSECTION, FULL_BODY, OTHER, NOT_SPECIFIED
DifficultyLevel 10 values: BEGINNER through LEGENDARY, OTHER, NOT_SPECIFIED
TagType 9 values: REGION, MUSCLE_GROUP, MOVEMENT_PATTERN, EQUIPMENT, OBJECTIVE, SPECIALTY, POSITION, DIFFICULTY_CONTEXT, CUSTOM

Lookup Maps

  • TAG_TYPE_EMOJI: Record<TagType, string> — emoji for each tag type
  • TAG_TYPE_LABELS: Record<TagType, string> — display label for each tag type

Service Layer

File: Okta-Mobile/services/exerciseService.ts

Singleton service using httpClient for all exercise API calls.

Method HTTP Endpoint Notes
listExercises(lang) GET /v2/exercises?lang=... Unwraps response.exercises
getExercise(id) GET /v2/exercises/exercise/:id Returns full exercise
createExercise(data) POST /v2/exercises Creates new exercise
updateExercise(id, data) PATCH /exercises/:id Updates exercise fields
deleteExercise(id) DELETE /exercises/:id Soft-deletes exercise
addFavorite(id) POST /exercises/:id/favorite Adds to favorites
removeFavorite(id) DELETE /exercises/:id/favorite Removes from favorites
toggleFavorite(id, isFav) Calls add/remove based on current state
getMediaUploadUrl(id, type, contentType) POST /exercises/:id/media/upload-url Returns signed S3 URL + webhook token
confirmVideoUpload(token) POST /exercises/upload-complete/:token Triggers Cloudflare Stream processing
deleteMedia(id, type) DELETE /exercises/:id/media/:type Deletes video or photo
getExerciseTags(id, lang) GET /v2/exercises/exercise/:id/tags?lang=... Unwraps response.tags
getTags(lang, includeCustom) GET /v2/exercises/tags?lang=...&includeCustom=... Unwraps response.tags
replaceExerciseTags(id, tagKeys) PUT /v2/exercises/exercise/:id/tags Replaces all tags

Response Unwrapping

The API wraps responses in container objects. The service unwraps them:

// exercises API returns { exercises: [...] }
async listExercises(lang: string): Promise<Exercise[]> {
  const response = await httpClient.get<ExercisesResponse>(`/v2/exercises?lang=${lang}`);
  return response.exercises || [];
}

// tags API returns { tags: [...], language, total }
async getTags(lang: string, includeCustom: boolean = true): Promise<Tag[]> {
  const response = await httpClient.get<TagsResponse>(
    `/v2/exercises/tags?lang=${lang}&includeCustom=${includeCustom}`
  );
  return response.tags || [];
}

async getExerciseTags(exerciseId: number, lang: string): Promise<Tag[]> {
  const response = await httpClient.get<{ tags: Tag[] }>(
    `/v2/exercises/exercise/${exerciseId}/tags?lang=${lang}`
  );
  return response.tags || [];
}

getExercise(id) returns the raw exercise record from the detail endpoint. Tags are fetched separately via getExerciseTags(...) for detail/edit screens.


Hooks

useExercises

File: Okta-Mobile/hooks/useExercises.ts

const { exercises, loading, error, refetch } = useExercises();
  • Fetches all exercises on mount via exerciseService.listExercises(currentLocale)
  • Locale sourced from useTranslation() context
  • Includes Array.isArray(data) ? data : [] safety guard
  • Refetches when locale changes

useTags

File: Okta-Mobile/hooks/useTags.ts

const { tags, loading, error, refetch } = useTags();
  • Fetches all tags on mount via exerciseService.getTags(currentLocale, true)
  • Same locale handling and safety guards as useExercises

API Changes

Exercise List Endpoint

File: OktaPT-API/src/routes/exercises/exercises.ts

Added cloudflareStreamId: true and kinescopeVideoId: true to the Prisma select clause in the GET /v2/exercises handler. This allows the mobile app to generate video thumbnails directly from the list response without making additional per-exercise API calls.

Background: The web app doesn't include these fields in the exercise list response. Instead, it uses a separate useExerciseMedia hook that fetches GET /exercises/:id/media for each exercise with an uploadedVideoKey. This N+1 pattern works on web but is inefficient for mobile. Adding the fields directly to the list response is a more efficient approach.

Search Endpoint

File: OktaPT-API/src/services/exerciseService.ts

The searchExercises() function (used when a search query q is provided) already includes cloudflareStreamId: true in its select clause.

App Config

File: Okta-Mobile/app.config.js

Added NSPhotoLibraryUsageDescription to iOS infoPlist for expo-image-picker photo library access.


Translations

Added to both Okta-Mobile/locales/en.json and Okta-Mobile/locales/ru.json:

Tab Title

"tabTitles": {
  "exercises": "Exercises"  // ru: "Упражнения"
}

Exercise Library Section

All keys under exerciseLibrary.*:

Key EN Purpose
title Exercise Library Screen title
search Search exercises... Exercise search placeholder
filterByTags Filter by Tags Filter sheet title
matchAll Match All AND mode label
matchAny Match Any OR mode label
searchTags Search tags... Tag search placeholder
loadingTags Loading tags... Tag sheet loading state
noTagsFound No tags found Tag sheet empty state
clearAll Clear All Clear filters button
apply Apply Apply button
allExercises All Exercises Segment label
myPrivate My Private Segment label
favorites Favorites Segment label
noExercises No exercises found Empty state
noExercisesSubtext Try adjusting your search or filters Empty state hint
createdBy by {{name}} Creator attribution
system System System exercise creator
createExercise Create Exercise Create screen title
editExercise Edit Exercise Edit screen title
name Name Form field label
description Description Form field label
category Category Form field label
difficulty Difficulty Form field label
public Public Form field label
tags Tags Form field label
addTags Add Tags Tag picker button
video Video Media section label
addVideo Add Video Video picker button
photo Photo Media section label
addPhoto Add Photo Photo picker button
save Save Submit button
delete Delete Delete button
deleteConfirm Delete this exercise? Delete alert title
deleteMessage This action cannot be undone. Delete alert message
cancel Cancel Cancel button
recordVideo Record Video Action sheet option
chooseFromLibrary Choose from Library Action sheet option
takePhoto Take Photo Action sheet option
cameraPermission Camera permission is required... Permission alert
libraryPermission Photo library permission is required... Permission alert

Dependencies

Package Purpose Install
expo-image-picker Camera and photo library access for media capture npx expo install expo-image-picker

All other dependencies (react-native, @expo/vector-icons, expo-router, etc.) were already present.


Differences from Web Exercise Library

Aspect Web (OktaPT-FE) Mobile (Okta-Mobile)
Thumbnail data Fetches cloudflareStreamId per-exercise via GET /exercises/:id/media Gets cloudflareStreamId directly from exercise list response
Video playback @cloudflare/stream-react Stream component WebView iframe
View modes Grid (VirtuosoGrid) + Table toggle Single-column FlatList cards
Filter UI Sidebar drawer (TagFilterDrawer) Bottom sheet modal (TagFilterSheet)
Media capture File input (<input type="file">) expo-image-picker with camera/library action sheet
Haptics None hapticLight() on interactive elements
Feature gate Not feature-flagged therapistExerciseManagement feature flag
Tag filtering Advanced (category, difficulty, muscle, creator, media, tags) Tag-based only (AND/OR mode)

Known Considerations

Hermes Engine Compatibility

React Native's Hermes engine has limitations with certain JavaScript features:

  • Set iteration: useState<Set<T>> and iterating over Sets can throw "iterator method is not callable". Use arrays instead.
  • for...of on non-array iterables: Can fail silently or throw. Use .forEach() or .map() instead.
  • Safety guards: Always use Array.isArray(data) ? data : [] before iterating API responses, as the response shape may not match expectations during error conditions.

Nullable Tag Colors

Tag color is String? in Prisma (nullable). All components that render tag colors must provide a fallback:

const DEFAULT_TAG_COLOR = "#6B7280";
const safeColor = color || DEFAULT_TAG_COLOR;

API Response Wrapping

The exercise and tag API endpoints wrap their responses: - GET /v2/exercises returns { exercises: [...] } (not a raw array) - GET /v2/exercises/tags returns { tags: [...], language, total } (not a raw array)

The service layer unwraps these before returning to hooks.


Document Covers
exercise-library.md Exercise library end-to-end (data model, tag system, API endpoints, security, web UI)
exercise-media-rendering.md Video/thumbnail priority fallback across providers
exercise-scope-service.md Visibility filtering (buildExerciseWhere)
feature-flags.md Per-tenant feature flag system
mobile-navigation-design.md Tab bar design, colors, animations
mobile-role-separation.md Doctor/patient route groups and guards
personalized-exercise-videos.md Per-patient exercise video recording
mobile-translations.md Mobile i18n system

Key Files

New Files

File Purpose
Okta-Mobile/types/exercise.ts Exercise, Tag, Filter type definitions and enums
Okta-Mobile/services/exerciseService.ts Exercise CRUD, media, tags, favorites API service
Okta-Mobile/hooks/useExercises.ts Exercise list data hook
Okta-Mobile/hooks/useTags.ts Tags data hook
Okta-Mobile/lib/exerciseFilters.ts Client-side exercise filtering utility
Okta-Mobile/app/(tabs)/exercises.tsx Exercise library tab screen
Okta-Mobile/app/(doctor)/(exercises)/_layout.tsx Exercise route group layout
Okta-Mobile/app/(doctor)/(exercises)/detail.tsx Exercise detail screen
Okta-Mobile/app/(doctor)/(exercises)/create.tsx Create exercise screen
Okta-Mobile/app/(doctor)/(exercises)/edit.tsx Edit exercise screen
Okta-Mobile/components/exercises/ExerciseCard.tsx Exercise card component
Okta-Mobile/components/exercises/ExerciseListHeader.tsx Search + filter + segments header
Okta-Mobile/components/exercises/TagFilterSheet.tsx Tag filter bottom sheet
Okta-Mobile/components/exercises/tagFilterUtils.ts Tag section ordering, grouping, sorting, and search behavior
Okta-Mobile/components/exercises/TagChip.tsx Tag chip component
Okta-Mobile/components/exercises/ExerciseForm.tsx Create/edit form component
Okta-Mobile/components/exercises/MediaPicker.tsx Camera/library media picker
Okta-Mobile/__tests__/tagFilterSheet.test.tsx Tag sheet/helper regression coverage

Modified Files

File Change
Okta-Mobile/components/navigation/tabBarConfig.ts Added exercises tab config (cyan, fitness icon)
Okta-Mobile/app/(tabs)/_layout.tsx Registered exercises tab (doctor-only, feature-flagged)
Okta-Mobile/app/(doctor)/_layout.tsx Registered (exercises) route group
Okta-Mobile/locales/en.json Added tabTitles.exercises and exerciseLibrary.* section
Okta-Mobile/locales/ru.json Added tabTitles.exercises and exerciseLibrary.* section (Russian)
Okta-Mobile/app.config.js Added NSPhotoLibraryUsageDescription for expo-image-picker
OktaPT-API/src/routes/exercises/exercises.ts Added cloudflareStreamId to exercise list select

Reused Existing Code

File What's Reused
Okta-Mobile/lib/exerciseUtils.ts getExerciseThumbnailUrl(), hasVideoSource(), ExerciseMedia interface
Okta-Mobile/lib/httpClient.ts All API calls via singleton HTTP client
Okta-Mobile/lib/designSystem.ts hapticLight(), hapticMedium(), hapticSuccess(), createPressHandlers()
Okta-Mobile/components/navigation/tabBarConfig.ts useTabBarBottomPadding()
Okta-Mobile/components/LoadingState.tsx Loading screen component
Okta-Mobile/components/ErrorState.tsx Error screen with retry
Okta-Mobile/components/SuccessToast.tsx Success notifications
Okta-Mobile/context/TenantFeaturesContext.tsx hasFeature() for feature flag gating
Okta-Mobile/context/I18nContext.tsx useTranslation() for i18n