mobile-frontend

Mobile Frontend Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "mobile-frontend" with this command: npx skills add deancochran/gradientpeak/deancochran-gradientpeak-mobile-frontend

Mobile Frontend Skill

Core Principles

  • Every Text Must Be Styled - React Native doesn't cascade styles; every Text component needs direct className

  • Event-Driven Hooks - Subscribe to specific events only, not all events

  • Shared Service Instances - Use ActivityRecorderProvider for single service instance across components

  • Semantic Colors - Always use design tokens (text-foreground, bg-background, etc.)

  • Consume-Once Navigation - Use activitySelectionStore for complex object navigation instead of URL params

  • Platform-Specific Code - Use NativeWind classes (ios:pt-12 android:pt-6) for platform differences

Patterns to Follow

Pattern 1: Text Styling (CRITICAL)

When to use: Every single Text component in the app Why: React Native has no style inheritance

// ❌ BAD - Text inherits nothing <View className="text-foreground"> <Text>This text has no color!</Text> </View>

// ✅ GOOD - Direct styling on every Text <View className="bg-background"> <Text className="text-foreground font-semibold">Title</Text> <Text className="text-muted-foreground text-sm">Subtitle</Text> </View>

// ✅ GOOD - Semantic color variants <Text variant="h1" className="text-foreground">Heading</Text> <Text variant="p" className="text-foreground">Paragraph</Text> <Text variant="muted" className="text-muted-foreground">Secondary</Text>

Key Points:

  • Style every Text element explicitly

  • Use semantic color classes: text-foreground , text-muted-foreground , text-destructive

  • Use variant prop when available on Text component

  • TextClassContext can provide default styling in custom components

Pattern 2: React Native Reusables Icon Usage

When to use: Any time you need an icon Why: Provides consistent styling and theming

import { Icon } from '@/components/ui/icon'; import { Home, Activity, Settings } from 'lucide-react-native';

// ✅ CORRECT - Icon wrapper component <Icon as={Home} size={24} className="text-foreground" />

// ✅ CORRECT - In Button <Button variant="default"> <Icon as={Activity} size={18} /> <Text className="text-primary-foreground">Start</Text> </Button>

// ❌ WRONG - Direct icon usage <Home size={24} /> {/* No styling context */

Key Points:

  • Always use <Icon as={IconComponent} /> pattern

  • Icons automatically get theme colors

  • Can override with className

  • Works inside Button/Card components

Pattern 3: Shared Service via Provider

When to use: ActivityRecorder service across multiple screens Why: Ensures single service instance, prevents state fragmentation

// ✅ GOOD - Single shared instance // In recording layout (_layout.tsx) <ActivityRecorderProvider profile={profile}> <Stack /> </ActivityRecorderProvider>;

// In any screen function RecordScreen() { const service = useSharedActivityRecorder(); // Same instance everywhere const state = useRecordingState(service); // ... }

// ❌ BAD - Multiple instances function Screen1() { const service = useActivityRecorder(profile); // Different instance } function Screen2() { const service = useActivityRecorder(profile); // Different instance }

Key Points:

  • Wrap recording screens with ActivityRecorderProvider

  • Use useSharedActivityRecorder() to access service

  • Service state persists across navigation

  • Automatic cleanup on unmount

Pattern 4: Event-Driven Hook Subscriptions

When to use: Subscribing to ActivityRecorder events Why: Prevents unnecessary re-renders, optimizes performance

// ✅ GOOD - Subscribe to specific event export function useRecordingState( service: ActivityRecorderService | null, ): RecordingState { const [state, setState] = useState<RecordingState>( service?.state ?? "pending", );

useEffect(() => { if (!service) return;

setState(service.state);

const subscription = service.addListener("stateChanged", (newState) => {
  setState(newState);
});

return () => subscription.remove(); // Always clean up

}, [service]);

return state; }

// ❌ BAD - Subscribe to all events useEffect(() => { const sub = service.onAnyEvent(() => { setNeedsUpdate(true); // Re-render entire component on any event }); return () => sub.remove(); }, [service]);

Key Points:

  • Use specific hooks: useRecordingState , useCurrentReadings , useSessionStats

  • Each hook subscribes to specific events only

  • Always return cleanup function

  • Component only re-renders when subscribed values change

Pattern 5: Consume-Once Navigation Store

When to use: Navigating with complex objects (activity plans, activity selection) Why: URL params can't encode complex objects

// ✅ GOOD - Use selection store // In source screen activitySelectionStore.setSelection({ category: "run", location: "outdoor" }); router.push("/(internal)/record");

// In destination screen const selection = activitySelectionStore.peekSelection(); if (selection) { service.selectActivityFromPayload(selection); activitySelectionStore.consumeSelection(); // Clear after use }

// ❌ BAD - URL params for complex objects router.push({ pathname: "/record", params: { plan: activityPlan }, // Serialization fails });

Key Points:

  • Store complex objects before navigation

  • Consume once in destination screen

  • Clear selection after reading

  • Handles navigation back gracefully

Pattern 6: NativeWind Platform-Specific Styling

When to use: Different styling for iOS vs Android Why: Platform-specific design guidelines

// ✅ GOOD - Platform variants <View className="ios:pt-12 android:pt-6 bg-background"> <Text className="ios:text-lg android:text-base text-foreground"> Platform Text </Text> </View>;

// ✅ GOOD - Safe area handling import { useSafeAreaInsets } from "react-native-safe-area-context";

function Screen() { const insets = useSafeAreaInsets();

return ( <View style={{ paddingTop: insets.top }} className="flex-1 bg-background"> {/* Content */} </View> ); }

Key Points:

  • Use ios: and android: prefixes

  • Combine with safe area insets for notch handling

  • Platform.select() for complex conditional logic

Pattern 7: Form Mutation with Retry

When to use: Creating/updating data with forms Why: Automatic retry, error handling, field error mapping

import { useFormMutation } from "@/lib/hooks/useFormMutation";

const mutation = useFormMutation({ mutationFn: async (data) => trpc.activities.create.mutate(data), form, // React Hook Form instance (optional) invalidateQueries: [["activities"]], successMessage: "Activity created!", retryAttempts: 2, onSuccess: () => router.back(), onError: (error) => { // Field errors automatically mapped to form toast.error(error.message); }, });

<Button onPress={form.handleSubmit(mutation.mutate)} disabled={mutation.isLoading}

<Text className="text-primary-foreground"> {mutation.isLoading ? "Creating..." : "Create"} </Text> </Button>;

Key Points:

  • Automatic network error retry with exponential backoff

  • Field errors mapped to React Hook Form

  • Cache invalidation on success

  • Loading/success/error states built-in

Pattern 8: Memoized List Items

When to use: FlatList with many items Why: Prevents unnecessary re-renders

import { memo } from "react";

export const ActivityListItem = memo( ({ activity, onPress }: Props) => { return ( <TouchableOpacity onPress={onPress}> <Text className="text-foreground">{activity.name}</Text> </TouchableOpacity> ); }, (prev, next) => { // Custom comparison - return true if equal (DON'T re-render) return ( prev.activity.id === next.activity.id && prev.activity.name === next.activity.name && prev.activity.distance_meters === next.activity.distance_meters ); }, );

ActivityListItem.displayName = "ActivityListItem";

// Usage in FlatList <FlatList data={activities} renderItem={({ item }) => ( <ActivityListItem activity={item} onPress={handlePress} /> )} keyExtractor={(item) => item.id} />;

Key Points:

  • Use React.memo with custom comparison

  • Compare only fields that affect rendering

  • Set displayName for debugging

  • Use with FlatList for best performance

Anti-Patterns to Avoid

Anti-Pattern 1: Multiple Service Instances

Problem: Each component gets different service, sensors not shared

// ❌ BAD function Screen1() { const service = useActivityRecorder(profile); // Instance A } function Screen2() { const service = useActivityRecorder(profile); // Instance B - different! }

// ✅ CORRECT <ActivityRecorderProvider profile={profile}> <Screen1 /> <Screen2 /> </ActivityRecorderProvider>;

function Screen1() { const service = useSharedActivityRecorder(); // Same instance } function Screen2() { const service = useSharedActivityRecorder(); // Same instance }

Anti-Pattern 2: Forgetting Subscription Cleanup

Problem: Memory leaks from event listeners

// ❌ BAD useEffect(() => { service.addListener("stateChanged", handleStateChange); // Missing cleanup! }, [service]);

// ✅ CORRECT useEffect(() => { const subscription = service.addListener("stateChanged", handleStateChange); return () => subscription.remove(); // Always clean up }, [service]);

Anti-Pattern 3: Over-Subscribing to Events

Problem: Component re-renders on every event

// ❌ BAD useEffect(() => { const sub = service.onAnyEvent(() => { setNeedsUpdate(true); // Re-render on ANY event }); return () => sub.remove(); }, [service]);

// ✅ CORRECT const readings = useCurrentReadings(service); // Only re-renders on sensor updates const stats = useSessionStats(service); // Only re-renders on stat changes

Anti-Pattern 4: Missing TextClassContext in Custom Components

Problem: Text inside custom button has no styling

// ❌ BAD function CustomButton({ children }) { return ( <Pressable> <Text>{children}</Text> {/* No styling context */} </Pressable> ); }

// ✅ CORRECT function CustomButton({ children, textClassName }) { return ( <TextClassContext.Provider value={textClassName}> <Pressable> <Text>{children}</Text> {/* Gets context styling */} </Pressable> </TextClassContext.Provider> ); }

File Organization

apps/mobile/ ├── app/ # Expo Router screens │ ├── (external)/ # Public routes │ ├── (internal)/ │ │ ├── (tabs)/ # Tab navigation │ │ ├── (standard)/ # Stack navigation │ │ └── record/ # Recording flow ├── components/ │ ├── ui/ # React Native Reusables │ ├── recording/ # Recording UI │ ├── activity/ # Activity components │ └── shared/ # Shared components ├── lib/ │ ├── hooks/ # Custom hooks │ │ └── useActivityRecorder.ts # 8 specialized hooks │ ├── stores/ # Zustand stores │ ├── services/ # Business logic │ └── providers/ # React Context providers └── assets/

Naming Conventions

  • Components: PascalCase → ActivityCard.tsx , RecordingFooter.tsx

  • Hooks: camelCase with use prefix → useActivityRecorder.ts , useFormMutation.ts

  • Stores: camelCase with Store suffix → authStore.ts , activitySelectionStore.ts

  • Utilities: camelCase → formatDuration.ts

  • Constants: SCREAMING_SNAKE_CASE → MAX_HEART_RATE = 220

Common Scenarios

Scenario 1: Creating a New Recording Screen Component

Approach:

  • Import service from provider

  • Use specific hooks for data needs

  • Style all Text elements

  • Handle loading/error states

  • Wrap with ErrorBoundary

Example:

import { useSharedActivityRecorder } from "@/lib/providers/ActivityRecorderProvider"; import { useRecordingState, useCurrentReadings, } from "@/lib/hooks/useActivityRecorder"; import { ErrorBoundary, ScreenErrorFallback } from "@/components/ErrorBoundary";

function RecordingMetrics() { const service = useSharedActivityRecorder(); const state = useRecordingState(service); const readings = useCurrentReadings(service);

if (!service) { return <Text className="text-muted-foreground">Loading...</Text>; }

return ( <View className="p-4 bg-card"> <Text className="text-foreground text-lg font-semibold"> {readings.heartRate ? ${readings.heartRate} bpm : "--"} </Text> </View> ); }

export default function RecordingMetricsWithErrorBoundary() { return ( <ErrorBoundary fallback={ScreenErrorFallback}> <RecordingMetrics /> </ErrorBoundary> ); }

Scenario 2: Form with Validation and Submission

Approach:

  • Use React Hook Form + Zod

  • Use useFormMutation for submission

  • Handle field errors automatically

  • Show loading state on button

Example:

import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { activitySchema } from "@repo/core/schemas"; import { useFormMutation } from "@/lib/hooks/useFormMutation"; import { trpc } from "@/lib/trpc";

function CreateActivityForm() { const form = useForm({ resolver: zodResolver(activitySchema), defaultValues: { name: "", type: "run", distance: 0 }, });

const mutation = useFormMutation({ mutationFn: async (data) => trpc.activities.create.mutate(data), form, invalidateQueries: [["activities"]], successMessage: "Activity created!", onSuccess: () => router.back(), });

return ( <View className="p-4"> <Input {...form.register("name")} placeholder="Activity name" className="bg-background" /> {form.formState.errors.name && ( <Text className="text-destructive text-sm"> {form.formState.errors.name.message} </Text> )}

  &#x3C;Button
    onPress={form.handleSubmit(mutation.mutate)}
    disabled={mutation.isLoading}
    className="mt-4"
  >
    &#x3C;Text className="text-primary-foreground">
      {mutation.isLoading ? "Creating..." : "Create"}
    &#x3C;/Text>
  &#x3C;/Button>
&#x3C;/View>

); }

Dependencies

Required:

  • expo v54+

  • expo-router v6+

  • react-native-reusables (shadcn-inspired components)

  • nativewind v4

  • lucide-react-native (icons)

  • @tanstack/react-query v5

  • zustand (state management)

Optional:

  • react-hook-form
  • @hookform/resolvers (complex forms)
  • expo-location (GPS tracking)

  • expo-sensors (device sensors)

Forbidden:

  • Never import from @repo/supabase directly (use tRPC)

  • Never import database clients in mobile app

Testing Requirements

  • Test component rendering with React Native Testing Library

  • Test hooks with renderHook from testing library

  • Mock services/stores for isolated testing

  • Test event handler callbacks with jest.fn()

  • Test navigation with mocked router

Checklist

Quick reference for mobile implementation:

  • Every Text component has className with color

  • Icons use <Icon as={Component} /> pattern

  • Service accessed via useSharedActivityRecorder

  • Event subscriptions cleaned up in useEffect

  • Complex navigation uses selection store

  • Platform-specific styles use ios:/android: prefixes

  • Forms use useFormMutation for submissions

  • List items memoized with custom comparison

  • Error boundaries wrap screens

  • Safe area insets handled for notches

Related Skills

  • Core Package Skill - Pure function patterns

  • Backend Skill - tRPC integration

  • Testing Skill - Mobile testing patterns

Version History

  • 1.0.0 (2026-01-21): Initial version based on codebase analysis

Next Review: 2026-02-21

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

image-gen

Generate AI images from text prompts. Triggers on: "生成图片", "画一张", "AI图", "generate image", "配图", "create picture", "draw", "visualize", "generate an image".

Archived SourceRecently Updated
General

explainer

Create explainer videos with narration and AI-generated visuals. Triggers on: "解说视频", "explainer video", "explain this as a video", "tutorial video", "introduce X (video)", "解释一下XX(视频形式)".

Archived SourceRecently Updated
General

asr

Transcribe audio files to text using local speech recognition. Triggers on: "转录", "transcribe", "语音转文字", "ASR", "识别音频", "把这段音频转成文字".

Archived SourceRecently Updated