mobile-frontend

📁 deancochran/gradientpeak 📅 10 days ago
1
总安装量
1
周安装量
#43451
全站排名
安装命令
npx skills add https://github.com/deancochran/gradientpeak --skill mobile-frontend

Agent 安装分布

antigravity 1

Skill 文档

Mobile Frontend Skill

Core Principles

  1. Every Text Must Be Styled – React Native doesn’t cascade styles; every Text component needs direct className
  2. Event-Driven Hooks – Subscribe to specific events only, not all events
  3. Shared Service Instances – Use ActivityRecorderProvider for single service instance across components
  4. Semantic Colors – Always use design tokens (text-foreground, bg-background, etc.)
  5. Consume-Once Navigation – Use activitySelectionStore for complex object navigation instead of URL params
  6. 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:

  1. Import service from provider
  2. Use specific hooks for data needs
  3. Style all Text elements
  4. Handle loading/error states
  5. 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:

  1. Use React Hook Form + Zod
  2. Use useFormMutation for submission
  3. Handle field errors automatically
  4. 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:

  • 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

Version History

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

Next Review: 2026-02-21