src/features/share-story, with constants in src/constants/story.ts. All paths are relative to the mobile repo root.
Flow
There are two ways in:- From a finished workout.
finishWorkoutinsrc/features/workouts/presentation/WorkoutDayScreen.tsxbuilds aWorkoutSummary, and on a successful log callssetSummarywith theworkoutNumberfrom the response, thenrouter.replace('/workout/complete'). - From history.
WorkoutHistoryScreencallssetSummary(summaryFromHistory(detail))and pushes the same route. History summaries haveprCount: 0andprs: [].
src/app/(app)/workout/complete.tsx sets gestureEnabled: false. The carousel is a horizontal swipe surface, and an edge swipe back would also skip the finish button and leave the summary in the store.
The summary store
src/features/share-story/stores/shareStore.ts is a small in-memory Zustand store: summary, setSummary(summary) and clear(). It is not persisted. If the screen mounts with no summary it redirects to /home.
finishWorkout fills it:
exerciseshas one entry per exercise with at least one done set: the count of done sets, and the reps and weight of its top set (heaviest, more reps break a tie).prshas one entry per exercise that set a record, with the best flagged set and the previous record (prevWeight,prevReps).prCountisprs.length, so two record sets on one lift count once.bestSetcomes frombestSetFromLogged(entries).- Any weight or reps that is zero or less becomes
null. The templates treat zero as a real value, so bodyweight sets must carrynull. workoutNumberis the lifetime ordinal from the server, ornullwhen the response has none.
Summary helpers
src/features/share-story/lib/summary.ts:
featuredLift is why the record templates never drop out and never claim a record that was not set.
Screen structure
WorkoutCompleteScreen in src/features/share-story/presentation stacks, top to bottom: StorySummaryHeader (a greeting with the trainee’s first name), a hint line, the carousel slot, StoryDots, ShareRow and the finish button. The status bar is hidden.
Intro
The screen has three phases,'intro' | 'swap' | 'carousel'.
intro:StoryIntroAnimationplays overIntroPopBurst. The share row is locked (pointerEvents: 'none', opacity 0.35).swap: the carousel mounts under the intro’s last frame. The intro unmounts once the carousel reportsonReady, so there is no blank frame.carousel: normal use.
INTRO_MS = 4000 (components/intro/introTimeline.ts). The screen unlocks after INTRO_SAFETY_MS = 5000 even if the end event never fires, and leaves swap after SWAP_FALLBACK_MS = 600 even if onReady never fires. With reduced motion enabled (useReducedMotion()), the screen starts in carousel, unlocked.
Sizing
The phone mockup has fixed geometry incomponents/phone/phoneGeometry.ts (PHONE.frameWidth = 244, PHONE.frameHeight = 514, screen 230 by 500). The screen measures the slot and scales the mockup down to fit:
Templates
src/features/share-story/overlays/index.ts exports the list in design order.
STORY_TEMPLATE_COUNTis 11.defaultTemplateIndexisfloor(11 / 2), which is 5, therailcard.- Every template has
variant: 'stats'and receives only{ summary }(OverlayProps). overlayTemplatesFor(summary)always returns all 11. Its parameter is unused and kept so call sites compile.- Shared pieces are in
overlays/kit.tsx: theheeboandassistantfont maps,u(px)which converts design pixels at 1080 width to canvas points (px / 3), ink colours, theRLMandLRMdirection marks, and theCoachLogo,PerformMarkandPoweredBycomponents.
Canvas
Templates are drawn on a fixed design canvas and scaled.ScaledStory renders its children at exactly 360 by 640 inside a view with collapsable={false}, then scales that view with a transform to the width on screen. The capture ref points at the unscaled 360 by 640 view, so the export does not depend on the mockup size.
StoryContent is the canvas content: a background layer and the template on top. The background is the trainee’s photo as a full-bleed cover image, or MirrorPatternBackground when no photo was chosen. The pattern is part of the exported image. The coach’s story background image is deliberately not used here.
Both ScaledStory and StoryContent spread physicalLtr from src/lib/rtl. The canvas uses physical left and a top left transform origin, and without opting out of right to left mirroring it renders shifted. See RTL and language.
Carousel
StoryCardsCarousel renders the 11 templates three times (33 slots) in a horizontal Animated.ScrollView to get an infinite loop. When a scroll settles in the first or last copy, it jumps without animation to the same template in the middle copy. It is not a FlatList, because virtualisation cannot guarantee that a jump target 11 slots away is mounted. Only slots near the centre mount a real template (HOT_RADIUS = 2).
The snap pitch is rounded to whole device pixels. On Android snapToInterval is pitch + 0.001.
canvasRef is attached to exactly one mounted canvas, the settled active card. Everything in the share row captures that ref.
Photo and coach identity
The camera button on the mockup opens anAlert with camera, gallery and, when a photo is set, remove. useSelfie() wraps expo-image-picker with allowsEditing: true, aspect: [9, 16] and quality: 0.9. The camera option requests camera permission and opens the front camera.
storyIdentity(brand) in lib/storyIdentity.ts derives what the mockup’s Instagram chrome and two templates show:
avatarUrl:storyProfileImage, thencoachAvatarUrl, thenlogoUrl.handle:instagramHandlewith any leading@removed, ornull.username: the handle, else the studio name, else an empty string. Used by the chrome only.footerHandle:@handle, ornull. Templates 9 and 10 hide the slot when it isnull. The studio name never replaces it.
IgStoryChrome) is a preview. It sits outside the capture ref and is not exported.
Capture
src/features/share-story/lib/storyShare.ts uses captureRef from react-native-view-shot.
react-native-view-shot reads width and height as pixels on Android and as points on iOS, where the renderer multiplies by the screen scale. Dividing on iOS makes both platforms export exactly 1080 by 1920.
Both throw
story-canvas-missing when the ref is empty.
Share actions
useStorySharing(canvasRef) exposes four actions and activeAction, which drives the spinner on the pressed button. Each action returns a boolean. A success plays a haptic. A thrown error is caught and turned into false, and the screen then shows a failure toast.
Instagram Stories
Instagram Stories
shareStories captures a data URI and calls openInstagramStory(dataUri) in lib/instagramStory.ts:false, the action captures a temp file and falls back to the system share sheet.WhatsApp
shareWhatsApp captures a temp file and calls shareWhatsAppImage(uri). That first checks Linking.canOpenURL('whatsapp://app'), then calls Share.shareSingle with social: Social.Whatsapp, url, type: 'image/png' and message: '', wrapped in a 12 second timeout. If WhatsApp is not installed or the share fails, the action falls back to the system share sheet.Save to the photo library
Save to the photo library
save captures a temp file and calls saveStoryImage(uri), which requests write-only access with requestPermissionsAsync(true) from expo-media-library and then calls Asset.create(...). libraryFileUri adds the file:// prefix to a bare path first.leaving flag, replaces the route with /workouts and clears the summary. The flag stops the “no summary” guard from redirecting to /home on the way out.
EXPO_PUBLIC_FB_APP_ID
INSTAGRAM_SOURCE_APP_ID reads the EXPO_PUBLIC_FB_APP_ID environment variable at build time. It is passed to Share.shareSingle as appId for the Instagram Stories share. The variable is listed, commented out, in .env.example, and is also referenced in eas.json. Never commit a real value to docs or source.
- On iOS,
openInstagramStoryreturnsfalsestraight away when the id is empty. The Stories button then always uses the system share sheet. - On Android the direct share is attempted even with an empty id.
Platform notes
react-native-shareis loaded lazily. Both call sites useawait import('react-native-share')inside atryblock, so a binary without the native module fails into the fallback path instead of crashing at startup. See Expo Go guards.- iOS URL scheme queries.
app.jsonlistsinstagram-stories,instagramandwhatsappunderLSApplicationQueriesSchemes. TheLinking.canOpenURL('whatsapp://app')check inisWhatsAppInstalleddepends on thewhatsappentry. - Android package visibility.
app.jsonaddscom.whatsapptomanifestQueriesthroughexpo-build-properties, for the same check. - WhatsApp timeout. The call passes
message: ''explicitly and is bounded byWHATSAPP_SHARE_TIMEOUT_MS. A timeout is rethrown, skips the share sheet fallback and surfaces as a failed share, so the button cannot spin forever. - Saving uses
Asset.create. Permission copy for the photo library is set in theexpo-media-libraryplugin config inapp.json, withisAccessMediaLocationEnabled: false.app.jsonalso blocks the AndroidREAD_MEDIA_*andREAD_EXTERNAL_STORAGEpermissions, and the save path only requests write access. - Android alert buttons. Android keeps only the first three buttons of an
Alert. With a photo set, the photo dialog has four, so cancel is dropped. The dialog passescancelable: trueso back or an outside tap still dismisses it. - Singular transforms. The intro uses
MIN_SCALE = 0.001instead of zero, because iOS rejects a scale of zero. - Font scaling. Text on the screen sets
maxFontSizeMultiplierso large accessibility text sizes do not break the fixed layout.
app.json or a native dependency. The JavaScript in this feature can ship over the air. See OTA updates.