Add PostCSS configuration and skills lock file
- Created a new PostCSS configuration file to integrate Tailwind CSS. - Added a skills lock file containing various Expo skills with their respective source and computed hashes.
This commit is contained in:
321
.agents/skills/building-native-ui/SKILL.md
Normal file
321
.agents/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
.agents/skills/building-native-ui/references/animations.md
Normal file
220
.agents/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
.agents/skills/building-native-ui/references/controls.md
Normal file
270
.agents/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
.agents/skills/building-native-ui/references/form-sheet.md
Normal file
253
.agents/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
.agents/skills/building-native-ui/references/gradients.md
Normal file
106
.agents/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
.agents/skills/building-native-ui/references/icons.md
Normal file
213
.agents/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
.agents/skills/building-native-ui/references/media.md
Normal file
198
.agents/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;
|
||||||
|
}
|
||||||
|
```
|
||||||
229
.agents/skills/building-native-ui/references/route-structure.md
Normal file
229
.agents/skills/building-native-ui/references/route-structure.md
Normal file
@@ -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
.agents/skills/building-native-ui/references/search.md
Normal file
248
.agents/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
.agents/skills/building-native-ui/references/storage.md
Normal file
121
.agents/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
.agents/skills/building-native-ui/references/tabs.md
Normal file
433
.agents/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
.agents/skills/building-native-ui/references/visual-effects.md
Normal file
197
.agents/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
.agents/skills/building-native-ui/references/webgpu-three.md
Normal file
605
.agents/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
158
.agents/skills/building-native-ui/references/zoom-transitions.md
Normal file
158
.agents/skills/building-native-ui/references/zoom-transitions.md
Normal file
@@ -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
.agents/skills/expo-api-routes/SKILL.md
Normal file
368
.agents/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
.agents/skills/expo-cicd-workflows/SKILL.md
Normal file
92
.agents/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
.agents/skills/expo-cicd-workflows/scripts/fetch.js
Normal file
109
.agents/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);
|
||||||
|
}
|
||||||
11
.agents/skills/expo-cicd-workflows/scripts/package.json
Normal file
11
.agents/skills/expo-cicd-workflows/scripts/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@expo/cicd-workflows-skill",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
.agents/skills/expo-cicd-workflows/scripts/validate.js
Normal file
84
.agents/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
.agents/skills/expo-deployment/SKILL.md
Normal file
190
.agents/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
|
||||||
|
```
|
||||||
479
.agents/skills/expo-deployment/references/app-store-metadata.md
Normal file
479
.agents/skills/expo-deployment/references/app-store-metadata.md
Normal file
@@ -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
.agents/skills/expo-deployment/references/ios-app-store.md
Normal file
355
.agents/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
.agents/skills/expo-deployment/references/play-store.md
Normal file
246
.agents/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
.agents/skills/expo-deployment/references/testflight.md
Normal file
58
.agents/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
.agents/skills/expo-deployment/references/workflows.md
Normal file
200
.agents/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
.agents/skills/expo-dev-client/SKILL.md
Normal file
164
.agents/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
.agents/skills/expo-tailwind-setup/SKILL.md
Normal file
480
.agents/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
.agents/skills/native-data-fetching/SKILL.md
Normal file
507
.agents/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
|
||||||
129
.agents/skills/upgrading-expo/SKILL.md
Normal file
129
.agents/skills/upgrading-expo/SKILL.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
---
|
||||||
|
name: upgrading-expo
|
||||||
|
description: Guidelines for upgrading Expo SDK versions and fixing dependency issues
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ./references/new-architecture.md -- SDK +53: New Architecture migration guide
|
||||||
|
- ./references/react-19.md -- SDK +54: React 19 changes (useContext → use, Context.Provider → Context, forwardRef removal)
|
||||||
|
- ./references/react-compiler.md -- SDK +54: React Compiler setup and migration guide
|
||||||
|
- ./references/native-tabs.md -- SDK +55: Native tabs changes (Icon/Label/Badge now accessed via NativeTabs.Trigger.\*)
|
||||||
|
- ./references/expo-av-to-audio.md -- Migrate audio playback and recording from expo-av to expo-audio
|
||||||
|
- ./references/expo-av-to-video.md -- Migrate video playback from expo-av to expo-video
|
||||||
|
|
||||||
|
## Beta/Preview Releases
|
||||||
|
|
||||||
|
Beta versions use `.preview` suffix (e.g., `55.0.0-preview.2`), published under `@next` tag.
|
||||||
|
|
||||||
|
Check if latest is beta: https://exp.host/--/api/v2/versions (look for `-preview` in `expoVersion`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@next --fix # install beta
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step-by-Step Upgrade Process
|
||||||
|
|
||||||
|
1. Upgrade Expo and dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@latest
|
||||||
|
npx expo install --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run diagnostics: `npx expo-doctor`
|
||||||
|
|
||||||
|
3. Clear caches and reinstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo export -p ios --clear
|
||||||
|
rm -rf node_modules .expo
|
||||||
|
watchman watch-del-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Breaking Changes Checklist
|
||||||
|
|
||||||
|
- Check for removed APIs in release notes
|
||||||
|
- Update import paths for moved modules
|
||||||
|
- Review native module changes requiring prebuild
|
||||||
|
- Test all camera, audio, and video features
|
||||||
|
- Verify navigation still works correctly
|
||||||
|
|
||||||
|
## Prebuild for Native Changes
|
||||||
|
|
||||||
|
If upgrading requires native changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo prebuild --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
This regenerates the `ios` and `android` directories. Ensure the project is not a bare workflow app before running this command.
|
||||||
|
|
||||||
|
## Clear caches for bare workflow
|
||||||
|
|
||||||
|
- Clear the cocoapods cache for iOS: `cd ios && pod install --repo-update`
|
||||||
|
- Clear derived data for Xcode: `npx expo run:ios --no-build-cache`
|
||||||
|
- Clear the Gradle cache for Android: `cd android && ./gradlew clean`
|
||||||
|
|
||||||
|
## Housekeeping
|
||||||
|
|
||||||
|
- Review release notes for the target SDK version at https://expo.dev/changelog
|
||||||
|
- If using Expo SDK 54 or later, ensure react-native-worklets is installed — this is required for react-native-reanimated to work.
|
||||||
|
- Enable React Compiler in SDK 54+ by adding `"experiments": { "reactCompiler": true }` to app.json — it's stable and recommended
|
||||||
|
- Delete sdkVersion from `app.json` to let Expo manage it automatically
|
||||||
|
- Remove implicit packages from `package.json`: `@babel/core`, `babel-preset-expo`, `expo-constants`.
|
||||||
|
- If the babel.config.js only contains 'babel-preset-expo', delete the file
|
||||||
|
- If the metro.config.js only contains expo defaults, delete the file
|
||||||
|
|
||||||
|
## Deprecated Packages
|
||||||
|
|
||||||
|
| Old Package | Replacement |
|
||||||
|
| -------------------- | ---------------------------------------------------- |
|
||||||
|
| `expo-av` | `expo-audio` and `expo-video` |
|
||||||
|
| `expo-permissions` | Individual package permission APIs |
|
||||||
|
| `@expo/vector-icons` | `expo-symbols` (for SF Symbols) |
|
||||||
|
| `AsyncStorage` | `expo-sqlite/localStorage/install` |
|
||||||
|
| `expo-app-loading` | `expo-splash-screen` |
|
||||||
|
| expo-linear-gradient | experimental_backgroundImage + CSS gradients in View |
|
||||||
|
|
||||||
|
When migrating deprecated packages, update all code usage before removing the old package. For expo-av, consult the migration references to convert Audio.Sound to useAudioPlayer, Audio.Recording to useAudioRecorder, and Video components to VideoView with useVideoPlayer.
|
||||||
|
|
||||||
|
## expo.install.exclude
|
||||||
|
|
||||||
|
Check if package.json has excluded packages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": { "install": { "exclude": ["react-native-reanimated"] } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exclusions are often workarounds that may no longer be needed after upgrading. Review each one.
|
||||||
|
## Removing patches
|
||||||
|
|
||||||
|
Check if there are any outdated patches in the `patches/` directory. Remove them if they are no longer needed.
|
||||||
|
|
||||||
|
## Postcss
|
||||||
|
|
||||||
|
- `autoprefixer` isn't needed in SDK +53. Remove it from dependencies and check `postcss.config.js` or `postcss.config.mjs` to remove it from the plugins list.
|
||||||
|
- Use `postcss.config.mjs` in SDK +53.
|
||||||
|
|
||||||
|
## Metro
|
||||||
|
|
||||||
|
Remove redundant metro config options:
|
||||||
|
|
||||||
|
- resolver.unstable_enablePackageExports is enabled by default in SDK +53.
|
||||||
|
- `experimentalImportSupport` is enabled by default in SDK +54.
|
||||||
|
- `EXPO_USE_FAST_RESOLVER=1` is removed in SDK +54.
|
||||||
|
- cjs and mjs extensions are supported by default in SDK +50.
|
||||||
|
- Expo webpack is deprecated, migrate to [Expo Router and Metro web](https://docs.expo.dev/router/migrate/from-expo-webpack/).
|
||||||
|
|
||||||
|
## Hermes engine v1
|
||||||
|
|
||||||
|
Since SDK 55, users can opt-in to use Hermes engine v1 for improved runtime performance. This requires setting `useHermesV1: true` in the `expo-build-properties` config plugin, and may require a specific version of the `hermes-compiler` npm package. Hermes v1 will become a default in some future SDK release.
|
||||||
|
|
||||||
|
## New Architecture
|
||||||
|
|
||||||
|
The new architecture is enabled by default, the app.json field `"newArchEnabled": true` is no longer needed as it's the default. Expo Go only supports the new architecture as of SDK +53.
|
||||||
132
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
132
.agents/skills/upgrading-expo/references/expo-av-to-audio.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Migrating from expo-av to expo-audio
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
import { Audio } from 'expo-av';
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { useAudioPlayer, useAudioRecorder, RecordingPresets, AudioModule, setAudioModeAsync } from 'expo-audio';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Playback
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [sound, setSound] = useState<Audio.Sound>();
|
||||||
|
|
||||||
|
async function playSound() {
|
||||||
|
const { sound } = await Audio.Sound.createAsync(require('./audio.mp3'));
|
||||||
|
setSound(sound);
|
||||||
|
await sound.playAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return sound ? () => { sound.unloadAsync(); } : undefined;
|
||||||
|
}, [sound]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useAudioPlayer(require('./audio.mp3'));
|
||||||
|
|
||||||
|
// Play
|
||||||
|
player.play();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Recording
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [recording, setRecording] = useState<Audio.Recording>();
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
await Audio.requestPermissionsAsync();
|
||||||
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||||||
|
const { recording } = await Audio.Recording.createAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
||||||
|
setRecording(recording);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
await recording?.stopAndUnloadAsync();
|
||||||
|
const uri = recording?.getURI();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
await AudioModule.requestRecordingPermissionsAsync();
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
recorder.record();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording() {
|
||||||
|
await recorder.stop();
|
||||||
|
const uri = recorder.uri;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Mode
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await Audio.setAudioModeAsync({
|
||||||
|
allowsRecordingIOS: true,
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
staysActiveInBackground: true,
|
||||||
|
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-audio)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await setAudioModeAsync({
|
||||||
|
playsInSilentMode: true,
|
||||||
|
shouldPlayInBackground: true,
|
||||||
|
interruptionMode: 'doNotMix',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Mapping
|
||||||
|
|
||||||
|
| expo-av | expo-audio |
|
||||||
|
|---------|------------|
|
||||||
|
| `Audio.Sound.createAsync()` | `useAudioPlayer(source)` |
|
||||||
|
| `sound.playAsync()` | `player.play()` |
|
||||||
|
| `sound.pauseAsync()` | `player.pause()` |
|
||||||
|
| `sound.setPositionAsync(ms)` | `player.seekTo(seconds)` |
|
||||||
|
| `sound.setVolumeAsync(vol)` | `player.volume = vol` |
|
||||||
|
| `sound.setRateAsync(rate)` | `player.playbackRate = rate` |
|
||||||
|
| `sound.setIsLoopingAsync(loop)` | `player.loop = loop` |
|
||||||
|
| `sound.unloadAsync()` | Automatic via hook |
|
||||||
|
| `playbackStatus.positionMillis` | `player.currentTime` (seconds) |
|
||||||
|
| `playbackStatus.durationMillis` | `player.duration` (seconds) |
|
||||||
|
| `playbackStatus.isPlaying` | `player.playing` |
|
||||||
|
| `Audio.Recording.createAsync()` | `useAudioRecorder(preset)` |
|
||||||
|
| `Audio.RecordingOptionsPresets.*` | `RecordingPresets.*` |
|
||||||
|
| `recording.stopAndUnloadAsync()` | `recorder.stop()` |
|
||||||
|
| `recording.getURI()` | `recorder.uri` |
|
||||||
|
| `Audio.requestPermissionsAsync()` | `AudioModule.requestRecordingPermissionsAsync()` |
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
- **No auto-reset on finish**: After `play()` completes, the player stays paused at the end. To replay, call `player.seekTo(0)` then `play()`
|
||||||
|
- **Time in seconds**: expo-audio uses seconds, not milliseconds (matching web standards)
|
||||||
|
- **Immediate loading**: Audio loads immediately when the hook mounts—no explicit preloading needed
|
||||||
|
- **Automatic cleanup**: No need to call `unloadAsync()`, hooks handle resource cleanup on unmount
|
||||||
|
- **Multiple players**: Create multiple `useAudioPlayer` instances and store them—all load immediately
|
||||||
|
- **Direct property access**: Set volume, rate, loop directly on the player object (`player.volume = 0.5`)
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/audio/
|
||||||
160
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
160
.agents/skills/upgrading-expo/references/expo-av-to-video.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Migrating from expo-av to expo-video
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
import { Video, ResizeMode } from 'expo-av';
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { useVideoPlayer, VideoView, VideoSource } from 'expo-video';
|
||||||
|
import { useEvent, useEventListener } from 'expo';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Video Playback
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const videoRef = useRef<Video>(null);
|
||||||
|
const [status, setStatus] = useState({});
|
||||||
|
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={{ uri: 'https://example.com/video.mp4' }}
|
||||||
|
style={{ width: 350, height: 200 }}
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
isLooping
|
||||||
|
onPlaybackStatusUpdate={setStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Control
|
||||||
|
videoRef.current?.playAsync();
|
||||||
|
videoRef.current?.pauseAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useVideoPlayer('https://example.com/video.mp4', player => {
|
||||||
|
player.loop = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||||
|
|
||||||
|
<VideoView
|
||||||
|
player={player}
|
||||||
|
style={{ width: 350, height: 200 }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Control
|
||||||
|
player.play();
|
||||||
|
player.pause();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status Updates
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video
|
||||||
|
onPlaybackStatusUpdate={status => {
|
||||||
|
if (status.isLoaded) {
|
||||||
|
console.log(status.positionMillis, status.durationMillis, status.isPlaying);
|
||||||
|
if (status.didJustFinish) console.log('finished');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Reactive state
|
||||||
|
const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
|
||||||
|
|
||||||
|
// Side effects
|
||||||
|
useEventListener(player, 'playToEnd', () => console.log('finished'));
|
||||||
|
|
||||||
|
// Direct access
|
||||||
|
console.log(player.currentTime, player.duration, player.playing);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Files
|
||||||
|
|
||||||
|
### Before (expo-av)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Video source={require('./video.mp4')} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (expo-video)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const player = useVideoPlayer({ assetId: require('./video.mp4') });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fullscreen and PiP
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoView
|
||||||
|
player={player}
|
||||||
|
allowsFullscreen
|
||||||
|
allowsPictureInPicture
|
||||||
|
onFullscreenEnter={() => {}}
|
||||||
|
onFullscreenExit={() => {}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
For PiP and background playback, add to app.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"plugins": [
|
||||||
|
["expo-video", { "supportsBackgroundPlayback": true, "supportsPictureInPicture": true }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Mapping
|
||||||
|
|
||||||
|
| expo-av | expo-video |
|
||||||
|
|---------|------------|
|
||||||
|
| `<Video>` | `<VideoView>` |
|
||||||
|
| `ref={videoRef}` | `player={useVideoPlayer()}` |
|
||||||
|
| `source={{ uri }}` | Pass to `useVideoPlayer(uri)` |
|
||||||
|
| `resizeMode={ResizeMode.CONTAIN}` | `contentFit="contain"` |
|
||||||
|
| `isLooping` | `player.loop = true` |
|
||||||
|
| `shouldPlay` | `player.play()` in setup |
|
||||||
|
| `isMuted` | `player.muted = true` |
|
||||||
|
| `useNativeControls` | `nativeControls={true}` |
|
||||||
|
| `onPlaybackStatusUpdate` | `useEvent` / `useEventListener` |
|
||||||
|
| `videoRef.current.playAsync()` | `player.play()` |
|
||||||
|
| `videoRef.current.pauseAsync()` | `player.pause()` |
|
||||||
|
| `videoRef.current.replayAsync()` | `player.replay()` |
|
||||||
|
| `videoRef.current.setPositionAsync(ms)` | `player.currentTime = seconds` |
|
||||||
|
| `status.positionMillis` | `player.currentTime` (seconds) |
|
||||||
|
| `status.durationMillis` | `player.duration` (seconds) |
|
||||||
|
| `status.didJustFinish` | `useEventListener(player, 'playToEnd')` |
|
||||||
|
|
||||||
|
## Key Differences
|
||||||
|
|
||||||
|
- **Separate player and view**: Player logic decoupled from the view—one player can be used across multiple views
|
||||||
|
- **Time in seconds**: Uses seconds, not milliseconds
|
||||||
|
- **Event system**: Uses `useEvent`/`useEventListener` from `expo` instead of callback props
|
||||||
|
- **Video preloading**: Create a player without mounting a VideoView to preload for faster transitions
|
||||||
|
- **Built-in caching**: Set `useCaching: true` in VideoSource for persistent offline caching
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- **Uninstall expo-av first**: On Android, having both expo-av and expo-video installed can cause VideoView to show a black screen. Uninstall expo-av before installing expo-video
|
||||||
|
- **Android: Reusing players**: Mounting the same player in multiple VideoViews simultaneously can cause black screens on Android (works on iOS)
|
||||||
|
- **Android: currentTime in setup**: Setting `player.currentTime` in the `useVideoPlayer` setup callback may not work on Android—set it after mount instead
|
||||||
|
- **Changing source**: Use `player.replace(newSource)` to change videos without recreating the player
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
https://docs.expo.dev/versions/latest/sdk/video/
|
||||||
124
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
124
.agents/skills/upgrading-expo/references/native-tabs.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Native Tabs Migration (SDK 55)
|
||||||
|
|
||||||
|
In SDK 55, `Label`, `Icon`, `Badge`, and `VectorIcon` are now accessed as static properties on `NativeTabs.Trigger` rather than separate imports.
|
||||||
|
|
||||||
|
## Import Changes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SDK 53/54
|
||||||
|
import {
|
||||||
|
NativeTabs,
|
||||||
|
Icon,
|
||||||
|
Label,
|
||||||
|
Badge,
|
||||||
|
VectorIcon,
|
||||||
|
} from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
// SDK 55+
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Changes
|
||||||
|
|
||||||
|
| SDK 53/54 | SDK 55+ |
|
||||||
|
| ---------------- | ----------------------------------- |
|
||||||
|
| `<Icon />` | `<NativeTabs.Trigger.Icon />` |
|
||||||
|
| `<Label />` | `<NativeTabs.Trigger.Label />` |
|
||||||
|
| `<Badge />` | `<NativeTabs.Trigger.Badge />` |
|
||||||
|
| `<VectorIcon />` | `<NativeTabs.Trigger.VectorIcon />` |
|
||||||
|
| (n/a) | `<NativeTabs.BottomAccessory />` |
|
||||||
|
|
||||||
|
## New in SDK 55
|
||||||
|
|
||||||
|
### BottomAccessory
|
||||||
|
|
||||||
|
New component for Apple Music-style mini players on iOS +26 that float above the tab bar:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.BottomAccessory>
|
||||||
|
{/* Content above tabs */}
|
||||||
|
</NativeTabs.BottomAccessory>
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
On Android and web, this component will render as a no-op. Position a view absolutely above the tab bar instead.
|
||||||
|
|
||||||
|
### Icon `md` Prop
|
||||||
|
|
||||||
|
New `md` prop for Material icon glyphs on Android (alongside existing `drawable`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<NativeTabs.Trigger.Icon sf="house" md="home" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Migration Example
|
||||||
|
|
||||||
|
### Before (SDK 53/54)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
NativeTabs,
|
||||||
|
Icon,
|
||||||
|
Label,
|
||||||
|
Badge,
|
||||||
|
} from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<Label>Home</Label>
|
||||||
|
<Icon sf="house.fill" />
|
||||||
|
<Badge>3</Badge>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(settings)">
|
||||||
|
<Label>Settings</Label>
|
||||||
|
<Icon sf="gear" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<Label>Search</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (SDK 55+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs minimizeBehavior="onScrollDown">
|
||||||
|
<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.Trigger name="(settings)">
|
||||||
|
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(search)" role="search">
|
||||||
|
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
1. Remove `Icon`, `Label`, `Badge`, `VectorIcon` from imports
|
||||||
|
2. Keep only `NativeTabs` import from `expo-router/unstable-native-tabs`
|
||||||
|
3. Replace `<Icon />` with `<NativeTabs.Trigger.Icon />`
|
||||||
|
4. Replace `<Label />` with `<NativeTabs.Trigger.Label />`
|
||||||
|
5. Replace `<Badge />` with `<NativeTabs.Trigger.Badge />`
|
||||||
|
6. Replace `<VectorIcon />` with `<NativeTabs.Trigger.VectorIcon />`
|
||||||
|
|
||||||
|
- Read docs for more info https://docs.expo.dev/versions/v55.0.0/sdk/router-native-tabs/
|
||||||
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
79
.agents/skills/upgrading-expo/references/new-architecture.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# New Architecture
|
||||||
|
|
||||||
|
The New Architecture is enabled by default in Expo SDK 53+. It replaces the legacy bridge with a faster, synchronous communication layer between JavaScript and native code.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full guide: https://docs.expo.dev/guides/new-architecture/
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
- **JSI (JavaScript Interface)** — Direct synchronous calls between JS and native
|
||||||
|
- **Fabric** — New rendering system with concurrent features
|
||||||
|
- **TurboModules** — Lazy-loaded native modules with type safety
|
||||||
|
|
||||||
|
## SDK Compatibility
|
||||||
|
|
||||||
|
| SDK Version | New Architecture Status |
|
||||||
|
| ----------- | ----------------------- |
|
||||||
|
| SDK 53+ | Enabled by default |
|
||||||
|
| SDK 52 | Opt-in via app.json |
|
||||||
|
| SDK 51- | Experimental |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
New Architecture is enabled by default. To explicitly disable (not recommended):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"newArchEnabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expo Go
|
||||||
|
|
||||||
|
Expo Go only supports the New Architecture as of SDK 53. Apps using the old architecture must use development builds.
|
||||||
|
|
||||||
|
## Common Migration Issues
|
||||||
|
|
||||||
|
### Native Module Compatibility
|
||||||
|
|
||||||
|
Some older native modules may not support the New Architecture. Check:
|
||||||
|
|
||||||
|
1. Module documentation for New Architecture support
|
||||||
|
2. GitHub issues for compatibility discussions
|
||||||
|
3. Consider alternatives if module is unmaintained
|
||||||
|
|
||||||
|
### Reanimated
|
||||||
|
|
||||||
|
React Native Reanimated requires `react-native-worklets` in SDK 54+:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install react-native-worklets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout Animations
|
||||||
|
|
||||||
|
Some layout animations behave differently. Test thoroughly after upgrading.
|
||||||
|
|
||||||
|
## Verifying New Architecture
|
||||||
|
|
||||||
|
Check if New Architecture is active:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Returns true if Fabric is enabled
|
||||||
|
const isNewArch = global._IS_FABRIC !== undefined;
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify from the command line if the currently running app uses the New Architecture: `bunx xcobra expo eval "_IS_FABRIC"` -> `true`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
1. **Clear caches** — `npx expo start --clear`
|
||||||
|
2. **Clean prebuild** — `npx expo prebuild --clean`
|
||||||
|
3. **Check native modules** — Ensure all dependencies support New Architecture
|
||||||
|
4. **Review console warnings** — Legacy modules log compatibility warnings
|
||||||
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
79
.agents/skills/upgrading-expo/references/react-19.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# React 19
|
||||||
|
|
||||||
|
React 19 is included in Expo SDK 54. This release simplifies several common patterns.
|
||||||
|
|
||||||
|
## Context Changes
|
||||||
|
|
||||||
|
### useContext → use
|
||||||
|
|
||||||
|
The `use` hook replaces `useContext`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
import { useContext } from "react";
|
||||||
|
const value = useContext(MyContext);
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
import { use } from "react";
|
||||||
|
const value = use(MyContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `use` hook can also read promises, enabling Suspense-based data fetching.
|
||||||
|
- `use` can be called conditionally, this simplifies components that consume multiple contexts.
|
||||||
|
|
||||||
|
### Context.Provider → Context
|
||||||
|
|
||||||
|
Context providers no longer need the `.Provider` suffix:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
<ThemeContext.Provider value={theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
<ThemeContext value={theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
## ref as a Prop
|
||||||
|
|
||||||
|
### Removing forwardRef
|
||||||
|
|
||||||
|
Components can now receive `ref` as a regular prop. `forwardRef` is no longer needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (React 18)
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
const Input = forwardRef<TextInput, Props>((props, ref) => {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
// After (React 19)
|
||||||
|
function Input({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
1. Remove `forwardRef` wrapper
|
||||||
|
2. Add `ref` to the props destructuring
|
||||||
|
3. Update the type to include `ref?: React.Ref<T>`
|
||||||
|
|
||||||
|
## Other React 19 Features
|
||||||
|
|
||||||
|
- **Actions** — Functions that handle async transitions
|
||||||
|
- **useOptimistic** — Optimistic UI updates
|
||||||
|
- **useFormStatus** — Form submission state (web)
|
||||||
|
- **Document Metadata** — Native `<title>` and `<meta>` support (web)
|
||||||
|
|
||||||
|
## Cleanup Checklist
|
||||||
|
|
||||||
|
When upgrading to SDK 54:
|
||||||
|
|
||||||
|
- [ ] Replace `useContext` with `use`
|
||||||
|
- [ ] Remove `.Provider` from Context components
|
||||||
|
- [ ] Remove `forwardRef` wrappers, use `ref` prop instead
|
||||||
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
59
.agents/skills/upgrading-expo/references/react-compiler.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# React Compiler
|
||||||
|
|
||||||
|
React Compiler is stable in Expo SDK 54 and later. It automatically memoizes components and hooks, eliminating the need for manual `useMemo`, `useCallback`, and `React.memo`.
|
||||||
|
|
||||||
|
## Enabling React Compiler
|
||||||
|
|
||||||
|
Add to `app.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"experiments": {
|
||||||
|
"reactCompiler": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What React Compiler Does
|
||||||
|
|
||||||
|
- Automatically memoizes components and values
|
||||||
|
- Eliminates unnecessary re-renders
|
||||||
|
- Removes the need for manual `useMemo` and `useCallback`
|
||||||
|
- Works with existing code without modifications
|
||||||
|
|
||||||
|
## Cleanup After Enabling
|
||||||
|
|
||||||
|
Once React Compiler is enabled, you can remove manual memoization:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Before (manual memoization)
|
||||||
|
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
|
||||||
|
const memoizedCallback = useCallback(() => doSomething(a), [a]);
|
||||||
|
const MemoizedComponent = React.memo(MyComponent);
|
||||||
|
|
||||||
|
// After (React Compiler handles it)
|
||||||
|
const value = computeExpensive(a, b);
|
||||||
|
const callback = () => doSomething(a);
|
||||||
|
// Just use MyComponent directly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Expo SDK 54 or later
|
||||||
|
- New Architecture enabled (default in SDK 54+)
|
||||||
|
|
||||||
|
## Verifying It's Working
|
||||||
|
|
||||||
|
React Compiler runs at build time. Check the Metro bundler output for compilation messages. You can also use React DevTools to verify components are being optimized.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Ensure New Architecture is enabled
|
||||||
|
2. Clear Metro cache: `npx expo start --clear`
|
||||||
|
3. Check for incompatible patterns in your code (rare)
|
||||||
|
|
||||||
|
React Compiler is designed to work with idiomatic React code. If it can't safely optimize a component, it skips that component without breaking your app.
|
||||||
417
.agents/skills/use-dom/SKILL.md
Normal file
417
.agents/skills/use-dom/SKILL.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
---
|
||||||
|
name: use-dom
|
||||||
|
description: Use Expo DOM components to run web code in a webview on native and as-is on web. Migrate web code to native incrementally.
|
||||||
|
version: 1.0.0
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
## What are DOM Components?
|
||||||
|
|
||||||
|
DOM components allow web code to run verbatim in a webview on native platforms while rendering as-is on web. This enables using web-only libraries like `recharts`, `react-syntax-highlighter`, or any React web library in your Expo app without modification.
|
||||||
|
|
||||||
|
## When to Use DOM Components
|
||||||
|
|
||||||
|
Use DOM components when you need:
|
||||||
|
|
||||||
|
- **Web-only libraries** — Charts (recharts, chart.js), syntax highlighters, rich text editors, or any library that depends on DOM APIs
|
||||||
|
- **Migrating web code** — Bring existing React web components to native without rewriting
|
||||||
|
- **Complex HTML/CSS layouts** — When CSS features aren't available in React Native
|
||||||
|
- **iframes or embeds** — Embedding external content that requires a browser context
|
||||||
|
- **Canvas or WebGL** — Web graphics APIs not available natively
|
||||||
|
|
||||||
|
## When NOT to Use DOM Components
|
||||||
|
|
||||||
|
Avoid DOM components when:
|
||||||
|
|
||||||
|
- **Native performance is critical** — Webviews add overhead
|
||||||
|
- **Simple UI** — React Native components are more efficient for basic layouts
|
||||||
|
- **Deep native integration** — Use local modules instead for native APIs
|
||||||
|
- **Layout routes** — `_layout` files cannot be DOM components
|
||||||
|
|
||||||
|
## Basic DOM Component
|
||||||
|
|
||||||
|
Create a new file with the `'use dom';` directive at the top:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/WebChart.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
export default function WebChart({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: number[];
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<h2>Chart Data</h2>
|
||||||
|
<ul>
|
||||||
|
{data.map((value, i) => (
|
||||||
|
<li key={i}>{value}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules for DOM Components
|
||||||
|
|
||||||
|
1. **Must have `'use dom';` directive** at the top of the file
|
||||||
|
2. **Single default export** — One React component per file
|
||||||
|
3. **Own file** — Cannot be defined inline or combined with native components
|
||||||
|
4. **Serializable props only** — Strings, numbers, booleans, arrays, plain objects
|
||||||
|
5. **Include CSS in the component file** — DOM components run in isolated context
|
||||||
|
|
||||||
|
## The `dom` Prop
|
||||||
|
|
||||||
|
Every DOM component receives a special `dom` prop for webview configuration. Always type it in your props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyComponent({ content }: Props) {
|
||||||
|
return <div>{content}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common `dom` Prop Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Disable body scrolling
|
||||||
|
<DOMComponent dom={{ scrollEnabled: false }} />
|
||||||
|
|
||||||
|
// Flow under the notch (disable safe area insets)
|
||||||
|
<DOMComponent dom={{ contentInsetAdjustmentBehavior: "never" }} />
|
||||||
|
|
||||||
|
// Control size manually
|
||||||
|
<DOMComponent dom={{ style: { width: 300, height: 400 } }} />
|
||||||
|
|
||||||
|
// Combine options
|
||||||
|
<DOMComponent
|
||||||
|
dom={{
|
||||||
|
scrollEnabled: false,
|
||||||
|
contentInsetAdjustmentBehavior: "never",
|
||||||
|
style: { width: '100%', height: 500 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exposing Native Actions to the Webview
|
||||||
|
|
||||||
|
Pass async functions as props to expose native functionality to the DOM component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/index.tsx (native)
|
||||||
|
import { Alert } from "react-native";
|
||||||
|
import DOMComponent from "@/components/dom-component";
|
||||||
|
|
||||||
|
export default function Screen() {
|
||||||
|
return (
|
||||||
|
<DOMComponent
|
||||||
|
showAlert={async (message: string) => {
|
||||||
|
Alert.alert("From Web", message);
|
||||||
|
}}
|
||||||
|
saveData={async (data: { name: string; value: number }) => {
|
||||||
|
// Save to native storage, database, etc.
|
||||||
|
console.log("Saving:", data);
|
||||||
|
return { success: true };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/dom-component.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showAlert: (message: string) => Promise<void>;
|
||||||
|
saveData: (data: {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}) => Promise<{ success: boolean }>;
|
||||||
|
dom?: import("expo/dom").DOMProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DOMComponent({ showAlert, saveData }: Props) {
|
||||||
|
const handleClick = async () => {
|
||||||
|
await showAlert("Hello from the webview!");
|
||||||
|
const result = await saveData({ name: "test", value: 42 });
|
||||||
|
console.log("Save result:", result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleClick}>Trigger Native Action</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Web Libraries
|
||||||
|
|
||||||
|
DOM components can use any web library:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/syntax-highlight.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||||
|
import { docco } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
language: string;
|
||||||
|
dom?: import("expo/dom").DOMProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyntaxHighlight({ code, language }: Props) {
|
||||||
|
return (
|
||||||
|
<SyntaxHighlighter language={language} style={docco}>
|
||||||
|
{code}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/chart.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Array<{ name: string; value: number }>;
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chart({ data }: Props) {
|
||||||
|
return (
|
||||||
|
<LineChart width={400} height={300} data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#8884d8" />
|
||||||
|
</LineChart>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS in DOM Components
|
||||||
|
|
||||||
|
CSS imports must be in the DOM component file since they run in isolated context:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/styled-component.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import "@/styles.css"; // CSS file in same directory
|
||||||
|
|
||||||
|
export default function StyledComponent({
|
||||||
|
dom,
|
||||||
|
}: {
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="title">Styled Content</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use inline styles / CSS-in-JS:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#333",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StyledComponent({
|
||||||
|
dom,
|
||||||
|
}: {
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<h1 style={styles.title}>Styled Content</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expo Router in DOM Components
|
||||||
|
|
||||||
|
The expo-router `<Link />` component and router API work inside DOM components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import { Link, useRouter } from "expo-router";
|
||||||
|
|
||||||
|
export default function Navigation({
|
||||||
|
dom,
|
||||||
|
}: {
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<Link href="/about">About</Link>
|
||||||
|
<button onClick={() => router.push("/settings")}>Settings</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Router APIs That Require Props
|
||||||
|
|
||||||
|
These hooks don't work directly in DOM components because they need synchronous access to native routing state:
|
||||||
|
|
||||||
|
- `useLocalSearchParams()`
|
||||||
|
- `useGlobalSearchParams()`
|
||||||
|
- `usePathname()`
|
||||||
|
- `useSegments()`
|
||||||
|
- `useRootNavigation()`
|
||||||
|
- `useRootNavigationState()`
|
||||||
|
|
||||||
|
**Solution:** Read these values in the native parent and pass as props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/[id].tsx (native)
|
||||||
|
import { useLocalSearchParams, usePathname } from "expo-router";
|
||||||
|
import DOMComponent from "@/components/dom-component";
|
||||||
|
|
||||||
|
export default function Screen() {
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return <DOMComponent id={id as string} pathname={pathname} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/dom-component.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
pathname: string;
|
||||||
|
dom?: import("expo/dom").DOMProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DOMComponent({ id, pathname }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Current ID: {id}</p>
|
||||||
|
<p>Current Path: {pathname}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detecting DOM Environment
|
||||||
|
|
||||||
|
Check if code is running in a DOM component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import { IS_DOM } from "expo/dom";
|
||||||
|
|
||||||
|
export default function Component({
|
||||||
|
dom,
|
||||||
|
}: {
|
||||||
|
dom?: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
return <div>{IS_DOM ? "Running in DOM component" : "Running natively"}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
Prefer requiring assets instead of using the public directory:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
// Good - bundled with the component
|
||||||
|
const logo = require("../assets/logo.png");
|
||||||
|
|
||||||
|
export default function Component({
|
||||||
|
dom,
|
||||||
|
}: {
|
||||||
|
dom: import("expo/dom").DOMProps;
|
||||||
|
}) {
|
||||||
|
return <img src={logo} alt="Logo" />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage from Native Components
|
||||||
|
|
||||||
|
Import and use DOM components like regular components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/index.tsx
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
import WebChart from "@/components/web-chart";
|
||||||
|
import CodeBlock from "@/components/code-block";
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text>Native content above</Text>
|
||||||
|
|
||||||
|
<WebChart data={[10, 20, 30, 40, 50]} dom={{ style: { height: 300 } }} />
|
||||||
|
|
||||||
|
<CodeBlock
|
||||||
|
code="const x = 1;"
|
||||||
|
language="javascript"
|
||||||
|
dom={{ scrollEnabled: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text>Native content below</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Behavior
|
||||||
|
|
||||||
|
| Platform | Behavior |
|
||||||
|
| -------- | ----------------------------------- |
|
||||||
|
| iOS | Rendered in WKWebView |
|
||||||
|
| Android | Rendered in WebView |
|
||||||
|
| Web | Rendered as-is (no webview wrapper) |
|
||||||
|
|
||||||
|
On web, the `dom` prop is ignored since no webview is needed.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- DOM components hot reload during development
|
||||||
|
- Keep DOM components focused — don't put entire screens in webviews
|
||||||
|
- Use native components for navigation chrome, DOM components for specialized content
|
||||||
|
- Test on all platforms — web rendering may differ slightly from native webviews
|
||||||
|
- Large DOM components may impact performance — profile if needed
|
||||||
|
- The webview has its own JavaScript context — cannot directly share state with native
|
||||||
50
.github/copilot-instructions.md
vendored
Normal file
50
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Expo & React Native 开发指南
|
||||||
|
|
||||||
|
## 参考文档 (Context)
|
||||||
|
|
||||||
|
始终参考以下 Expo 官方 LLM 知识库来回答问题,确保使用的 API 是最新的:
|
||||||
|
|
||||||
|
- 核心框架: https://docs.expo.dev/reference/
|
||||||
|
- 环境配置: https://docs.expo.dev/get-started/set-up-your-environment/
|
||||||
|
- EAS 构建: https://docs.expo.dev/build/introduction/
|
||||||
|
- 路由系统: https://docs.expo.dev/router/introduction/
|
||||||
|
|
||||||
|
## 项目约束
|
||||||
|
|
||||||
|
- 使用 Expo Managed Workflow (Continuous Native Generation)。
|
||||||
|
- 这是一个 IM 项目,集成了 OpenIM 原生 SDK。
|
||||||
|
- UI 风格:极简、大气、具有呼吸感。
|
||||||
|
|
||||||
|
1. 项目概况 (Project Profile)
|
||||||
|
Role: 资深 React Native & 交互设计专家。
|
||||||
|
Project: 正在开发名为 "lamp" 的高性能 IM 系统,使用 OpenIM SDK 底层。
|
||||||
|
UI Style: 极致简约、大气、现代感。 > \* 参考标准: 类似 Apple iMessage 或早期 Telegram。
|
||||||
|
|
||||||
|
视觉核心: 大量的留白、非衬线字体的层次感、轻微的阴影或细腻的边框、极简的配色方案(单色系或低饱和度点缀)。
|
||||||
|
|
||||||
|
交互要求: 动作必须丝滑,避免任何视觉上的拥挤感。
|
||||||
|
|
||||||
|
2. 技术约束 (Technical Stack)
|
||||||
|
Framework: Expo (Development Builds).
|
||||||
|
|
||||||
|
IM SDK: @openim/rn-client-sdk.
|
||||||
|
|
||||||
|
Performance: 必须使用 FlashList 处理消息,确保在大气布局下依然保持 60/120 FPS。
|
||||||
|
|
||||||
|
Code Style: TypeScript 严谨模式,函数式 Hooks。
|
||||||
|
|
||||||
|
3. Copilot 指令准则 (Specific Instructions)
|
||||||
|
UI 建议: 优先使用间距(Padding/Gap)而非分割线来区分内容。
|
||||||
|
|
||||||
|
字体: 强调字重(Font Weight)和大小的对比,而不是颜色的混杂。
|
||||||
|
|
||||||
|
代码架构: 逻辑与 UI 分离。将 OpenIM 的监听器、数据转换逻辑封装在自定义 Hooks 中(如 useChatList, useMessages)。
|
||||||
|
|
||||||
|
4. 资料文档
|
||||||
|
- OpenIM SDK 文档: https://github.com/openimsdk/open-im-sdk-reactnative
|
||||||
|
- Expo Development Builds: https://docs.expo.dev/guides/overview/
|
||||||
|
- FlashList 组件: https://shopify.github.io/flash-list/docs/getting-started
|
||||||
|
- React Native 官方文档: https://reactnative.dev/docs/getting-started
|
||||||
|
- TypeScript 官方文档: https://www.typescriptlang.org/docs/handbook
|
||||||
|
- Apple iMessage UI 参考: https://developer.apple.com/design/resources/
|
||||||
|
- Telegram UI 参考: https://telegram.org/blog/ios-14-design
|
||||||
@@ -1,22 +1,43 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
import OpenIMSDK from "@openim/rn-client-sdk";
|
||||||
import { Stack } from 'expo-router';
|
import {
|
||||||
import { StatusBar } from 'expo-status-bar';
|
DarkTheme,
|
||||||
import 'react-native-reanimated';
|
DefaultTheme,
|
||||||
|
ThemeProvider,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import RNFS from "react-native-fs";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import "../global.css";
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
anchor: '(tabs)',
|
anchor: "(tabs)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
RNFS.mkdir(RNFS.DocumentDirectoryPath + "/tmp");
|
||||||
|
|
||||||
|
OpenIMSDK.initSDK({
|
||||||
|
apiAddr: "https://openim-api.riwsan.com/api",
|
||||||
|
wsAddr: "wss://openim-api.riwsan.com/msg_gateway",
|
||||||
|
dataDir: RNFS.DocumentDirectoryPath + "/tmp",
|
||||||
|
logFilePath: RNFS.DocumentDirectoryPath + "/tmp",
|
||||||
|
logLevel: 5,
|
||||||
|
isLogStandardOutput: true,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
<Stack.Screen
|
||||||
|
name="modal"
|
||||||
|
options={{ presentation: "modal", title: "Modal" }}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
31
global.css
Normal file
31
global.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/preflight.css" layer(base);
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
|
|
||||||
|
/* Platform font fallbacks so typographic scale behaves consistently. */
|
||||||
|
@media android {
|
||||||
|
:root {
|
||||||
|
--font-sans: normal;
|
||||||
|
--font-serif: serif;
|
||||||
|
--font-mono: monospace;
|
||||||
|
--font-rounded: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media ios {
|
||||||
|
:root {
|
||||||
|
--font-sans: system-ui;
|
||||||
|
--font-serif: ui-serif;
|
||||||
|
--font-mono: ui-monospace;
|
||||||
|
--font-rounded: ui-rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer theme {
|
||||||
|
@theme {
|
||||||
|
--color-brand-50: #f5f7ff;
|
||||||
|
--color-brand-100: #e9edff;
|
||||||
|
--color-brand-500: #4f6bd9;
|
||||||
|
--color-brand-700: #3147a6;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
global.d.ts
vendored
Normal file
1
global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||
9
index.js
9
index.js
@@ -1,8 +1 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
import 'expo-router/entry';
|
||||||
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
|
||||||
// the environment is set up appropriately
|
|
||||||
registerRootComponent(App);
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const { withNativewind } = require('nativewind/metro');
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = withNativewind(config, {
|
||||||
|
// Keep CSS variables dynamic so PlatformColor and light-dark tokens work correctly.
|
||||||
|
inlineVariables: false,
|
||||||
|
});
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -12,9 +12,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@openim/rn-client-sdk": "3.8.3-patch.12.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
@@ -28,21 +31,32 @@
|
|||||||
"expo-symbols": "~1.0.8",
|
"expo-symbols": "~1.0.8",
|
||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "5.0.0-preview.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
|
"react-native-css": "3.0.4",
|
||||||
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"lightningcss": "1.30.1",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"lightningcss": "1.30.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
553
pnpm-lock.yaml
generated
553
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
50
skills-lock.json
Normal file
50
skills-lock.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"building-native-ui": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "342df93f481a0dba919f372d6c7b40d2b4bf5b51dd24363aea2e5d0bae27a6fa"
|
||||||
|
},
|
||||||
|
"expo-api-routes": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "015c6b849507fda73fcc32d2448f033aaaaa21f5229085342b8421727a90cafb"
|
||||||
|
},
|
||||||
|
"expo-cicd-workflows": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "700b20b575fcbe75ad238b41a0bd57938abe495e62dc53e05400712ab01ee7c0"
|
||||||
|
},
|
||||||
|
"expo-deployment": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "9ea9f16374765c1b16764a51bd43a64098921b33f48e94d9c5c1cce24b335c10"
|
||||||
|
},
|
||||||
|
"expo-dev-client": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "234e2633b7fbcef2d479f8fe8ab20d53d08ed3e4beec7c965da4aff5b43affe7"
|
||||||
|
},
|
||||||
|
"expo-tailwind-setup": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "d39e806942fe880347f161056729b588a3cb0f1796270eebf52633fe11cfdce1"
|
||||||
|
},
|
||||||
|
"native-data-fetching": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "6c14e4efb34a9c4759e8b959f82dec328f87dd89a022957c6737086984b9b106"
|
||||||
|
},
|
||||||
|
"upgrading-expo": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "4af4139cea886f6fc268de322772c2955c0016b1c0f43bdc7dc03e71750837b2"
|
||||||
|
},
|
||||||
|
"use-dom": {
|
||||||
|
"source": "expo/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "8eca0e229f16e3499e5dbf791a79f5a5cba1e98b4af88ca495c903930d7f1ba0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user