update config, admin-web tooling & relocate agent skills
Update app.json config and add new dependencies in package.json. Update .gitignore for new patterns. Add timed-exercise editor/list components, warmup/stretch video migration, and Supabase helpers in admin-web. Relocate agent skills from .agents/skills/ to .opencode/skills/.
This commit is contained in:
321
.opencode/skills/building-native-ui/SKILL.md
Normal file
321
.opencode/skills/building-native-ui/SKILL.md
Normal file
@@ -0,0 +1,321 @@
|
||||
---
|
||||
name: building-native-ui
|
||||
description: Complete guide for building beautiful apps with Expo Router. Covers fundamentals, styling, components, navigation, animations, patterns, and native tabs.
|
||||
version: 1.0.1
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Expo UI Guidelines
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
```
|
||||
references/
|
||||
animations.md Reanimated: entering, exiting, layout, scroll-driven, gestures
|
||||
controls.md Native iOS: Switch, Slider, SegmentedControl, DateTimePicker, Picker
|
||||
form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
|
||||
gradients.md CSS gradients via experimental_backgroundImage (New Arch only)
|
||||
icons.md SF Symbols via expo-image (sf: source), names, animations, weights
|
||||
media.md Camera, audio, video, and file saving
|
||||
route-structure.md Route conventions, dynamic routes, groups, folder organization
|
||||
search.md Search bar with headers, useSearch hook, filtering patterns
|
||||
storage.md SQLite, AsyncStorage, SecureStore
|
||||
tabs.md NativeTabs, migration from JS tabs, iOS 26 features
|
||||
toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
|
||||
visual-effects.md Blur (expo-blur) and liquid glass (expo-glass-effect)
|
||||
webgpu-three.md 3D graphics, games, GPU visualizations with WebGPU and Three.js
|
||||
zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)
|
||||
```
|
||||
|
||||
## Running the App
|
||||
|
||||
**CRITICAL: Always try Expo Go first before creating custom builds.**
|
||||
|
||||
Most Expo apps work in Expo Go without any custom native code. Before running `npx expo run:ios` or `npx expo run:android`:
|
||||
|
||||
1. **Start with Expo Go**: Run `npx expo start` and scan the QR code with Expo Go
|
||||
2. **Check if features work**: Test your app thoroughly in Expo Go
|
||||
3. **Only create custom builds when required** - see below
|
||||
|
||||
### When Custom Builds Are Required
|
||||
|
||||
You need `npx expo run:ios/android` or `eas build` ONLY when using:
|
||||
|
||||
- **Local Expo modules** (custom native code in `modules/`)
|
||||
- **Apple targets** (widgets, app clips, extensions via `@bacons/apple-targets`)
|
||||
- **Third-party native modules** not included in Expo Go
|
||||
- **Custom native configuration** that can't be expressed in `app.json`
|
||||
|
||||
### When Expo Go Works
|
||||
|
||||
Expo Go supports a huge range of features out of the box:
|
||||
|
||||
- All `expo-*` packages (camera, location, notifications, etc.)
|
||||
- Expo Router navigation
|
||||
- Most UI libraries (reanimated, gesture handler, etc.)
|
||||
- Push notifications, deep links, and more
|
||||
|
||||
**If you're unsure, try Expo Go first.** Creating custom builds adds complexity, slower iteration, and requires Xcode/Android Studio setup.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Be cautious of unterminated strings. Ensure nested backticks are escaped; never forget to escape quotes correctly.
|
||||
- Always use import statements at the top of the file.
|
||||
- Always use kebab-case for file names, e.g. `comment-card.tsx`
|
||||
- Always remove old route files when moving or restructuring navigation
|
||||
- Never use special characters in file names
|
||||
- Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
|
||||
|
||||
## Routes
|
||||
|
||||
See `./references/route-structure.md` for detailed route conventions.
|
||||
|
||||
- Routes belong in the `app` directory.
|
||||
- Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
|
||||
- Ensure the app always has a route that matches "/", it may be inside a group route.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- Never use modules removed from React Native such as Picker, WebView, SafeAreaView, or AsyncStorage
|
||||
- Never use legacy expo-permissions
|
||||
- `expo-audio` not `expo-av`
|
||||
- `expo-video` not `expo-av`
|
||||
- `expo-image` with `source="sf:name"` for SF Symbols, not `expo-symbols` or `@expo/vector-icons`
|
||||
- `react-native-safe-area-context` not react-native SafeAreaView
|
||||
- `process.env.EXPO_OS` not `Platform.OS`
|
||||
- `React.use` not `React.useContext`
|
||||
- `expo-image` Image component instead of intrinsic element `img`
|
||||
- `expo-glass-effect` for liquid glass backdrops
|
||||
|
||||
## Responsiveness
|
||||
|
||||
- Always wrap root component in a scroll view for responsiveness
|
||||
- Use `<ScrollView contentInsetAdjustmentBehavior="automatic" />` instead of `<SafeAreaView>` for smarter safe area insets
|
||||
- `contentInsetAdjustmentBehavior="automatic"` should be applied to FlatList and SectionList as well
|
||||
- Use flexbox instead of Dimensions API
|
||||
- ALWAYS prefer `useWindowDimensions` over `Dimensions.get()` to measure screen size
|
||||
|
||||
## Behavior
|
||||
|
||||
- Use expo-haptics conditionally on iOS to make more delightful experiences
|
||||
- Use views with built-in haptics like `<Switch />` from React Native and `@react-native-community/datetimepicker`
|
||||
- When a route belongs to a Stack, its first child should almost always be a ScrollView with `contentInsetAdjustmentBehavior="automatic"` set
|
||||
- When adding a `ScrollView` to the page it should almost always be the first component inside the route component
|
||||
- Prefer `headerSearchBarOptions` in Stack.Screen options to add a search bar
|
||||
- Use the `<Text selectable />` prop on text containing data that could be copied
|
||||
- Consider formatting large numbers like 1.4M or 38k
|
||||
- Never use intrinsic elements like 'img' or 'div' unless in a webview or Expo DOM component
|
||||
|
||||
# Styling
|
||||
|
||||
Follow Apple Human Interface Guidelines.
|
||||
|
||||
## General Styling Rules
|
||||
|
||||
- Prefer flex gap over margin and padding styles
|
||||
- Prefer padding over margin where possible
|
||||
- Always account for safe area, either with stack headers, tabs, or ScrollView/FlatList `contentInsetAdjustmentBehavior="automatic"`
|
||||
- Ensure both top and bottom safe area insets are accounted for
|
||||
- Inline styles not StyleSheet.create unless reusing styles is faster
|
||||
- Add entering and exiting animations for state changes
|
||||
- Use `{ borderCurve: 'continuous' }` for rounded corners unless creating a capsule shape
|
||||
- ALWAYS use a navigation stack title instead of a custom text element on the page
|
||||
- When padding a ScrollView, use `contentContainerStyle` padding and gap instead of padding on the ScrollView itself (reduces clipping)
|
||||
- CSS and Tailwind are not supported - use inline styles
|
||||
|
||||
## Text Styling
|
||||
|
||||
- Add the `selectable` prop to every `<Text/>` element displaying important data or error messages
|
||||
- Counters should use `{ fontVariant: 'tabular-nums' }` for alignment
|
||||
|
||||
## Shadows
|
||||
|
||||
Use CSS `boxShadow` style prop. NEVER use legacy React Native shadow or elevation styles.
|
||||
|
||||
```tsx
|
||||
<View style={{ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)" }} />
|
||||
```
|
||||
|
||||
'inset' shadows are supported.
|
||||
|
||||
# Navigation
|
||||
|
||||
## Link
|
||||
|
||||
Use `<Link href="/path" />` from 'expo-router' for navigation between routes.
|
||||
|
||||
```tsx
|
||||
import { Link } from 'expo-router';
|
||||
|
||||
// Basic link
|
||||
<Link href="/path" />
|
||||
|
||||
// Wrapping custom components
|
||||
<Link href="/path" asChild>
|
||||
<Pressable>...</Pressable>
|
||||
</Link>
|
||||
```
|
||||
|
||||
Whenever possible, include a `<Link.Preview>` to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
|
||||
|
||||
## Stack
|
||||
|
||||
- ALWAYS use `_layout.tsx` files to define stacks
|
||||
- Use Stack from 'expo-router/stack' for native navigation stacks
|
||||
|
||||
### Page Title
|
||||
|
||||
Set the page title in Stack.Screen options:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen options={{ title: "Home" }} />
|
||||
```
|
||||
|
||||
## Context Menus
|
||||
|
||||
Add long press context menus to Link components:
|
||||
|
||||
```tsx
|
||||
import { Link } from "expo-router";
|
||||
|
||||
<Link href="/settings" asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable>
|
||||
<Card />
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Menu>
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={handleSharePress}
|
||||
/>
|
||||
<Link.MenuAction
|
||||
title="Block"
|
||||
icon="nosign"
|
||||
destructive
|
||||
onPress={handleBlockPress}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => {}}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>;
|
||||
```
|
||||
|
||||
## Link Previews
|
||||
|
||||
Use link previews frequently to enhance navigation:
|
||||
|
||||
```tsx
|
||||
<Link href="/settings">
|
||||
<Link.Trigger>
|
||||
<Pressable>
|
||||
<Card />
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
```
|
||||
|
||||
Link preview can be used with context menus.
|
||||
|
||||
## Modal
|
||||
|
||||
Present a screen as a modal:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen name="modal" options={{ presentation: "modal" }} />
|
||||
```
|
||||
|
||||
Prefer this to building a custom modal component.
|
||||
|
||||
## Sheet
|
||||
|
||||
Present a screen as a dynamic form sheet:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5, 1.0],
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
- Using `contentStyle: { backgroundColor: "transparent" }` makes the background liquid glass on iOS 26+.
|
||||
|
||||
## Common route structure
|
||||
|
||||
A standard app layout with tabs and stacks inside each tab:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <NativeTabs />
|
||||
(index,search)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — Main list
|
||||
search.tsx — Search view
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||
import { Theme } from "../components/theme";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Theme>
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(index)">
|
||||
<Icon sf="list.dash" />
|
||||
<Label>Items</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search" />
|
||||
</NativeTabs>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create a shared group route so both tabs can push common screens:
|
||||
|
||||
```tsx
|
||||
// app/(index,search)/_layout.tsx
|
||||
import { Stack } from "expo-router/stack";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
export default function Layout({ segment }) {
|
||||
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||
const titles: Record<string, string> = { index: "Items", search: "Search" };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerLargeTitleShadowVisible: false,
|
||||
headerLargeStyle: { backgroundColor: "transparent" },
|
||||
headerTitleStyle: { color: PlatformColor("label") },
|
||||
headerLargeTitle: true,
|
||||
headerBlurEffect: "none",
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
|
||||
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
220
.opencode/skills/building-native-ui/references/animations.md
Normal file
220
.opencode/skills/building-native-ui/references/animations.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Animations
|
||||
|
||||
Use Reanimated v4. Avoid React Native's built-in Animated API.
|
||||
|
||||
## Entering and Exiting Animations
|
||||
|
||||
Use Animated.View with entering and exiting animations. Layout animations can animate state changes.
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LinearTransition,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn}
|
||||
exiting={FadeOut}
|
||||
layout={LinearTransition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## On-Scroll Animations
|
||||
|
||||
Create high-performance scroll animations using Reanimated's hooks:
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
useAnimatedRef,
|
||||
useScrollViewOffset,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function Page() {
|
||||
const ref = useAnimatedRef();
|
||||
const scroll = useScrollViewOffset(ref);
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.ScrollView ref={ref}>
|
||||
<Animated.View style={style} />
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Animation Presets
|
||||
|
||||
### Entering Animations
|
||||
|
||||
- `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight`
|
||||
- `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight`
|
||||
- `ZoomIn`, `ZoomInUp`, `ZoomInDown`
|
||||
- `BounceIn`, `BounceInUp`, `BounceInDown`
|
||||
|
||||
### Exiting Animations
|
||||
|
||||
- `FadeOut`, `FadeOutUp`, `FadeOutDown`, `FadeOutLeft`, `FadeOutRight`
|
||||
- `SlideOutUp`, `SlideOutDown`, `SlideOutLeft`, `SlideOutRight`
|
||||
- `ZoomOut`, `ZoomOutUp`, `ZoomOutDown`
|
||||
- `BounceOut`, `BounceOutUp`, `BounceOutDown`
|
||||
|
||||
### Layout Animations
|
||||
|
||||
- `LinearTransition` — Smooth linear interpolation
|
||||
- `SequencedTransition` — Sequenced property changes
|
||||
- `FadingTransition` — Fade between states
|
||||
|
||||
## Customizing Animations
|
||||
|
||||
```tsx
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(500).delay(200)}
|
||||
exiting={FadeOut.duration(300)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Modifiers
|
||||
|
||||
```tsx
|
||||
// Duration in milliseconds
|
||||
FadeIn.duration(300);
|
||||
|
||||
// Delay before starting
|
||||
FadeIn.delay(100);
|
||||
|
||||
// Spring physics
|
||||
FadeIn.springify();
|
||||
FadeIn.springify().damping(15).stiffness(100);
|
||||
|
||||
// Easing curves
|
||||
FadeIn.easing(Easing.bezier(0.25, 0.1, 0.25, 1));
|
||||
|
||||
// Chaining
|
||||
FadeInDown.duration(400).delay(200).springify();
|
||||
```
|
||||
|
||||
## Shared Value Animations
|
||||
|
||||
For imperative control over animations:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
const offset = useSharedValue(0);
|
||||
|
||||
// Spring animation
|
||||
offset.value = withSpring(100);
|
||||
|
||||
// Timing animation
|
||||
offset.value = withTiming(100, { duration: 300 });
|
||||
|
||||
// Use in styles
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: offset.value }],
|
||||
}));
|
||||
```
|
||||
|
||||
## Gesture Animations
|
||||
|
||||
Combine with React Native Gesture Handler:
|
||||
|
||||
```tsx
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function DraggableBox() {
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onUpdate((e) => {
|
||||
translateX.value = e.translationX;
|
||||
translateY.value = e.translationY;
|
||||
})
|
||||
.onEnd(() => {
|
||||
translateX.value = withSpring(0);
|
||||
translateY.value = withSpring(0);
|
||||
});
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value },
|
||||
{ translateY: translateY.value },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.box, style]} />
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Animations
|
||||
|
||||
Animate with keyboard height changes:
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
useAnimatedKeyboard,
|
||||
useAnimatedStyle,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
function KeyboardAwareView() {
|
||||
const keyboard = useAnimatedKeyboard();
|
||||
|
||||
const style = useAnimatedStyle(() => ({
|
||||
paddingBottom: keyboard.height.value,
|
||||
}));
|
||||
|
||||
return <Animated.View style={style}>{/* content */}</Animated.View>;
|
||||
}
|
||||
```
|
||||
|
||||
## Staggered List Animations
|
||||
|
||||
Animate list items with delays:
|
||||
|
||||
```tsx
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Animated.View
|
||||
key={item.id}
|
||||
entering={FadeInUp.delay(index * 50)}
|
||||
exiting={FadeOutUp}
|
||||
>
|
||||
<ListItem item={item} />
|
||||
</Animated.View>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Add entering and exiting animations for state changes
|
||||
- Use layout animations when items are added/removed from lists
|
||||
- Use `useAnimatedStyle` for scroll-driven animations
|
||||
- Prefer `interpolate` with "clamp" for bounded values
|
||||
- You can't pass PlatformColors to reanimated views or styles; use static colors instead
|
||||
- Keep animations under 300ms for responsive feel
|
||||
- Use spring animations for natural movement
|
||||
- Avoid animating layout properties (width, height) when possible — prefer transforms
|
||||
270
.opencode/skills/building-native-ui/references/controls.md
Normal file
270
.opencode/skills/building-native-ui/references/controls.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Native Controls
|
||||
|
||||
Native iOS controls provide built-in haptics, accessibility, and platform-appropriate styling.
|
||||
|
||||
## Switch
|
||||
|
||||
Use for binary on/off settings. Has built-in haptics.
|
||||
|
||||
```tsx
|
||||
import { Switch } from "react-native";
|
||||
import { useState } from "react";
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
<Switch value={enabled} onValueChange={setEnabled} />;
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
```tsx
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={setEnabled}
|
||||
trackColor={{ false: "#767577", true: "#81b0ff" }}
|
||||
thumbColor={enabled ? "#f5dd4b" : "#f4f3f4"}
|
||||
ios_backgroundColor="#3e3e3e"
|
||||
/>
|
||||
```
|
||||
|
||||
## Segmented Control
|
||||
|
||||
Use for non-navigational tabs or mode selection. Avoid changing default colors.
|
||||
|
||||
```tsx
|
||||
import SegmentedControl from "@react-native-segmented-control/segmented-control";
|
||||
import { useState } from "react";
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
<SegmentedControl
|
||||
values={["All", "Active", "Done"]}
|
||||
selectedIndex={index}
|
||||
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Maximum 4 options — use a picker for more
|
||||
- Keep labels short (1-2 words)
|
||||
- Avoid custom colors — native styling adapts to dark mode
|
||||
|
||||
### With Icons (iOS 14+)
|
||||
|
||||
```tsx
|
||||
<SegmentedControl
|
||||
values={[
|
||||
{ label: "List", icon: "list.bullet" },
|
||||
{ label: "Grid", icon: "square.grid.2x2" },
|
||||
]}
|
||||
selectedIndex={index}
|
||||
onChange={({ nativeEvent }) => setIndex(nativeEvent.selectedSegmentIndex)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Slider
|
||||
|
||||
Continuous value selection.
|
||||
|
||||
```tsx
|
||||
import Slider from "@react-native-community/slider";
|
||||
import { useState } from "react";
|
||||
|
||||
const [value, setValue] = useState(0.5);
|
||||
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={1}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
```tsx
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
step={1}
|
||||
minimumTrackTintColor="#007AFF"
|
||||
maximumTrackTintColor="#E5E5EA"
|
||||
thumbTintColor="#007AFF"
|
||||
/>
|
||||
```
|
||||
|
||||
### Discrete Steps
|
||||
|
||||
```tsx
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
minimumValue={0}
|
||||
maximumValue={10}
|
||||
step={1}
|
||||
/>
|
||||
```
|
||||
|
||||
## Date/Time Picker
|
||||
|
||||
Compact pickers with popovers. Has built-in haptics.
|
||||
|
||||
```tsx
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { useState } from "react";
|
||||
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
onChange={(event, selectedDate) => {
|
||||
if (selectedDate) setDate(selectedDate);
|
||||
}}
|
||||
mode="datetime"
|
||||
/>;
|
||||
```
|
||||
|
||||
### Modes
|
||||
|
||||
- `date` — Date only
|
||||
- `time` — Time only
|
||||
- `datetime` — Date and time
|
||||
|
||||
### Display Styles
|
||||
|
||||
```tsx
|
||||
// Compact inline (default)
|
||||
<DateTimePicker value={date} mode="date" />
|
||||
|
||||
// Spinner wheel
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="date"
|
||||
display="spinner"
|
||||
style={{ width: 200, height: 150 }}
|
||||
/>
|
||||
|
||||
// Full calendar
|
||||
<DateTimePicker value={date} mode="date" display="inline" />
|
||||
```
|
||||
|
||||
### Time Intervals
|
||||
|
||||
```tsx
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="time"
|
||||
minuteInterval={15}
|
||||
/>
|
||||
```
|
||||
|
||||
### Min/Max Dates
|
||||
|
||||
```tsx
|
||||
<DateTimePicker
|
||||
value={date}
|
||||
mode="date"
|
||||
minimumDate={new Date(2020, 0, 1)}
|
||||
maximumDate={new Date(2030, 11, 31)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Stepper
|
||||
|
||||
Increment/decrement numeric values.
|
||||
|
||||
```tsx
|
||||
import { Stepper } from "react-native";
|
||||
import { useState } from "react";
|
||||
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
<Stepper
|
||||
value={count}
|
||||
onValueChange={setCount}
|
||||
minimumValue={0}
|
||||
maximumValue={10}
|
||||
/>;
|
||||
```
|
||||
|
||||
## TextInput
|
||||
|
||||
Native text input with various keyboard types.
|
||||
|
||||
```tsx
|
||||
import { TextInput } from "react-native";
|
||||
|
||||
<TextInput
|
||||
placeholder="Enter text..."
|
||||
placeholderTextColor="#999"
|
||||
style={{
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Keyboard Types
|
||||
|
||||
```tsx
|
||||
// Email
|
||||
<TextInput keyboardType="email-address" autoCapitalize="none" />
|
||||
|
||||
// Phone
|
||||
<TextInput keyboardType="phone-pad" />
|
||||
|
||||
// Number
|
||||
<TextInput keyboardType="numeric" />
|
||||
|
||||
// Password
|
||||
<TextInput secureTextEntry />
|
||||
|
||||
// Search
|
||||
<TextInput
|
||||
returnKeyType="search"
|
||||
enablesReturnKeyAutomatically
|
||||
/>
|
||||
```
|
||||
|
||||
### Multiline
|
||||
|
||||
```tsx
|
||||
<TextInput
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Picker (Wheel)
|
||||
|
||||
For selection from many options (5+ items).
|
||||
|
||||
```tsx
|
||||
import { Picker } from "@react-native-picker/picker";
|
||||
import { useState } from "react";
|
||||
|
||||
const [selected, setSelected] = useState("js");
|
||||
|
||||
<Picker selectedValue={selected} onValueChange={setSelected}>
|
||||
<Picker.Item label="JavaScript" value="js" />
|
||||
<Picker.Item label="TypeScript" value="ts" />
|
||||
<Picker.Item label="Python" value="py" />
|
||||
<Picker.Item label="Go" value="go" />
|
||||
</Picker>;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Haptics**: Switch and DateTimePicker have built-in haptics — don't add extra
|
||||
- **Accessibility**: Native controls have proper accessibility labels by default
|
||||
- **Dark Mode**: Avoid custom colors — native styling adapts automatically
|
||||
- **Spacing**: Use consistent padding around controls (12-16pt)
|
||||
- **Labels**: Place labels above or to the left of controls
|
||||
- **Grouping**: Group related controls in sections with headers
|
||||
253
.opencode/skills/building-native-ui/references/form-sheet.md
Normal file
253
.opencode/skills/building-native-ui/references/form-sheet.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Form Sheets in Expo Router
|
||||
|
||||
This skill covers implementing form sheets with footers using Expo Router's Stack navigator and react-native-screens.
|
||||
|
||||
## Overview
|
||||
|
||||
Form sheets are modal presentations that appear as a card sliding up from the bottom of the screen. They're ideal for:
|
||||
|
||||
- Quick actions and confirmations
|
||||
- Settings panels
|
||||
- Login/signup flows
|
||||
- Action sheets with custom content
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Expo Router Stack navigator
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Form Sheet with Footer
|
||||
|
||||
Configure the Stack.Screen with transparent backgrounds and sheet presentation:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="about"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.25],
|
||||
headerTransparent: true,
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Header style={{ backgroundColor: "transparent" }}></Stack.Header>
|
||||
</Stack.Screen>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Form Sheet Screen Content
|
||||
|
||||
> Requires Expo SDK 55 or later.
|
||||
|
||||
Use `flex: 1` to allow the content to fill available space, enabling footer positioning:
|
||||
|
||||
```tsx
|
||||
// app/about.tsx
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
|
||||
export default function AboutSheet() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Main content */}
|
||||
<View style={styles.content}>
|
||||
<Text>Sheet Content</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer - stays at bottom */}
|
||||
<View style={styles.footer}>
|
||||
<Text>Footer Content</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Formsheet with interactive content below
|
||||
|
||||
Use `sheetLargestUndimmedDetentIndex` (zero-indexed) to keep content behind the form sheet interactive — e.g. letting users pan a map beneath it. Setting it to `1` allows interaction at the first two detents but dims on the third.
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="info-sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.2, 0.5, 1.0],
|
||||
sheetLargestUndimmedDetentIndex: 1,
|
||||
/* other options */
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Options
|
||||
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ----------------------------------------------------------- |
|
||||
| `presentation` | `string` | Set to `'formSheet'` for sheet presentation |
|
||||
| `sheetGrabberVisible` | `boolean` | Shows the drag handle at the top of the sheet |
|
||||
| `sheetAllowedDetents` | `number[]` | Array of detent heights (0-1 range, e.g., `[0.25]` for 25%) |
|
||||
| `headerTransparent` | `boolean` | Makes header background transparent |
|
||||
| `contentStyle` | `object` | Style object for the screen content container |
|
||||
| `title` | `string` | Screen title (set to `''` for no title) |
|
||||
|
||||
## Common Detent Values
|
||||
|
||||
- `[0.25]` - Quarter sheet (compact actions)
|
||||
- `[0.5]` - Half sheet (medium content)
|
||||
- `[0.75]` - Three-quarter sheet (detailed forms)
|
||||
- `[0.25, 0.5, 1]` - Multiple stops (expandable sheet)
|
||||
|
||||
## Complete Example
|
||||
|
||||
```tsx
|
||||
// _layout.tsx
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ title: "Home" }} />
|
||||
<Stack.Screen
|
||||
name="confirm"
|
||||
options={{
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
presentation: "formSheet",
|
||||
title: "",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.25],
|
||||
headerTransparent: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Header style={{ backgroundColor: "transparent" }}>
|
||||
<Stack.Header.Right />
|
||||
</Stack.Header>
|
||||
</Stack.Screen>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/confirm.tsx
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function ConfirmSheet() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Confirm Action</Text>
|
||||
<Text style={styles.description}>
|
||||
Are you sure you want to proceed?
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable style={styles.cancelButton} onPress={() => router.back()}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.confirmButton} onPress={() => router.back()}>
|
||||
<Text style={styles.confirmText}>Confirm</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#f0f0f0",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
confirmButton: {
|
||||
flex: 1,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#007AFF",
|
||||
alignItems: "center",
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "white",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Content not filling sheet
|
||||
|
||||
Make sure the root View uses `flex: 1`:
|
||||
|
||||
```tsx
|
||||
<View style={{ flex: 1 }}>{/* content */}</View>
|
||||
```
|
||||
|
||||
### Sheet background showing through
|
||||
|
||||
Set `contentStyle: { backgroundColor: 'transparent' }` in options and style your content container with the desired background color instead.
|
||||
106
.opencode/skills/building-native-ui/references/gradients.md
Normal file
106
.opencode/skills/building-native-ui/references/gradients.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# CSS Gradients
|
||||
|
||||
> **New Architecture Only**: CSS gradients require React Native's New Architecture (Fabric). They are not available in the old architecture or Expo Go.
|
||||
|
||||
Use CSS gradients with the `experimental_backgroundImage` style property.
|
||||
|
||||
## Linear Gradients
|
||||
|
||||
```tsx
|
||||
// Top to bottom
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)'
|
||||
}} />
|
||||
|
||||
// Left to right
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to right, #ff0000 0%, #0000ff 100%)'
|
||||
}} />
|
||||
|
||||
// Diagonal
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(45deg, #ff0000 0%, #00ff00 50%, #0000ff 100%)'
|
||||
}} />
|
||||
|
||||
// Using degrees
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(135deg, transparent 0%, black 100%)'
|
||||
}} />
|
||||
```
|
||||
|
||||
## Radial Gradients
|
||||
|
||||
```tsx
|
||||
// Circle at center
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(circle at center, rgba(255, 0, 0, 1) 0%, rgba(0, 0, 255, 1) 100%)'
|
||||
}} />
|
||||
|
||||
// Ellipse
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(ellipse at center, #fff 0%, #000 100%)'
|
||||
}} />
|
||||
|
||||
// Positioned
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'radial-gradient(circle at top left, #ff0000 0%, transparent 70%)'
|
||||
}} />
|
||||
```
|
||||
|
||||
## Multiple Gradients
|
||||
|
||||
Stack multiple gradients by comma-separating them:
|
||||
|
||||
```tsx
|
||||
<View style={{
|
||||
experimental_backgroundImage: `
|
||||
linear-gradient(to bottom, transparent 0%, black 100%),
|
||||
radial-gradient(circle at top right, rgba(255, 0, 0, 0.5) 0%, transparent 50%)
|
||||
`
|
||||
}} />
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Overlay on Image
|
||||
|
||||
```tsx
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
experimental_backgroundImage: 'linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 50%)'
|
||||
}} />
|
||||
</View>
|
||||
```
|
||||
|
||||
### Frosted Glass Effect
|
||||
|
||||
```tsx
|
||||
<View style={{
|
||||
experimental_backgroundImage: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
}} />
|
||||
```
|
||||
|
||||
### Button Gradient
|
||||
|
||||
```tsx
|
||||
<Pressable style={{
|
||||
experimental_backgroundImage: 'linear-gradient(to bottom, #4CAF50 0%, #388E3C 100%)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<Text style={{ color: 'white', textAlign: 'center' }}>Submit</Text>
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Do NOT use `expo-linear-gradient` — use CSS gradients instead
|
||||
- Gradients are strings, not objects
|
||||
- Use `rgba()` for transparency, or `transparent` keyword
|
||||
- Color stops use percentages (0%, 50%, 100%)
|
||||
- Direction keywords: `to top`, `to bottom`, `to left`, `to right`, `to top left`, etc.
|
||||
- Degree values: `45deg`, `90deg`, `135deg`, etc.
|
||||
213
.opencode/skills/building-native-ui/references/icons.md
Normal file
213
.opencode/skills/building-native-ui/references/icons.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Icons (SF Symbols)
|
||||
|
||||
Use SF Symbols for native feel. Never use FontAwesome or Ionicons.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
<SymbolView
|
||||
tintColor={PlatformColor("label")}
|
||||
resizeMode="scaleAspectFit"
|
||||
name="square.and.arrow.down"
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="star.fill" // SF Symbol name (required)
|
||||
tintColor={PlatformColor("label")} // Icon color
|
||||
size={24} // Shorthand for width/height
|
||||
resizeMode="scaleAspectFit" // How to scale
|
||||
weight="regular" // thin | ultraLight | light | regular | medium | semibold | bold | heavy | black
|
||||
scale="medium" // small | medium | large
|
||||
style={{ width: 16, height: 16 }} // Standard style props
|
||||
/>
|
||||
```
|
||||
|
||||
## Common Icons
|
||||
|
||||
### Navigation & Actions
|
||||
- `house.fill` - home
|
||||
- `gear` - settings
|
||||
- `magnifyingglass` - search
|
||||
- `plus` - add
|
||||
- `xmark` - close
|
||||
- `chevron.left` - back
|
||||
- `chevron.right` - forward
|
||||
- `arrow.left` - back arrow
|
||||
- `arrow.right` - forward arrow
|
||||
|
||||
### Media
|
||||
- `play.fill` - play
|
||||
- `pause.fill` - pause
|
||||
- `stop.fill` - stop
|
||||
- `backward.fill` - rewind
|
||||
- `forward.fill` - fast forward
|
||||
- `speaker.wave.2.fill` - volume
|
||||
- `speaker.slash.fill` - mute
|
||||
|
||||
### Camera
|
||||
- `camera` - camera
|
||||
- `camera.fill` - camera filled
|
||||
- `arrow.triangle.2.circlepath` - flip camera
|
||||
- `photo` - gallery/photos
|
||||
- `bolt` - flash
|
||||
- `bolt.slash` - flash off
|
||||
|
||||
### Communication
|
||||
- `message` - message
|
||||
- `message.fill` - message filled
|
||||
- `envelope` - email
|
||||
- `envelope.fill` - email filled
|
||||
- `phone` - phone
|
||||
- `phone.fill` - phone filled
|
||||
- `video` - video call
|
||||
- `video.fill` - video call filled
|
||||
|
||||
### Social
|
||||
- `heart` - like
|
||||
- `heart.fill` - liked
|
||||
- `star` - favorite
|
||||
- `star.fill` - favorited
|
||||
- `hand.thumbsup` - thumbs up
|
||||
- `hand.thumbsdown` - thumbs down
|
||||
- `person` - profile
|
||||
- `person.fill` - profile filled
|
||||
- `person.2` - people
|
||||
- `person.2.fill` - people filled
|
||||
|
||||
### Content Actions
|
||||
- `square.and.arrow.up` - share
|
||||
- `square.and.arrow.down` - download
|
||||
- `doc.on.doc` - copy
|
||||
- `trash` - delete
|
||||
- `pencil` - edit
|
||||
- `folder` - folder
|
||||
- `folder.fill` - folder filled
|
||||
- `bookmark` - bookmark
|
||||
- `bookmark.fill` - bookmarked
|
||||
|
||||
### Status & Feedback
|
||||
- `checkmark` - success/done
|
||||
- `checkmark.circle.fill` - completed
|
||||
- `xmark.circle.fill` - error/failed
|
||||
- `exclamationmark.triangle` - warning
|
||||
- `info.circle` - info
|
||||
- `questionmark.circle` - help
|
||||
- `bell` - notification
|
||||
- `bell.fill` - notification filled
|
||||
|
||||
### Misc
|
||||
- `ellipsis` - more options
|
||||
- `ellipsis.circle` - more in circle
|
||||
- `line.3.horizontal` - menu/hamburger
|
||||
- `slider.horizontal.3` - filters
|
||||
- `arrow.clockwise` - refresh
|
||||
- `location` - location
|
||||
- `location.fill` - location filled
|
||||
- `map` - map
|
||||
- `mappin` - pin
|
||||
- `clock` - time
|
||||
- `calendar` - calendar
|
||||
- `link` - link
|
||||
- `nosign` - block/prohibited
|
||||
|
||||
## Animated Symbols
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="checkmark.circle"
|
||||
animationSpec={{
|
||||
effect: {
|
||||
type: "bounce",
|
||||
direction: "up",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Animation Effects
|
||||
|
||||
- `bounce` - Bouncy animation
|
||||
- `pulse` - Pulsing effect
|
||||
- `variableColor` - Color cycling
|
||||
- `scale` - Scale animation
|
||||
|
||||
```tsx
|
||||
// Bounce with direction
|
||||
animationSpec={{
|
||||
effect: { type: "bounce", direction: "up" } // up | down
|
||||
}}
|
||||
|
||||
// Pulse
|
||||
animationSpec={{
|
||||
effect: { type: "pulse" }
|
||||
}}
|
||||
|
||||
// Variable color (multicolor symbols)
|
||||
animationSpec={{
|
||||
effect: {
|
||||
type: "variableColor",
|
||||
cumulative: true,
|
||||
reversing: true
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
## Symbol Weights
|
||||
|
||||
```tsx
|
||||
// Lighter weights
|
||||
<SymbolView name="star" weight="ultraLight" />
|
||||
<SymbolView name="star" weight="thin" />
|
||||
<SymbolView name="star" weight="light" />
|
||||
|
||||
// Default
|
||||
<SymbolView name="star" weight="regular" />
|
||||
|
||||
// Heavier weights
|
||||
<SymbolView name="star" weight="medium" />
|
||||
<SymbolView name="star" weight="semibold" />
|
||||
<SymbolView name="star" weight="bold" />
|
||||
<SymbolView name="star" weight="heavy" />
|
||||
<SymbolView name="star" weight="black" />
|
||||
```
|
||||
|
||||
## Symbol Scales
|
||||
|
||||
```tsx
|
||||
<SymbolView name="star" scale="small" />
|
||||
<SymbolView name="star" scale="medium" /> // default
|
||||
<SymbolView name="star" scale="large" />
|
||||
```
|
||||
|
||||
## Multicolor Symbols
|
||||
|
||||
Some symbols support multiple colors:
|
||||
|
||||
```tsx
|
||||
<SymbolView
|
||||
name="cloud.sun.rain.fill"
|
||||
type="multicolor"
|
||||
/>
|
||||
```
|
||||
|
||||
## Finding Symbol Names
|
||||
|
||||
1. Use the SF Symbols app on macOS (free from Apple)
|
||||
2. Search at https://developer.apple.com/sf-symbols/
|
||||
3. Symbol names use dot notation: `square.and.arrow.up`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use SF Symbols over vector icon libraries
|
||||
- Match symbol weight to nearby text weight
|
||||
- Use `.fill` variants for selected/active states
|
||||
- Use PlatformColor for tint to support dark mode
|
||||
- Keep icons at consistent sizes (16, 20, 24, 32)
|
||||
198
.opencode/skills/building-native-ui/references/media.md
Normal file
198
.opencode/skills/building-native-ui/references/media.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Media
|
||||
|
||||
## Camera
|
||||
|
||||
- Hide navigation headers when there's a full screen camera
|
||||
- Ensure to flip the camera with `mirror` to emulate social apps
|
||||
- Use liquid glass buttons on cameras
|
||||
- Icons: `arrow.triangle.2.circlepath` (flip), `photo` (gallery), `bolt` (flash)
|
||||
- Eagerly request camera permission
|
||||
- Lazily request media library permission
|
||||
|
||||
```tsx
|
||||
import React, { useRef, useState } from "react";
|
||||
import { View, TouchableOpacity, Text, Alert } from "react-native";
|
||||
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
|
||||
import * as MediaLibrary from "expo-media-library";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
function Camera({ onPicture }: { onPicture: (uri: string) => Promise<void> }) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [type, setType] = useState<CameraType>("back");
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
|
||||
if (!permission?.granted) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: PlatformColor("systemBackground") }}>
|
||||
<Text style={{ color: PlatformColor("label"), padding: 16 }}>Camera access is required</Text>
|
||||
<GlassView isInteractive tintColor={PlatformColor("systemBlue")} style={{ borderRadius: 12 }}>
|
||||
<TouchableOpacity onPress={requestPermission} style={{ padding: 12, borderRadius: 12 }}>
|
||||
<Text style={{ color: "white" }}>Grant Permission</Text>
|
||||
</TouchableOpacity>
|
||||
</GlassView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const takePhoto = async () => {
|
||||
await Haptics.selectionAsync();
|
||||
if (!cameraRef.current) return;
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8 });
|
||||
await onPicture(photo.uri);
|
||||
};
|
||||
|
||||
const selectPhoto = async () => {
|
||||
await Haptics.selectionAsync();
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: "images",
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
if (!result.canceled && result.assets?.[0]) {
|
||||
await onPicture(result.assets[0].uri);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<CameraView ref={cameraRef} mirror style={{ flex: 1 }} facing={type} />
|
||||
<View style={{ position: "absolute", left: 0, right: 0, bottom: bottom, gap: 16, alignItems: "center" }}>
|
||||
<GlassView isInteractive style={{ padding: 8, borderRadius: 99 }}>
|
||||
<TouchableOpacity onPress={takePhoto} style={{ width: 64, height: 64, borderRadius: 99, backgroundColor: "white" }} />
|
||||
</GlassView>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-around", paddingHorizontal: 8 }}>
|
||||
<GlassButton onPress={selectPhoto} icon="photo" />
|
||||
<GlassButton onPress={() => setType(t => t === "back" ? "front" : "back")} icon="arrow.triangle.2.circlepath" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Audio Playback
|
||||
|
||||
Use `expo-audio` not `expo-av`:
|
||||
|
||||
```tsx
|
||||
import { useAudioPlayer } from 'expo-audio';
|
||||
|
||||
const player = useAudioPlayer({ uri: 'https://stream.nightride.fm/rektory.mp3' });
|
||||
|
||||
<Button title="Play" onPress={() => player.play()} />
|
||||
```
|
||||
|
||||
## Audio Recording (Microphone)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useAudioRecorder,
|
||||
AudioModule,
|
||||
RecordingPresets,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorderState,
|
||||
} from 'expo-audio';
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Button } from 'react-native';
|
||||
|
||||
function App() {
|
||||
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const recorderState = useAudioRecorderState(audioRecorder);
|
||||
|
||||
const record = async () => {
|
||||
await audioRecorder.prepareToRecordAsync();
|
||||
audioRecorder.record();
|
||||
};
|
||||
|
||||
const stop = () => audioRecorder.stop();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const status = await AudioModule.requestRecordingPermissionsAsync();
|
||||
if (status.granted) {
|
||||
setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true });
|
||||
} else {
|
||||
Alert.alert('Permission to access microphone was denied');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={recorderState.isRecording ? 'Stop' : 'Start'}
|
||||
onPress={recorderState.isRecording ? stop : record}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Video Playback
|
||||
|
||||
Use `expo-video` not `expo-av`:
|
||||
|
||||
```tsx
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import { useEvent } from 'expo';
|
||||
|
||||
const videoSource = 'https://example.com/video.mp4';
|
||||
|
||||
const player = useVideoPlayer(videoSource, player => {
|
||||
player.loop = true;
|
||||
player.play();
|
||||
});
|
||||
|
||||
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||
|
||||
<VideoView player={player} fullscreenOptions={{}} allowsPictureInPicture />
|
||||
```
|
||||
|
||||
VideoView options:
|
||||
- `allowsPictureInPicture`: boolean
|
||||
- `contentFit`: 'contain' | 'cover' | 'fill'
|
||||
- `nativeControls`: boolean
|
||||
- `playsInline`: boolean
|
||||
- `startsPictureInPictureAutomatically`: boolean
|
||||
|
||||
## Saving Media
|
||||
|
||||
```tsx
|
||||
import * as MediaLibrary from "expo-media-library";
|
||||
|
||||
const { granted } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (granted) {
|
||||
await MediaLibrary.saveToLibraryAsync(uri);
|
||||
}
|
||||
```
|
||||
|
||||
### Saving Base64 Images
|
||||
|
||||
`MediaLibrary.saveToLibraryAsync` only accepts local file paths. Save base64 strings to disk first:
|
||||
|
||||
```tsx
|
||||
import { File, Paths } from "expo-file-system/next";
|
||||
|
||||
function base64ToLocalUri(base64: string, filename?: string) {
|
||||
if (!filename) {
|
||||
const match = base64.match(/^data:(image\/[a-zA-Z]+);base64,/);
|
||||
const ext = match ? match[1].split("/")[1] : "jpg";
|
||||
filename = `generated-${Date.now()}.${ext}`;
|
||||
}
|
||||
|
||||
if (base64.startsWith("data:")) base64 = base64.split(",")[1];
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(new ArrayBuffer(len));
|
||||
for (let i = 0; i < len; i++) bytes[i] = binaryString.charCodeAt(i);
|
||||
|
||||
const f = new File(Paths.cache, filename);
|
||||
f.create({ overwrite: true });
|
||||
f.write(bytes);
|
||||
return f.uri;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
# Route Structure
|
||||
|
||||
## File Conventions
|
||||
|
||||
- Routes belong in the `app` directory
|
||||
- Use `[]` for dynamic routes, e.g. `[id].tsx`
|
||||
- Routes can never be named `(foo).tsx` - use `(foo)/index.tsx` instead
|
||||
- Use `(group)` routes to simplify the public URL structure
|
||||
- NEVER co-locate components, types, or utilities in the app directory - these should be in separate directories like `components/`, `utils/`, etc.
|
||||
- The app directory should only contain route and `_layout` files; every file should export a default component
|
||||
- Ensure the app always has a route that matches "/" so the app is never blank
|
||||
- ALWAYS use `_layout.tsx` files to define stacks
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
Use square brackets for dynamic segments:
|
||||
|
||||
```
|
||||
app/
|
||||
users/
|
||||
[id].tsx # Matches /users/123, /users/abc
|
||||
[id]/
|
||||
posts.tsx # Matches /users/123/posts
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
Use `[...slug]` for catch-all routes:
|
||||
|
||||
```
|
||||
app/
|
||||
docs/
|
||||
[...slug].tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
Access query parameters with the `useLocalSearchParams` hook:
|
||||
|
||||
```tsx
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
function Page() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
}
|
||||
```
|
||||
|
||||
For dynamic routes, the parameter name matches the file name:
|
||||
|
||||
- `[id].tsx` → `useLocalSearchParams<{ id: string }>()`
|
||||
- `[slug].tsx` → `useLocalSearchParams<{ slug: string }>()`
|
||||
|
||||
## Pathname
|
||||
|
||||
Access the current pathname with the `usePathname` hook:
|
||||
|
||||
```tsx
|
||||
import { usePathname } from "expo-router";
|
||||
|
||||
function Component() {
|
||||
const pathname = usePathname(); // e.g. "/users/123"
|
||||
}
|
||||
```
|
||||
|
||||
## Group Routes
|
||||
|
||||
Use parentheses for groups that don't affect the URL:
|
||||
|
||||
```
|
||||
app/
|
||||
(auth)/
|
||||
login.tsx # URL: /login
|
||||
register.tsx # URL: /register
|
||||
(main)/
|
||||
index.tsx # URL: /
|
||||
settings.tsx # URL: /settings
|
||||
```
|
||||
|
||||
Groups are useful for:
|
||||
|
||||
- Organizing related routes
|
||||
- Applying different layouts to route groups
|
||||
- Keeping URLs clean
|
||||
|
||||
## Stacks and Tabs Structure
|
||||
|
||||
When an app has tabs, the header and title should be set in a Stack that is nested INSIDE each tab. This allows tabs to have their own headers and distinct histories. The root layout should often not have a header.
|
||||
|
||||
- Set the 'headerShown' option to false on the tab layout
|
||||
- Use (group) routes to simplify the public URL structure
|
||||
- You may need to delete or refactor existing routes to fit this structure
|
||||
|
||||
Example structure:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <Tabs />
|
||||
(home)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
(settings)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
(home,settings)/
|
||||
info.tsx — <ScrollView /> (shared across tabs)
|
||||
```
|
||||
|
||||
## Array Routes for Multiple Stacks
|
||||
|
||||
Use array routes '(index,settings)' to create multiple stacks. This is useful for tabs that need to share screens across stacks.
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <Tabs />
|
||||
(index,settings)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — <ScrollView />
|
||||
settings.tsx — <ScrollView />
|
||||
```
|
||||
|
||||
This requires a specialized layout with explicit anchor routes:
|
||||
|
||||
```tsx
|
||||
// app/(index,settings)/_layout.tsx
|
||||
import { useMemo } from "react";
|
||||
import Stack from "expo-router/stack";
|
||||
|
||||
export const unstable_settings = {
|
||||
index: { anchor: "index" },
|
||||
settings: { anchor: "settings" },
|
||||
};
|
||||
|
||||
export default function Layout({ segment }: { segment: string }) {
|
||||
const screen = segment.match(/\((.*)\)/)?.[1]!;
|
||||
|
||||
const options = useMemo(() => {
|
||||
switch (screen) {
|
||||
case "index":
|
||||
return { headerRight: () => <></> };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [screen]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name={screen} options={options} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Complete App Structure Example
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx — <NativeTabs />
|
||||
(index,search)/
|
||||
_layout.tsx — <Stack />
|
||||
index.tsx — Main list
|
||||
search.tsx — Search view
|
||||
i/[id].tsx — Detail page
|
||||
components/
|
||||
theme.tsx
|
||||
list.tsx
|
||||
utils/
|
||||
storage.ts
|
||||
use-search.ts
|
||||
```
|
||||
|
||||
## Layout Files
|
||||
|
||||
Every directory can have a `_layout.tsx` file that wraps all routes in that directory:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from "expo-router/stack";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Label>Home</Label>
|
||||
<Icon sf="house.fill" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Route Settings
|
||||
|
||||
Export `unstable_settings` to configure route behavior:
|
||||
|
||||
```tsx
|
||||
export const unstable_settings = {
|
||||
anchor: "index",
|
||||
};
|
||||
```
|
||||
|
||||
- `initialRouteName` was renamed to `anchor` in v4
|
||||
|
||||
## Not Found Routes
|
||||
|
||||
Create a `+not-found.tsx` file to handle unmatched routes:
|
||||
|
||||
```tsx
|
||||
// app/+not-found.tsx
|
||||
import { Link } from "expo-router";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Page not found</Text>
|
||||
<Link href="/">Go home</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
248
.opencode/skills/building-native-ui/references/search.md
Normal file
248
.opencode/skills/building-native-ui/references/search.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Search
|
||||
|
||||
## Header Search Bar
|
||||
|
||||
Add a search bar to the stack header with `headerSearchBarOptions`:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
onChangeText: (event) => console.log(event.nativeEvent.text),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```tsx
|
||||
headerSearchBarOptions: {
|
||||
// Placeholder text
|
||||
placeholder: "Search items...",
|
||||
|
||||
// Auto-capitalize behavior
|
||||
autoCapitalize: "none",
|
||||
|
||||
// Input type
|
||||
inputType: "text", // "text" | "phone" | "number" | "email"
|
||||
|
||||
// Cancel button text (iOS)
|
||||
cancelButtonText: "Cancel",
|
||||
|
||||
// Hide when scrolling (iOS)
|
||||
hideWhenScrolling: true,
|
||||
|
||||
// Hide navigation bar during search (iOS)
|
||||
hideNavigationBar: true,
|
||||
|
||||
// Obscure background during search (iOS)
|
||||
obscureBackground: true,
|
||||
|
||||
// Placement
|
||||
placement: "automatic", // "automatic" | "inline" | "stacked"
|
||||
|
||||
// Callbacks
|
||||
onChangeText: (event) => {},
|
||||
onSearchButtonPress: (event) => {},
|
||||
onCancelButtonPress: (event) => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
}
|
||||
```
|
||||
|
||||
## useSearch Hook
|
||||
|
||||
Reusable hook for search state management:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigation } from "expo-router";
|
||||
|
||||
export function useSearch(options: any = {}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: true,
|
||||
headerSearchBarOptions: {
|
||||
...options,
|
||||
onChangeText(e: any) {
|
||||
setSearch(e.nativeEvent.text);
|
||||
options.onChangeText?.(e);
|
||||
},
|
||||
onSearchButtonPress(e: any) {
|
||||
setSearch(e.nativeEvent.text);
|
||||
options.onSearchButtonPress?.(e);
|
||||
},
|
||||
onCancelButtonPress(e: any) {
|
||||
setSearch("");
|
||||
options.onCancelButtonPress?.(e);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [options, navigation]);
|
||||
|
||||
return search;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
function SearchScreen() {
|
||||
const search = useSearch({ placeholder: "Search items..." });
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={filteredItems}
|
||||
renderItem={({ item }) => <ItemRow item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Filtering Patterns
|
||||
|
||||
### Simple Text Filter
|
||||
|
||||
```tsx
|
||||
const filtered = items.filter(item =>
|
||||
item.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
```
|
||||
|
||||
### Multiple Fields
|
||||
|
||||
```tsx
|
||||
const filtered = items.filter(item => {
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Debounced Search
|
||||
|
||||
For expensive filtering or API calls:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function SearchScreen() {
|
||||
const search = useSearch();
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const filteredItems = useMemo(() =>
|
||||
items.filter(item =>
|
||||
item.name.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
),
|
||||
[debouncedSearch]
|
||||
);
|
||||
|
||||
return <FlatList data={filteredItems} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Search with Native Tabs
|
||||
|
||||
When using NativeTabs with a search role, the search bar integrates with the tab bar:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(home)">
|
||||
<Label>Home</Label>
|
||||
<Icon sf="house.fill" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<Label>Search</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/(search)/_layout.tsx
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e) => setSearch(e.nativeEvent.text),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
```
|
||||
|
||||
## Empty States
|
||||
|
||||
Show appropriate UI when search returns no results:
|
||||
|
||||
```tsx
|
||||
function SearchResults({ search, items }) {
|
||||
const filtered = items.filter(/* ... */);
|
||||
|
||||
if (search && filtered.length === 0) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel") }}>
|
||||
No results for "{search}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <FlatList data={filtered} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Search Suggestions
|
||||
|
||||
Show recent searches or suggestions:
|
||||
|
||||
```tsx
|
||||
function SearchScreen() {
|
||||
const search = useSearch();
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
||||
if (!search && recentSearches.length > 0) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel") }}>
|
||||
Recent Searches
|
||||
</Text>
|
||||
{recentSearches.map((term) => (
|
||||
<Pressable key={term} onPress={() => /* apply search */}>
|
||||
<Text>{term}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SearchResults search={search} />;
|
||||
}
|
||||
```
|
||||
121
.opencode/skills/building-native-ui/references/storage.md
Normal file
121
.opencode/skills/building-native-ui/references/storage.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Storage
|
||||
|
||||
## Key-Value Storage
|
||||
|
||||
Use the localStorage polyfill for key-value storage. **Never use AsyncStorage**
|
||||
|
||||
```tsx
|
||||
import "expo-sqlite/localStorage/install";
|
||||
|
||||
// Simple get/set
|
||||
localStorage.setItem("key", "value");
|
||||
localStorage.getItem("key");
|
||||
|
||||
// Store objects as JSON
|
||||
localStorage.setItem("user", JSON.stringify({ name: "John", id: 1 }));
|
||||
const user = JSON.parse(localStorage.getItem("user") ?? "{}");
|
||||
```
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Use Case | Solution |
|
||||
| ---------------------------------------------------- | ----------------------- |
|
||||
| Simple key-value (settings, preferences, small data) | `localStorage` polyfill |
|
||||
| Large datasets, complex queries, relational data | Full `expo-sqlite` |
|
||||
| Sensitive data (tokens, passwords) | `expo-secure-store` |
|
||||
|
||||
## Storage with React State
|
||||
|
||||
Create a storage utility with subscriptions for reactive updates:
|
||||
|
||||
```tsx
|
||||
// utils/storage.ts
|
||||
import "expo-sqlite/localStorage/install";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string, defaultValue: T): T {
|
||||
const value = localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : defaultValue;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
listeners.get(key)?.forEach((fn) => fn());
|
||||
},
|
||||
|
||||
subscribe(key: string, listener: Listener): () => void {
|
||||
if (!listeners.has(key)) listeners.set(key, new Set());
|
||||
listeners.get(key)!.add(listener);
|
||||
return () => listeners.get(key)?.delete(listener);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## React Hook for Storage
|
||||
|
||||
```tsx
|
||||
// hooks/use-storage.ts
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { storage } from "@/utils/storage";
|
||||
|
||||
export function useStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] {
|
||||
const value = useSyncExternalStore(
|
||||
(cb) => storage.subscribe(key, cb),
|
||||
() => storage.get(key, defaultValue)
|
||||
);
|
||||
|
||||
return [value, (newValue: T) => storage.set(key, newValue)];
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```tsx
|
||||
function Settings() {
|
||||
const [theme, setTheme] = useStorage("theme", "light");
|
||||
|
||||
return (
|
||||
<Switch
|
||||
value={theme === "dark"}
|
||||
onValueChange={(dark) => setTheme(dark ? "dark" : "light")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Full SQLite for Complex Data
|
||||
|
||||
For larger datasets or complex queries, use expo-sqlite directly:
|
||||
|
||||
```tsx
|
||||
import * as SQLite from "expo-sqlite";
|
||||
|
||||
const db = await SQLite.openDatabaseAsync("app.db");
|
||||
|
||||
// Create table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
location TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert
|
||||
await db.runAsync("INSERT INTO events (title, date) VALUES (?, ?)", [
|
||||
"Meeting",
|
||||
"2024-01-15",
|
||||
]);
|
||||
|
||||
// Query
|
||||
const events = await db.getAllAsync("SELECT * FROM events WHERE date > ?", [
|
||||
"2024-01-01",
|
||||
]);
|
||||
```
|
||||
433
.opencode/skills/building-native-ui/references/tabs.md
Normal file
433
.opencode/skills/building-native-ui/references/tabs.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# Native Tabs
|
||||
|
||||
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
|
||||
|
||||
**SDK 54+. SDK 55 recommended.**
|
||||
|
||||
## SDK Compatibility
|
||||
|
||||
| Aspect | SDK 54 | SDK 55+ |
|
||||
| ------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Import | `import { NativeTabs, Icon, Label, Badge, VectorIcon }` | `import { NativeTabs }` only |
|
||||
| Icon | `<Icon sf="house.fill" />` | `<NativeTabs.Trigger.Icon sf="house.fill" />` |
|
||||
| Label | `<Label>Home</Label>` | `<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>` |
|
||||
| Badge | `<Badge>9+</Badge>` | `<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>` |
|
||||
| Android icons | `drawable` prop | `md` prop (Material Symbols) |
|
||||
|
||||
All examples below use SDK 55 syntax. For SDK 54, replace `NativeTabs.Trigger.Icon/Label/Badge` with standalone `Icon`, `Label`, `Badge` imports.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- You must include a trigger for each tab
|
||||
- The `NativeTabs.Trigger` 'name' must match the route name, including parentheses (e.g. `<NativeTabs.Trigger name="(search)">`)
|
||||
- Prefer search tab to be last in the list so it can combine with the search bar
|
||||
- Use the 'role' prop for common tab types
|
||||
- Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)
|
||||
|
||||
## Platform Features
|
||||
|
||||
Native Tabs use platform-specific tab bar implementations:
|
||||
|
||||
- **iOS 26+**: Liquid glass effects with system-native appearance
|
||||
- **Android**: Material 3 bottom navigation
|
||||
- Better performance and native feel
|
||||
|
||||
## Icon Component
|
||||
|
||||
```tsx
|
||||
// SF Symbol (iOS) + Material Symbol (Android)
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
|
||||
// State variants
|
||||
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />
|
||||
|
||||
// Custom image
|
||||
<NativeTabs.Trigger.Icon src={require('./icon.png')} />
|
||||
|
||||
// Xcode asset catalog — iOS only (SDK 55+)
|
||||
<NativeTabs.Trigger.Icon xcasset="home-icon" />
|
||||
<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />
|
||||
|
||||
// Rendering mode — iOS only (SDK 55+)
|
||||
<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />
|
||||
<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />
|
||||
```
|
||||
|
||||
`renderingMode`: `"template"` applies tint color (single-color icons), `"original"` preserves source colors (gradients). Android always uses original.
|
||||
|
||||
## Label & Badge
|
||||
|
||||
```tsx
|
||||
// Label
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}
|
||||
|
||||
// Badge
|
||||
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
|
||||
<NativeTabs.Trigger.Badge /> {/* dot indicator */}
|
||||
```
|
||||
|
||||
## iOS 26 Features
|
||||
|
||||
### Liquid Glass Tab Bar
|
||||
|
||||
The tab bar automatically adopts liquid glass appearance on iOS 26+.
|
||||
|
||||
### Minimize on Scroll
|
||||
|
||||
```tsx
|
||||
<NativeTabs minimizeBehavior="onScrollDown">
|
||||
```
|
||||
|
||||
### Search Tab
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="(search)" role="search">
|
||||
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Note**: Place search tab last for best UX.
|
||||
|
||||
### Role Prop
|
||||
|
||||
Use semantic roles for special tab types:
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="search" role="search" />
|
||||
<NativeTabs.Trigger name="favorites" role="favorites" />
|
||||
<NativeTabs.Trigger name="more" role="more" />
|
||||
```
|
||||
|
||||
Available roles: `search` | `more` | `favorites` | `bookmarks` | `contacts` | `downloads` | `featured` | `history` | `mostRecent` | `mostViewed` | `recents` | `topRated`
|
||||
|
||||
## Customization
|
||||
|
||||
### Tint Color
|
||||
|
||||
```tsx
|
||||
<NativeTabs tintColor="#007AFF">
|
||||
```
|
||||
|
||||
### Dynamic Colors (iOS)
|
||||
|
||||
Use DynamicColorIOS for colors that adapt to liquid glass:
|
||||
|
||||
```tsx
|
||||
import { DynamicColorIOS, Platform } from 'react-native';
|
||||
|
||||
const adaptiveBlue = Platform.select({
|
||||
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
|
||||
default: '#007AFF',
|
||||
});
|
||||
|
||||
<NativeTabs tintColor={adaptiveBlue}>
|
||||
```
|
||||
|
||||
## Conditional Tabs
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
|
||||
<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.**
|
||||
|
||||
**Note**: Hidden tabs cannot be navigated to!
|
||||
|
||||
## Behavior Options
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger
|
||||
name="home"
|
||||
disablePopToTop // Don't pop stack when tapping active tab
|
||||
disableScrollToTop // Don't scroll to top when tapping active tab
|
||||
disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)
|
||||
>
|
||||
```
|
||||
|
||||
## Hidden Tab Bar (SDK 55+)
|
||||
|
||||
Use `hidden` prop on `NativeTabs` to hide the entire tab bar dynamically:
|
||||
|
||||
```tsx
|
||||
<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>
|
||||
```
|
||||
|
||||
## Bottom Accessory (SDK 55+)
|
||||
|
||||
`NativeTabs.BottomAccessory` renders content above the tab bar (iOS 26+). Uses `usePlacement()` to adapt between `'regular'` and `'inline'` layouts.
|
||||
|
||||
**Important**: Two instances render simultaneously — store state outside the component (props, context, or external store).
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function MiniPlayer({
|
||||
isPlaying,
|
||||
onToggle,
|
||||
}: {
|
||||
isPlaying: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const placement = NativeTabs.BottomAccessory.usePlacement();
|
||||
if (placement === "inline") {
|
||||
return (
|
||||
<Pressable onPress={onToggle}>
|
||||
<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return <View>{/* full player UI */}</View>;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.BottomAccessory>
|
||||
<MiniPlayer
|
||||
isPlaying={isPlaying}
|
||||
onToggle={() => setIsPlaying(!isPlaying)}
|
||||
/>
|
||||
</NativeTabs.BottomAccessory>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Safe Area Handling (SDK 55+)
|
||||
|
||||
SDK 55 handles safe areas automatically:
|
||||
|
||||
- **Android**: Content wrapped in SafeAreaView (bottom inset)
|
||||
- **iOS**: First ScrollView gets automatic `contentInsetAdjustmentBehavior`
|
||||
|
||||
To opt out per-tab, use `disableAutomaticContentInsets` and manage manually:
|
||||
|
||||
```tsx
|
||||
<NativeTabs.Trigger name="index" disableAutomaticContentInsets>
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
```tsx
|
||||
// In the screen
|
||||
import { SafeAreaView } from "react-native-screens/experimental";
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
|
||||
{/* content */}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Vector Icons
|
||||
|
||||
If you must use @expo/vector-icons instead of SF Symbols:
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
|
||||
<NativeTabs.Trigger name="home">
|
||||
<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
</NativeTabs.Trigger>
|
||||
```
|
||||
|
||||
**Prefer SF Symbols + `md` prop over vector icons for native feel.**
|
||||
|
||||
If you are using SDK 55 and later **use the md prop to specify Material Symbols used on Android**.
|
||||
|
||||
## Structure with Stacks
|
||||
|
||||
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
|
||||
|
||||
```tsx
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="(home)">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
// app/(tabs)/(home)/_layout.tsx
|
||||
import Stack from "expo-router/stack";
|
||||
|
||||
export default function HomeStack() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{ title: "Home", headerLargeTitle: true }}
|
||||
/>
|
||||
<Stack.Screen name="details" options={{ title: "Details" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Web Layout
|
||||
|
||||
Use platform-specific files for separate native and web tab layouts:
|
||||
|
||||
```
|
||||
app/
|
||||
_layout.tsx # NativeTabs for iOS/Android
|
||||
_layout.web.tsx # Headless tabs for web (expo-router/ui)
|
||||
```
|
||||
|
||||
Or extract to a component: `components/app-tabs.tsx` + `components/app-tabs.web.tsx`.
|
||||
|
||||
## Migration from JS Tabs
|
||||
|
||||
### Before (JS Tabs)
|
||||
|
||||
```tsx
|
||||
import { Tabs } from "expo-router";
|
||||
|
||||
<Tabs>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,
|
||||
tabBarBadge: 3,
|
||||
}}
|
||||
/>
|
||||
</Tabs>;
|
||||
```
|
||||
|
||||
### After (Native Tabs)
|
||||
|
||||
```tsx
|
||||
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>;
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| JS Tabs | Native Tabs |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `<Tabs.Screen>` | `<NativeTabs.Trigger>` |
|
||||
| `options={{ title }}` | `<NativeTabs.Trigger.Label>` |
|
||||
| `options={{ tabBarIcon }}` | `<NativeTabs.Trigger.Icon>` |
|
||||
| `tabBarBadge` option | `<NativeTabs.Trigger.Badge>` |
|
||||
| Props-based API | Component-based API |
|
||||
| Headers built-in | Nest `<Stack>` for headers |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Android**: Maximum 5 tabs (Material Design constraint)
|
||||
- **Nesting**: Native tabs cannot nest inside other native tabs
|
||||
- **Tab bar height**: Cannot be measured programmatically
|
||||
- **FlatList transparency**: Use `disableTransparentOnScrollEdge` to fix issues
|
||||
- **Dynamic tabs**: Tabs must be static; changes remount navigator and lose state
|
||||
|
||||
## Keyboard Handling (Android)
|
||||
|
||||
Configure in app.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
1. **Icons not showing on Android**: Add `md` prop (SDK 55) or use VectorIcon
|
||||
2. **Headers missing**: Nest a Stack inside each tab group
|
||||
3. **Trigger name mismatch**: `name` must match exact route name including parentheses
|
||||
4. **Badge not visible**: Badge must be a child of Trigger, not a prop
|
||||
5. **Tab bar transparent on iOS 18 and earlier**: If the screen uses a `ScrollView` or `FlatList`, make sure it is the first opaque child of the screen component. If it needs to be wrapped in another `View`, ensure the wrapper uses `collapsable={false}`. If the screen does not use a `ScrollView` or `FlatList`, set `disableTransparentOnScrollEdge` to `true` in the `NativeTabs.Trigger` options, to make the tab bar opaque.
|
||||
6. **Scroll to top not working**: Ensure `disableScrollToTop` is not set on the active tab's Trigger and `ScrollView` is the first child of the screen component.
|
||||
7. **Header buttons flicker when navigating between tabs**: Make sure the app is wrapped in a `ThemeProvider`
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ThemeProvider,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
} from "@react-navigation/native";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
const colorScheme = useColorScheme();
|
||||
return (
|
||||
<ThemeProvider theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If the app only uses a light or dark theme, you can directly pass `DarkTheme` or `DefaultTheme` to `ThemeProvider` without checking the color scheme.
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider, DarkTheme } from "@react-navigation/native";
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<ThemeProvider theme={DarkTheme}>
|
||||
<Stack />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# Toolbars and headers
|
||||
|
||||
Add native iOS toolbar items to Stack screens. Items can be placed in the header (left/right) or in a bottom toolbar area.
|
||||
|
||||
**Important:** iOS only. Available in Expo SDK 55+.
|
||||
|
||||
## Notes app example
|
||||
|
||||
```tsx
|
||||
import { Stack } from "expo-router";
|
||||
import { ScrollView } from "react-native";
|
||||
|
||||
export default function FoldersScreen() {
|
||||
return (
|
||||
<>
|
||||
{/* ScrollView must be the first child of the screen */}
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{/* Screen content */}
|
||||
</ScrollView>
|
||||
<Stack.Screen.Title large>Folders</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
{/* Header toolbar - right side */}
|
||||
<Stack.Toolbar placement="right">
|
||||
<Stack.Toolbar.Button icon="folder.badge.plus" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Edit</Stack.Toolbar.Button>
|
||||
</Stack.Toolbar>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<Stack.Toolbar placement="bottom">
|
||||
<Stack.Toolbar.SearchBarSlot />
|
||||
<Stack.Toolbar.Button
|
||||
icon="square.and.pencil"
|
||||
onPress={() => {}}
|
||||
separateBackground
|
||||
/>
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Mail inbox example
|
||||
|
||||
```tsx
|
||||
import { Color, Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
|
||||
export default function InboxScreen() {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
>
|
||||
{/* Screen content */}
|
||||
</ScrollView>
|
||||
<Stack.Screen options={{ headerTransparent: true }} />
|
||||
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
{/* Header toolbar - right side */}
|
||||
<Stack.Toolbar placement="right">
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Select</Stack.Toolbar.Button>
|
||||
<Stack.Toolbar.Menu icon="ellipsis">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.Menu inline title="Sort By">
|
||||
<Stack.Toolbar.MenuAction isOn>
|
||||
Categories
|
||||
</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction>List</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="info.circle">
|
||||
About categories
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="person.circle">
|
||||
Show Contact Photos
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
</Stack.Toolbar>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<Stack.Toolbar placement="bottom">
|
||||
<Stack.Toolbar.Button
|
||||
icon="line.3.horizontal.decrease"
|
||||
selected={isFilterOpen}
|
||||
onPress={() => setIsFilterOpen((prev) => !prev)}
|
||||
/>
|
||||
<Stack.Toolbar.View hidden={!isFilterOpen}>
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: Color.ios.systemBlue,
|
||||
}}
|
||||
>
|
||||
Unread
|
||||
</Text>
|
||||
</View>
|
||||
</Stack.Toolbar.View>
|
||||
<Stack.Toolbar.Spacer />
|
||||
<Stack.Toolbar.SearchBarSlot />
|
||||
<Stack.Toolbar.Button
|
||||
icon="square.and.pencil"
|
||||
onPress={() => {}}
|
||||
separateBackground
|
||||
/>
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Placement
|
||||
|
||||
- `"left"` - Header left
|
||||
- `"right"` - Header right
|
||||
- `"bottom"` (default) - Bottom toolbar
|
||||
|
||||
## Components
|
||||
|
||||
### Button
|
||||
|
||||
- Icon button: `<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />`
|
||||
- Text button: `<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>`
|
||||
|
||||
**Props:** `icon`, `image`, `onPress`, `disabled`, `hidden`, `variant` (`"plain"` | `"done"` | `"prominent"`), `tintColor`
|
||||
|
||||
### Menu
|
||||
|
||||
Dropdown menu for grouping actions.
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.Menu icon="ellipsis">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.MenuAction>Sort by Recently Added</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction isOn>
|
||||
Sort by Date Captured
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.Menu title="Filter">
|
||||
<Stack.Toolbar.Menu inline>
|
||||
<Stack.Toolbar.MenuAction isOn icon="square.grid.2x2">
|
||||
All Items
|
||||
</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
<Stack.Toolbar.MenuAction icon="heart">Favorites</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction icon="photo">Photos</Stack.Toolbar.MenuAction>
|
||||
<Stack.Toolbar.MenuAction icon="video">Videos</Stack.Toolbar.MenuAction>
|
||||
</Stack.Toolbar.Menu>
|
||||
</Stack.Toolbar.Menu>
|
||||
```
|
||||
|
||||
**Menu Props:** All Button props plus `title`, `inline`, `palette`, `elementSize` (`"small"` | `"medium"` | `"large"`)
|
||||
|
||||
**MenuAction Props:** `icon`, `onPress`, `isOn`, `destructive`, `disabled`, `subtitle`
|
||||
|
||||
When creating a palette with dividers, use `inline` combined with `elementSize="small"`. `palette` will not apply dividers on iOS 26.
|
||||
|
||||
### Spacer
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.Spacer /> // Bottom toolbar - flexible
|
||||
<Stack.Toolbar.Spacer width={16} /> // Header - requires explicit width
|
||||
```
|
||||
|
||||
### View
|
||||
|
||||
Embed custom React Native components. When adding a custom view make sure that there is only a single child with **explicit width and height**.
|
||||
|
||||
```tsx
|
||||
<Stack.Toolbar.View>
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
</View>
|
||||
</Stack.Toolbar.View>
|
||||
```
|
||||
|
||||
You can pass custom components to views as well:
|
||||
|
||||
```tsx
|
||||
function CustomFilterView() {
|
||||
return (
|
||||
<View style={{ width: 70, height: 32, justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: 700 }}>Filter by</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
...
|
||||
<Stack.Toolbar.View>
|
||||
<CustomFilterView />
|
||||
</Stack.Toolbar.View>
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
- When creating more complex headers, extract them to a single component
|
||||
|
||||
```tsx
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<InboxHeader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxHeader() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen.Title>Inbox</Stack.Screen.Title>
|
||||
<Stack.SearchBar placeholder="Search" onChangeText={() => {}} />
|
||||
<Stack.Toolbar placement="right">{/* Toolbar buttons */}</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- When using `Stack.Toolbar`, make sure that all `Stack.Toolbar.*` components are wrapped inside `Stack.Toolbar` component.
|
||||
|
||||
This will **not work**:
|
||||
|
||||
```tsx
|
||||
function Buttons() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<Stack.Toolbar placement="right">
|
||||
<Buttons /> {/* ❌ This will NOT work */}
|
||||
</Stack.Toolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This will work:
|
||||
|
||||
```tsx
|
||||
function ToolbarWithButtons() {
|
||||
return (
|
||||
<Stack.Toolbar>
|
||||
<Stack.Toolbar.Button icon="star.fill" onPress={() => {}} />
|
||||
<Stack.Toolbar.Button onPress={() => {}}>Done</Stack.Toolbar.Button>
|
||||
</Stack.Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function Page() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView>{/* Screen content */}</ScrollView>
|
||||
<ToolbarWithButtons /> {/* ✅ This will work */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- iOS only
|
||||
- `placement="bottom"` can only be used inside screen components (not in layout files)
|
||||
- `Stack.Toolbar.Badge` only works with `placement="left"` or `"right"`
|
||||
- Header Spacers require explicit `width`
|
||||
|
||||
## Reference
|
||||
|
||||
Docs https://docs.expo.dev/versions/unversioned/sdk/router - read to see the full API.
|
||||
197
.opencode/skills/building-native-ui/references/visual-effects.md
Normal file
197
.opencode/skills/building-native-ui/references/visual-effects.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Visual Effects
|
||||
|
||||
## Backdrop Blur
|
||||
|
||||
Use `expo-blur` for blur effects. Prefer systemMaterial tints as they adapt to dark mode.
|
||||
|
||||
```tsx
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
<BlurView tint="systemMaterial" intensity={100} />;
|
||||
```
|
||||
|
||||
### Tint Options
|
||||
|
||||
```tsx
|
||||
// System materials (adapt to dark mode)
|
||||
<BlurView tint="systemMaterial" />
|
||||
<BlurView tint="systemThinMaterial" />
|
||||
<BlurView tint="systemUltraThinMaterial" />
|
||||
<BlurView tint="systemThickMaterial" />
|
||||
<BlurView tint="systemChromeMaterial" />
|
||||
|
||||
// Basic tints
|
||||
<BlurView tint="light" />
|
||||
<BlurView tint="dark" />
|
||||
<BlurView tint="default" />
|
||||
|
||||
// Prominent (more visible)
|
||||
<BlurView tint="prominent" />
|
||||
|
||||
// Extra light/dark
|
||||
<BlurView tint="extraLight" />
|
||||
```
|
||||
|
||||
### Intensity
|
||||
|
||||
Control blur strength with `intensity` (0-100):
|
||||
|
||||
```tsx
|
||||
<BlurView tint="systemMaterial" intensity={50} /> // Subtle
|
||||
<BlurView tint="systemMaterial" intensity={100} /> // Full
|
||||
```
|
||||
|
||||
### Rounded Corners
|
||||
|
||||
BlurView requires `overflow: 'hidden'` to clip rounded corners:
|
||||
|
||||
```tsx
|
||||
<BlurView
|
||||
tint="systemMaterial"
|
||||
intensity={100}
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Overlay Pattern
|
||||
|
||||
Common pattern for overlaying blur on content:
|
||||
|
||||
```tsx
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image source={{ uri: '...' }} style={{ width: '100%', height: 200 }} />
|
||||
<BlurView
|
||||
tint="systemUltraThinMaterial"
|
||||
intensity={80}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: 'white' }}>Caption</Text>
|
||||
</BlurView>
|
||||
</View>
|
||||
```
|
||||
|
||||
## Glass Effects (iOS 26+)
|
||||
|
||||
Use `expo-glass-effect` for liquid glass backdrops on iOS 26+.
|
||||
|
||||
```tsx
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
|
||||
<GlassView style={{ borderRadius: 16, padding: 16 }}>
|
||||
<Text>Content inside glass</Text>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Interactive Glass
|
||||
|
||||
Add `isInteractive` for buttons and pressable glass:
|
||||
|
||||
```tsx
|
||||
import { GlassView } from "expo-glass-effect";
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { PlatformColor } from "react-native";
|
||||
|
||||
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||
<Pressable style={{ padding: 12 }} onPress={handlePress}>
|
||||
<SymbolView name="plus" tintColor={PlatformColor("label")} size={36} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Glass Buttons
|
||||
|
||||
Create liquid glass buttons:
|
||||
|
||||
```tsx
|
||||
function GlassButton({ icon, onPress }) {
|
||||
return (
|
||||
<GlassView isInteractive style={{ borderRadius: 50 }}>
|
||||
<Pressable style={{ padding: 12 }} onPress={onPress}>
|
||||
<SymbolView name={icon} tintColor={PlatformColor("label")} size={24} />
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<GlassButton icon="plus" onPress={handleAdd} />
|
||||
<GlassButton icon="gear" onPress={handleSettings} />
|
||||
```
|
||||
|
||||
### Glass Card
|
||||
|
||||
```tsx
|
||||
<GlassView style={{ borderRadius: 20, padding: 20 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '600', color: PlatformColor("label") }}>
|
||||
Card Title
|
||||
</Text>
|
||||
<Text style={{ color: PlatformColor("secondaryLabel"), marginTop: 8 }}>
|
||||
Card content goes here
|
||||
</Text>
|
||||
</GlassView>
|
||||
```
|
||||
|
||||
### Checking Availability
|
||||
|
||||
```tsx
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
|
||||
if (isLiquidGlassAvailable()) {
|
||||
// Use GlassView
|
||||
} else {
|
||||
// Fallback to BlurView or solid background
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback Pattern
|
||||
|
||||
```tsx
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
function AdaptiveGlass({ children, style }) {
|
||||
if (isLiquidGlassAvailable()) {
|
||||
return <GlassView style={style}>{children}</GlassView>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BlurView tint="systemMaterial" intensity={80} style={style}>
|
||||
{children}
|
||||
</BlurView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sheet with Glass Background
|
||||
|
||||
Make sheet backgrounds liquid glass on iOS 26+:
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name="sheet"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5, 1.0],
|
||||
contentStyle: { backgroundColor: "transparent" },
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use `systemMaterial` tints for automatic dark mode support
|
||||
- Always set `overflow: 'hidden'` on BlurView for rounded corners
|
||||
- Use `isInteractive` on GlassView for buttons and pressables
|
||||
- Check `isLiquidGlassAvailable()` and provide fallbacks
|
||||
- Avoid nesting blur views (performance impact)
|
||||
- Keep blur intensity reasonable (50-100) for readability
|
||||
605
.opencode/skills/building-native-ui/references/webgpu-three.md
Normal file
605
.opencode/skills/building-native-ui/references/webgpu-three.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# WebGPU & Three.js for Expo
|
||||
|
||||
**Use this skill for ANY 3D graphics, games, GPU compute, or Three.js features in React Native.**
|
||||
|
||||
## Locked Versions (Tested & Working)
|
||||
|
||||
```json
|
||||
{
|
||||
"react-native-wgpu": "^0.4.1",
|
||||
"three": "0.172.0",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"wgpu-matrix": "^3.0.2",
|
||||
"@types/three": "0.172.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** These versions are tested together. Mismatched versions cause type errors and runtime issues.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install react-native-wgpu@^0.4.1 three@0.172.0 @react-three/fiber@^9.4.0 wgpu-matrix@^3.0.2 @types/three@0.172.0 --legacy-peer-deps
|
||||
```
|
||||
|
||||
**Note:** `--legacy-peer-deps` may be required due to peer dependency conflicts with canary Expo versions.
|
||||
|
||||
## Metro Configuration
|
||||
|
||||
Create `metro.config.js` in project root:
|
||||
|
||||
```js
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// Force 'three' to webgpu build
|
||||
if (moduleName.startsWith("three")) {
|
||||
moduleName = "three/webgpu";
|
||||
}
|
||||
|
||||
// Use standard react-three/fiber instead of React Native version
|
||||
if (platform !== "web" && moduleName.startsWith("@react-three/fiber")) {
|
||||
return context.resolveRequest(
|
||||
{
|
||||
...context,
|
||||
unstable_conditionNames: ["module"],
|
||||
mainFields: ["module"],
|
||||
},
|
||||
moduleName,
|
||||
platform
|
||||
);
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
```
|
||||
|
||||
## Required Lib Files
|
||||
|
||||
Create these files in `src/lib/`:
|
||||
|
||||
### 1. make-webgpu-renderer.ts
|
||||
|
||||
```ts
|
||||
import type { NativeCanvas } from "react-native-wgpu";
|
||||
import * as THREE from "three/webgpu";
|
||||
|
||||
export class ReactNativeCanvas {
|
||||
constructor(private canvas: NativeCanvas) {}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
set width(width: number) {
|
||||
this.canvas.width = width;
|
||||
}
|
||||
set height(height: number) {
|
||||
this.canvas.height = height;
|
||||
}
|
||||
get clientWidth() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
get clientHeight() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
set clientWidth(width: number) {
|
||||
this.canvas.width = width;
|
||||
}
|
||||
set clientHeight(height: number) {
|
||||
this.canvas.height = height;
|
||||
}
|
||||
|
||||
addEventListener(_type: string, _listener: EventListener) {}
|
||||
removeEventListener(_type: string, _listener: EventListener) {}
|
||||
dispatchEvent(_event: Event) {}
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() {}
|
||||
}
|
||||
|
||||
export const makeWebGPURenderer = (
|
||||
context: GPUCanvasContext,
|
||||
{ antialias = true }: { antialias?: boolean } = {}
|
||||
) =>
|
||||
new THREE.WebGPURenderer({
|
||||
antialias,
|
||||
// @ts-expect-error
|
||||
canvas: new ReactNativeCanvas(context.canvas),
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. fiber-canvas.tsx
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { ReconcilerRoot, RootState } from "@react-three/fiber";
|
||||
import {
|
||||
extend,
|
||||
createRoot,
|
||||
unmountComponentAtNode,
|
||||
events,
|
||||
} from "@react-three/fiber";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { PixelRatio } from "react-native";
|
||||
import { Canvas, type CanvasRef } from "react-native-wgpu";
|
||||
|
||||
import {
|
||||
makeWebGPURenderer,
|
||||
ReactNativeCanvas,
|
||||
} from "@/lib/make-webgpu-renderer";
|
||||
|
||||
// Extend THREE namespace for R3F - add all components you use
|
||||
extend({
|
||||
AmbientLight: THREE.AmbientLight,
|
||||
DirectionalLight: THREE.DirectionalLight,
|
||||
PointLight: THREE.PointLight,
|
||||
SpotLight: THREE.SpotLight,
|
||||
Mesh: THREE.Mesh,
|
||||
Group: THREE.Group,
|
||||
Points: THREE.Points,
|
||||
BoxGeometry: THREE.BoxGeometry,
|
||||
SphereGeometry: THREE.SphereGeometry,
|
||||
CylinderGeometry: THREE.CylinderGeometry,
|
||||
ConeGeometry: THREE.ConeGeometry,
|
||||
DodecahedronGeometry: THREE.DodecahedronGeometry,
|
||||
BufferGeometry: THREE.BufferGeometry,
|
||||
BufferAttribute: THREE.BufferAttribute,
|
||||
MeshStandardMaterial: THREE.MeshStandardMaterial,
|
||||
MeshBasicMaterial: THREE.MeshBasicMaterial,
|
||||
PointsMaterial: THREE.PointsMaterial,
|
||||
PerspectiveCamera: THREE.PerspectiveCamera,
|
||||
Scene: THREE.Scene,
|
||||
});
|
||||
|
||||
interface FiberCanvasProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewProps["style"];
|
||||
camera?: THREE.PerspectiveCamera;
|
||||
scene?: THREE.Scene;
|
||||
}
|
||||
|
||||
export const FiberCanvas = ({
|
||||
children,
|
||||
style,
|
||||
scene,
|
||||
camera,
|
||||
}: FiberCanvasProps) => {
|
||||
const root = useRef<ReconcilerRoot<OffscreenCanvas>>(null!);
|
||||
const canvasRef = useRef<CanvasRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const context = canvasRef.current!.getContext("webgpu")!;
|
||||
const renderer = makeWebGPURenderer(context);
|
||||
|
||||
// @ts-expect-error - ReactNativeCanvas wraps native canvas
|
||||
const canvas = new ReactNativeCanvas(context.canvas) as HTMLCanvasElement;
|
||||
canvas.width = canvas.clientWidth * PixelRatio.get();
|
||||
canvas.height = canvas.clientHeight * PixelRatio.get();
|
||||
const size = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: canvas.clientWidth,
|
||||
height: canvas.clientHeight,
|
||||
};
|
||||
|
||||
if (!root.current) {
|
||||
root.current = createRoot(canvas);
|
||||
}
|
||||
root.current.configure({
|
||||
size,
|
||||
events,
|
||||
scene,
|
||||
camera,
|
||||
gl: renderer,
|
||||
frameloop: "always",
|
||||
dpr: 1,
|
||||
onCreated: async (state: RootState) => {
|
||||
// @ts-expect-error - WebGPU renderer has init method
|
||||
await state.gl.init();
|
||||
const renderFrame = state.gl.render.bind(state.gl);
|
||||
state.gl.render = (s: THREE.Scene, c: THREE.Camera) => {
|
||||
renderFrame(s, c);
|
||||
context?.present();
|
||||
};
|
||||
},
|
||||
});
|
||||
root.current.render(children);
|
||||
return () => {
|
||||
if (canvas != null) {
|
||||
unmountComponentAtNode(canvas!);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return <Canvas ref={canvasRef} style={style} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Basic 3D Scene
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { View } from "react-native";
|
||||
import { useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
|
||||
function RotatingBox() {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
ref.current.rotation.x += delta;
|
||||
ref.current.rotation.y += delta * 0.5;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="hotpink" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function Scene() {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(0, 2, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<RotatingBox />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<Scene />
|
||||
</FiberCanvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading (Recommended)
|
||||
|
||||
Use React.lazy to code-split Three.js for better loading:
|
||||
|
||||
```tsx
|
||||
import React, { Suspense } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
|
||||
const Scene = React.lazy(() => import("@/components/scene"));
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<Scene />
|
||||
</Suspense>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Geometries
|
||||
|
||||
```tsx
|
||||
// Box
|
||||
<mesh>
|
||||
<boxGeometry args={[width, height, depth]} />
|
||||
<meshStandardMaterial color="red" />
|
||||
</mesh>
|
||||
|
||||
// Sphere
|
||||
<mesh>
|
||||
<sphereGeometry args={[radius, widthSegments, heightSegments]} />
|
||||
<meshStandardMaterial color="blue" />
|
||||
</mesh>
|
||||
|
||||
// Cylinder
|
||||
<mesh>
|
||||
<cylinderGeometry args={[radiusTop, radiusBottom, height, segments]} />
|
||||
<meshStandardMaterial color="green" />
|
||||
</mesh>
|
||||
|
||||
// Cone
|
||||
<mesh>
|
||||
<coneGeometry args={[radius, height, segments]} />
|
||||
<meshStandardMaterial color="yellow" />
|
||||
</mesh>
|
||||
```
|
||||
|
||||
## Lighting
|
||||
|
||||
```tsx
|
||||
// Ambient (uniform light everywhere)
|
||||
<ambientLight intensity={0.5} />
|
||||
|
||||
// Directional (sun-like)
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
|
||||
// Point (light bulb)
|
||||
<pointLight position={[0, 5, 0]} intensity={2} distance={10} />
|
||||
|
||||
// Spot (flashlight)
|
||||
<spotLight position={[0, 10, 0]} angle={0.3} penumbra={1} intensity={2} />
|
||||
```
|
||||
|
||||
## Animation with useFrame
|
||||
|
||||
```tsx
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three/webgpu";
|
||||
|
||||
function AnimatedMesh() {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
// Runs every frame - delta is time since last frame
|
||||
useFrame((state, delta) => {
|
||||
// Rotate
|
||||
ref.current.rotation.y += delta;
|
||||
|
||||
// Oscillate position
|
||||
ref.current.position.y = Math.sin(state.clock.elapsedTime) * 2;
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<boxGeometry />
|
||||
<meshStandardMaterial color="orange" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Particle Systems
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
|
||||
function Particles({ count = 500 }) {
|
||||
const ref = useRef<THREE.Points>(null!);
|
||||
const positions = useRef<Float32Array>(new Float32Array(count * 3));
|
||||
|
||||
useEffect(() => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.current[i * 3] = (Math.random() - 0.5) * 50;
|
||||
positions.current[i * 3 + 1] = (Math.random() - 0.5) * 50;
|
||||
positions.current[i * 3 + 2] = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
}, [count]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Animate particles
|
||||
for (let i = 0; i < count; i++) {
|
||||
positions.current[i * 3 + 1] -= delta * 2;
|
||||
if (positions.current[i * 3 + 1] < -25) {
|
||||
positions.current[i * 3 + 1] = 25;
|
||||
}
|
||||
}
|
||||
ref.current.geometry.attributes.position.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<points ref={ref}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions.current, 3]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial color="#ffffff" size={0.2} sizeAttenuation />
|
||||
</points>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Controls (Orbit)
|
||||
|
||||
See the full `orbit-controls.tsx` implementation in the lib files. Usage:
|
||||
|
||||
```tsx
|
||||
import { View } from "react-native";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
import useControls from "@/lib/orbit-controls";
|
||||
|
||||
function Scene() {
|
||||
const [OrbitControls, events] = useControls();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }} {...events}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<OrbitControls />
|
||||
{/* Your 3D content */}
|
||||
</FiberCanvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### 1. "X is not part of the THREE namespace"
|
||||
|
||||
**Problem:** Error like `AmbientLight is not part of the THREE namespace`
|
||||
|
||||
**Solution:** Add the missing component to the `extend()` call in fiber-canvas.tsx:
|
||||
|
||||
```tsx
|
||||
extend({
|
||||
AmbientLight: THREE.AmbientLight,
|
||||
// Add other missing components...
|
||||
});
|
||||
```
|
||||
|
||||
### 2. TypeScript Errors with Three.js
|
||||
|
||||
**Problem:** Type mismatches between three.js and R3F
|
||||
|
||||
**Solution:** Use `@ts-expect-error` comments where needed:
|
||||
|
||||
```tsx
|
||||
// @ts-expect-error - WebGPU renderer types don't match
|
||||
await state.gl.init();
|
||||
```
|
||||
|
||||
### 3. Blank Screen
|
||||
|
||||
**Problem:** Canvas renders but nothing visible
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Ensure camera is positioned correctly and looking at scene
|
||||
2. Add lighting (objects are black without light)
|
||||
3. Check that `extend()` includes all components used
|
||||
|
||||
### 4. Performance Issues
|
||||
|
||||
**Problem:** Low frame rate or stuttering
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Reduce polygon count in geometries
|
||||
- Use `useMemo` for static data
|
||||
- Limit particle count
|
||||
- Use `instancedMesh` for many identical objects
|
||||
|
||||
### 5. Peer Dependency Errors
|
||||
|
||||
**Problem:** npm install fails with ERESOLVE
|
||||
|
||||
**Solution:** Use `--legacy-peer-deps`:
|
||||
|
||||
```bash
|
||||
npm install <packages> --legacy-peer-deps
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
WebGPU requires a custom build:
|
||||
|
||||
```bash
|
||||
npx expo prebuild
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
**Note:** WebGPU does NOT work in Expo Go.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ └── index.tsx # Entry point with lazy loading
|
||||
├── components/
|
||||
│ ├── scene.tsx # Main 3D scene
|
||||
│ └── game.tsx # Game logic
|
||||
└── lib/
|
||||
├── fiber-canvas.tsx # R3F canvas wrapper
|
||||
├── make-webgpu-renderer.ts # WebGPU renderer
|
||||
└── orbit-controls.tsx # Touch controls
|
||||
```
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need 3D graphics?
|
||||
├── Simple shapes → mesh + geometry + material
|
||||
├── Animated objects → useFrame + refs
|
||||
├── Many objects → instancedMesh
|
||||
├── Particles → Points + BufferGeometry
|
||||
│
|
||||
Need interaction?
|
||||
├── Orbit camera → useControls hook
|
||||
├── Touch objects → onClick on mesh
|
||||
├── Gestures → react-native-gesture-handler
|
||||
│
|
||||
Performance critical?
|
||||
├── Static geometry → useMemo
|
||||
├── Many instances → InstancedMesh
|
||||
└── Complex scenes → LOD (Level of Detail)
|
||||
```
|
||||
|
||||
## Example: Complete Game Scene
|
||||
|
||||
```tsx
|
||||
import * as THREE from "three/webgpu";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { FiberCanvas } from "@/lib/fiber-canvas";
|
||||
|
||||
function Player({ position }: { position: THREE.Vector3 }) {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
|
||||
useFrame(() => {
|
||||
ref.current.position.copy(position);
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={ref}>
|
||||
<coneGeometry args={[0.5, 1, 8]} />
|
||||
<meshStandardMaterial color="#00ffff" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function GameScene({ playerX }: { playerX: number }) {
|
||||
const { camera } = useThree();
|
||||
const playerPos = useRef(new THREE.Vector3(0, 0, 0));
|
||||
|
||||
playerPos.current.x = playerX;
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(0, 10, 15);
|
||||
camera.lookAt(0, 0, 0);
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 10, 5]} />
|
||||
<Player position={playerPos.current} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Game() {
|
||||
const [playerX, setPlayerX] = useState(0);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||
<FiberCanvas style={{ flex: 1 }}>
|
||||
<GameScene playerX={playerX} />
|
||||
</FiberCanvas>
|
||||
|
||||
<View style={{ position: "absolute", bottom: 40, flexDirection: "row" }}>
|
||||
<Pressable onPress={() => setPlayerX((x) => x - 1)}>
|
||||
<Text style={{ color: "#fff", fontSize: 32 }}>◀</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setPlayerX((x) => x + 1)}>
|
||||
<Text style={{ color: "#fff", fontSize: 32 }}>▶</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
# Apple Zoom Transitions
|
||||
|
||||
Fluid zoom transitions for navigating between screens. iOS 18+, Expo SDK 55+, Stack navigator only.
|
||||
|
||||
```tsx
|
||||
import { Link } from "expo-router";
|
||||
```
|
||||
|
||||
## Basic Zoom
|
||||
|
||||
Use `withAppleZoom` on `Link.Trigger` to zoom the entire trigger element into the destination screen:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<Pressable>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 120, height: 120, borderRadius: 12 }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Targeted Zoom with `Link.AppleZoom`
|
||||
|
||||
Wrap only the element that should animate. Siblings outside `Link.AppleZoom` are not part of the transition:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger>
|
||||
<Pressable style={{ alignItems: "center" }}>
|
||||
<Link.AppleZoom>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 200, aspectRatio: 4 / 3 }}
|
||||
/>
|
||||
</Link.AppleZoom>
|
||||
<Text>Caption text (not zoomed)</Text>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
</Link>
|
||||
```
|
||||
|
||||
`Link.AppleZoom` accepts only a single child element.
|
||||
|
||||
## Destination Target
|
||||
|
||||
Use `Link.AppleZoomTarget` on the destination screen to align the zoom animation to a specific element:
|
||||
|
||||
```tsx
|
||||
// Destination screen (e.g., app/photo.tsx)
|
||||
import { Link } from "expo-router";
|
||||
|
||||
export default function PhotoScreen() {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Link.AppleZoomTarget>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/full.jpg" }}
|
||||
style={{ width: "100%", aspectRatio: 4 / 3 }}
|
||||
/>
|
||||
</Link.AppleZoomTarget>
|
||||
<Text>Photo details below</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Without a target, the zoom animates to fill the entire destination screen.
|
||||
|
||||
## Custom Alignment Rectangle
|
||||
|
||||
For manual control over where the zoom lands on the destination, use `alignmentRect` instead of `Link.AppleZoomTarget`:
|
||||
|
||||
```tsx
|
||||
<Link.AppleZoom alignmentRect={{ x: 0, y: 0, width: 200, height: 300 }}>
|
||||
<Image source={{ uri: "https://example.com/thumb.jpg" }} />
|
||||
</Link.AppleZoom>
|
||||
```
|
||||
|
||||
Coordinates are in the destination screen's coordinate space. Prefer `Link.AppleZoomTarget` when possible — use `alignmentRect` only when the target element isn't available as a React component.
|
||||
|
||||
## Controlling Dismissal
|
||||
|
||||
Zoom screens support interactive dismissal gestures by default (pinch, swipe down when scrolled to top, swipe from leading edge). Use `usePreventZoomTransitionDismissal` on the destination screen to control this.
|
||||
|
||||
### Disable all dismissal gestures
|
||||
|
||||
```tsx
|
||||
import { usePreventZoomTransitionDismissal } from "expo-router";
|
||||
|
||||
export default function PhotoScreen() {
|
||||
usePreventZoomTransitionDismissal();
|
||||
return <Image source={{ uri: "https://example.com/full.jpg" }} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Restrict dismissal to a specific area
|
||||
|
||||
Use `unstable_dismissalBoundsRect` to prevent conflicts with scrollable content:
|
||||
|
||||
```tsx
|
||||
usePreventZoomTransitionDismissal({
|
||||
unstable_dismissalBoundsRect: {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 300,
|
||||
maxY: 300,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This is useful when the destination contains a zoomable scroll view — the system gives that scroll view precedence over the dismiss gesture.
|
||||
|
||||
## Combining with Link.Preview
|
||||
|
||||
Zoom transitions work alongside long-press previews:
|
||||
|
||||
```tsx
|
||||
<Link href="/photo" asChild>
|
||||
<Link.Trigger withAppleZoom>
|
||||
<Pressable>
|
||||
<Image
|
||||
source={{ uri: "https://example.com/thumb.jpg" }}
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Good use cases:**
|
||||
- Thumbnail → full image (gallery, profile photos)
|
||||
- Card → detail screen with similar visual content
|
||||
- Source and destination with similar aspect ratios
|
||||
|
||||
**Avoid:**
|
||||
- Skinny full-width list rows as zoom sources — the transition looks unnatural
|
||||
- Mismatched aspect ratios between source and destination without `alignmentRect`
|
||||
- Using zoom with sheets or popovers — only works in Stack navigator
|
||||
- Hiding the navigation bar — known issues with header visibility during transitions
|
||||
|
||||
**Tips:**
|
||||
- Always provide a close or back button — dismissal gestures are not discoverable
|
||||
- If the destination has a zoomable scroll view, use `unstable_dismissalBoundsRect` to avoid gesture conflicts
|
||||
- Source view doesn't need to match the tap target — only the `Link.AppleZoom` wrapped element animates
|
||||
- When source is unavailable (e.g., scrolled off screen), the transition zooms from the center of the screen
|
||||
|
||||
## References
|
||||
|
||||
- Expo Router Zoom Transitions: https://docs.expo.dev/router/advanced/zoom-transition/
|
||||
- Link.AppleZoom API: https://docs.expo.dev/versions/v55.0.0/sdk/router/#linkapplezoom
|
||||
- Apple UIKit Fluid Transitions: https://developer.apple.com/documentation/uikit/enhancing-your-app-with-fluid-transitions
|
||||
368
.opencode/skills/expo-api-routes/SKILL.md
Normal file
368
.opencode/skills/expo-api-routes/SKILL.md
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
name: expo-api-routes
|
||||
description: Guidelines for creating API routes in Expo Router with EAS Hosting
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
## When to Use API Routes
|
||||
|
||||
Use API routes when you need:
|
||||
|
||||
- **Server-side secrets** — API keys, database credentials, or tokens that must never reach the client
|
||||
- **Database operations** — Direct database queries that shouldn't be exposed
|
||||
- **Third-party API proxies** — Hide API keys when calling external services (OpenAI, Stripe, etc.)
|
||||
- **Server-side validation** — Validate data before database writes
|
||||
- **Webhook endpoints** — Receive callbacks from services like Stripe or GitHub
|
||||
- **Rate limiting** — Control access at the server level
|
||||
- **Heavy computation** — Offload processing that would be slow on mobile
|
||||
|
||||
## When NOT to Use API Routes
|
||||
|
||||
Avoid API routes when:
|
||||
|
||||
- **Data is already public** — Use direct fetch to public APIs instead
|
||||
- **No secrets required** — Static data or client-safe operations
|
||||
- **Real-time updates needed** — Use WebSockets or services like Supabase Realtime
|
||||
- **Simple CRUD** — Consider Firebase, Supabase, or Convex for managed backends
|
||||
- **File uploads** — Use direct-to-storage uploads (S3 presigned URLs, Cloudflare R2)
|
||||
- **Authentication only** — Use Clerk, Auth0, or Firebase Auth instead
|
||||
|
||||
## File Structure
|
||||
|
||||
API routes live in the `app` directory with `+api.ts` suffix:
|
||||
|
||||
```
|
||||
app/
|
||||
api/
|
||||
hello+api.ts → GET /api/hello
|
||||
users+api.ts → /api/users
|
||||
users/[id]+api.ts → /api/users/:id
|
||||
(tabs)/
|
||||
index.tsx
|
||||
```
|
||||
|
||||
## Basic API Route
|
||||
|
||||
```ts
|
||||
// app/api/hello+api.ts
|
||||
export function GET(request: Request) {
|
||||
return Response.json({ message: "Hello from Expo!" });
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
Export named functions for each HTTP method:
|
||||
|
||||
```ts
|
||||
// app/api/items+api.ts
|
||||
export function GET(request: Request) {
|
||||
return Response.json({ items: [] });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return Response.json({ created: body }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const body = await request.json();
|
||||
return Response.json({ updated: body });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
```ts
|
||||
// app/api/users/[id]+api.ts
|
||||
export function GET(request: Request, { id }: { id: string }) {
|
||||
return Response.json({ userId: id });
|
||||
}
|
||||
```
|
||||
|
||||
## Request Handling
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```ts
|
||||
export function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") ?? "1";
|
||||
const limit = url.searchParams.get("limit") ?? "10";
|
||||
|
||||
return Response.json({ page, limit });
|
||||
}
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
```ts
|
||||
export function GET(request: Request) {
|
||||
const auth = request.headers.get("Authorization");
|
||||
|
||||
if (!auth) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return Response.json({ authenticated: true });
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Body
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request) {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json({ error: "Missing fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Use `process.env` for server-side secrets:
|
||||
|
||||
```ts
|
||||
// app/api/ai+api.ts
|
||||
export async function POST(request: Request) {
|
||||
const { prompt } = await request.json();
|
||||
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return Response.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
Set environment variables:
|
||||
|
||||
- **Local**: Create `.env` file (never commit)
|
||||
- **EAS Hosting**: Use `eas env:create` or Expo dashboard
|
||||
|
||||
## CORS Headers
|
||||
|
||||
Add CORS for web clients:
|
||||
|
||||
```ts
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
export function OPTIONS() {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ data: "value" }, { headers: corsHeaders });
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Process...
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("API error:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Locally
|
||||
|
||||
Start the development server with API routes:
|
||||
|
||||
```bash
|
||||
npx expo serve
|
||||
```
|
||||
|
||||
This starts a local server at `http://localhost:8081` with full API route support.
|
||||
|
||||
Test with curl:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8081/api/hello
|
||||
curl -X POST http://localhost:8081/api/users -H "Content-Type: application/json" -d '{"name":"Test"}'
|
||||
```
|
||||
|
||||
## Deployment to EAS Hosting
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
eas deploy
|
||||
```
|
||||
|
||||
This builds and deploys your API routes to EAS Hosting (Cloudflare Workers).
|
||||
|
||||
### Environment Variables for Production
|
||||
|
||||
```bash
|
||||
# Create a secret
|
||||
eas env:create --name OPENAI_API_KEY --value sk-xxx --environment production
|
||||
|
||||
# Or use the Expo dashboard
|
||||
```
|
||||
|
||||
### Custom Domain
|
||||
|
||||
Configure in `eas.json` or Expo dashboard.
|
||||
|
||||
## EAS Hosting Runtime (Cloudflare Workers)
|
||||
|
||||
API routes run on Cloudflare Workers. Key limitations:
|
||||
|
||||
### Missing/Limited APIs
|
||||
|
||||
- **No Node.js filesystem** — `fs` module unavailable
|
||||
- **No native Node modules** — Use Web APIs or polyfills
|
||||
- **Limited execution time** — 30 second timeout for CPU-intensive tasks
|
||||
- **No persistent connections** — WebSockets require Durable Objects
|
||||
- **fetch is available** — Use standard fetch for HTTP requests
|
||||
|
||||
### Use Web APIs Instead
|
||||
|
||||
```ts
|
||||
// Use Web Crypto instead of Node crypto
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode("data")
|
||||
);
|
||||
|
||||
// Use fetch instead of node-fetch
|
||||
const response = await fetch("https://api.example.com");
|
||||
|
||||
// Use Response/Request (already available)
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
```
|
||||
|
||||
### Database Options
|
||||
|
||||
Since filesystem is unavailable, use cloud databases:
|
||||
|
||||
- **Cloudflare D1** — SQLite at the edge
|
||||
- **Turso** — Distributed SQLite
|
||||
- **PlanetScale** — Serverless MySQL
|
||||
- **Supabase** — Postgres with REST API
|
||||
- **Neon** — Serverless Postgres
|
||||
|
||||
Example with Turso:
|
||||
|
||||
```ts
|
||||
// app/api/users+api.ts
|
||||
import { createClient } from "@libsql/client/web";
|
||||
|
||||
const db = createClient({
|
||||
url: process.env.TURSO_URL!,
|
||||
authToken: process.env.TURSO_AUTH_TOKEN!,
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const result = await db.execute("SELECT * FROM users");
|
||||
return Response.json(result.rows);
|
||||
}
|
||||
```
|
||||
|
||||
## Calling API Routes from Client
|
||||
|
||||
```ts
|
||||
// From React Native components
|
||||
const response = await fetch("/api/hello");
|
||||
const data = await response.json();
|
||||
|
||||
// With body
|
||||
const response = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "John" }),
|
||||
});
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication Middleware
|
||||
|
||||
```ts
|
||||
// utils/auth.ts
|
||||
export async function requireAuth(request: Request) {
|
||||
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (!token) {
|
||||
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token...
|
||||
return { userId: "123" };
|
||||
}
|
||||
|
||||
// app/api/protected+api.ts
|
||||
import { requireAuth } from "../../utils/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { userId } = await requireAuth(request);
|
||||
return Response.json({ userId });
|
||||
}
|
||||
```
|
||||
|
||||
### Proxy External API
|
||||
|
||||
```ts
|
||||
// app/api/weather+api.ts
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const city = url.searchParams.get("city");
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.weather.com/v1/current?city=${city}&key=${process.env.WEATHER_API_KEY}`
|
||||
);
|
||||
|
||||
return Response.json(await response.json());
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- NEVER expose API keys or secrets in client code
|
||||
- ALWAYS validate and sanitize user input
|
||||
- Use proper HTTP status codes (200, 201, 400, 401, 404, 500)
|
||||
- Handle errors gracefully with try/catch
|
||||
- Keep API routes focused — one responsibility per endpoint
|
||||
- Use TypeScript for type safety
|
||||
- Log errors server-side for debugging
|
||||
92
.opencode/skills/expo-cicd-workflows/SKILL.md
Normal file
92
.opencode/skills/expo-cicd-workflows/SKILL.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: expo-cicd-workflows
|
||||
description: Helps understand and write EAS workflow YAML files for Expo projects. Use this skill when the user asks about CI/CD or workflows in an Expo or EAS context, mentions .eas/workflows/, or wants help with EAS build pipelines or deployment automation.
|
||||
allowed-tools: "Read,Write,Bash(node:*)"
|
||||
version: 1.0.0
|
||||
license: MIT License
|
||||
---
|
||||
|
||||
# EAS Workflows Skill
|
||||
|
||||
Help developers write and edit EAS CI/CD workflow YAML files.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
Fetch these resources before generating or validating workflow files. Use the fetch script (implemented using Node.js) in this skill's `scripts/` directory; it caches responses using ETags for efficiency:
|
||||
|
||||
```bash
|
||||
# Fetch resources
|
||||
node {baseDir}/scripts/fetch.js <url>
|
||||
```
|
||||
|
||||
1. **JSON Schema** — https://api.expo.dev/v2/workflows/schema
|
||||
- It is NECESSARY to fetch this schema
|
||||
- Source of truth for validation
|
||||
- All job types and their required/optional parameters
|
||||
- Trigger types and configurations
|
||||
- Runner types, VM images, and all enums
|
||||
|
||||
2. **Syntax Documentation** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/syntax.mdx
|
||||
- Overview of workflow YAML syntax
|
||||
- Examples and English explanations
|
||||
- Expression syntax and contexts
|
||||
|
||||
3. **Pre-packaged Jobs** — https://raw.githubusercontent.com/expo/expo/refs/heads/main/docs/pages/eas/workflows/pre-packaged-jobs.mdx
|
||||
- Documentation for supported pre-packaged job types
|
||||
- Job-specific parameters and outputs
|
||||
|
||||
Do not rely on memorized values; these resources evolve as new features are added.
|
||||
|
||||
## Workflow File Location
|
||||
|
||||
Workflows live in `.eas/workflows/*.yml` (or `.yaml`).
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
A workflow file has these top-level keys:
|
||||
|
||||
- `name` — Display name for the workflow
|
||||
- `on` — Triggers that start the workflow (at least one required)
|
||||
- `jobs` — Job definitions (required)
|
||||
- `defaults` — Shared defaults for all jobs
|
||||
- `concurrency` — Control parallel workflow runs
|
||||
|
||||
Consult the schema for the full specification of each section.
|
||||
|
||||
## Expressions
|
||||
|
||||
Use `${{ }}` syntax for dynamic values. The schema defines available contexts:
|
||||
|
||||
- `github.*` — GitHub repository and event information
|
||||
- `inputs.*` — Values from `workflow_dispatch` inputs
|
||||
- `needs.*` — Outputs and status from dependent jobs
|
||||
- `jobs.*` — Job outputs (alternative syntax)
|
||||
- `steps.*` — Step outputs within custom jobs
|
||||
- `workflow.*` — Workflow metadata
|
||||
|
||||
## Generating Workflows
|
||||
|
||||
When generating or editing workflows:
|
||||
|
||||
1. Fetch the schema to get current job types, parameters, and allowed values
|
||||
2. Validate that required fields are present for each job type
|
||||
3. Verify job references in `needs` and `after` exist in the workflow
|
||||
4. Check that expressions reference valid contexts and outputs
|
||||
5. Ensure `if` conditions respect the schema's length constraints
|
||||
|
||||
## Validation
|
||||
|
||||
After generating or editing a workflow file, validate it against the schema:
|
||||
|
||||
```sh
|
||||
# Install dependencies if missing
|
||||
[ -d "{baseDir}/scripts/node_modules" ] || npm install --prefix {baseDir}/scripts
|
||||
|
||||
node {baseDir}/scripts/validate.js <workflow.yml> [workflow2.yml ...]
|
||||
```
|
||||
|
||||
The validator fetches the latest schema and checks the YAML structure. Fix any reported errors before considering the workflow complete.
|
||||
|
||||
## Answering Questions
|
||||
|
||||
When users ask about available options (job types, triggers, runner types, etc.), fetch the schema and derive the answer from it rather than relying on potentially outdated information.
|
||||
109
.opencode/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
109
.opencode/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const CACHE_DIRECTORY = resolve(import.meta.dirname, '.cache');
|
||||
const DEFAULT_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
export async function fetchCached(url) {
|
||||
await mkdir(CACHE_DIRECTORY, { recursive: true });
|
||||
|
||||
const cacheFile = resolve(CACHE_DIRECTORY, hashUrl(url) + '.json');
|
||||
const cached = await loadCacheEntry(cacheFile);
|
||||
if (cached && cached.expires > Math.floor(Date.now() / 1000)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Make request, with conditional If-None-Match if we have an ETag.
|
||||
// Cache-Control: max-age=0 overrides Node's default 'no-cache' to allow 304 responses.
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=0',
|
||||
...(cached?.etag && { 'If-None-Match': cached.etag }),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 304 && cached) {
|
||||
// Refresh expiration and return cached data
|
||||
const entry = { ...cached, expires: getExpires(response.headers) };
|
||||
await saveCacheEntry(cacheFile, entry);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const etag = response.headers.get('etag');
|
||||
const data = await response.text();
|
||||
const expires = getExpires(response.headers);
|
||||
|
||||
await saveCacheEntry(cacheFile, { url, etag, expires, data });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function hashUrl(url) {
|
||||
return createHash('sha256').update(url).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
async function loadCacheEntry(cacheFile) {
|
||||
try {
|
||||
return JSON.parse(await readFile(cacheFile, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCacheEntry(cacheFile, entry) {
|
||||
await writeFile(cacheFile, JSON.stringify(entry, null, 2));
|
||||
}
|
||||
|
||||
function getExpires(headers) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Prefer Cache-Control: max-age
|
||||
const maxAgeSeconds = parseMaxAge(headers.get('cache-control'));
|
||||
if (maxAgeSeconds != null) {
|
||||
return now + maxAgeSeconds;
|
||||
}
|
||||
|
||||
// Fall back to Expires header
|
||||
const expires = headers.get('expires');
|
||||
if (expires) {
|
||||
const expiresTime = Date.parse(expires);
|
||||
if (!Number.isNaN(expiresTime)) {
|
||||
return Math.floor(expiresTime / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Default TTL
|
||||
return now + DEFAULT_TTL_SECONDS;
|
||||
}
|
||||
|
||||
function parseMaxAge(cacheControl) {
|
||||
if (!cacheControl) {
|
||||
return null;
|
||||
}
|
||||
const match = cacheControl.match(/max-age=(\d+)/i);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const url = process.argv[2];
|
||||
|
||||
if (!url || url === '--help' || url === '-h') {
|
||||
console.log(`Usage: fetch <url>
|
||||
|
||||
Fetches a URL with HTTP caching (ETags + Cache-Control/Expires).
|
||||
Default TTL: ${DEFAULT_TTL_SECONDS / 60} minutes.
|
||||
Cache is stored in: ${CACHE_DIRECTORY}/`);
|
||||
process.exit(url ? 0 : 1);
|
||||
}
|
||||
|
||||
const data = await fetchCached(url);
|
||||
console.log(data);
|
||||
}
|
||||
84
.opencode/skills/expo-cicd-workflows/scripts/validate.js
Normal file
84
.opencode/skills/expo-cicd-workflows/scripts/validate.js
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import addFormats from 'ajv-formats';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import { fetchCached } from './fetch.js';
|
||||
|
||||
const SCHEMA_URL = 'https://api.expo.dev/v2/workflows/schema';
|
||||
|
||||
async function fetchSchema() {
|
||||
const data = await fetchCached(SCHEMA_URL);
|
||||
const body = JSON.parse(data);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function createValidator(schema) {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
return ajv.compile(schema);
|
||||
}
|
||||
|
||||
async function validateFile(validator, filePath) {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = yaml.load(content);
|
||||
} catch (e) {
|
||||
return { valid: false, error: `YAML parse error: ${e.message}` };
|
||||
}
|
||||
|
||||
const valid = validator(doc);
|
||||
if (!valid) {
|
||||
return { valid: false, error: formatErrors(validator.errors) };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function formatErrors(errors) {
|
||||
return errors
|
||||
.map((error) => {
|
||||
const path = error.instancePath || '(root)';
|
||||
const allowed = error.params?.allowedValues?.join(', ');
|
||||
return ` ${path}: ${error.message}${allowed ? ` (allowed: ${allowed})` : ''}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const files = args.filter((a) => !a.startsWith('-'));
|
||||
|
||||
if (files.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: validate <workflow.yml> [workflow2.yml ...]
|
||||
|
||||
Validates EAS workflow YAML files against the official schema.`);
|
||||
process.exit(files.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
const schema = await fetchSchema();
|
||||
const validator = createValidator(schema);
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolve(process.cwd(), file);
|
||||
const result = await validateFile(validator, filePath);
|
||||
|
||||
if (result.valid) {
|
||||
console.log(`✓ ${file}`);
|
||||
} else {
|
||||
console.error(`✗ ${file}\n${result.error}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(hasErrors ? 1 : 0);
|
||||
}
|
||||
190
.opencode/skills/expo-deployment/SKILL.md
Normal file
190
.opencode/skills/expo-deployment/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: expo-deployment
|
||||
description: Deploying Expo apps to iOS App Store, Android Play Store, web hosting, and API routes
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
This skill covers deploying Expo applications across all platforms using EAS (Expo Application Services).
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
- ./references/workflows.md -- CI/CD workflows for automated deployments and PR previews
|
||||
- ./references/testflight.md -- Submitting iOS builds to TestFlight for beta testing
|
||||
- ./references/app-store-metadata.md -- Managing App Store metadata and ASO optimization
|
||||
- ./references/play-store.md -- Submitting Android builds to Google Play Store
|
||||
- ./references/ios-app-store.md -- iOS App Store submission and review process
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install EAS CLI
|
||||
|
||||
```bash
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
```
|
||||
|
||||
### Initialize EAS
|
||||
|
||||
```bash
|
||||
npx eas-cli@latest init
|
||||
```
|
||||
|
||||
This creates `eas.json` with build profiles.
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Production Builds
|
||||
|
||||
```bash
|
||||
# iOS App Store build
|
||||
npx eas-cli@latest build -p ios --profile production
|
||||
|
||||
# Android Play Store build
|
||||
npx eas-cli@latest build -p android --profile production
|
||||
|
||||
# Both platforms
|
||||
npx eas-cli@latest build --profile production
|
||||
```
|
||||
|
||||
### Submit to Stores
|
||||
|
||||
```bash
|
||||
# iOS: Build and submit to App Store Connect
|
||||
npx eas-cli@latest build -p ios --profile production --submit
|
||||
|
||||
# Android: Build and submit to Play Store
|
||||
npx eas-cli@latest build -p android --profile production --submit
|
||||
|
||||
# Shortcut for iOS TestFlight
|
||||
npx testflight
|
||||
```
|
||||
|
||||
## Web Deployment
|
||||
|
||||
Deploy web apps using EAS Hosting:
|
||||
|
||||
```bash
|
||||
# Deploy to production
|
||||
npx expo export -p web
|
||||
npx eas-cli@latest deploy --prod
|
||||
|
||||
# Deploy PR preview
|
||||
npx eas-cli@latest deploy
|
||||
```
|
||||
|
||||
## EAS Configuration
|
||||
|
||||
Standard `eas.json` for production deployments:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "your@email.com",
|
||||
"ascAppId": "1234567890"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Guides
|
||||
|
||||
### iOS
|
||||
|
||||
- Use `npx testflight` for quick TestFlight submissions
|
||||
- Configure Apple credentials via `eas credentials`
|
||||
- See ./reference/testflight.md for credential setup
|
||||
- See ./reference/ios-app-store.md for App Store submission
|
||||
|
||||
### Android
|
||||
|
||||
- Set up Google Play Console service account
|
||||
- Configure tracks: internal → closed → open → production
|
||||
- See ./reference/play-store.md for detailed setup
|
||||
|
||||
### Web
|
||||
|
||||
- EAS Hosting provides preview URLs for PRs
|
||||
- Production deploys to your custom domain
|
||||
- See ./reference/workflows.md for CI/CD automation
|
||||
|
||||
## Automated Deployments
|
||||
|
||||
Use EAS Workflows for CI/CD:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit-ios:
|
||||
type: submit
|
||||
needs: [build-ios]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
```
|
||||
|
||||
See ./reference/workflows.md for more workflow examples.
|
||||
|
||||
## Version Management
|
||||
|
||||
EAS manages version numbers automatically with `appVersionSource: "remote"`:
|
||||
|
||||
```bash
|
||||
# Check current versions
|
||||
eas build:version:get
|
||||
|
||||
# Manually set version
|
||||
eas build:version:set -p ios --build-number 42
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
# List recent builds
|
||||
eas build:list
|
||||
|
||||
# Check build status
|
||||
eas build:view
|
||||
|
||||
# View submission status
|
||||
eas submit:list
|
||||
```
|
||||
@@ -0,0 +1,479 @@
|
||||
# App Store Metadata
|
||||
|
||||
Manage App Store metadata and optimize for ASO using EAS Metadata.
|
||||
|
||||
## What is EAS Metadata?
|
||||
|
||||
EAS Metadata automates App Store presence management from the command line using a `store.config.json` file instead of manually filling forms in App Store Connect. It includes built-in validation to catch common rejection pitfalls.
|
||||
|
||||
**Current Status:** Preview, Apple App Store only.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Pull Existing Metadata
|
||||
|
||||
If your app is already published, pull current metadata:
|
||||
|
||||
```bash
|
||||
eas metadata:pull
|
||||
```
|
||||
|
||||
This creates `store.config.json` with your current App Store configuration.
|
||||
|
||||
### Push Metadata Updates
|
||||
|
||||
After editing your config, push changes:
|
||||
|
||||
```bash
|
||||
eas metadata:push
|
||||
```
|
||||
|
||||
**Important:** You must submit a binary via `eas submit` before pushing metadata for new apps.
|
||||
|
||||
## Configuration File
|
||||
|
||||
Create `store.config.json` at your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"configVersion": 0,
|
||||
"apple": {
|
||||
"copyright": "2025 Your Company",
|
||||
"categories": ["UTILITIES", "PRODUCTIVITY"],
|
||||
"info": {
|
||||
"en-US": {
|
||||
"title": "App Name",
|
||||
"subtitle": "Your compelling tagline",
|
||||
"description": "Full app description...",
|
||||
"keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"releaseNotes": "What's new in this version...",
|
||||
"promoText": "Limited time offer!",
|
||||
"privacyPolicyUrl": "https://example.com/privacy",
|
||||
"supportUrl": "https://example.com/support",
|
||||
"marketingUrl": "https://example.com"
|
||||
}
|
||||
},
|
||||
"advisory": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"gamblingSimulated": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"contests": "NONE",
|
||||
"gambling": false,
|
||||
"unrestrictedWebAccess": false,
|
||||
"seventeenPlus": false
|
||||
},
|
||||
"release": {
|
||||
"automaticRelease": true,
|
||||
"phasedRelease": true
|
||||
},
|
||||
"review": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"email": "review@example.com",
|
||||
"phone": "+1 555-123-4567",
|
||||
"notes": "Demo account: test@example.com / password123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## App Store Optimization (ASO)
|
||||
|
||||
### Title Optimization (30 characters max)
|
||||
|
||||
The title is the most important ranking factor. Include your brand name and 1-2 strongest keywords.
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Budgetly - Money Tracker"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Brand name first for recognition
|
||||
- Include highest-volume keyword
|
||||
- Avoid generic words like "app" or "the"
|
||||
- Title keywords boost rankings by ~10%
|
||||
|
||||
### Subtitle Optimization (30 characters max)
|
||||
|
||||
The subtitle appears below your title in search results. Use it for your unique value proposition.
|
||||
|
||||
```json
|
||||
{
|
||||
"subtitle": "Smart Expense & Budget Planner"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Don't duplicate keywords from title (Apple counts each word once)
|
||||
- Highlight your main differentiator
|
||||
- Include secondary high-value keywords
|
||||
- Focus on benefits, not features
|
||||
|
||||
### Keywords Field (100 characters max)
|
||||
|
||||
Hidden from users but crucial for discoverability. Use comma-separated keywords without spaces after commas.
|
||||
|
||||
```json
|
||||
{
|
||||
"keywords": [
|
||||
"finance,budget,expense,money,tracker,savings,bills,income,spending,wallet,personal,weekly,monthly"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Use all 100 characters
|
||||
- Separate with commas only (no spaces)
|
||||
- No duplicates from title/subtitle
|
||||
- Include singular forms (Apple handles plurals)
|
||||
- Add synonyms and alternate spellings
|
||||
- Include competitor brand names (carefully)
|
||||
- Use digits instead of spelled numbers ("5" not "five")
|
||||
- Skip articles and prepositions
|
||||
|
||||
### Description Optimization
|
||||
|
||||
The iOS description is NOT indexed for search but critical for conversion. Focus on convincing users to download.
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Take control of your finances with Budgetly, the intuitive money management app trusted by over 1 million users.\n\nKEY FEATURES:\n• Smart budget tracking - Set limits and watch your progress\n• Expense categorization - Know exactly where your money goes\n• Bill reminders - Never miss a payment\n• Beautiful charts - Visualize your financial health\n• Bank sync - Connect 10,000+ institutions\n• Cloud backup - Your data, always safe\n\nWHY BUDGETLY?\nUnlike complex spreadsheets or basic calculators, Budgetly learns your spending habits and provides personalized insights. Our users save an average of $300/month within 3 months.\n\nPRIVACY FIRST\nYour financial data is encrypted end-to-end. We never sell your information.\n\nDownload Budgetly today and start your journey to financial freedom!"
|
||||
}
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Front-load the first 3 lines (visible before "more")
|
||||
- Use bullet points for features
|
||||
- Include social proof (user counts, ratings, awards)
|
||||
- Add a clear call-to-action
|
||||
- Mention privacy/security for sensitive apps
|
||||
- Update with each release
|
||||
|
||||
### Release Notes
|
||||
|
||||
Shown to existing users deciding whether to update.
|
||||
|
||||
```json
|
||||
{
|
||||
"releaseNotes": "Version 2.5 brings exciting improvements:\n\n• NEW: Dark mode support\n• NEW: Widget for home screen\n• IMPROVED: 50% faster sync\n• FIXED: Notification timing issues\n\nLove Budgetly? Please leave a review!"
|
||||
}
|
||||
```
|
||||
|
||||
### Promo Text (170 characters max)
|
||||
|
||||
Appears above description; can be updated without new binary. Great for time-sensitive promotions.
|
||||
|
||||
```json
|
||||
{
|
||||
"promoText": "🎉 New Year Special: Premium features free for 30 days! Start 2025 with better finances."
|
||||
}
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
Primary category is most important for browsing and rankings.
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": ["FINANCE", "PRODUCTIVITY"]
|
||||
}
|
||||
```
|
||||
|
||||
**Available Categories:**
|
||||
|
||||
- BOOKS, BUSINESS, DEVELOPER_TOOLS, EDUCATION
|
||||
- ENTERTAINMENT, FINANCE, FOOD_AND_DRINK
|
||||
- GAMES (with subcategories), GRAPHICS_AND_DESIGN
|
||||
- HEALTH_AND_FITNESS, KIDS (age-gated)
|
||||
- LIFESTYLE, MAGAZINES_AND_NEWSPAPERS
|
||||
- MEDICAL, MUSIC, NAVIGATION, NEWS
|
||||
- PHOTO_AND_VIDEO, PRODUCTIVITY, REFERENCE
|
||||
- SHOPPING, SOCIAL_NETWORKING, SPORTS
|
||||
- STICKERS (with subcategories), TRAVEL
|
||||
- UTILITIES, WEATHER
|
||||
|
||||
## Localization
|
||||
|
||||
Localize metadata for each target market. Keywords should be researched per locale—direct translations often miss regional search terms.
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"en-US": {
|
||||
"title": "Budgetly - Money Tracker",
|
||||
"subtitle": "Smart Expense Planner",
|
||||
"keywords": ["budget,finance,money,expense,tracker"]
|
||||
},
|
||||
"es-ES": {
|
||||
"title": "Budgetly - Control de Gastos",
|
||||
"subtitle": "Planificador de Presupuesto",
|
||||
"keywords": ["presupuesto,finanzas,dinero,gastos,ahorro"]
|
||||
},
|
||||
"ja": {
|
||||
"title": "Budgetly - 家計簿アプリ",
|
||||
"subtitle": "簡単支出管理",
|
||||
"keywords": ["家計簿,支出,予算,節約,お金"]
|
||||
},
|
||||
"de-DE": {
|
||||
"title": "Budgetly - Haushaltsbuch",
|
||||
"subtitle": "Ausgaben Verwalten",
|
||||
"keywords": ["budget,finanzen,geld,ausgaben,sparen"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Supported Locales:**
|
||||
`ar-SA`, `ca`, `cs`, `da`, `de-DE`, `el`, `en-AU`, `en-CA`, `en-GB`, `en-US`, `es-ES`, `es-MX`, `fi`, `fr-CA`, `fr-FR`, `he`, `hi`, `hr`, `hu`, `id`, `it`, `ja`, `ko`, `ms`, `nl-NL`, `no`, `pl`, `pt-BR`, `pt-PT`, `ro`, `ru`, `sk`, `sv`, `th`, `tr`, `uk`, `vi`, `zh-Hans`, `zh-Hant`
|
||||
|
||||
## Dynamic Configuration
|
||||
|
||||
Use JavaScript for dynamic values like copyright year or fetched translations.
|
||||
|
||||
### Basic Dynamic Config
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
copyright: `${year} Your Company, Inc.`,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Async Configuration (External Localization)
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
module.exports = async () => {
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
// Fetch translations from CMS/localization service
|
||||
const translations = await fetch(
|
||||
"https://api.example.com/app-store-copy"
|
||||
).then((r) => r.json());
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
info: translations,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Environment-Based Config
|
||||
|
||||
```js
|
||||
// store.config.js
|
||||
const baseConfig = require("./store.config.json");
|
||||
|
||||
const isProduction = process.env.EAS_BUILD_PROFILE === "production";
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
apple: {
|
||||
...baseConfig.apple,
|
||||
info: {
|
||||
"en-US": {
|
||||
...baseConfig.apple.info["en-US"],
|
||||
promoText: isProduction
|
||||
? "Download now and get started!"
|
||||
: "[BETA] Help us test new features!",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Update `eas.json` to use JS config:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"metadataPath": "./store.config.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Age Rating (Advisory)
|
||||
|
||||
Answer content questions honestly to get an appropriate age rating.
|
||||
|
||||
**Content Descriptors:**
|
||||
|
||||
- `NONE` - Content not present
|
||||
- `INFREQUENT_OR_MILD` - Occasional mild content
|
||||
- `FREQUENT_OR_INTENSE` - Regular or strong content
|
||||
|
||||
```json
|
||||
{
|
||||
"advisory": {
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"contests": "NONE",
|
||||
"gambling": false,
|
||||
"gamblingSimulated": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"unrestrictedWebAccess": false,
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"seventeenPlus": false,
|
||||
"kidsAgeBand": "NINE_TO_ELEVEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Kids Age Bands:** `FIVE_AND_UNDER`, `SIX_TO_EIGHT`, `NINE_TO_ELEVEN`
|
||||
|
||||
## Release Strategy
|
||||
|
||||
Control how your app rolls out to users.
|
||||
|
||||
```json
|
||||
{
|
||||
"release": {
|
||||
"automaticRelease": true,
|
||||
"phasedRelease": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `automaticRelease: true` - Release immediately upon approval
|
||||
- `automaticRelease: false` - Manual release after approval
|
||||
- `automaticRelease: "2025-02-01T10:00:00Z"` - Schedule release (RFC 3339)
|
||||
- `phasedRelease: true` - 7-day gradual rollout (1%, 2%, 5%, 10%, 20%, 50%, 100%)
|
||||
|
||||
## Review Information
|
||||
|
||||
Provide contact info and test credentials for the App Review team.
|
||||
|
||||
```json
|
||||
{
|
||||
"review": {
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"email": "app-review@company.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"demoUsername": "demo@example.com",
|
||||
"demoPassword": "ReviewDemo2025!",
|
||||
"notes": "To test premium features:\n1. Log in with demo credentials\n2. Navigate to Settings > Subscription\n3. Tap 'Restore Purchase' - sandbox purchase will be restored\n\nFor location features, allow location access when prompted."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ASO Checklist
|
||||
|
||||
### Before Each Release
|
||||
|
||||
- [ ] Update keywords based on performance data
|
||||
- [ ] Refresh description with new features
|
||||
- [ ] Write compelling release notes
|
||||
- [ ] Update promo text if running campaigns
|
||||
- [ ] Verify all URLs are valid
|
||||
|
||||
### Monthly ASO Tasks
|
||||
|
||||
- [ ] Analyze keyword rankings
|
||||
- [ ] Research competitor keywords
|
||||
- [ ] Check conversion rates in App Analytics
|
||||
- [ ] Review user feedback for keyword ideas
|
||||
- [ ] A/B test screenshots in App Store Connect
|
||||
|
||||
### Keyword Research Tips
|
||||
|
||||
1. **Brainstorm features** - List all app capabilities
|
||||
2. **Mine reviews** - Find words users actually use
|
||||
3. **Analyze competitors** - Check their titles/subtitles
|
||||
4. **Use long-tail keywords** - Less competition, higher intent
|
||||
5. **Consider misspellings** - Common typos can drive traffic
|
||||
6. **Track seasonality** - Some keywords peak at certain times
|
||||
|
||||
### Metrics to Monitor
|
||||
|
||||
- **Impressions** - How often your app appears in search
|
||||
- **Product Page Views** - Users who tap to learn more
|
||||
- **Conversion Rate** - Views → Downloads
|
||||
- **Keyword Rankings** - Position for target keywords
|
||||
- **Category Ranking** - Position in your categories
|
||||
|
||||
## VS Code Integration
|
||||
|
||||
Install the [Expo Tools extension](https://marketplace.visualstudio.com/items?itemName=expo.vscode-expo-tools) for:
|
||||
|
||||
- Auto-complete for all schema properties
|
||||
- Inline validation and warnings
|
||||
- Quick fixes for common issues
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Binary not found"
|
||||
|
||||
Push a binary with `eas submit` before pushing metadata.
|
||||
|
||||
### "Invalid keywords"
|
||||
|
||||
- Check total length is ≤100 characters
|
||||
- Remove spaces after commas
|
||||
- Remove duplicate words
|
||||
|
||||
### "Description too long"
|
||||
|
||||
Description maximum is 4000 characters.
|
||||
|
||||
### Pull doesn't update JS config
|
||||
|
||||
`eas metadata:pull` creates a JSON file; import it into your JS config.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Automate metadata updates in your deployment pipeline:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
jobs:
|
||||
submit-and-metadata:
|
||||
steps:
|
||||
- name: Submit to App Store
|
||||
run: eas submit -p ios --latest
|
||||
|
||||
- name: Push Metadata
|
||||
run: eas metadata:push
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Update metadata every 4-6 weeks for optimal ASO
|
||||
- 70% of App Store visitors use search to find apps
|
||||
- Apps with 4+ star ratings get featured more often
|
||||
- Localized apps see 128% more downloads per country
|
||||
- First 3 lines of description are most critical (shown before "more")
|
||||
- Use all 100 keyword characters—every character counts
|
||||
355
.opencode/skills/expo-deployment/references/ios-app-store.md
Normal file
355
.opencode/skills/expo-deployment/references/ios-app-store.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Submitting to iOS App Store
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Apple Developer Account** - Enroll at [developer.apple.com](https://developer.apple.com)
|
||||
2. **App Store Connect App** - Create your app record before first submission
|
||||
3. **Apple Credentials** - Configure via EAS or environment variables
|
||||
|
||||
## Credential Setup
|
||||
|
||||
### Using EAS Credentials
|
||||
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
```
|
||||
|
||||
This interactive flow helps you:
|
||||
- Create or select a distribution certificate
|
||||
- Create or select a provisioning profile
|
||||
- Configure App Store Connect API key (recommended)
|
||||
|
||||
### App Store Connect API Key (Recommended)
|
||||
|
||||
API keys avoid 2FA prompts in CI/CD:
|
||||
|
||||
1. Go to App Store Connect → Users and Access → Keys
|
||||
2. Click "+" to create a new key
|
||||
3. Select "App Manager" role (minimum for submissions)
|
||||
4. Download the `.p8` key file
|
||||
|
||||
Configure in `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascApiKeyPath": "./AuthKey_XXXXX.p8",
|
||||
"ascApiKeyIssuerId": "xxxxx-xxxx-xxxx-xxxx-xxxxx",
|
||||
"ascApiKeyId": "XXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use environment variables:
|
||||
|
||||
```bash
|
||||
EXPO_ASC_API_KEY_PATH=./AuthKey.p8
|
||||
EXPO_ASC_API_KEY_ISSUER_ID=xxxxx-xxxx-xxxx-xxxx-xxxxx
|
||||
EXPO_ASC_API_KEY_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
### Apple ID Authentication (Alternative)
|
||||
|
||||
For manual submissions, you can use Apple ID:
|
||||
|
||||
```bash
|
||||
EXPO_APPLE_ID=your@email.com
|
||||
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
Note: Requires app-specific password for accounts with 2FA.
|
||||
|
||||
## Submission Commands
|
||||
|
||||
```bash
|
||||
# Build and submit to App Store Connect
|
||||
eas build -p ios --profile production --submit
|
||||
|
||||
# Submit latest build
|
||||
eas submit -p ios --latest
|
||||
|
||||
# Submit specific build
|
||||
eas submit -p ios --id BUILD_ID
|
||||
|
||||
# Quick TestFlight submission
|
||||
npx testflight
|
||||
```
|
||||
|
||||
## App Store Connect Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
Before submitting, complete in App Store Connect:
|
||||
|
||||
1. **App Information**
|
||||
- Primary language
|
||||
- Bundle ID (must match `app.json`)
|
||||
- SKU (unique identifier)
|
||||
|
||||
2. **Pricing and Availability**
|
||||
- Price tier
|
||||
- Available countries
|
||||
|
||||
3. **App Privacy**
|
||||
- Privacy policy URL
|
||||
- Data collection declarations
|
||||
|
||||
4. **App Review Information**
|
||||
- Contact information
|
||||
- Demo account (if login required)
|
||||
- Notes for reviewers
|
||||
|
||||
### EAS Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"resourceClass": "m-medium",
|
||||
"autoIncrement": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "your@email.com",
|
||||
"ascAppId": "1234567890",
|
||||
"appleTeamId": "XXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Find `ascAppId` in App Store Connect → App Information → Apple ID.
|
||||
|
||||
## TestFlight vs App Store
|
||||
|
||||
### TestFlight (Beta Testing)
|
||||
|
||||
- Builds go to TestFlight automatically after submission
|
||||
- Internal testers (up to 100) - immediate access
|
||||
- External testers (up to 10,000) - requires beta review
|
||||
- Builds expire after 90 days
|
||||
|
||||
### App Store (Production)
|
||||
|
||||
- Requires passing App Review
|
||||
- Submit for review from App Store Connect
|
||||
- Choose release timing (immediate, scheduled, manual)
|
||||
|
||||
## App Review Process
|
||||
|
||||
### What Reviewers Check
|
||||
|
||||
1. **Functionality** - App works as described
|
||||
2. **UI/UX** - Follows Human Interface Guidelines
|
||||
3. **Content** - Appropriate and accurate
|
||||
4. **Privacy** - Data handling matches declarations
|
||||
5. **Legal** - Complies with local laws
|
||||
|
||||
### Common Rejection Reasons
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Crashes/bugs | Test thoroughly before submission |
|
||||
| Incomplete metadata | Fill all required fields |
|
||||
| Placeholder content | Remove "lorem ipsum" and test data |
|
||||
| Missing login credentials | Provide demo account |
|
||||
| Privacy policy missing | Add URL in App Store Connect |
|
||||
| Guideline 4.2 (minimum functionality) | Ensure app provides value |
|
||||
|
||||
### Expedited Review
|
||||
|
||||
Request expedited review for:
|
||||
- Critical bug fixes
|
||||
- Time-sensitive events
|
||||
- Security issues
|
||||
|
||||
Go to App Store Connect → your app → App Review → Request Expedited Review.
|
||||
|
||||
## Version and Build Numbers
|
||||
|
||||
iOS uses two version identifiers:
|
||||
|
||||
- **Version** (`CFBundleShortVersionString`): User-facing, e.g., "1.2.3"
|
||||
- **Build Number** (`CFBundleVersion`): Internal, must increment for each upload
|
||||
|
||||
Configure in `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"version": "1.2.3",
|
||||
"ios": {
|
||||
"buildNumber": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `autoIncrement: true`, EAS handles build numbers automatically.
|
||||
|
||||
## Release Options
|
||||
|
||||
### Automatic Release
|
||||
|
||||
Release immediately when approved:
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"automaticRelease": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled Release
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"automaticRelease": "2025-03-01T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phased Release
|
||||
|
||||
Gradual rollout over 7 days:
|
||||
|
||||
```json
|
||||
{
|
||||
"apple": {
|
||||
"release": {
|
||||
"phasedRelease": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rollout: Day 1 (1%) → Day 2 (2%) → Day 3 (5%) → Day 4 (10%) → Day 5 (20%) → Day 6 (50%) → Day 7 (100%)
|
||||
|
||||
## Certificates and Provisioning
|
||||
|
||||
### Distribution Certificate
|
||||
|
||||
- Required for App Store submissions
|
||||
- Limited to 3 per Apple Developer account
|
||||
- Valid for 1 year
|
||||
- EAS manages automatically
|
||||
|
||||
### Provisioning Profile
|
||||
|
||||
- Links app, certificate, and entitlements
|
||||
- App Store profiles don't include device UDIDs
|
||||
- EAS creates and manages automatically
|
||||
|
||||
### Check Current Credentials
|
||||
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
|
||||
# Sync with Apple Developer Portal
|
||||
eas credentials -p ios --sync
|
||||
```
|
||||
|
||||
## App Store Metadata
|
||||
|
||||
Use EAS Metadata to manage App Store listing from code:
|
||||
|
||||
```bash
|
||||
# Pull existing metadata
|
||||
eas metadata:pull
|
||||
|
||||
# Push changes
|
||||
eas metadata:push
|
||||
```
|
||||
|
||||
See ./app-store-metadata.md for detailed configuration.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No suitable application records found"
|
||||
|
||||
Create the app in App Store Connect first with matching bundle ID.
|
||||
|
||||
### "The bundle version must be higher"
|
||||
|
||||
Increment build number. With `autoIncrement: true`, this is automatic.
|
||||
|
||||
### "Missing compliance information"
|
||||
|
||||
Add export compliance to `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"ios": {
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "Invalid provisioning profile"
|
||||
|
||||
```bash
|
||||
eas credentials -p ios --sync
|
||||
```
|
||||
|
||||
### Build stuck in "Processing"
|
||||
|
||||
App Store Connect processing can take 5-30 minutes. Check status in App Store Connect → TestFlight.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For automated submissions in CI/CD:
|
||||
|
||||
```yaml
|
||||
# .eas/workflows/release.yml
|
||||
name: Release to App Store
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit:
|
||||
type: submit
|
||||
needs: [build]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Submit to TestFlight early and often for feedback
|
||||
- Use beta app review for external testers to catch issues before App Store review
|
||||
- Respond to reviewer questions promptly in App Store Connect
|
||||
- Keep demo account credentials up to date
|
||||
- Monitor App Store Connect notifications for review updates
|
||||
- Use phased release for major updates to catch issues early
|
||||
246
.opencode/skills/expo-deployment/references/play-store.md
Normal file
246
.opencode/skills/expo-deployment/references/play-store.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Submitting to Google Play Store
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Google Play Console Account** - Register at [play.google.com/console](https://play.google.com/console)
|
||||
2. **App Created in Console** - Create your app listing before first submission
|
||||
3. **Service Account** - For automated submissions via EAS
|
||||
|
||||
## Service Account Setup
|
||||
|
||||
### 1. Create Service Account
|
||||
|
||||
1. Go to Google Cloud Console → IAM & Admin → Service Accounts
|
||||
2. Create a new service account
|
||||
3. Grant the "Service Account User" role
|
||||
4. Create and download a JSON key
|
||||
|
||||
### 2. Link to Play Console
|
||||
|
||||
1. Go to Play Console → Setup → API access
|
||||
2. Click "Link" next to your Google Cloud project
|
||||
3. Under "Service accounts", click "Manage Play Console permissions"
|
||||
4. Grant "Release to production" permission (or appropriate track permissions)
|
||||
|
||||
### 3. Configure EAS
|
||||
|
||||
Add the service account key path to `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store the key file securely and add it to `.gitignore`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For CI/CD, use environment variables instead of file paths:
|
||||
|
||||
```bash
|
||||
# Base64-encoded service account JSON
|
||||
EXPO_ANDROID_SERVICE_ACCOUNT_KEY_BASE64=...
|
||||
```
|
||||
|
||||
Or use EAS Secrets:
|
||||
|
||||
```bash
|
||||
eas secret:create --name GOOGLE_SERVICE_ACCOUNT --value "$(cat google-service-account.json)" --type file
|
||||
```
|
||||
|
||||
Then reference in `eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "@secret:GOOGLE_SERVICE_ACCOUNT"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Release Tracks
|
||||
|
||||
Google Play uses tracks for staged rollouts:
|
||||
|
||||
| Track | Purpose |
|
||||
|-------|---------|
|
||||
| `internal` | Internal testing (up to 100 testers) |
|
||||
| `alpha` | Closed testing |
|
||||
| `beta` | Open testing |
|
||||
| `production` | Public release |
|
||||
|
||||
### Track Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"track": "production",
|
||||
"releaseStatus": "completed"
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"android": {
|
||||
"track": "internal",
|
||||
"releaseStatus": "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Release Status Options
|
||||
|
||||
- `completed` - Immediately available on the track
|
||||
- `draft` - Upload only, release manually in Console
|
||||
- `halted` - Pause an in-progress rollout
|
||||
- `inProgress` - Staged rollout (requires `rollout` percentage)
|
||||
|
||||
## Staged Rollout
|
||||
|
||||
```json
|
||||
{
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"track": "production",
|
||||
"releaseStatus": "inProgress",
|
||||
"rollout": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This releases to 10% of users. Increase via Play Console or subsequent submissions.
|
||||
|
||||
## Submission Commands
|
||||
|
||||
```bash
|
||||
# Build and submit to internal track
|
||||
eas build -p android --profile production --submit
|
||||
|
||||
# Submit existing build to Play Store
|
||||
eas submit -p android --latest
|
||||
|
||||
# Submit specific build
|
||||
eas submit -p android --id BUILD_ID
|
||||
```
|
||||
|
||||
## App Signing
|
||||
|
||||
### Google Play App Signing (Recommended)
|
||||
|
||||
EAS uses Google Play App Signing by default:
|
||||
|
||||
1. First upload: EAS creates upload key, Play Store manages signing key
|
||||
2. Play Store re-signs your app with the signing key
|
||||
3. Upload key can be reset if compromised
|
||||
|
||||
### Checking Signing Status
|
||||
|
||||
```bash
|
||||
eas credentials -p android
|
||||
```
|
||||
|
||||
## Version Codes
|
||||
|
||||
Android requires incrementing `versionCode` for each upload:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With `appVersionSource: "remote"`, EAS tracks version codes automatically.
|
||||
|
||||
## First Submission Checklist
|
||||
|
||||
Before your first Play Store submission:
|
||||
|
||||
- [ ] Create app in Google Play Console
|
||||
- [ ] Complete app content declaration (privacy policy, ads, etc.)
|
||||
- [ ] Set up store listing (title, description, screenshots)
|
||||
- [ ] Complete content rating questionnaire
|
||||
- [ ] Set up pricing and distribution
|
||||
- [ ] Create service account with proper permissions
|
||||
- [ ] Configure `eas.json` with service account path
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "App not found"
|
||||
|
||||
The app must exist in Play Console before EAS can submit. Create it manually first.
|
||||
|
||||
### "Version code already used"
|
||||
|
||||
Increment `versionCode` in `app.json` or use `autoIncrement: true` in `eas.json`.
|
||||
|
||||
### "Service account lacks permission"
|
||||
|
||||
Ensure the service account has "Release to production" permission in Play Console → API access.
|
||||
|
||||
### "APK not acceptable"
|
||||
|
||||
Play Store requires AAB (Android App Bundle) for new apps:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Testing Distribution
|
||||
|
||||
For quick internal distribution without Play Store:
|
||||
|
||||
```bash
|
||||
# Build with internal distribution
|
||||
eas build -p android --profile development
|
||||
|
||||
# Share the APK link with testers
|
||||
```
|
||||
|
||||
Or use EAS Update for OTA updates to existing installs.
|
||||
|
||||
## Monitoring Submissions
|
||||
|
||||
```bash
|
||||
# Check submission status
|
||||
eas submit:list -p android
|
||||
|
||||
# View specific submission
|
||||
eas submit:view SUBMISSION_ID
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Start with `internal` track for testing before production
|
||||
- Use staged rollouts for production releases
|
||||
- Keep service account key secure - never commit to git
|
||||
- Set up Play Console notifications for review status
|
||||
- Pre-launch reports in Play Console catch issues before review
|
||||
58
.opencode/skills/expo-deployment/references/testflight.md
Normal file
58
.opencode/skills/expo-deployment/references/testflight.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# TestFlight
|
||||
|
||||
Always ship to TestFlight first. Internal testers, then external testers, then App Store. Never skip this.
|
||||
|
||||
## Submit
|
||||
|
||||
```bash
|
||||
npx testflight
|
||||
```
|
||||
|
||||
That's it. One command builds and submits to TestFlight.
|
||||
|
||||
## Skip the Prompts
|
||||
|
||||
Set these once and forget:
|
||||
|
||||
```bash
|
||||
EXPO_APPLE_ID=you@email.com
|
||||
EXPO_APPLE_TEAM_ID=XXXXXXXXXX
|
||||
```
|
||||
|
||||
The CLI prints your Team ID when you run `npx testflight`. Copy it.
|
||||
|
||||
## Why TestFlight First
|
||||
|
||||
- Internal testers get builds instantly (no review)
|
||||
- External testers require one Beta App Review, then instant updates
|
||||
- Catch crashes before App Store review rejects you
|
||||
- TestFlight crash reports are better than App Store crash reports
|
||||
- 90 days to test before builds expire
|
||||
- Real users on real devices, not simulators
|
||||
|
||||
## Tester Strategy
|
||||
|
||||
**Internal (100 max)**: Your team. Immediate access. Use for every build.
|
||||
|
||||
**External (10,000 max)**: Beta users. First build needs review (~24h), then instant. Always have an external group—even if it's just friends. Real feedback beats assumptions.
|
||||
|
||||
## Tips
|
||||
|
||||
- Submit to external TestFlight the moment internal looks stable
|
||||
- Beta App Review is faster and more lenient than App Store Review
|
||||
- Add release notes—testers actually read them
|
||||
- Use TestFlight's built-in feedback and screenshots
|
||||
- Never go straight to App Store. Ever.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No suitable application records found"**
|
||||
Create the app in App Store Connect first. Bundle ID must match.
|
||||
|
||||
**"The bundle version must be higher"**
|
||||
Use `autoIncrement: true` in `eas.json`. Problem solved.
|
||||
|
||||
**Credentials issues**
|
||||
```bash
|
||||
eas credentials -p ios
|
||||
```
|
||||
200
.opencode/skills/expo-deployment/references/workflows.md
Normal file
200
.opencode/skills/expo-deployment/references/workflows.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# EAS Workflows
|
||||
|
||||
Automate builds, submissions, and deployments with EAS Workflows.
|
||||
|
||||
## Web Deployment
|
||||
|
||||
Deploy web apps on push to main:
|
||||
|
||||
`.eas/workflows/deploy.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# https://docs.expo.dev/eas/workflows/syntax/#deploy
|
||||
jobs:
|
||||
deploy_web:
|
||||
type: deploy
|
||||
params:
|
||||
prod: true
|
||||
```
|
||||
|
||||
## PR Previews
|
||||
|
||||
### Web PR Previews
|
||||
|
||||
```yaml
|
||||
name: Web PR Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
type: deploy
|
||||
params:
|
||||
prod: false
|
||||
```
|
||||
|
||||
### Native PR Previews with EAS Updates
|
||||
|
||||
Deploy OTA updates for pull requests:
|
||||
|
||||
```yaml
|
||||
name: PR Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
type: update
|
||||
params:
|
||||
branch: "pr-${{ github.event.pull_request.number }}"
|
||||
message: "PR #${{ github.event.pull_request.number }}"
|
||||
```
|
||||
|
||||
## Production Release
|
||||
|
||||
Complete release workflow for both platforms:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
build-android:
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
submit-ios:
|
||||
type: submit
|
||||
needs: [build-ios]
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
|
||||
submit-android:
|
||||
type: submit
|
||||
needs: [build-android]
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Build on Push
|
||||
|
||||
Trigger builds when pushing to specific branches:
|
||||
|
||||
```yaml
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
type: build
|
||||
params:
|
||||
platform: all
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Conditional Jobs
|
||||
|
||||
Run jobs based on conditions:
|
||||
|
||||
```yaml
|
||||
name: Conditional Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
type: run
|
||||
params:
|
||||
command: |
|
||||
if git diff --name-only HEAD~1 | grep -q "^src/"; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
type: build
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
params:
|
||||
platform: all
|
||||
profile: production
|
||||
```
|
||||
|
||||
## Workflow Syntax Reference
|
||||
|
||||
### Triggers
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Daily at midnight
|
||||
workflow_dispatch: # Manual trigger
|
||||
```
|
||||
|
||||
### Job Types
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `build` | Create app builds |
|
||||
| `submit` | Submit to app stores |
|
||||
| `update` | Publish OTA updates |
|
||||
| `deploy` | Deploy web apps |
|
||||
| `run` | Execute custom commands |
|
||||
|
||||
### Job Dependencies
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
first:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
|
||||
second:
|
||||
type: submit
|
||||
needs: [first] # Runs after 'first' completes
|
||||
params:
|
||||
platform: ios
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `workflow_dispatch` for manual production releases
|
||||
- Combine PR previews with GitHub status checks
|
||||
- Use tags for versioned releases
|
||||
- Keep sensitive values in EAS Secrets, not workflow files
|
||||
164
.opencode/skills/expo-dev-client/SKILL.md
Normal file
164
.opencode/skills/expo-dev-client/SKILL.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: expo-dev-client
|
||||
description: Build and distribute Expo development clients locally or via TestFlight
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
Use EAS Build to create development clients for testing native code changes on physical devices. Use this for creating custom Expo Go clients for testing branches of your app.
|
||||
|
||||
## Important: When Development Clients Are Needed
|
||||
|
||||
**Only create development clients when your app requires custom native code.** Most apps work fine in Expo Go.
|
||||
|
||||
You need a dev client ONLY when using:
|
||||
- Local Expo modules (custom native code)
|
||||
- Apple targets (widgets, app clips, extensions)
|
||||
- Third-party native modules not in Expo Go
|
||||
|
||||
**Try Expo Go first** with `npx expo start`. If everything works, you don't need a dev client.
|
||||
|
||||
## EAS Configuration
|
||||
|
||||
Ensure `eas.json` has a development profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"development": {
|
||||
"autoIncrement": true,
|
||||
"developmentClient": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {},
|
||||
"development": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- `developmentClient: true` - Bundles expo-dev-client for development builds
|
||||
- `autoIncrement: true` - Automatically increments build numbers
|
||||
- `appVersionSource: "remote"` - Uses EAS as the source of truth for version numbers
|
||||
|
||||
## Building for TestFlight
|
||||
|
||||
Build iOS dev client and submit to TestFlight in one command:
|
||||
|
||||
```bash
|
||||
eas build -p ios --profile development --submit
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the development client in the cloud
|
||||
2. Automatically submit to App Store Connect
|
||||
3. Send you an email when the build is ready in TestFlight
|
||||
|
||||
After receiving the TestFlight email:
|
||||
1. Download the build from TestFlight on your device
|
||||
2. Launch the app to see the expo-dev-client UI
|
||||
3. Connect to your local Metro bundler or scan a QR code
|
||||
|
||||
## Building Locally
|
||||
|
||||
Build a development client on your machine:
|
||||
|
||||
```bash
|
||||
# iOS (requires Xcode)
|
||||
eas build -p ios --profile development --local
|
||||
|
||||
# Android
|
||||
eas build -p android --profile development --local
|
||||
```
|
||||
|
||||
Local builds output:
|
||||
- iOS: `.ipa` file
|
||||
- Android: `.apk` or `.aab` file
|
||||
|
||||
## Installing Local Builds
|
||||
|
||||
Install iOS build on simulator:
|
||||
|
||||
```bash
|
||||
# Find the .app in the .tar.gz output
|
||||
tar -xzf build-*.tar.gz
|
||||
xcrun simctl install booted ./path/to/App.app
|
||||
```
|
||||
|
||||
Install iOS build on device (requires signing):
|
||||
|
||||
```bash
|
||||
# Use Xcode Devices window or ideviceinstaller
|
||||
ideviceinstaller -i build.ipa
|
||||
```
|
||||
|
||||
Install Android build:
|
||||
|
||||
```bash
|
||||
adb install build.apk
|
||||
```
|
||||
|
||||
## Building for Specific Platform
|
||||
|
||||
```bash
|
||||
# iOS only
|
||||
eas build -p ios --profile development
|
||||
|
||||
# Android only
|
||||
eas build -p android --profile development
|
||||
|
||||
# Both platforms
|
||||
eas build --profile development
|
||||
```
|
||||
|
||||
## Checking Build Status
|
||||
|
||||
```bash
|
||||
# List recent builds
|
||||
eas build:list
|
||||
|
||||
# View build details
|
||||
eas build:view
|
||||
```
|
||||
|
||||
## Using the Dev Client
|
||||
|
||||
Once installed, the dev client provides:
|
||||
- **Development server connection** - Enter your Metro bundler URL or scan QR
|
||||
- **Build information** - View native build details
|
||||
- **Launcher UI** - Switch between development servers
|
||||
|
||||
Connect to local development:
|
||||
|
||||
```bash
|
||||
# Start Metro bundler
|
||||
npx expo start --dev-client
|
||||
|
||||
# Scan QR code with dev client or enter URL manually
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Build fails with signing errors:**
|
||||
```bash
|
||||
eas credentials
|
||||
```
|
||||
|
||||
**Clear build cache:**
|
||||
```bash
|
||||
eas build -p ios --profile development --clear-cache
|
||||
```
|
||||
|
||||
**Check EAS CLI version:**
|
||||
```bash
|
||||
eas --version
|
||||
eas update
|
||||
```
|
||||
480
.opencode/skills/expo-tailwind-setup/SKILL.md
Normal file
480
.opencode/skills/expo-tailwind-setup/SKILL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: expo-tailwind-setup
|
||||
description: Set up Tailwind CSS v4 in Expo with react-native-css and NativeWind v5 for universal styling
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Tailwind CSS Setup for Expo with react-native-css
|
||||
|
||||
This guide covers setting up Tailwind CSS v4 in Expo using react-native-css and NativeWind v5 for universal styling across iOS, Android, and Web.
|
||||
|
||||
## Overview
|
||||
|
||||
This setup uses:
|
||||
|
||||
- **Tailwind CSS v4** - Modern CSS-first configuration
|
||||
- **react-native-css** - CSS runtime for React Native
|
||||
- **NativeWind v5** - Metro transformer for Tailwind in React Native
|
||||
- **@tailwindcss/postcss** - PostCSS plugin for Tailwind v4
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npx expo install tailwindcss@^4 nativewind@5.0.0-preview.2 react-native-css@0.0.0-nightly.5ce6396 @tailwindcss/postcss tailwind-merge clsx
|
||||
```
|
||||
|
||||
Add resolutions for lightningcss compatibility:
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"resolutions": {
|
||||
"lightningcss": "1.30.1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- autoprefixer is not needed in Expo because of lightningcss
|
||||
- postcss is included in expo by default
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Metro Config
|
||||
|
||||
Create or update `metro.config.js`:
|
||||
|
||||
```js
|
||||
// metro.config.js
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativewind } = require("nativewind/metro");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativewind(config, {
|
||||
// inline variables break PlatformColor in CSS variables
|
||||
inlineVariables: false,
|
||||
// We add className support manually
|
||||
globalClassNamePolyfill: false,
|
||||
});
|
||||
```
|
||||
|
||||
### PostCSS Config
|
||||
|
||||
Create `postcss.config.mjs`:
|
||||
|
||||
```js
|
||||
// postcss.config.mjs
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Global CSS
|
||||
|
||||
Create `src/global.css`:
|
||||
|
||||
```css
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css";
|
||||
|
||||
/* Platform-specific font families */
|
||||
@media android {
|
||||
:root {
|
||||
--font-mono: monospace;
|
||||
--font-rounded: normal;
|
||||
--font-serif: serif;
|
||||
--font-sans: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media ios {
|
||||
:root {
|
||||
--font-mono: ui-monospace;
|
||||
--font-serif: ui-serif;
|
||||
--font-sans: system-ui;
|
||||
--font-rounded: ui-rounded;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IMPORTANT: No Babel Config Needed
|
||||
|
||||
With Tailwind v4 and NativeWind v5, you do NOT need a babel.config.js for Tailwind. Remove any NativeWind babel presets if present:
|
||||
|
||||
```js
|
||||
// DELETE babel.config.js if it only contains NativeWind config
|
||||
// The following is NO LONGER needed:
|
||||
// module.exports = function (api) {
|
||||
// api.cache(true);
|
||||
// return {
|
||||
// presets: [
|
||||
// ["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
// "nativewind/babel",
|
||||
// ],
|
||||
// };
|
||||
// };
|
||||
```
|
||||
|
||||
## CSS Component Wrappers
|
||||
|
||||
Since react-native-css requires explicit CSS element wrapping, create reusable components:
|
||||
|
||||
### Main Components (`src/tw/index.tsx`)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useCssElement,
|
||||
useNativeVariable as useFunctionalVariable,
|
||||
} from "react-native-css";
|
||||
|
||||
import { Link as RouterLink } from "expo-router";
|
||||
import Animated from "react-native-reanimated";
|
||||
import React from "react";
|
||||
import {
|
||||
View as RNView,
|
||||
Text as RNText,
|
||||
Pressable as RNPressable,
|
||||
ScrollView as RNScrollView,
|
||||
TouchableHighlight as RNTouchableHighlight,
|
||||
TextInput as RNTextInput,
|
||||
StyleSheet,
|
||||
} from "react-native";
|
||||
|
||||
// CSS-enabled Link
|
||||
export const Link = (
|
||||
props: React.ComponentProps<typeof RouterLink> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RouterLink, props, { className: "style" });
|
||||
};
|
||||
|
||||
Link.Trigger = RouterLink.Trigger;
|
||||
Link.Menu = RouterLink.Menu;
|
||||
Link.MenuAction = RouterLink.MenuAction;
|
||||
Link.Preview = RouterLink.Preview;
|
||||
|
||||
// CSS Variable hook
|
||||
export const useCSSVariable =
|
||||
process.env.EXPO_OS !== "web"
|
||||
? useFunctionalVariable
|
||||
: (variable: string) => `var(${variable})`;
|
||||
|
||||
// View
|
||||
export type ViewProps = React.ComponentProps<typeof RNView> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const View = (props: ViewProps) => {
|
||||
return useCssElement(RNView, props, { className: "style" });
|
||||
};
|
||||
View.displayName = "CSS(View)";
|
||||
|
||||
// Text
|
||||
export const Text = (
|
||||
props: React.ComponentProps<typeof RNText> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNText, props, { className: "style" });
|
||||
};
|
||||
Text.displayName = "CSS(Text)";
|
||||
|
||||
// ScrollView
|
||||
export const ScrollView = (
|
||||
props: React.ComponentProps<typeof RNScrollView> & {
|
||||
className?: string;
|
||||
contentContainerClassName?: string;
|
||||
}
|
||||
) => {
|
||||
return useCssElement(RNScrollView, props, {
|
||||
className: "style",
|
||||
contentContainerClassName: "contentContainerStyle",
|
||||
});
|
||||
};
|
||||
ScrollView.displayName = "CSS(ScrollView)";
|
||||
|
||||
// Pressable
|
||||
export const Pressable = (
|
||||
props: React.ComponentProps<typeof RNPressable> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNPressable, props, { className: "style" });
|
||||
};
|
||||
Pressable.displayName = "CSS(Pressable)";
|
||||
|
||||
// TextInput
|
||||
export const TextInput = (
|
||||
props: React.ComponentProps<typeof RNTextInput> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(RNTextInput, props, { className: "style" });
|
||||
};
|
||||
TextInput.displayName = "CSS(TextInput)";
|
||||
|
||||
// AnimatedScrollView
|
||||
export const AnimatedScrollView = (
|
||||
props: React.ComponentProps<typeof Animated.ScrollView> & {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
contentContainerClassName?: string;
|
||||
}
|
||||
) => {
|
||||
return useCssElement(Animated.ScrollView, props, {
|
||||
className: "style",
|
||||
contentClassName: "contentContainerStyle",
|
||||
contentContainerClassName: "contentContainerStyle",
|
||||
});
|
||||
};
|
||||
|
||||
// TouchableHighlight with underlayColor extraction
|
||||
function XXTouchableHighlight(
|
||||
props: React.ComponentProps<typeof RNTouchableHighlight>
|
||||
) {
|
||||
const { underlayColor, ...style } = StyleSheet.flatten(props.style) || {};
|
||||
return (
|
||||
<RNTouchableHighlight
|
||||
underlayColor={underlayColor}
|
||||
{...props}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const TouchableHighlight = (
|
||||
props: React.ComponentProps<typeof RNTouchableHighlight>
|
||||
) => {
|
||||
return useCssElement(XXTouchableHighlight, props, { className: "style" });
|
||||
};
|
||||
TouchableHighlight.displayName = "CSS(TouchableHighlight)";
|
||||
```
|
||||
|
||||
### Image Component (`src/tw/image.tsx`)
|
||||
|
||||
```tsx
|
||||
import { useCssElement } from "react-native-css";
|
||||
import React from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { Image as RNImage } from "expo-image";
|
||||
|
||||
const AnimatedExpoImage = Animated.createAnimatedComponent(RNImage);
|
||||
|
||||
export type ImageProps = React.ComponentProps<typeof Image>;
|
||||
|
||||
function CSSImage(props: React.ComponentProps<typeof AnimatedExpoImage>) {
|
||||
// @ts-expect-error: Remap objectFit style to contentFit property
|
||||
const { objectFit, objectPosition, ...style } =
|
||||
StyleSheet.flatten(props.style) || {};
|
||||
|
||||
return (
|
||||
<AnimatedExpoImage
|
||||
contentFit={objectFit}
|
||||
contentPosition={objectPosition}
|
||||
{...props}
|
||||
source={
|
||||
typeof props.source === "string" ? { uri: props.source } : props.source
|
||||
}
|
||||
// @ts-expect-error: Style is remapped above
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Image = (
|
||||
props: React.ComponentProps<typeof CSSImage> & { className?: string }
|
||||
) => {
|
||||
return useCssElement(CSSImage, props, { className: "style" });
|
||||
};
|
||||
|
||||
Image.displayName = "CSS(Image)";
|
||||
```
|
||||
|
||||
### Animated Components (`src/tw/animated.tsx`)
|
||||
|
||||
```tsx
|
||||
import * as TW from "./index";
|
||||
import RNAnimated from "react-native-reanimated";
|
||||
|
||||
export const Animated = {
|
||||
...RNAnimated,
|
||||
View: RNAnimated.createAnimatedComponent(TW.View),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Import CSS-wrapped components from your tw directory:
|
||||
|
||||
```tsx
|
||||
import { View, Text, ScrollView, Image } from "@/tw";
|
||||
|
||||
export default function MyScreen() {
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="p-4 gap-4">
|
||||
<Text className="text-xl font-bold text-gray-900">Hello Tailwind!</Text>
|
||||
<Image
|
||||
className="w-full h-48 rounded-lg object-cover"
|
||||
source={{ uri: "https://example.com/image.jpg" }}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Theme Variables
|
||||
|
||||
Add custom theme variables in your global.css using `@theme`:
|
||||
|
||||
```css
|
||||
@layer theme {
|
||||
@theme {
|
||||
/* Custom fonts */
|
||||
--font-rounded: "SF Pro Rounded", sans-serif;
|
||||
|
||||
/* Custom line heights */
|
||||
--text-xs--line-height: calc(1em / 0.75);
|
||||
--text-sm--line-height: calc(1.25em / 0.875);
|
||||
--text-base--line-height: calc(1.5em / 1);
|
||||
|
||||
/* Custom leading scales */
|
||||
--leading-tight: 1.25em;
|
||||
--leading-snug: 1.375em;
|
||||
--leading-normal: 1.5em;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Platform-Specific Styles
|
||||
|
||||
Use platform media queries for platform-specific styling:
|
||||
|
||||
```css
|
||||
@media ios {
|
||||
:root {
|
||||
--font-sans: system-ui;
|
||||
--font-rounded: ui-rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@media android {
|
||||
:root {
|
||||
--font-sans: normal;
|
||||
--font-rounded: normal;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Apple System Colors with CSS Variables
|
||||
|
||||
Create a CSS file for Apple semantic colors:
|
||||
|
||||
```css
|
||||
/* src/css/sf.css */
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Accent colors with light/dark mode */
|
||||
--sf-blue: light-dark(rgb(0 122 255), rgb(10 132 255));
|
||||
--sf-green: light-dark(rgb(52 199 89), rgb(48 209 89));
|
||||
--sf-red: light-dark(rgb(255 59 48), rgb(255 69 58));
|
||||
|
||||
/* Gray scales */
|
||||
--sf-gray: light-dark(rgb(142 142 147), rgb(142 142 147));
|
||||
--sf-gray-2: light-dark(rgb(174 174 178), rgb(99 99 102));
|
||||
|
||||
/* Text colors */
|
||||
--sf-text: light-dark(rgb(0 0 0), rgb(255 255 255));
|
||||
--sf-text-2: light-dark(rgb(60 60 67 / 0.6), rgb(235 235 245 / 0.6));
|
||||
|
||||
/* Background colors */
|
||||
--sf-bg: light-dark(rgb(255 255 255), rgb(0 0 0));
|
||||
--sf-bg-2: light-dark(rgb(242 242 247), rgb(28 28 30));
|
||||
}
|
||||
|
||||
/* iOS native colors via platformColor */
|
||||
@media ios {
|
||||
:root {
|
||||
--sf-blue: platformColor(systemBlue);
|
||||
--sf-green: platformColor(systemGreen);
|
||||
--sf-red: platformColor(systemRed);
|
||||
--sf-gray: platformColor(systemGray);
|
||||
--sf-text: platformColor(label);
|
||||
--sf-text-2: platformColor(secondaryLabel);
|
||||
--sf-bg: platformColor(systemBackground);
|
||||
--sf-bg-2: platformColor(secondarySystemBackground);
|
||||
}
|
||||
}
|
||||
|
||||
/* Register as Tailwind theme colors */
|
||||
@layer theme {
|
||||
@theme {
|
||||
--color-sf-blue: var(--sf-blue);
|
||||
--color-sf-green: var(--sf-green);
|
||||
--color-sf-red: var(--sf-red);
|
||||
--color-sf-gray: var(--sf-gray);
|
||||
--color-sf-text: var(--sf-text);
|
||||
--color-sf-text-2: var(--sf-text-2);
|
||||
--color-sf-bg: var(--sf-bg);
|
||||
--color-sf-bg-2: var(--sf-bg-2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then use in components:
|
||||
|
||||
```tsx
|
||||
<Text className="text-sf-text">Primary text</Text>
|
||||
<Text className="text-sf-text-2">Secondary text</Text>
|
||||
<View className="bg-sf-bg">...</View>
|
||||
```
|
||||
|
||||
## Using CSS Variables in JavaScript
|
||||
|
||||
Use the `useCSSVariable` hook:
|
||||
|
||||
```tsx
|
||||
import { useCSSVariable } from "@/tw";
|
||||
|
||||
function MyComponent() {
|
||||
const blue = useCSSVariable("--sf-blue");
|
||||
|
||||
return <View style={{ borderColor: blue }} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Key Differences from NativeWind v4 / Tailwind v3
|
||||
|
||||
1. **No babel.config.js** - Configuration is now CSS-first
|
||||
2. **PostCSS plugin** - Uses `@tailwindcss/postcss` instead of `tailwindcss`
|
||||
3. **CSS imports** - Use `@import "tailwindcss/..."` instead of `@tailwind` directives
|
||||
4. **Theme config** - Use `@theme` in CSS instead of `tailwind.config.js`
|
||||
5. **Component wrappers** - Must wrap components with `useCssElement` for className support
|
||||
6. **Metro config** - Use `withNativewind` with different options (`inlineVariables: false`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Styles not applying
|
||||
|
||||
1. Ensure you have the CSS file imported in your app entry
|
||||
2. Check that components are wrapped with `useCssElement`
|
||||
3. Verify Metro config has `withNativewind` applied
|
||||
|
||||
### Platform colors not working
|
||||
|
||||
1. Use `platformColor()` in `@media ios` blocks
|
||||
2. Fall back to `light-dark()` for web/Android
|
||||
|
||||
### TypeScript errors
|
||||
|
||||
Add className to component props:
|
||||
|
||||
```tsx
|
||||
type Props = React.ComponentProps<typeof RNView> & { className?: string };
|
||||
```
|
||||
507
.opencode/skills/native-data-fetching/SKILL.md
Normal file
507
.opencode/skills/native-data-fetching/SKILL.md
Normal file
@@ -0,0 +1,507 @@
|
||||
---
|
||||
name: native-data-fetching
|
||||
description: Use when implementing or debugging ANY network request, API call, or data fetching. Covers fetch API, React Query, SWR, error handling, caching, offline support, and Expo Router data loaders (useLoaderData).
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Expo Networking
|
||||
|
||||
**You MUST use this skill for ANY networking work including API requests, data fetching, caching, or network debugging.**
|
||||
|
||||
## References
|
||||
|
||||
Consult these resources as needed:
|
||||
|
||||
```
|
||||
references/
|
||||
expo-router-loaders.md Route-level data loading with Expo Router loaders (web, SDK 55+)
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when:
|
||||
|
||||
- Implementing API requests
|
||||
- Setting up data fetching (React Query, SWR)
|
||||
- Using Expo Router data loaders (`useLoaderData`, web SDK 55+)
|
||||
- Debugging network failures
|
||||
- Implementing caching strategies
|
||||
- Handling offline scenarios
|
||||
- Authentication/token management
|
||||
- Configuring API URLs and environment variables
|
||||
|
||||
## Preferences
|
||||
|
||||
- Avoid axios, prefer expo/fetch
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### 1. Basic Fetch Usage
|
||||
|
||||
**Simple GET request**:
|
||||
|
||||
```tsx
|
||||
const fetchUser = async (userId: string) => {
|
||||
const response = await fetch(`https://api.example.com/users/${userId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
**POST request with body**:
|
||||
|
||||
```tsx
|
||||
const createUser = async (userData: UserData) => {
|
||||
const response = await fetch("https://api.example.com/users", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. React Query (TanStack Query)
|
||||
|
||||
**Setup**:
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Stack />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Fetching data**:
|
||||
|
||||
```tsx
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (error) return <Error message={error.message} />;
|
||||
|
||||
return <Profile user={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutations**:
|
||||
|
||||
```tsx
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function CreateUserForm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: UserData) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
return <Form onSubmit={handleSubmit} isLoading={mutation.isPending} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
**Comprehensive error handling**:
|
||||
|
||||
```tsx
|
||||
class ApiError extends Error {
|
||||
constructor(message: string, public status: number, public code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
error.message || "Request failed",
|
||||
response.status,
|
||||
error.code
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
// Network error (no internet, timeout, etc.)
|
||||
throw new ApiError("Network error", 0, "NETWORK_ERROR");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Retry logic**:
|
||||
|
||||
```tsx
|
||||
const fetchWithRetry = async (
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
retries = 3
|
||||
) => {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await fetchWithErrorHandling(url, options);
|
||||
} catch (error) {
|
||||
if (i === retries - 1) throw error;
|
||||
// Exponential backoff
|
||||
await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Authentication
|
||||
|
||||
**Token management**:
|
||||
|
||||
```tsx
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const TOKEN_KEY = "auth_token";
|
||||
|
||||
export const auth = {
|
||||
getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
|
||||
setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
|
||||
removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
};
|
||||
|
||||
// Authenticated fetch wrapper
|
||||
const authFetch = async (url: string, options: RequestInit = {}) => {
|
||||
const token = await auth.getToken();
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Token refresh**:
|
||||
|
||||
```tsx
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
const getValidToken = async (): Promise<string> => {
|
||||
const token = await auth.getToken();
|
||||
|
||||
if (!token || isTokenExpired(token)) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshToken().finally(() => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise!;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Offline Support
|
||||
|
||||
**Check network status**:
|
||||
|
||||
```tsx
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
|
||||
// Hook for network status
|
||||
function useNetworkStatus() {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setIsOnline(state.isConnected ?? true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
```
|
||||
|
||||
**Offline-first with React Query**:
|
||||
|
||||
```tsx
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
|
||||
// Sync React Query with network status
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setOnline(state.isConnected ?? true);
|
||||
});
|
||||
});
|
||||
|
||||
// Queries will pause when offline and resume when online
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Environment Variables
|
||||
|
||||
**Using environment variables for API configuration**:
|
||||
|
||||
Expo supports environment variables with the `EXPO_PUBLIC_` prefix. These are inlined at build time and available in your JavaScript code.
|
||||
|
||||
```tsx
|
||||
// .env
|
||||
EXPO_PUBLIC_API_URL=https://api.example.com
|
||||
EXPO_PUBLIC_API_VERSION=v1
|
||||
|
||||
// Usage in code
|
||||
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const response = await fetch(`${API_URL}/users`);
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
**Environment-specific configuration**:
|
||||
|
||||
```tsx
|
||||
// .env.development
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
// .env.production
|
||||
EXPO_PUBLIC_API_URL=https://api.production.com
|
||||
```
|
||||
|
||||
**Creating an API client with environment config**:
|
||||
|
||||
```tsx
|
||||
// api/client.ts
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||
|
||||
if (!BASE_URL) {
|
||||
throw new Error("EXPO_PUBLIC_API_URL is not defined");
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: async <T,>(path: string): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
post: async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Important notes**:
|
||||
|
||||
- Only variables prefixed with `EXPO_PUBLIC_` are exposed to the client bundle
|
||||
- Never put secrets (API keys with write access, database passwords) in `EXPO_PUBLIC_` variables—they're visible in the built app
|
||||
- Environment variables are inlined at **build time**, not runtime
|
||||
- Restart the dev server after changing `.env` files
|
||||
- For server-side secrets in API routes, use variables without the `EXPO_PUBLIC_` prefix
|
||||
|
||||
**TypeScript support**:
|
||||
|
||||
```tsx
|
||||
// types/env.d.ts
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
EXPO_PUBLIC_API_URL: string;
|
||||
EXPO_PUBLIC_API_VERSION?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Request Cancellation
|
||||
|
||||
**Cancel on unmount**:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then((response) => response.json())
|
||||
.then(setData)
|
||||
.catch((error) => {
|
||||
if (error.name !== "AbortError") {
|
||||
setError(error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [url]);
|
||||
```
|
||||
|
||||
**With React Query** (automatic):
|
||||
|
||||
```tsx
|
||||
// React Query automatically cancels requests when queries are invalidated
|
||||
// or components unmount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
User asks about networking
|
||||
|-- Route-level data loading (web, SDK 55+)?
|
||||
| \-- Expo Router loaders — see references/expo-router-loaders.md
|
||||
|
|
||||
|-- Basic fetch?
|
||||
| \-- Use fetch API with error handling
|
||||
|
|
||||
|-- Need caching/state management?
|
||||
| |-- Complex app -> React Query (TanStack Query)
|
||||
| \-- Simpler needs -> SWR or custom hooks
|
||||
|
|
||||
|-- Authentication?
|
||||
| |-- Token storage -> expo-secure-store
|
||||
| \-- Token refresh -> Implement refresh flow
|
||||
|
|
||||
|-- Error handling?
|
||||
| |-- Network errors -> Check connectivity first
|
||||
| |-- HTTP errors -> Parse response, throw typed errors
|
||||
| \-- Retries -> Exponential backoff
|
||||
|
|
||||
|-- Offline support?
|
||||
| |-- Check status -> NetInfo
|
||||
| \-- Queue requests -> React Query persistence
|
||||
|
|
||||
|-- Environment/API config?
|
||||
| |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
|
||||
| |-- Server secrets -> Non-prefixed env vars (API routes only)
|
||||
| \-- Multiple environments -> .env.development, .env.production
|
||||
|
|
||||
\-- Performance?
|
||||
|-- Caching -> React Query with staleTime
|
||||
|-- Deduplication -> React Query handles this
|
||||
\-- Cancellation -> AbortController or React Query
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Wrong: No error handling**
|
||||
|
||||
```tsx
|
||||
const data = await fetch(url).then((r) => r.json());
|
||||
```
|
||||
|
||||
**Right: Check response status**
|
||||
|
||||
```tsx
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
**Wrong: Storing tokens in AsyncStorage**
|
||||
|
||||
```tsx
|
||||
await AsyncStorage.setItem("token", token); // Not secure!
|
||||
```
|
||||
|
||||
**Right: Use SecureStore for sensitive data**
|
||||
|
||||
```tsx
|
||||
await SecureStore.setItemAsync("token", token);
|
||||
```
|
||||
|
||||
## Example Invocations
|
||||
|
||||
User: "How do I make API calls in React Native?"
|
||||
-> Use fetch, wrap with error handling
|
||||
|
||||
User: "Should I use React Query or SWR?"
|
||||
-> React Query for complex apps, SWR for simpler needs
|
||||
|
||||
User: "My app needs to work offline"
|
||||
-> Use NetInfo for status, React Query persistence for caching
|
||||
|
||||
User: "How do I handle authentication tokens?"
|
||||
-> Store in expo-secure-store, implement refresh flow
|
||||
|
||||
User: "API calls are slow"
|
||||
-> Check caching strategy, use React Query staleTime
|
||||
|
||||
User: "How do I configure different API URLs for dev and prod?"
|
||||
-> Use EXPO*PUBLIC* env vars with .env.development and .env.production files
|
||||
|
||||
User: "Where should I put my API key?"
|
||||
-> Client-safe keys: EXPO*PUBLIC* in .env. Secret keys: non-prefixed env vars in API routes only
|
||||
|
||||
User: "How do I load data for a page in Expo Router?"
|
||||
-> See references/expo-router-loaders.md for route-level loaders (web, SDK 55+). For native, use React Query or fetch.
|
||||
@@ -0,0 +1,341 @@
|
||||
# Expo Router Data Loaders
|
||||
|
||||
Route-level data loading for web apps using Expo SDK 55+. Loaders are async functions exported from route files that load data before the route renders, following the Remix/React Router loader model.
|
||||
|
||||
**Dual execution model:**
|
||||
|
||||
- **Initial page load (SSR):** The loader runs server-side. Its return value is serialized as JSON and embedded in the HTML response.
|
||||
- **Client-side navigation:** The browser fetches the loader data from the server via HTTP. The route renders once the data arrives.
|
||||
|
||||
You write one function and the framework manages when and how it executes.
|
||||
|
||||
## Configuration
|
||||
|
||||
**Requirements:** Expo SDK 55+, web output mode (`npx expo serve` or `npx expo export --platform web`) set in `app.json` or `app.config.js`.
|
||||
|
||||
**Server rendering:**
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"web": {
|
||||
"output": "server"
|
||||
},
|
||||
"plugins": [
|
||||
["expo-router", {
|
||||
"unstable_useServerDataLoaders": true,
|
||||
"unstable_useServerRendering": true
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Static/SSG:**
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"web": {
|
||||
"output": "static"
|
||||
},
|
||||
"plugins": [
|
||||
["expo-router", {
|
||||
"unstable_useServerDataLoaders": true
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| | `"server"` | `"static"` |
|
||||
|---|-----------|------------|
|
||||
| `unstable_useServerDataLoaders` | Required | Required |
|
||||
| `unstable_useServerRendering` | Required | Not required |
|
||||
| Loader runs on | Live server (every request) | Build time (static generation) |
|
||||
| `request` object | Full access (headers, cookies) | Not available |
|
||||
| Hosting | Node.js server (EAS Hosting) | Any static host (Netlify, Vercel, S3) |
|
||||
|
||||
## Imports
|
||||
|
||||
Loaders use two packages:
|
||||
|
||||
- **`expo-router`** — `useLoaderData` hook
|
||||
- **`expo-server`** — `LoaderFunction` type, `StatusError`, `setResponseHeaders`. Always available (dependency of `expo-router`), no install needed.
|
||||
|
||||
## Basic Loader
|
||||
|
||||
For loaders without params, a plain async function works:
|
||||
|
||||
```tsx
|
||||
// app/posts/index.tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
export async function loader() {
|
||||
const response = await fetch("https://api.example.com/posts");
|
||||
const posts = await response.json();
|
||||
return { posts };
|
||||
}
|
||||
|
||||
function PostList() {
|
||||
const { posts } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{posts.map((post) => (
|
||||
<Text key={post.id}>{post.title}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
return (
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useLoaderData` is typed via `typeof loader` — the generic parameter infers the return type.
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
For loaders with params, use the `LoaderFunction<T>` type from `expo-server`. The first argument is the request (an immutable `Request`-like object, or `undefined` in static mode). The second is `params` (`Record<string, string | string[]>`), which contains **path parameters only**. Access individual params with a cast like `params.id as string`. For query parameters, use `new URL(request.url).searchParams`:
|
||||
|
||||
```tsx
|
||||
// app/posts/[id].tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { StatusError, type LoaderFunction } from "expo-server";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
type Post = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction<{ post: Post }> = async (
|
||||
request,
|
||||
params,
|
||||
) => {
|
||||
const id = params.id as string;
|
||||
const response = await fetch(`https://api.example.com/posts/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StatusError(404, `Post ${id} not found`);
|
||||
}
|
||||
|
||||
const post: Post = await response.json();
|
||||
return { post };
|
||||
};
|
||||
|
||||
function PostContent() {
|
||||
const { post } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>{post.title}</Text>
|
||||
<Text>{post.body}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PostDetail() {
|
||||
return (
|
||||
<Suspense fallback={<ActivityIndicator size="large" />}>
|
||||
<PostContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Catch-all routes access `params.slug` the same way:
|
||||
|
||||
```tsx
|
||||
// app/docs/[...slug].tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
type Doc = { title: string; content: string };
|
||||
|
||||
export const loader: LoaderFunction<{ doc: Doc }> = async (request, params) => {
|
||||
const slug = params.slug as string[];
|
||||
const path = slug.join("/");
|
||||
const doc = await fetchDoc(path);
|
||||
return { doc };
|
||||
};
|
||||
```
|
||||
|
||||
Query parameters are available via the `request` object (server output mode only):
|
||||
|
||||
```tsx
|
||||
// app/search.tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ results: any[]; query: string }> = async (request) => {
|
||||
// Assuming request.url is `/search?q=expo&page=2`
|
||||
const url = new URL(request!.url);
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
|
||||
const results = await fetchSearchResults(query, page);
|
||||
return { results, query };
|
||||
};
|
||||
```
|
||||
|
||||
## Server-Side Secrets & Request Access
|
||||
|
||||
Loaders run on the server, so you can access secrets and server-only resources directly:
|
||||
|
||||
```tsx
|
||||
// app/dashboard.tsx
|
||||
import { type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ balance: any; isAuthenticated: boolean }> = async (
|
||||
request,
|
||||
params,
|
||||
) => {
|
||||
const data = await fetch("https://api.stripe.com/v1/balance", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionToken = request?.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];
|
||||
|
||||
const balance = await data.json();
|
||||
return { balance, isAuthenticated: !!sessionToken };
|
||||
};
|
||||
```
|
||||
|
||||
The `request` object is available in server output mode. In static output mode, `request` is always `undefined`.
|
||||
|
||||
## Response Utilities
|
||||
|
||||
### Setting Response Headers
|
||||
|
||||
```tsx
|
||||
// app/products.tsx
|
||||
import { setResponseHeaders } from "expo-server";
|
||||
|
||||
export async function loader() {
|
||||
setResponseHeaders({
|
||||
"Cache-Control": "public, max-age=300",
|
||||
});
|
||||
|
||||
const products = await fetchProducts();
|
||||
return { products };
|
||||
}
|
||||
```
|
||||
|
||||
### Throwing HTTP Errors
|
||||
|
||||
```tsx
|
||||
// app/products/[id].tsx
|
||||
import { StatusError, type LoaderFunction } from "expo-server";
|
||||
|
||||
export const loader: LoaderFunction<{ product: Product }> = async (request, params) => {
|
||||
const id = params.id as string;
|
||||
const product = await fetchProduct(id);
|
||||
|
||||
if (!product) {
|
||||
throw new StatusError(404, "Product not found");
|
||||
}
|
||||
|
||||
return { product };
|
||||
};
|
||||
```
|
||||
|
||||
## Suspense & Error Boundaries
|
||||
|
||||
### Loading States with Suspense
|
||||
|
||||
`useLoaderData()` suspends during client-side navigation. Push it into a child component and wrap with `<Suspense>`:
|
||||
|
||||
```tsx
|
||||
// app/posts/index.tsx
|
||||
import { Suspense } from "react";
|
||||
import { useLoaderData } from "expo-router";
|
||||
import { ActivityIndicator, View, Text } from "react-native";
|
||||
|
||||
export async function loader() {
|
||||
const response = await fetch("https://api.example.com/posts");
|
||||
return { posts: await response.json() };
|
||||
}
|
||||
|
||||
function PostList() {
|
||||
const { posts } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{posts.map((post) => (
|
||||
<Text key={post.id}>{post.title}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Posts() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<PostList />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `<Suspense>` boundary must be above the component calling `useLoaderData()`. On initial page load the data is already in the HTML, suspension only occurs during client-side navigation.
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
```tsx
|
||||
// app/posts/[id].tsx
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<Text>Error: {error.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
When a loader throws (including `StatusError`), the nearest `ErrorBoundary` catches it.
|
||||
|
||||
## Static vs Server Rendering
|
||||
|
||||
| | Server (`"server"`) | Static (`"static"`) |
|
||||
|---|---|---|
|
||||
| **When loader runs** | Every request (live) | At build time (`npx expo export`) |
|
||||
| **Data freshness** | Fresh on initial server request | Stale until next build |
|
||||
| **`request` object** | Full access | Not available |
|
||||
| **Hosting** | Node.js server (EAS Hosting) | Any static host |
|
||||
| **Use case** | Personalized/dynamic content | Marketing pages, blogs, docs |
|
||||
|
||||
**Choose server** when data changes frequently or content is personalized (cookies, auth, headers).
|
||||
|
||||
**Choose static** when content is the same for all users and changes infrequently.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Loaders are web-only; use client-side fetching (React Query, fetch) for native
|
||||
- Loaders cannot be used in `_layout` files — only in route files
|
||||
- Use `LoaderFunction<T>` from `expo-server` to type loaders that use params
|
||||
- The request object is immutable — use optional chaining (`request?.headers`) as it may be `undefined` in static mode
|
||||
- Return only JSON-serializable values (no `Date`, `Map`, `Set`, class instances, functions)
|
||||
- Use non-prefixed `process.env` vars for secrets in loaders, not `EXPO_PUBLIC_` (which is embedded in the client bundle)
|
||||
- Use `StatusError` from `expo-server` for HTTP error responses
|
||||
- Use `setResponseHeaders` from `expo-server` to set headers
|
||||
- Export `ErrorBoundary` from route files to handle loader failures gracefully
|
||||
- Validate and sanitize user input (params, query strings) before using in database queries or API calls
|
||||
- Handle errors gracefully with try/catch; log server-side for debugging
|
||||
- Loader data is currently cached for the session. This is a known limitation that will be lifted in a future release
|
||||
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
355
.opencode/skills/tabatago-production-tracker/SKILL.md
Normal file
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: tabatago-production-tracker
|
||||
description: >
|
||||
Inventaire complet des features et tracker de production pour l'application TabataGo.
|
||||
Utilise ce skill pour savoir si l'app est prête pour la production, connaître le statut
|
||||
de chaque feature, créer des tickets, mettre à jour la progression, ou générer un rapport
|
||||
de production-readiness. Déclenche ce skill dès que l'utilisateur mentionne : "prêt pour
|
||||
la prod", "statut des features", "qu'est-ce qu'il reste à faire", "production checklist",
|
||||
"feature inventory", "roadmap", ou demande si une feature spécifique de TabataGo est
|
||||
implementée. Ce skill est la source de vérité pour savoir ce qui est fait, ce qui est
|
||||
en cours, et ce qui bloque la mise en production.
|
||||
---
|
||||
|
||||
# TabataGo — Feature Inventory & Production Readiness
|
||||
|
||||
## Comment utiliser ce skill
|
||||
|
||||
1. **Rapport de statut** → Lire la section FEATURE INVENTORY et afficher un tableau de bord visuel
|
||||
2. **Mettre à jour une feature** → Modifier son statut dans FEATURE INVENTORY ci-dessous
|
||||
3. **Savoir si on est prêt pour la prod** → Lire la section PRODUCTION GATE CONDITIONS
|
||||
4. **Ajouter une feature** → Ajouter une entrée dans la bonne épopée
|
||||
|
||||
Statuts possibles :
|
||||
- `[ ]` — À faire
|
||||
- `[~]` — En cours
|
||||
- `[x]` — Fait et testé
|
||||
- `[!]` — Bloquant / Problème
|
||||
|
||||
---
|
||||
|
||||
## FEATURE INVENTORY
|
||||
|
||||
### ÉPOPÉE 1 — FUNNEL D'ACQUISITION (Onboarding)
|
||||
|
||||
> **Note :** L'onboarding est implémenté comme un funnel 6 écrans dans `app/onboarding.tsx` (1342 lignes) :
|
||||
> ProblemScreen → EmpathyScreen → SolutionScreen → WowScreen → PersonalizationScreen → PaywallScreen.
|
||||
> Les US-01 à US-04 sont mappées sur ces 6 écrans.
|
||||
|
||||
#### US-01 · Écran Problème (Pain Point)
|
||||
**Story :** En tant que nouvel utilisateur, je vois en premier l'écran qui met en évidence mon problème (manque de temps, pas de salle, manque de motivation) afin de me sentir compris avant qu'on me propose une solution.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-001 | Écran hero avec headline "Le sport sans excuses" | `[x]` | P0 | `ProblemScreen` avec animations clock + text |
|
||||
| F-002 | 3 pain points illustrés (pas de temps / pas d'équipement / pas de motivation) | `[x]` | P0 | `EmpathyScreen` — barriers grid avec icônes |
|
||||
| F-003 | CTA "Commencer" → navigation vers choix raison | `[x]` | P0 | `onNext()` entre chaque step |
|
||||
| F-004 | Animation d'entrée (FadeIn + SlideUp) | `[x]` | P1 | Spring + timing animations avec Animated API |
|
||||
|
||||
#### US-02 · Écran Raison (Why Screen)
|
||||
**Story :** En tant que nouvel utilisateur, je sélectionne ma raison principale de ne pas faire de sport afin que l'app me propose un parcours personnalisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-005 | Sélection parmi : Pas le temps / Ne sais pas comment / Pas d'équipement / Manque de motivation | `[x]` | P0 | `EmpathyScreen` — 4 barrier options avec multi-select (max 2) |
|
||||
| F-006 | Chaque option avec icône + label + sous-titre court | `[x]` | P0 | `barrierCard` avec icône + i18n labels |
|
||||
| F-007 | Enregistrement du choix dans le profil local | `[x]` | P0 | Via `useUserStore` + `completeOnboarding({barriers})` |
|
||||
| F-008 | Progression (barre step 1/4) | `[x]` | P1 | TOTAL_STEPS = 6, step indicator intégré |
|
||||
|
||||
#### US-03 · Écran Solution (Tabata Pitch)
|
||||
**Story :** En tant qu'utilisateur ayant identifié son problème, je découvre comment Tabata résout précisément MON problème afin d'être convaincu de continuer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-009 | Affichage dynamique selon la raison choisie (US-02) | `[x]` | P0 | `SolutionScreen` — contexte basé sur barriers |
|
||||
| F-010 | Explication Tabata : 20s effort / 10s repos / 8 rounds / 4 min | `[x]` | P0 | Dans `SolutionScreen` |
|
||||
| F-011 | 3 avantages clés avec icônes (scientifiquement prouvé, sans matériel, court) | `[x]` | P0 | Avec icônes SF Symbols |
|
||||
| F-012 | CTA "Je veux essayer" → navigation vers config profil | `[x]` | P0 | `onNext()` → PersonalizationScreen |
|
||||
| F-013 | Animation des bénéfices (StaggerList) | `[x]` | P2 | `WowScreen` — stagger animation rows + CTA fadeIn |
|
||||
|
||||
#### US-04 · Configuration Profil
|
||||
**Story :** En tant que nouvel utilisateur convaincu, je renseigne mon prénom et mon objectif afin que l'app me personnalise l'expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-014 | Champ prénom (TextField) avec validation non-vide | `[x]` | P0 | `PersonalizationScreen` — TextInput |
|
||||
| F-015 | Choix nombre de séances/semaine (1 à 6, picker ou slider) | `[x]` | P0 | `weeklyFrequency` picker |
|
||||
| F-016 | Choix objectif : Forme générale / Cardio / Énergie / Perte de poids / Renforcement | `[x]` | P0 | `fitnessGoal` selector |
|
||||
| F-017 | Enregistrement en AsyncStorage / profil local | `[x]` | P0 | Via Zustand `userStore` persist middleware |
|
||||
| F-018 | Validation et navigation vers Paywall | `[x]` | P0 | Navigates to `PaywallScreen` (step 6) then `router.push('/paywall')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 2 — PAYWALL & MONÉTISATION
|
||||
|
||||
#### US-05 · Paywall
|
||||
**Story :** En tant qu'utilisateur ayant complété le funnel, je vois une offre claire et honnête avant d'accéder au contenu complet, afin de prendre une décision éclairée.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-019 | Écran paywall avec structure obligatoire : célébration → valeur → pricing → CTA | `[x]` | P0 | `app/paywall.tsx` (442 lignes) — PREMIUM_FEATURES list + PlanCard + CTA |
|
||||
| F-020 | Intégration RevenueCat (iOS App Store + Google Play) | `[x]` | P0 | `react-native-purchases` + `usePurchases` hook + `purchases.ts` service |
|
||||
| F-021 | Prix dynamique selon la localisation (géolocalisation ou store) | `[x]` | P0 | RevenueCat gère automatiquement via store pricing |
|
||||
| F-022 | Offre annuelle (prix mensuel affiché) + option mensuelle | `[x]` | P0 | `hasAnnual: true, hasMonthly: true` — PlanCard component |
|
||||
| F-023 | Essai gratuit 7 jours (configurable RevenueCat) | `[x]` | P0 | Trial support intégré dans paywall |
|
||||
| F-024 | CTA "Démarrer l'essai gratuit" (PrimaryButton full-width) | `[x]` | P0 | NativeButton CTA |
|
||||
| F-025 | Bouton fermeture toujours visible (accès mode free) | `[x]` | P0 | `closeButton` positionné avec `top: insets.top` |
|
||||
| F-026 | Lien "Restaurer les achats" | `[x]` | P0 | `handleRestore` → `restorePurchases()` |
|
||||
| F-027 | Lien CGU + Politique de confidentialité | `[x]` | P0 | Privacy + Terms links dans paywall, `app/terms.tsx` créé |
|
||||
| F-028 | Gestion état Premium (hook usePremium) | `[x]` | P0 | `usePurchases` hook avec state management |
|
||||
| F-029 | Zéro dark pattern (pas de compte à rebours fictif) | `[x]` | P0 | Aucun countdown/timer/urgency détecté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 3 — ONBOARDING CONTENU (post-paywall)
|
||||
|
||||
#### US-06 · Découverte des 3 Sections
|
||||
**Story :** En tant que nouvel abonné, je découvre les 3 grandes sections de l'app afin de comprendre comment le contenu est organisé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-030 | Écran onboarding 3 sections avec swipe/pagination | `[x]` | P0 | `app/discovery.tsx` — 3 SectionCards animées |
|
||||
| F-031 | Section "Haut du corps" — illustration + description | `[x]` | P0 | Discovery screen — Upper Body avec icône + description |
|
||||
| F-032 | Section "Bas du corps" — illustration + description | `[x]` | P0 | Discovery screen — Lower Body avec icône + description |
|
||||
| F-033 | Section "Corps complet" — illustration + description | `[x]` | P0 | Discovery screen — Full Body avec icône + description |
|
||||
| F-034 | CTA "Explorer les programmes" → Tab Programmes | `[x]` | P0 | CTA → `router.replace('/(tabs)')` |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 4 — PROGRAMMES & CATALOGUE
|
||||
|
||||
#### US-07 · Liste des Programmes
|
||||
**Story :** En tant qu'utilisateur connecté, je consulte le catalogue de programmes organisé par section et niveau afin de choisir celui qui correspond à mon niveau.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-035 | Écran Programmes avec onglets : Haut / Bas / Corps complet | `[~]` | P0 | Home tab a `BodyZoneCard` — pas d'onglets dédiés mais navigation par zone |
|
||||
| F-036 | CardProgram pour chaque programme (thumbnail, nom, level, durée, nb séances) | `[x]` | P0 | WorkoutCard + programme cards dans home |
|
||||
| F-037 | Filtre par niveau : Débutant / Intermédiaire / Avancé | `[ ]` | P0 | **MANQUANT** — Pas de filtres niveau |
|
||||
| F-038 | Progression visible sur chaque card (X/12 séances) | `[x]` | P0 | Progress tracking dans `programStore` + `workoutProgramStore` |
|
||||
| F-039 | Programme "recommandé" selon objectif choisi au profil | `[ ]` | P1 | Pas implémenté |
|
||||
| F-040 | Lock icon sur contenu premium (mode free) | `[x]` | P0 | `sessionLocked` dans `program/[id].tsx` |
|
||||
|
||||
#### US-08 · Détail d'un Programme
|
||||
**Story :** En tant qu'utilisateur, je consulte le détail d'un programme afin de savoir ce qui m'attend avant de commencer.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-041 | Écran détail : header image, nom, niveau, durée totale | `[x]` | P0 | `app/program/[id].tsx` (261 lignes) |
|
||||
| F-042 | Description du programme et objectifs | `[x]` | P0 | Programme detail avec description |
|
||||
| F-043 | Liste ordonnée des séances avec statut (fait / à venir) | `[x]` | P0 | `week.sessions.map` avec `isCompleted` check |
|
||||
| F-044 | CTA "Commencer la prochaine séance" | `[x]` | P0 | `getCurrentSession` → `router.push(/workout/...)` |
|
||||
| F-045 | Progression globale du programme (progress bar) | `[x]` | P0 | Progress tracking intégré |
|
||||
|
||||
#### US-09 · Structure des Programmes
|
||||
**Story :** En tant que product owner, chaque section dispose de 3 niveaux avec des séances progressives.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-046 | Haut du corps — Débutant (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/debutant.ts` |
|
||||
| F-047 | Haut du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | `src/shared/data/tabata/intermediaire.ts` |
|
||||
| F-048 | Haut du corps — Avancé (8–12 séances) | `[x]` | P1 | `src/shared/data/tabata/avance.ts` |
|
||||
| F-049 | Bas du corps — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-050 | Bas du corps — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-051 | Bas du corps — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
| F-052 | Corps complet — Débutant (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-053 | Corps complet — Intermédiaire (8–12 séances) | `[x]` | P0 | Dans les data files tabata |
|
||||
| F-054 | Corps complet — Avancé (8–12 séances) | `[x]` | P1 | Dans les data files tabata |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 5 — SÉANCE TABATA (Core Feature)
|
||||
|
||||
#### US-10 · Phase d'Échauffement
|
||||
**Story :** En tant qu'utilisateur sur le point de commencer une séance, je passe par une phase d'échauffement guidée avec les exercices expliqués visuellement afin de me préparer sans risque de blessure.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-055 | Écran échauffement avec liste des exercices de la séance | `[x]` | P0 | `WarmupOverlay` component |
|
||||
| F-056 | Fiche exercice : nom, muscles ciblés, description gestuelle | `[x]` | P0 | Exercise display dans warmup |
|
||||
| F-057 | Vidéo de démonstration en boucle (coach IA) par exercice | `[!]` | P0 | **BLOQUANT** — Code prêt (`VideoPlayer`) mais vidéos non créées |
|
||||
| F-058 | Conseil kiné (CardTip) pour chaque exercice | `[x]` | P0 | `TabataTip` component |
|
||||
| F-059 | Minuteur échauffement (ex: 5 min) avec CTA skip | `[x]` | P1 | WARMUP phase dans useTabataTimer |
|
||||
| F-060 | Navigation entre les exercices de l'échauffement (swipe ou bouton) | `[x]` | P0 | Dans WarmupOverlay |
|
||||
| F-061 | CTA "Je suis prêt(e)" → lancement séance | `[x]` | P0 | Transition WARMUP → WORK |
|
||||
|
||||
#### US-11 · Séance Tabata (Timer)
|
||||
**Story :** En tant qu'utilisateur en séance, je vois clairement la phase (effort / repos), le bloc et le round en cours afin de suivre ma séance sans regarder mon téléphone de près.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-062 | Timer géant (≥80px) : vert (effort 20s) / slate (repos 10s) / rouge (<10s) | `[x]` | P0 | `TimerRing` component avec PHASE_COLORS |
|
||||
| F-063 | Moteur de séquence : 3 blocs × 2 exercices × 3 rounds, alternance correcte | `[x]` | P0 | `useTabataTimer` — phases WARMUP/WORK/REST/INTER_BLOCK_REST/COOLDOWN/COMPLETE |
|
||||
| F-064 | Vidéo exercice plein écran en boucle (phase effort) | `[!]` | P0 | **BLOQUANT** — Code prêt mais vidéos non créées |
|
||||
| F-065 | Fond navy-800 uni (phase repos court 10s) | `[x]` | P0 | `TABATA_PHASE_COLORS.REST: PHASE.REST` |
|
||||
| F-066 | Écran repos long entre blocs (~60s) avec indication "PROCHAIN BLOC" | `[x]` | P0 | `INTER_BLOCK_REST` phase avec `AMBER[500]` |
|
||||
| F-067 | Indicateur de position : Bloc X/3 + Round Y/3 + Exercice A ou B | `[x]` | P0 | `BlockIndicator` + `RoundIndicator` + exercise name |
|
||||
| F-068 | Nom de l'exercice en cours en overlay | `[x]` | P0 | `ExerciseDisplay` component |
|
||||
| F-069 | Aperçu prochain exercice pendant le repos (thumbnail + nom) | `[x]` | P0 | Next preview dans player |
|
||||
| F-070 | Progress bar séance globale (sur les 18 intervalles) | `[x]` | P0 | `BurnBar` + `StatsOverlay` |
|
||||
| F-071 | Son (bip effort, bip repos, countdown 3-2-1, gong repos long) | `[x]` | P0 | `useAudio` + assets: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare |
|
||||
| F-072 | Toggle mute accessible en 1 tap | `[x]` | P0 | Mute toggle dans PlayerControls, volume 0 sur musique + SFX |
|
||||
| F-073 | Vibration haptique aux transitions effort/repos et fin de bloc | `[x]` | P1 | `useHaptics` intégré dans le player |
|
||||
| F-074 | Bouton pause (centré, accessible) | `[x]` | P0 | Dans `PlayerControls` — pause/resume |
|
||||
| F-075 | Bouton quitter (DangerButton, confirmation requise) | `[x]` | P0 | `Alert.alert` confirmation dans PlayerControls `handleQuit` |
|
||||
| F-076 | Pas de mise en veille écran pendant la séance (keepAwake) | `[x]` | P0 | `useKeepAwake()` dans les deux player screens |
|
||||
| F-077 | Gestion interruption (appel téléphonique → pause auto) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
#### US-12 · Fin de Séance
|
||||
**Story :** En tant qu'utilisateur ayant complété une séance, je vois un écran de célébration avec mes stats afin de me sentir accompli et motivé à revenir.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-078 | Écran célébration (BounceAnimation + serif italic) | `[x]` | P0 | `app/complete/[id].tsx` (712 lignes) — Animated celebrations |
|
||||
| F-079 | Stats : calories estimées, durée réelle, blocs complétés (X/3) | `[x]` | P0 | Calories + duration displayed |
|
||||
| F-080 | Marquage séance comme "completée" dans le programme | `[x]` | P0 | Via `activityStore.addWorkoutResult` |
|
||||
| F-081 | Feedback ressenti (FeedbackButton : Dur / Parfait / Trop facile) | `[x]` | P0 | 3 feedback chips dans `app/complete/[id].tsx` |
|
||||
| F-082 | Mise à jour streak hebdomadaire | `[x]` | P0 | `activityStore` streak calculation |
|
||||
| F-083 | CTA "Prochaine séance" + CTA "Retour accueil" | `[x]` | P0 | Next workout + return home CTAs |
|
||||
| F-084 | Conseil kiné post-séance (étirements recommandés) | `[ ]` | P1 | Non implémenté |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 6 — ACCUEIL & DASHBOARD
|
||||
|
||||
#### US-13 · Écran Accueil
|
||||
**Story :** En tant qu'utilisateur régulier, je vois en un coup d'œil mon prochain entraînement et ma progression cette semaine afin de rester motivé.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-085 | Greeting personnalisé "Bonjour [Prénom]" | `[x]` | P0 | `app/(tabs)/index.tsx` (463 lignes) — greeting avec nom |
|
||||
| F-086 | CardAccent "Prochaine séance" avec nom programme + CTA | `[x]` | P0 | `ContinueSessionCard` component |
|
||||
| F-087 | Streak hebdomadaire (StreakDot × 7 jours) | `[x]` | P0 | Streak display intégré |
|
||||
| F-088 | Stat rapide : séances faites cette semaine vs objectif | `[x]` | P0 | `QuickStats` component avec weekly stats |
|
||||
| F-089 | Section "Reprendre là où j'en suis" | `[x]` | P0 | `ContinueSessionCard` — reprise de programme |
|
||||
| F-090 | Notifications de rappel configurables | `[~]` | P1 | `expo-notifications` installé, `useNotifications` hook existe, toggle dans profile |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 7 — PROGRESSION & STATISTIQUES
|
||||
|
||||
#### US-14 · Écran Progression
|
||||
**Story :** En tant qu'utilisateur fidèle, je consulte mon historique et mes statistiques afin de voir ma progression concrète dans le temps.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-091 | Historique des séances (liste chronologique) | `[x]` | P0 | `app/(tabs)/activity.tsx` (581 lignes) — history list |
|
||||
| F-092 | Graphique hebdomadaire (barres ou ligne) | `[x]` | P1 | `WeeklyBar` component |
|
||||
| F-093 | Totaux : séances totales, minutes totales, calories totales | `[x]` | P0 | `StatCard` × 4 avec totaux |
|
||||
| F-094 | Record streak (max consécutif) | `[x]` | P1 | `activityStore.streak.longest` |
|
||||
| F-095 | Progression par programme (% complété) | `[~]` | P0 | Progression dans `programStore` mais pas affichée dans activity tab |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 8 — PROFIL & PARAMÈTRES
|
||||
|
||||
#### US-15 · Écran Profil
|
||||
**Story :** En tant qu'utilisateur, je peux modifier mon profil et gérer mon abonnement afin de garder le contrôle sur mon expérience.
|
||||
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-096 | Édition prénom, objectif, nb séances/semaine | `[~]` | P0 | Personalization section exists mais **édition directe non confirmée** |
|
||||
| F-097 | Statut abonnement (Free / Premium) avec date renouvellement | `[x]` | P0 | Subscription status affiché |
|
||||
| F-098 | Bouton gérer/annuler abonnement (lien store) | `[~]` | P0 | À vérifier — manage subscription pas confirmé |
|
||||
| F-099 | Toggle notifications | `[x]` | P1 | `NativeLabeledRow` avec daily reminders toggle |
|
||||
| F-100 | Liens : CGU, Politique de confidentialité, Contact support | `[x]` | P0 | Terms + Privacy dans paywall, CGU row dans profile, contact ✓ |
|
||||
| F-101 | Version de l'app | `[x]` | P0 | Version affichée dans about section |
|
||||
|
||||
---
|
||||
|
||||
### ÉPOPÉE 9 — INFRASTRUCTURE & QUALITÉ
|
||||
|
||||
#### US-16 · Architecture Technique
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-102 | React Native + Expo SDK setup | `[x]` | P0 | Expo SDK 54, React Native 0.81.5, React 19.1 |
|
||||
| F-103 | Navigation (Expo Router ou React Navigation) | `[x]` | P0 | Expo Router v6 avec tabs (index/activity/profile) |
|
||||
| F-104 | Design tokens implémentés (design-tokens.ts) | `[x]` | P0 | `colors.ts`, `typography.ts`, `spacing.ts`, `borderRadius.ts`, `animations.ts`, `ThemeContext` |
|
||||
| F-105 | State management (Zustand ou Context) | `[x]` | P0 | Zustand v5 — 6 stores: user, activity, player, program, tabataProgram, workoutProgram |
|
||||
| F-106 | Persistence locale (AsyncStorage) | `[x]` | P0 | AsyncStorage + React Query persist |
|
||||
| F-107 | RevenueCat SDK intégré (iOS + Android) | `[x]` | P0 | `react-native-purchases` v9 + service + hook + tests |
|
||||
| F-108 | Gestion erreurs globale (ErrorBoundary) | `[x]` | P0 | ErrorBoundary class dans `app/_layout.tsx` avec retry |
|
||||
| F-109 | Analytics (Mixpanel ou PostHog) — events funnel + rétention | `[x]` | P1 | PostHog + session replay, `track()` calls dans onboarding/player/complete |
|
||||
|
||||
#### US-17 · Contenu & Assets
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-110 | Vidéos coach IA — Haut du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-111 | Vidéos coach IA — Bas du corps (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-112 | Vidéos coach IA — Corps complet (tous exercices) | `[ ]` | P0 | **MANQUANT** — Vidéos à créer |
|
||||
| F-113 | Sons : bip effort, bip repos, countdown, gong repos long, fanfare fin | `[x]` | P0 | 10 fichiers audio: beep_short, beep_double, beep_long, count_1/2/3, bell, fanfare, countdown.wav, complete.wav, phase-start.wav + 2 musiques |
|
||||
| F-114 | Illustrations sections (onboarding) | `[~]` | P1 | Discovery screen utilise des icônes SF Symbols, pas d'illustrations custom |
|
||||
| F-115 | Icônes app (toutes tailles iOS + Android) | `[~]` | P0 | Icons existent (icon.png, android-icon-*, favicon) mais **contiennent encore des logos React placeholder** |
|
||||
| F-116 | Splash screen | `[x]` | P0 | `expo-splash-screen` + splash-icon.png |
|
||||
|
||||
#### US-18 · Tests & QA
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-117 | Tests unitaires moteur de séquence tabata (3 blocs × 2 exercices × 3 rounds) | `[x]` | P0 | `tabataProgramStore.test.ts` + `playerStore.test.ts` |
|
||||
| F-118 | Tests unitaires timer (20s effort / 10s repos / repos long inter-blocs) | `[x]` | P0 | `useTimer.test.ts` + `useTimer.integration.test.ts` |
|
||||
| F-119 | Tests unitaires RevenueCat (purchase flow) | `[x]` | P0 | `usePurchases.test.ts` + `purchases.test.ts` |
|
||||
| F-120 | Tests E2E funnel complet (Detox ou Maestro) | `[ ]` | P1 | Non implémenté |
|
||||
| F-121 | Test sur iPhone SE (petit écran) | `[ ]` | P0 | Non vérifié |
|
||||
| F-122 | Test sur Android (Samsung Galaxy S-series) | `[ ]` | P0 | Non vérifié |
|
||||
| F-123 | Test mode avion (gestion offline) | `[ ]` | P1 | Non vérifié |
|
||||
|
||||
#### US-19 · Store & Publication
|
||||
| # | Feature | Statut | Priorité | Notes |
|
||||
|---|---------|--------|----------|-------|
|
||||
| F-124 | Screenshots App Store (6.5" + 5.5" + iPad) | `[ ]` | P0 | Non créé |
|
||||
| F-125 | Screenshots Google Play | `[ ]` | P0 | Non créé |
|
||||
| F-126 | Description App Store (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-127 | Description Google Play (FR + EN) | `[ ]` | P0 | Non rédigé |
|
||||
| F-128 | Politique de confidentialité publiée (URL) | `[~]` | P0 | `app/privacy.tsx` existe mais pas publié en URL externe |
|
||||
| F-129 | CGU publiées (URL) | `[~]` | P0 | `app/terms.tsx` existe in-app, URL externe non publiée |
|
||||
| F-130 | Soumission TestFlight (iOS) | `[ ]` | P0 | Non soumis |
|
||||
| F-131 | Soumission Google Play Beta (Android) | `[ ]` | P0 | Non soumis |
|
||||
| F-132 | Review Apple (délai ~2-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
| F-133 | Review Google Play (délai ~1-3 jours) | `[ ]` | P0 | Non soumis |
|
||||
|
||||
---
|
||||
|
||||
## PRODUCTION GATE CONDITIONS
|
||||
|
||||
Pour que TabataGo soit considéré **prêt pour la production**, les conditions suivantes doivent être remplies :
|
||||
|
||||
### BLOQUANTS ABSOLUS (toutes les P0 doivent être `[x]`)
|
||||
|
||||
- [x] **FUNNEL COMPLET** : F-001 à F-018 tous `[x]`
|
||||
- [x] **PAYWALL FONCTIONNEL** : F-019 à F-028 — Tous complétés (CGU + privacy links ajoutés)
|
||||
- [x] **AU MOINS 1 PROGRAMME COMPLET** par section (Débutant minimum) : F-046, F-049, F-052 `[x]`
|
||||
- [x] **SÉANCE TABATA CORE** : F-062 à F-076 — Mute toggle + quit confirmation ajoutés
|
||||
- [x] **FIN DE SÉANCE** : F-078 à F-083 — Feedback buttons ajoutés
|
||||
- [ ] **LÉGAL** : F-128 partiellement, F-129 manquant, F-130/131 non soumis
|
||||
- [ ] **CONTENU** : F-110 à F-112 vidéos non créées
|
||||
|
||||
### REQUIS AVANT SCALE (peuvent être post-lancement v1.1)
|
||||
|
||||
- Notifications (F-090) — en cours
|
||||
- Analytics (F-109) — fait (PostHog)
|
||||
- Tests E2E (F-120) — non fait
|
||||
- Programmes Avancés (F-048, F-051, F-054) — fait
|
||||
- Gestion offline (F-123) — non vérifié
|
||||
|
||||
---
|
||||
|
||||
## COMMENT AFFICHER LE STATUT
|
||||
|
||||
Quand l'utilisateur demande le statut de production, générer un tableau de bord avec :
|
||||
|
||||
1. **Score global** : `[x]` / total features P0
|
||||
2. **Score par épopée** : tableau avec % de complétion
|
||||
3. **Features bloquantes** : liste des P0 encore à `[ ]` ou `[!]`
|
||||
4. **Verdict** : PRET / PRESQUE PRET (X bloquants) / NON PRET (X bloquants)
|
||||
|
||||
---
|
||||
|
||||
## NOTES DE MISE À JOUR
|
||||
|
||||
| Date | Changement | Auteur |
|
||||
|------|-----------|--------|
|
||||
| 2026-04-18 | Création initiale de l'inventaire (130 features) | TabataGo Team |
|
||||
| 2026-04-18 | Mise à jour structure séance : 3 blocs × 2 exercices × 3 rounds (133 features) | TabataGo Team |
|
||||
| 2026-04-18 | Audit complet code vs tracker — 85+ features marquées done, gaps identifiés | OpenCode Audit |
|
||||
| 2026-04-18 | Implémentation : ErrorBoundary, feedback buttons, mute/quit, legal links, discovery screen, i18n DE/ES | OpenCode |
|
||||
| 2026-04-18 | Mise à jour tracker : F-027/030-034/072/075/081/100/108 → [x], groupes paywall/séance/fin → [x] | OpenCode |
|
||||
Reference in New Issue
Block a user