Mobile App Development Guide
SkillDev toolsUse when working on the mobile app (packages/app). Covers Expo, React Native, expo-router navigation, NativeWind styling, React Query data fetching, translations, and mobile-specific patterns.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Mobile App Development Guide skill
What this skill tells your AI
The instructions your AI receives, as published by jvidalv/100cims in .claude/skills/app/SKILL.md and read by ahel’s review.
You are working on the 100cims mobile app (packages/app), an Expo React Native application for mountaineering.
Key Files
| File | Purpose |
|---|---|
app/_layout.tsx | Root layout with providers |
app.config.ts | Expo configuration, plugins, EAS project ID |
lib/api-client.ts | openapi-fetch client with auth |
types/api.ts | Generated OpenAPI types (DO NOT EDIT) |
domains/*/ | Feature-specific business logic |
components/ui/ | Reusable UI components |
translations/*.json | i18n message files |
Stack
- Expo SDK 56 with React Native 0.85 (new architecture enabled)
- expo-router 56 for file-based navigation
- NativeWind 5 (preview) for styling (Tailwind CSS)
- React Query 5 for server state
- React Intl for i18n (en, ca, es)
- expo-notifications for push (sends to Expo push service; see Push Notifications section)
Directory Structure
/app/: expo-router screens (file-based routing)/domains/: Feature-specific business logic/hooks/: Shared React hooks/lib/: Utility functions (api-client, auth, dates, images, location, validation)/components/: Reusable UI components/translations/: i18n message files/types/: Shared TypeScript types (api.ts auto-generated, mountain.ts, etc.)
Domain Pattern
Each domain folder typically contains:
*.api.ts: React Query hooks for API calls*.utils.ts: Domain-specific utilities*.types.ts: TypeScript types (optional)
Example: /domains/user/user.api.ts exports useUser(), useUpdateUser(), etc.
Shared Hooks
| Hook | Location | Purpose |
|---|---|---|
useImagePicker | hooks/use-image-picker.ts | Pick and optimize images with state management |
useMountainSelection | hooks/use-mountain-selection.ts | Manage mountain selection in challenge screens |
useLocation | hooks/use-location.ts | Get user's current location |
useIsKeyboardVisible | hooks/use-is-keyboard-visible.ts | Detect keyboard visibility |
useDistanceToTarget | hooks/use-distance-to-target.ts | Calculate distance to coordinates |
Shared Utilities
| Utility | Location | Purpose |
|---|---|---|
validateMountainForm | lib/mountain-validation.ts | Validate mountain form fields |
getImageOptimized | lib/images.ts | Optimize images for upload |
cleanText | lib/strings.ts | Normalize text for search |
getDistanceInKm | lib/location.ts | Calculate distance between coordinates |
reportErrorToDiscord | lib/report-error.ts | Fire-and-forget error report via EXPO_PUBLIC_DISCORD_ERROR_WEBHOOK — bypasses apiClient on purpose (works when our API is down) |
Shared Types
| Type | Location | Purpose |
|---|---|---|
MountainData | types/mountain.ts | Base mountain data from API |
MountainWithChallengeCount | types/mountain.ts | Mountain with challenge count |
NewMountainData | types/mountain.ts | New mountain for local state |
MountainInfo | types/mountain.ts | Minimal mountain info for display |
When a domain needs to name a specific field from a response (a slug, an id union, a status enum), anchor the alias to the generated openapi path instead of re-declaring as string — that way the type stays in lockstep if the backend later tightens it to a literal union. Example from domains/merch/cart.ts:
import type { paths } from "@/types/api";
type MerchEntry =
paths["/api/public/merch/"]["get"]["responses"][200]["content"]["application/json"]["message"][number];
type MerchSlug = MerchEntry["slug"];
Key Patterns
Using Shared Hooks
// Image picker with optimization
import { useImagePicker } from '@/hooks/use-image-picker';
const { imageUri, imageBase64, hasChanged, pickImage, reset } = useImagePicker({
initialUri: existingImageUrl,
});
// Mountain selection state management
import { useMountainSelection } from '@/hooks/use-mountain-selection';
const {
selectedMountainIds,
newMountains,
selectedMountainsForDisplay,
totalMountainCount,
handleSelectionChange,
handleAddNewMountain,
handleRemoveNewMountain,
} = useMountainSelection({ allMountains });
Form Validation
import { validateMountainForm } from '@/lib/mountain-validation';
const validation = validateMountainForm({ name, location, height, latitude, longitude }, intl);
if (!validation.valid) {
return Alert.alert(validation.error);
}
// Use validation.data.name, validation.data.height (parsed numbers), etc.
API Calls
// Use openapi-fetch client from lib/api-client.ts
import apiClient from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query';
export const useUser = (userId: string) => {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const { data, error } = await apiClient.GET('/api/user/{id}', {
params: { path: { id: userId } }
});
if (error) throw error;
return data;
}
});
};
Debounced field updates
When wiring debounce from lib/debounce.ts into an input's onChangeText, wrap the debounced function in useMemo — otherwise each render creates a fresh debounce with a new internal timer, so the previous pending update is orphaned and nothing actually fires. This bites hardest when the input lives inside a conditionally-rendered block (e.g. a tab) that unmounts before the debounce delay elapses.
const onChangeFirstName = useMemo(
() => debounce(async (firstName: string) => {
await updateUserMe({ firstName });
}, 500),
[updateUserMe],
);
Infinite Scroll with Pagination
import { useInfiniteQuery } from '@tanstack/react-query';
export const useItemsList = () => {
return useInfiniteQuery({
queryKey: ['items', 'list'],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const { data, error } = await apiClient.GET('/api/items', {
params: { query: { page: pageParam, limit: 50 } },
});
if (error) throw error;
return data.message;
},
getNextPageParam: (lastPage) => {
if (lastPage?.pagination?.hasMore) {
return lastPage.pagination.page + 1;
}
return undefined;
},
});
};
// In component:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useItemsList();
const items = data?.pages?.flatMap((p) => p?.items ?? []) ?? [];
<FlatList
data={items}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
Authentication
- JWT stored in AsyncStorage
setAuthToken(token)in lib/api-client.ts sets Authorization header- Protected routes check auth state in _layout.tsx
- Only log out on HTTP 401, never on generic query errors. React Query fires errors for network failures, 5xx, and
ECONNREFUSED(e.g. when the API server restarts in dev) — logging out on any of those wipes the AsyncStorage JWT and boots the user to the login screen on every API restart. Seedomains/user/user.api.tsuseUserMe: queryFn throwsnew Error(UNAUTHORIZED)specifically onresponse.status === 401, and the logout effect gates onerror.message === UNAUTHORIZED. Mirror this pattern in any new hook that reacts to auth errors.
Styling with NativeWind
<View className="flex-1 bg-white dark:bg-gray-900">
<Text className="text-lg font-bold text-gray-900">Title</Text>
</View>
Navigation
import { router } from 'expo-router';
// Navigate
router.push('/user/123');
// With params
router.push({ pathname: '/mountain/[id]', params: { id: '456' } });
NativeTabs (expo-router/unstable-native-tabs) — gotchas
The app uses NativeTabs (sibling subtrees: app/(tabs), app/challenges/(tabs), app/plans/(tabs), app/plan/[id]/(tabs), app/mountain/[slug]/(tabs)). The OS forbids nesting two NativeTabs in the same render tree, so each section's tab bar is a sibling subtree under the root Stack — only one bar is mounted at a time.
- Dynamic params: use
useGlobalSearchParams, notuseLocalSearchParams. Inside a NativeTabs eagerly-mounted child of a[slug]/[id]folder,useLocalSearchParamsreturnsundefinedfor the parent dynamic until the tab is focused.useGlobalSearchParamsreads the URL directly and works for both tab-bar taps andLinknav. The symptom is white-screen-on-tab-tap-only (Links work fine). Every plan/mountain detail tab screen does this. useSafeAreaInsets().bottomALREADY includes the tab bar height. iOS bumps the safe-area inset for any child of aUITabBarController. Don't add the tab-bar height on top — that double-counts and leaves a gap between your content and the bar. UseuseSafeAreaInsets().bottomalone (or theuseNativeTabBarInset()helper, which also handles Android's flipped behavior), plus an optional small visual buffer.- Single-child route groups collapse. A folder like
app/plans/containing only(tabs)/flattens to the route nameplans/(tabs). A<Stack.Screen name="plans" />declaration on the root Stack won't match and you'll see[Layout children]: No route named "plans" existswarnings. Either drop the declaration (Expo Router infers) or add a sibling file to keep the wrapper registered separately. - Trigger
hiddencauses a one-time remount on first transition. Use sparingly. For ownership-gated tabs (e.g.hidden={!isCreator}), gate on the full set of conditions the old in-content row had (status, dates, etc.) — moving navigation to a tab without copying the gates is a regression. - Hardcoded Tailwind heights don't survive NativeWind's static extraction when referenced from module-level string constants (e.g.
const H = "h-32"; ... className={twMerge("h-24", H)}). For dynamic sizing of header bars, use numericstyle={{ height: N }}instead. SeeBLURRED_SCREEN_HEADER_HEIGHTincomponents/ui/molecules/blurred-screen-header.tsx. - Child screens inside a tab need an explicit
Stack_layout.tsx. Puttingsummits.tsx(or any pushable child) next to the tab'sindex.tsxand trying to<Link href="/summits">to it from the tab will silently no-op under NativeTabs — taps fire, nothing happens, no warning. The fix: declare the tab as a route group folder (e.g.(tabs)/(home)/index.tsx+(tabs)/(home)/summits.tsx) AND add(tabs)/(home)/_layout.tsxreturning<Stack screenOptions={{ headerShown: false }} />. The trigger then names the group:<NativeTabs.Trigger name="(home)">. Group folders are invisible in URLs so/and/summitsstay the same. The shop folder gets away without its own_layoutbecause its tab is already a named folder (shop/), not a group — expo-router auto-stacks inside it; groups need the layout file to be pushable. Symptom: tapping a link to the child does nothing, no console error.
BlurredScreenHeader
Translucent absolute-positioned top bar used by Summit, Comments, Plan detail tabs, Plans list, Challenges layout, and the comment composer. API mirrors ScreenHeader (children = title, optional rightElement). It also accepts a plain string child and auto-wraps it in the standard <ThemedText className="max-w-56 text-lg font-medium"> — so <BlurredScreenHeader>{title}</BlurredScreenHeader> works without manual wrapping.
Because the header is position: absolute, consumers must pad the first content element below it. Use the exported BLURRED_SCREEN_HEADER_HEIGHT numeric constant as paddingTop via the style prop (NOT a className), and pair it with contentContainerStyle on scrollers — not contentContainerClassName — to avoid the NativeWind/className/style merge conflict that drops the padding. Example: <ScrollView contentContainerStyle={{ paddingTop: BLURRED_SCREEN_HEADER_HEIGHT, paddingHorizontal: 24, gap: 16 }}>.
Icons
All icons use lucide-react-native. Import the component by name, pass it to <LucideIcon icon={Mountain} /> — the wrapper handles theme-aware color (useColorScheme) and the muted gray treatment. There is no <Icon name="..."> string-based component any more; don't recreate one. Plumbing components (Button, ActionRow, SettingsOption.icon, Filter.icon) take icon: LucideIcon (the component by reference) — never a name string. See packages/app/components/ui/atoms/lucide-icon.tsx and app/index.tsx for the canonical call shape. Don't use @expo/vector-icons or expo-symbols — both are removed from the app bundle.
For the brand "essential" marker, use <LucideIcon icon={CircleDot} primary /> rather than hand-rolling a <View className="size-N rounded-full bg-primary" />. Raw rounded-full bg dots are still fine for notification/unread badges (absolute-positioned small dots) — the icon convention applies specifically to the essential/marker-with-label pattern next to a "Essential" string.
Action sections
When building an "Actions" section on a detail screen (mountain, plan, user profile, etc.), use the ActionRow molecule from @/components/ui/molecules instead of hand-rolling icon+text rows. Intents map to design tokens (primary, muted, blue, emerald, danger). Wrap in <Link asChild> for navigation rows. Supports badge (red dot) and iconOverride (e.g. spinner). icon is a lucide component: <ActionRow icon={Trash2} intent="danger">.... See app/plan/[id]/index.tsx and app/mountain/[slug]/index.tsx for examples.
Form inputs
The atoms barrel (@/components/ui/atoms) exports the canonical controls: ThemedTextInput, ThemedDateInput, ThemedCheckbox, ThemedToggleInput (yes/no with animated thumb), and ThemedTierPicker (1..N labeled pills — nullable, tap selected pill to clear). ThemedTierPicker takes an optional labels: readonly string[] so the same component works for numbered scales (count: 5) and named scales (labels={["Dangerous", "Risky", "Okay", "Safe", "Very safe"]}). Build label tuples at component-body level via intl.formatMessage — matches the existing "options list" pattern used by plan/challenge pickers.
Selected-state styling for selectable chips / pills across the app is border-primary bg-primary/10 text-primary (see app/user/summits/index.tsx, app/hiscores.tsx). The fully-filled bg-primary text-white treatment is reserved for primary CTAs (<Button>, pulse badges), not selectable list items — don't mix them.
Full-page edit screens
Edits live in dedicated push routes like /user/summits/[summit]/edit.tsx rather than bottom drawers. Detail pages trigger them via <Link asChild> wrapping an ActionRow (e.g. app/user/summits/[summit].tsx). The edit form mirrors the create flow's inputs but uses partial-payload mutations: the API body fields are all optional (t.Optional(...)) so the client sends only changed values — keeps everything backwards-compatible. See app/user/summits/[summit]/edit.tsx + api/routes/protected/summit/summit.update.post.ts for the template (photo replace, user add/remove, date edit).
Fatal error screens
When a screen's "load-bearing" React Query hooks fail (not 401 — that's handled in useUserMe via logout()), render <ErrorState context="..." error={...} onReload={...} /> from @/components/ui/molecules instead of letting the screen render with data: undefined. The molecule ships with Reload + Report buttons; Report POSTs to a Discord webhook via reportErrorToDiscord in lib/report-error.ts — bypassing apiClient on purpose so reporting works when our API is the thing that's down. Requires EXPO_PUBLIC_DISCORD_ERROR_WEBHOOK in env. For the 401 guard, compare error.message === UNAUTHORIZED (exported from domains/user/user.api.ts). Canonical setup: app/index.tsx derives hasFatalError from three hooks' isError + skips the Unauthorized message, then early-returns the <ErrorState> before the real render.
Share helpers
Text-only share messages go through shareDeeplink from @/lib/share — passes locale-keyed messages + a path, appends the getUrlDeeplink(path) URL, opens the native share sheet. Used by every detail page's Share action (mountain, plan, user, summit, challenge).
For rich story-ready image shares (summit + plan detail), render an off-screen <View> at fixed dimensions (e.g. 360×640 for a 9:16 story) and call captureAndShare from @/lib/share — it handles Image.prefetch (Promise.allSettled, skips data: URIs), captureRef at 1080×1920, expo-sharing.shareAsync, and falls back to the text-only share. Two gotchas: (1) view-shot doesn't reliably serialize NativeWind className into the captured image, so use inline style props inside the share card; (2) the off-screen wrapper needs collapsable={false} + position: absolute; left: -10000 + pointerEvents="none". See app/user/summits/[summit].tsx + components/summit/summit-share-card.tsx for the canonical setup and app/plan/[id]/index.tsx + components/plan/plan-share-card.tsx for a multi-image-grid variant.
Translations (CRITICAL)
All user-facing strings MUST use intl.formatMessage - never hardcode text.
import { useIntl } from 'react-intl';
const { formatMessage } = useIntl();
// Simple string
const title = formatMessage({ defaultMessage: 'Settings' });
// In JSX
<ThemedText>{intl.formatMessage({ defaultMessage: "Save changes" })}</ThemedText>
// With FormattedMessage component
import { FormattedMessage } from 'react-intl';
<FormattedMessage defaultMessage="Welcome back" />
Translation Workflow:
- Add strings in code using
defaultMessage(English text) - Run extraction:
yarn translations:extract - Run compilation:
yarn translations:generate - English (
translations/en.json) is auto-generated - Manually copy new keys to
translations/ca.json(Catalan) andtranslations/es.json(Spanish)- Sort order is strict ASCII (uppercase
A-Zbefore lowercasea-z, symbols+ /before digits). Don't usesort -f/ case-insensitivelocaleCompare— that reorders the existing file and produces a huge spurious diff. The safe approach is to insert each new hash at its strict-ASCII alphabetical position (or, when bulk-inserting, runJSON.stringify(Object.fromEntries(Object.entries(obj).sort()))— default JSsort()is codepoint-ascending, which matches the file's existing order).
- Sort order is strict ASCII (uppercase
- Translate the values in ca.json and es.json
Translation Files:
translations/raw-en.json- Extracted messages (intermediate)translations/en.json- Compiled English (used by app)translations/ca.json- Catalan translations (manual)translations/es.json- Spanish translations (manual)
Common Tasks
Add Native Module
npx expo install <package-name>
Requires a new EAS dev build (eas build --profile development); Expo Go won't load unlinked native modules.
If TS reports Cannot find module 'expo-modules-core' or empty types after adding an Expo module, add expo-modules-core as an explicit dep in packages/app/package.json (npx expo install expo-modules-core). Yarn workspaces nest it under node_modules/expo/node_modules/ where sibling packages can't resolve it.
Push Notifications
- Token flow:
lib/push.ts(getPushTokenIfGranted/requestPushPermission) →domains/user/push.api.ts(usePushTokenRegistration/usePushTokenMutation) →POST /api/protected/user/push-token. - Never call
requestPushPermission()directly from a screen. The native OS dialog is one-shot per install on iOS — a cold denial is permanent. Go throughhooks/use-ask-push-permission.tswhich shows our in-app CTA (PushPermissionDialog) first, honors a 7-day dismiss cooldown, and only triggers the OS dialog on user confirm. - Triggers are bound to context moments, not app launch: Plans first-visit, first summit created, and join-plan success (the last bypasses the cooldown with
ask({ bypassCooldown: true })). usePushTokenRegistrationin_layout.tsxre-registers the token on auth change but never prompts — it callsgetPushTokenIfGranted(no-ops unless permission already granted).- Notification body copy is server-side (API reads
userTable.locale) — do not translate push strings in the app. - Tap routing uses both
useLastNotificationResponse(cold-start) andaddNotificationResponseReceivedListener(warm); dedupe vianotification.request.identifier. - Physical device required — simulators/emulators don't receive tokens.
- Credentials live in EAS: APNs
.p8(iOS push), FCM V1 service account JSON (Android push, projectcims-bcc70), andGOOGLE_SERVICES_JSONfile secret (used byapp.config.tsandroid.googleServicesFile). Manage viaeas credentials/eas env:list.
Location permissions
Same "one-shot-per-install" concern as push. hooks/use-location.ts takes a prompt option:
prompt: false(default) — reads current permission state withgetForegroundPermissionsAsync, never opens the OS dialog. Use this in screens that show distance as an enhancement (home recommendations, mountain detail,_layout.tsx'sLocationSync). Falls back gracefully when permission is absent.prompt: true— only set onapp/mountains.tsx, the single context-bound trigger. The user just opened a list of peaks sorted by distance, so the OS prompt has clear context.
If you add a new screen that needs location, default to prompt: false + graceful fallback. Only add prompt: true to a screen where distance is the primary purpose and the user's intent is unambiguous.
Environment Variables
Must prefix with EXPO_PUBLIC_ to access in app:
EXPO_PUBLIC_API_URL: Backend URL- OAuth client IDs for Google/Apple
Removing Dependencies
Before removing a package from packages/app/package.json because "nothing imports it," verify it isn't a peer dependency of something that IS used. Many native / framework packages have no explicit imports in our source but are required transitively.
Check with:
node -e "console.log(JSON.stringify(require('./node_modules/PACKAGE/package.json').peerDependencies, null, 2))"
Known peer-dep requirements in this app (do not remove blindly):
| Package | Required by |
|---|---|
react-native-css | nativewind@5.x |
react-dom, react-native-safe-area-context, react-native-screens, react-native-web | expo-router |
react-native-worklets | react-native-reanimated@4.x (it's the runtime, not redundant) |
react-native-svg | lucide-react-native (icon rendering) |
react-native-web | also required by react-native-maps |
After editing package.json, run yarn install and read the warnings — unmet peer-dep warnings tell you what you just broke.
Build & Deploy
eas build --profile development # Dev build with expo-dev-client
eas build --profile preview # Internal preview
eas build --profile production # Production release
See eas.json for build profiles.
Signals
- GitHub stars
- 76
- Forks
- 13
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
app-jvidalv- Source
- github.com/jvidalv/100cims