mobile-frontend
npx skills add https://github.com/deancochran/gradientpeak --skill mobile-frontend
Agent 安装分布
Skill 文档
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:andandroid: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:
camelCasewithuseprefix âuseActivityRecorder.ts,useFormMutation.ts - Stores:
camelCasewithStoresuffix â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>
)}
<Button
onPress={form.handleSubmit(mutation.mutate)}
disabled={mutation.isLoading}
className="mt-4"
>
<Text className="text-primary-foreground">
{mutation.isLoading ? "Creating..." : "Create"}
</Text>
</Button>
</View>
);
}
Dependencies
Required:
expov54+expo-routerv6+react-native-reusables(shadcn-inspired components)nativewindv4lucide-react-native(icons)@tanstack/react-queryv5zustand(state management)
Optional:
react-hook-form+@hookform/resolvers(complex forms)expo-location(GPS tracking)expo-sensors(device sensors)
Forbidden:
- Never import from
@repo/supabasedirectly (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