After a workout is logged the trainee lands on a screen that turns the session into a 1080 by 1920 story image. The feature lives in 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. finishWorkout in src/features/workouts/presentation/WorkoutDayScreen.tsx builds a WorkoutSummary, and on a successful log calls setSummary with the workoutNumber from the response, then router.replace('/workout/complete').
  • From history. WorkoutHistoryScreen calls setSummary(summaryFromHistory(detail)) and pushes the same route. History summaries have prCount: 0 and prs: [].
The route file 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.
How finishWorkout fills it:
  • exercises has 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).
  • prs has one entry per exercise that set a record, with the best flagged set and the previous record (prevWeight, prevReps). prCount is prs.length, so two record sets on one lift count once.
  • bestSet comes from bestSetFromLogged(entries).
  • Any weight or reps that is zero or less becomes null. The templates treat zero as a real value, so bodyweight sets must carry null.
  • workoutNumber is the lifetime ordinal from the server, or null when 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: StoryIntroAnimation plays over IntroPopBurst. 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 reports onReady, so there is no blank frame.
  • carousel: normal use.
Timings: the intro runs on one clock of 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 in components/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_COUNT is 11. defaultTemplateIndex is floor(11 / 2), which is 5, the rail card.
  • 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: the heebo and assistant font maps, u(px) which converts design pixels at 1080 width to canvas points (px / 3), ink colours, the RLM and LRM direction marks, and the CoachLogo, PerformMark and PoweredBy components.

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. 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 an Alert 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, then coachAvatarUrl, then logoUrl.
  • handle: instagramHandle with any leading @ removed, or null.
  • username: the handle, else the studio name, else an empty string. Used by the chrome only.
  • footerHandle: @handle, or null. Templates 9 and 10 hide the slot when it is null. The studio name never replaces it.
The chrome around the story in the mockup (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.
shareStories captures a data URI and calls openInstagramStory(dataUri) in lib/instagramStory.ts:
If that returns false, the action captures a temp file and falls back to the system share sheet.
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 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.
The fourth button, labelled as a link in the UI, calls shareSheet. There is no workout link to share, so it shares the rendered image through Sharing.shareAsync(uri, { mimeType: 'image/png', UTI: 'public.png' }) from expo-sharing. It returns false when Sharing.isAvailableAsync() is false.
The finish button sets a 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, openInstagramStory returns false straight 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-share is loaded lazily. Both call sites use await import('react-native-share') inside a try block, 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.json lists instagram-stories, instagram and whatsapp under LSApplicationQueriesSchemes. The Linking.canOpenURL('whatsapp://app') check in isWhatsAppInstalled depends on the whatsapp entry.
  • Android package visibility. app.json adds com.whatsapp to manifestQueries through expo-build-properties, for the same check.
  • WhatsApp timeout. The call passes message: '' explicitly and is bounded by WHATSAPP_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 the expo-media-library plugin config in app.json, with isAccessMediaLocationEnabled: false. app.json also blocks the Android READ_MEDIA_* and READ_EXTERNAL_STORAGE permissions, 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 passes cancelable: true so back or an outside tap still dismisses it.
  • Singular transforms. The intro uses MIN_SCALE = 0.001 instead of zero, because iOS rejects a scale of zero.
  • Font scaling. Text on the screen sets maxFontSizeMultiplier so large accessibility text sizes do not break the fixed layout.
These changes need a native build when they touch app.json or a native dependency. The JavaScript in this feature can ship over the air. See OTA updates.